first commit
This commit is contained in:
2
src/gads_v2/__init__.py
Normal file
2
src/gads_v2/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
__version__ = "0.1.0"
|
||||
|
||||
67
src/gads_v2/cleanup.py
Normal file
67
src/gads_v2/cleanup.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from .config import ROOT
|
||||
from .history import now_local
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CleanupResult:
|
||||
retention_days: int
|
||||
scanned_files: int
|
||||
deleted_files: int
|
||||
deleted_bytes: int
|
||||
|
||||
|
||||
def cleanup_old_plans(retention_days: int = 7) -> CleanupResult:
|
||||
cutoff = now_local() - timedelta(days=max(retention_days, 0))
|
||||
plans_root = ROOT / "clients"
|
||||
scanned = 0
|
||||
deleted = 0
|
||||
deleted_bytes = 0
|
||||
if not plans_root.exists():
|
||||
return CleanupResult(retention_days, scanned, deleted, deleted_bytes)
|
||||
|
||||
for plans_dir in plans_root.glob("*/plans"):
|
||||
if not plans_dir.is_dir():
|
||||
continue
|
||||
for path in plans_dir.iterdir():
|
||||
if path.suffix.lower() not in {".json", ".md"}:
|
||||
continue
|
||||
scanned += 1
|
||||
try:
|
||||
modified = now_local().fromtimestamp(path.stat().st_mtime, tz=now_local().tzinfo)
|
||||
except OSError:
|
||||
continue
|
||||
if modified >= cutoff:
|
||||
continue
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
path.unlink()
|
||||
except OSError:
|
||||
continue
|
||||
deleted += 1
|
||||
deleted_bytes += size
|
||||
return CleanupResult(retention_days, scanned, deleted, deleted_bytes)
|
||||
|
||||
|
||||
def plan_retention_days(global_rules: dict, default: int = 7) -> int:
|
||||
cleanup_rules = global_rules.get("plan_cleanup", {})
|
||||
try:
|
||||
return int(cleanup_rules.get("retention_days", default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def print_cleanup_result(result: CleanupResult) -> None:
|
||||
if result.deleted_files <= 0:
|
||||
return
|
||||
mb = result.deleted_bytes / 1024 / 1024
|
||||
print(
|
||||
"\nAutoczyszczenie planow: "
|
||||
f"usunieto {result.deleted_files} plikow starszych niz {result.retention_days} dni "
|
||||
f"({mb:.2f} MB)."
|
||||
)
|
||||
1783
src/gads_v2/cli.py
Normal file
1783
src/gads_v2/cli.py
Normal file
File diff suppressed because it is too large
Load Diff
70
src/gads_v2/config.py
Normal file
70
src/gads_v2/config.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def load_env(path: Path | None = None) -> None:
|
||||
env_path = path or ROOT / ".env"
|
||||
if not env_path.exists():
|
||||
return
|
||||
for raw in env_path.read_text(encoding="utf-8-sig").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClientConfig:
|
||||
domain: str
|
||||
google_ads_customer_id: str
|
||||
adspro_client_id: str | None = None
|
||||
rules: dict | None = None
|
||||
|
||||
@property
|
||||
def safe_customer_id(self) -> str:
|
||||
return re.sub(r"\D", "", self.google_ads_customer_id)
|
||||
|
||||
def effective_rules(self, global_rules: dict, section: str) -> dict:
|
||||
rules = dict(global_rules.get(section, {}))
|
||||
rules.update((self.rules or {}).get(section, {}))
|
||||
return rules
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AppConfig:
|
||||
clients: dict[str, ClientConfig]
|
||||
global_rules: dict
|
||||
|
||||
|
||||
def load_config(path: Path | None = None) -> AppConfig:
|
||||
config_path = path or ROOT / "config" / "clients.toml"
|
||||
if not config_path.exists():
|
||||
example = ROOT / "config" / "clients.example.toml"
|
||||
raise FileNotFoundError(
|
||||
f"Brak {config_path}. Utworz ten plik na podstawie {example}."
|
||||
)
|
||||
data = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
||||
clients = {}
|
||||
for domain, row in data.get("clients", {}).items():
|
||||
clients[domain] = ClientConfig(
|
||||
domain=domain,
|
||||
google_ads_customer_id=str(row["google_ads_customer_id"]),
|
||||
adspro_client_id=str(row.get("adspro_client_id")) if row.get("adspro_client_id") else None,
|
||||
rules={key: value for key, value in row.items() if isinstance(value, dict)},
|
||||
)
|
||||
return AppConfig(clients=clients, global_rules=data.get("global_rules", {}))
|
||||
|
||||
|
||||
def client_dir(domain: str) -> Path:
|
||||
path = ROOT / "clients" / domain
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
33
src/gads_v2/google_ads.py
Normal file
33
src/gads_v2/google_ads.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from google.ads.googleads.client import GoogleAdsClient
|
||||
|
||||
|
||||
def get_google_ads_client(use_proto_plus: bool = True) -> GoogleAdsClient:
|
||||
developer_token = os.environ.get("GOOGLE_ADS_DEVELOPER_TOKEN") or os.environ.get(
|
||||
"GOOGLE_ADS_DEVELOPER_TOKNE"
|
||||
)
|
||||
if not developer_token:
|
||||
raise RuntimeError("Brak GOOGLE_ADS_DEVELOPER_TOKEN w .env.")
|
||||
return GoogleAdsClient.load_from_dict(
|
||||
{
|
||||
"developer_token": developer_token,
|
||||
"client_id": os.environ["GOOGLE_ADS_OAUTH2_CLIENT_ID"],
|
||||
"client_secret": os.environ["GOOGLE_ADS_OAUTH2_CLIENT_SECRET"],
|
||||
"refresh_token": os.environ["GOOGLE_ADS_OAUTH2_REFRESH_TOKEN"],
|
||||
"login_customer_id": os.environ["GOOGLE_ADS_MANAGER_ACCOUNT_ID"],
|
||||
"use_proto_plus": use_proto_plus,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def run_query(client: GoogleAdsClient, customer_id: str, query: str, timeout: float = 300.0) -> list[Any]:
|
||||
service = client.get_service("GoogleAdsService")
|
||||
rows = []
|
||||
for batch in service.search_stream(customer_id=customer_id, query=query, timeout=timeout):
|
||||
rows.extend(batch.results)
|
||||
return rows
|
||||
|
||||
49
src/gads_v2/history.py
Normal file
49
src/gads_v2/history.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .config import client_dir
|
||||
|
||||
|
||||
TZ = ZoneInfo("Europe/Warsaw")
|
||||
|
||||
|
||||
def now_local() -> datetime:
|
||||
return datetime.now(TZ)
|
||||
|
||||
|
||||
def append_history(domain: str, event: dict) -> Path:
|
||||
ts = now_local()
|
||||
event = {"timestamp": ts.isoformat(timespec="seconds"), **event}
|
||||
base = client_dir(domain) / "history"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
path = base / f"{ts.date().isoformat()}.jsonl"
|
||||
with path.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(event, ensure_ascii=False) + "\n")
|
||||
return path
|
||||
|
||||
|
||||
def append_change_markdown(domain: str, title: str, rows: list[dict]) -> Path:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "changes"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
path = base / f"{ts.date().isoformat()}.md"
|
||||
exists = path.exists()
|
||||
with path.open("a", encoding="utf-8") as f:
|
||||
if not exists:
|
||||
f.write(f"# Zmiany {ts.date().isoformat()}\n\n")
|
||||
f.write(f"## {ts.strftime('%H:%M')} - {title}\n\n")
|
||||
if not rows:
|
||||
f.write("Brak wdrozonych zmian.\n\n")
|
||||
return path
|
||||
keys = list(rows[0].keys())
|
||||
f.write("| " + " | ".join(keys) + " |\n")
|
||||
f.write("| " + " | ".join(["---"] * len(keys)) + " |\n")
|
||||
for row in rows:
|
||||
f.write("| " + " | ".join(str(row.get(k, "")) for k in keys) + " |\n")
|
||||
f.write("\n")
|
||||
return path
|
||||
|
||||
2
src/gads_v2/knowledge/__init__.py
Normal file
2
src/gads_v2/knowledge/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Local knowledge store helpers."""
|
||||
|
||||
260
src/gads_v2/knowledge/importer.py
Normal file
260
src/gads_v2/knowledge/importer.py
Normal file
@@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from ..task_catalog import load_tasks
|
||||
from .store import append_import_record, append_rules, ensure_knowledge_store, normalize_rule_row
|
||||
|
||||
|
||||
DEFAULT_MODEL = "gpt-4.1-mini"
|
||||
DEFAULT_MAX_RULES = 12
|
||||
MAX_SOURCE_CHARS = 60000
|
||||
|
||||
|
||||
RULE_SCHEMA = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"rules": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"topic": {"type": "string"},
|
||||
"suggested_task_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
"rule_type": {"type": "string"},
|
||||
"condition": {"type": "string"},
|
||||
"recommendation": {"type": "string"},
|
||||
"risk": {"type": "string"},
|
||||
"source": {"type": "string"},
|
||||
"confidence": {"type": "string"},
|
||||
"text": {"type": "string"},
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"topic",
|
||||
"suggested_task_ids",
|
||||
"rule_type",
|
||||
"condition",
|
||||
"recommendation",
|
||||
"risk",
|
||||
"source",
|
||||
"confidence",
|
||||
"text",
|
||||
],
|
||||
},
|
||||
},
|
||||
"notes": {"type": "string"},
|
||||
},
|
||||
"required": ["rules", "notes"],
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImportResult:
|
||||
source_path: Path
|
||||
source_name: str
|
||||
model: str
|
||||
rules_count: int
|
||||
rule_ids: list[str]
|
||||
notes: str
|
||||
dry_run: bool
|
||||
|
||||
|
||||
def import_knowledge_file(
|
||||
file_path: Path,
|
||||
source_name: str,
|
||||
model: str | None = None,
|
||||
max_rules: int = DEFAULT_MAX_RULES,
|
||||
dry_run: bool = False,
|
||||
) -> ImportResult:
|
||||
ensure_knowledge_store()
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"Nie znaleziono pliku wiedzy: {file_path}")
|
||||
if not file_path.is_file():
|
||||
raise ValueError(f"Sciezka nie jest plikiem: {file_path}")
|
||||
if not source_name.strip():
|
||||
raise ValueError("Podaj --source, czyli czytelna nazwe zrodla wiedzy.")
|
||||
|
||||
content = read_source_file(file_path)
|
||||
selected_model = model or os.environ.get("KNOWLEDGE_OPENAI_MODEL") or os.environ.get("OPENAI_MODEL") or DEFAULT_MODEL
|
||||
|
||||
if dry_run:
|
||||
return ImportResult(
|
||||
source_path=file_path,
|
||||
source_name=source_name,
|
||||
model=selected_model,
|
||||
rules_count=0,
|
||||
rule_ids=[],
|
||||
notes="Tryb dry-run: plik odczytany, API nie zostalo wywolane, reguly nie zostaly zapisane.",
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
api_key = os.environ.get("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
raise RuntimeError(
|
||||
"Brak OPENAI_API_KEY. Dodaj klucz do .env albo uruchom z --dry-run, "
|
||||
"zeby tylko sprawdzic plik wejsciowy."
|
||||
)
|
||||
|
||||
payload = call_openai_extractor(
|
||||
api_key=api_key,
|
||||
model=selected_model,
|
||||
source_name=source_name,
|
||||
content=content,
|
||||
max_rules=max_rules,
|
||||
)
|
||||
raw_rules = payload.get("rules") or []
|
||||
normalized_rules = []
|
||||
for row in raw_rules:
|
||||
normalized = normalize_rule_row({**row, "source": source_name, "task_ids": []})
|
||||
normalized_rules.append(normalized)
|
||||
|
||||
saved_rules = append_rules(normalized_rules, source_file=str(file_path))
|
||||
notes = str(payload.get("notes") or "").strip()
|
||||
append_import_record(
|
||||
{
|
||||
"file": str(file_path),
|
||||
"source": source_name,
|
||||
"model": selected_model,
|
||||
"rules_count": len(saved_rules),
|
||||
"notes": notes,
|
||||
}
|
||||
)
|
||||
return ImportResult(
|
||||
source_path=file_path,
|
||||
source_name=source_name,
|
||||
model=selected_model,
|
||||
rules_count=len(saved_rules),
|
||||
rule_ids=[rule.id for rule in saved_rules],
|
||||
notes=notes,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
|
||||
def read_source_file(path: Path) -> str:
|
||||
content = path.read_text(encoding="utf-8-sig", errors="replace")
|
||||
content = content.strip()
|
||||
if not content:
|
||||
raise ValueError(f"Plik wiedzy jest pusty: {path}")
|
||||
if len(content) > MAX_SOURCE_CHARS:
|
||||
return content[:MAX_SOURCE_CHARS]
|
||||
return content
|
||||
|
||||
|
||||
def call_openai_extractor(
|
||||
api_key: str,
|
||||
model: str,
|
||||
source_name: str,
|
||||
content: str,
|
||||
max_rules: int,
|
||||
) -> dict[str, Any]:
|
||||
base_url = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1").rstrip("/")
|
||||
endpoint = f"{base_url}/responses"
|
||||
response = requests.post(
|
||||
endpoint,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": model,
|
||||
"instructions": build_extractor_instructions(source_name, max_rules),
|
||||
"input": build_extractor_input(source_name, content),
|
||||
"text": {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": "knowledge_rules",
|
||||
"strict": True,
|
||||
"schema": RULE_SCHEMA,
|
||||
}
|
||||
},
|
||||
"max_output_tokens": 6000,
|
||||
"store": False,
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise RuntimeError(f"OpenAI API zwrocilo blad {response.status_code}: {response.text}")
|
||||
data = response.json()
|
||||
output_text = extract_output_text(data)
|
||||
if not output_text:
|
||||
raise RuntimeError("OpenAI API nie zwrocilo tekstu z regułami.")
|
||||
try:
|
||||
payload = json.loads(output_text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"OpenAI API zwrocilo nieprawidlowy JSON: {exc}") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("OpenAI API powinno zwrocic obiekt JSON.")
|
||||
return payload
|
||||
|
||||
|
||||
def extract_output_text(data: dict[str, Any]) -> str:
|
||||
if isinstance(data.get("output_text"), str):
|
||||
return data["output_text"]
|
||||
parts: list[str] = []
|
||||
for item in data.get("output", []) or []:
|
||||
for content in item.get("content", []) or []:
|
||||
if content.get("type") == "output_text" and isinstance(content.get("text"), str):
|
||||
parts.append(content["text"])
|
||||
elif isinstance(content.get("text"), str):
|
||||
parts.append(content["text"])
|
||||
return "\n".join(parts).strip()
|
||||
|
||||
|
||||
def build_extractor_instructions(source_name: str, max_rules: int) -> str:
|
||||
task_lines = []
|
||||
for task in load_tasks():
|
||||
task_lines.append(
|
||||
f"- {task.id}: {task.group_name} / {task.name}. {task.description}"
|
||||
)
|
||||
tasks_block = "\n".join(task_lines)
|
||||
return f"""
|
||||
Jestes parserem wiedzy dla terminalowego narzedzia Google Ads.
|
||||
Masz zamienic material zrodlowy na maksymalnie {max_rules} atomowych regul.
|
||||
|
||||
Zrodlo ma nazwe: {source_name}
|
||||
|
||||
Aktualnie istniejace task_id w narzedziu:
|
||||
{tasks_block}
|
||||
|
||||
Wymagania:
|
||||
- Pisz po polsku.
|
||||
- Wszystkie pola opisowe (`condition`, `recommendation`, `risk`, `text`, `notes`) musza byc po polsku.
|
||||
- Zwracaj tylko JSON zgodny ze schematem.
|
||||
- Jedna regula ma opisywac jeden konkretny wniosek, warunek, ryzyko albo rekomendacje.
|
||||
- Nie tworz regul ogolnikowych typu "monitoruj wyniki"; regula ma mowic co sprawdzic i dlaczego.
|
||||
- suggested_task_ids wypelniaj tylko istniejacymi task_id z listy powyzej.
|
||||
- suggested_task_ids ustaw tylko wtedy, gdy regula bezposrednio dotyczy danego zadania i moze byc uzyta w jego planie.
|
||||
- Nie proponuj zadania tylko dlatego, ze regula dotyczy podobnego typu kampanii albo ogolnej optymalizacji.
|
||||
- Jesli regula jest wazna, ale nie pasuje bezposrednio do istniejacego zadania, ustaw suggested_task_ids na [].
|
||||
- Narzedzie zapisze suggested_task_ids jako propozycje. Zadanie zostanie dopisane do task_ids dopiero po akceptacji uzytkownika.
|
||||
- source w kazdej regule ustaw dokladnie jako nazwe zrodla podana powyzej.
|
||||
- confidence ustaw jako low, medium albo high.
|
||||
- rule_type ustaw jako audit_check, recommendation, warning albo implementation_note.
|
||||
- topic ustaw krotko, np. search, pmax, shopping, feed-merchant, konwersje, ga4, gtm-tracking, negative-keywords, strategie-stawek, produkty.
|
||||
- id ma byc krotkim stabilnym identyfikatorem po angielsku albo bez polskich znakow.
|
||||
- text ma byc samodzielnym, jednozdaniowym opisem reguly.
|
||||
""".strip()
|
||||
|
||||
|
||||
def build_extractor_input(source_name: str, content: str) -> str:
|
||||
return f"""
|
||||
Przetworz ponizszy material na atomowe reguly dla narzedzia Google Ads.
|
||||
|
||||
Zrodlo: {source_name}
|
||||
|
||||
MATERIAL:
|
||||
{content}
|
||||
""".strip()
|
||||
107
src/gads_v2/knowledge/legacy_import.py
Normal file
107
src/gads_v2/knowledge/legacy_import.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from collections import defaultdict
|
||||
|
||||
import lancedb
|
||||
|
||||
from .store import append_import_record, append_rules, load_rules, normalize_rule_row
|
||||
|
||||
|
||||
DEFAULT_LEGACY_DB = Path(r"D:\google ads\lancedb")
|
||||
DEFAULT_LEGACY_TABLE = "fakty"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LegacyImportResult:
|
||||
db_path: Path
|
||||
table_name: str
|
||||
imported_count: int
|
||||
skipped_existing_count: int
|
||||
total_rows: int
|
||||
|
||||
|
||||
def import_legacy_lancedb(
|
||||
db_path: Path = DEFAULT_LEGACY_DB,
|
||||
table_name: str = DEFAULT_LEGACY_TABLE,
|
||||
limit: int | None = None,
|
||||
) -> LegacyImportResult:
|
||||
if not db_path.exists():
|
||||
raise FileNotFoundError(f"Nie znaleziono starej bazy LanceDB: {db_path}")
|
||||
|
||||
db = lancedb.connect(str(db_path))
|
||||
table_names = list(db.table_names()) if hasattr(db, "table_names") else list(db.list_tables())
|
||||
if table_name not in table_names:
|
||||
raise ValueError(f"Brak tabeli {table_name} w {db_path}. Dostepne tabele: {', '.join(table_names)}")
|
||||
|
||||
table = db.open_table(table_name)
|
||||
df = table.to_pandas()
|
||||
if limit is not None:
|
||||
df = df.head(max(limit, 0))
|
||||
|
||||
existing_ids = {rule.id for rule in load_rules()}
|
||||
rows: list[dict[str, Any]] = []
|
||||
skipped = 0
|
||||
source_file = str(db_path / f"{table_name}.lance")
|
||||
|
||||
occurrences: dict[str, int] = defaultdict(int)
|
||||
for _, record in df.iterrows():
|
||||
record_dict = record.to_dict()
|
||||
old_id = str(record_dict.get("id") or "").strip()
|
||||
occurrences[old_id] += 1
|
||||
row = legacy_record_to_rule(record_dict, source_file, occurrences[old_id])
|
||||
normalized = normalize_rule_row(row)
|
||||
if normalized["id"] in existing_ids:
|
||||
skipped += 1
|
||||
continue
|
||||
existing_ids.add(normalized["id"])
|
||||
rows.append(normalized)
|
||||
|
||||
saved = append_rules(rows)
|
||||
append_import_record(
|
||||
{
|
||||
"file": source_file,
|
||||
"source": f"legacy_lancedb:{table_name}",
|
||||
"model": "none",
|
||||
"rules_count": len(saved),
|
||||
"skipped_existing_count": skipped,
|
||||
"notes": "Import starej tabeli LanceDB bez API i bez przypisywania regul do zadan.",
|
||||
}
|
||||
)
|
||||
return LegacyImportResult(
|
||||
db_path=db_path,
|
||||
table_name=table_name,
|
||||
imported_count=len(saved),
|
||||
skipped_existing_count=skipped,
|
||||
total_rows=len(df),
|
||||
)
|
||||
|
||||
|
||||
def legacy_record_to_rule(record: dict[str, Any], source_file: str, occurrence: int = 1) -> dict[str, Any]:
|
||||
old_id = str(record.get("id") or "").strip()
|
||||
fact = str(record.get("fakt") or "").strip()
|
||||
topic = str(record.get("temat") or "legacy").strip()
|
||||
section = str(record.get("sekcja") or "").strip()
|
||||
source = str(record.get("zrodlo") or "legacy_lancedb").strip()
|
||||
target_id = old_id or fact[:80] or "legacy_rule"
|
||||
if occurrence > 1:
|
||||
target_id = f"{target_id[:80]}__{occurrence}"
|
||||
return {
|
||||
"id": target_id,
|
||||
"status": "active",
|
||||
"topic": topic,
|
||||
"task_ids": [],
|
||||
"suggested_task_ids": [],
|
||||
"rule_type": "implementation_note",
|
||||
"condition": section,
|
||||
"recommendation": fact,
|
||||
"risk": "",
|
||||
"source": source,
|
||||
"source_file": source_file,
|
||||
"confidence": "medium",
|
||||
"duplicate_of": "",
|
||||
"supersedes": [],
|
||||
"text": fact,
|
||||
}
|
||||
620
src/gads_v2/knowledge/store.py
Normal file
620
src/gads_v2/knowledge/store.py
Normal file
@@ -0,0 +1,620 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..config import ROOT
|
||||
|
||||
|
||||
KNOWLEDGE_DIR = ROOT / "knowledge"
|
||||
SOURCES_DIR = KNOWLEDGE_DIR / "sources"
|
||||
LANCEDB_DIR = KNOWLEDGE_DIR / "lancedb"
|
||||
RULES_PATH = KNOWLEDGE_DIR / "rules.jsonl"
|
||||
IMPORTS_PATH = KNOWLEDGE_DIR / "imports.jsonl"
|
||||
REVIEW_STATE_PATH = KNOWLEDGE_DIR / "review_state.json"
|
||||
ALLOWED_STATUSES = {"active", "draft", "archived", "duplicate"}
|
||||
DEFAULT_STATUS = "active"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KnowledgeRule:
|
||||
id: str
|
||||
status: str
|
||||
topic: str
|
||||
task_ids: list[str]
|
||||
suggested_task_ids: list[str]
|
||||
rule_type: str
|
||||
condition: str
|
||||
recommendation: str
|
||||
risk: str
|
||||
source: str
|
||||
source_file: str
|
||||
confidence: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
duplicate_of: str
|
||||
supersedes: list[str]
|
||||
text: str
|
||||
raw: dict[str, Any]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "KnowledgeRule":
|
||||
task_ids = normalize_task_id_list(data.get("task_ids") or [])
|
||||
suggested_task_ids = normalize_task_id_list(
|
||||
data.get("suggested_task_ids") or data.get("pending_task_ids") or []
|
||||
)
|
||||
text = str(data.get("text") or "").strip()
|
||||
if not text:
|
||||
text = " ".join(
|
||||
str(data.get(key) or "").strip()
|
||||
for key in ("condition", "recommendation", "risk")
|
||||
if str(data.get(key) or "").strip()
|
||||
)
|
||||
return cls(
|
||||
id=str(data.get("id") or "").strip(),
|
||||
status=normalize_status(data.get("status")),
|
||||
topic=str(data.get("topic") or "").strip(),
|
||||
task_ids=task_ids,
|
||||
suggested_task_ids=suggested_task_ids,
|
||||
rule_type=str(data.get("rule_type") or "").strip(),
|
||||
condition=str(data.get("condition") or "").strip(),
|
||||
recommendation=str(data.get("recommendation") or "").strip(),
|
||||
risk=str(data.get("risk") or "").strip(),
|
||||
source=str(data.get("source") or "").strip(),
|
||||
source_file=str(data.get("source_file") or "").strip(),
|
||||
confidence=str(data.get("confidence") or "").strip(),
|
||||
created_at=str(data.get("created_at") or "").strip(),
|
||||
updated_at=str(data.get("updated_at") or "").strip(),
|
||||
duplicate_of=str(data.get("duplicate_of") or "").strip(),
|
||||
supersedes=normalize_string_list(data.get("supersedes") or []),
|
||||
text=text,
|
||||
raw=data,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchResult:
|
||||
rule: KnowledgeRule
|
||||
score: int
|
||||
|
||||
|
||||
def ensure_knowledge_store() -> dict[str, Path]:
|
||||
KNOWLEDGE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
SOURCES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
LANCEDB_DIR.mkdir(parents=True, exist_ok=True)
|
||||
for path in (RULES_PATH, IMPORTS_PATH):
|
||||
if not path.exists():
|
||||
path.write_text("", encoding="utf-8")
|
||||
if not REVIEW_STATE_PATH.exists():
|
||||
REVIEW_STATE_PATH.write_text("{}", encoding="utf-8")
|
||||
return {
|
||||
"knowledge_dir": KNOWLEDGE_DIR,
|
||||
"sources_dir": SOURCES_DIR,
|
||||
"lancedb_dir": LANCEDB_DIR,
|
||||
"rules_path": RULES_PATH,
|
||||
"imports_path": IMPORTS_PATH,
|
||||
"review_state_path": REVIEW_STATE_PATH,
|
||||
}
|
||||
|
||||
|
||||
def load_rules(path: Path = RULES_PATH) -> list[KnowledgeRule]:
|
||||
ensure_knowledge_store()
|
||||
rows = read_rule_rows(path)
|
||||
return [KnowledgeRule.from_dict(row) for row in rows]
|
||||
|
||||
|
||||
def read_rule_rows(path: Path = RULES_PATH) -> list[dict[str, Any]]:
|
||||
ensure_knowledge_store()
|
||||
rows: list[dict[str, Any]] = []
|
||||
for line_number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Nieprawidlowy JSONL w {path}:{line_number}: {exc}") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"Wiersz {path}:{line_number} nie jest obiektem JSON.")
|
||||
rows.append(data)
|
||||
return rows
|
||||
|
||||
|
||||
def write_rule_rows(rows: list[dict[str, Any]], path: Path = RULES_PATH) -> None:
|
||||
ensure_knowledge_store()
|
||||
with path.open("w", encoding="utf-8", newline="\n") as f:
|
||||
for row in rows:
|
||||
f.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def append_rules(
|
||||
rule_rows: list[dict[str, Any]],
|
||||
path: Path = RULES_PATH,
|
||||
source_file: str = "",
|
||||
) -> list[KnowledgeRule]:
|
||||
ensure_knowledge_store()
|
||||
existing_ids = {rule.id for rule in load_rules(path) if rule.id}
|
||||
normalized_rows = []
|
||||
for row in rule_rows:
|
||||
if source_file and not row.get("source_file"):
|
||||
row = {**row, "source_file": source_file}
|
||||
normalized = normalize_rule_row(row)
|
||||
normalized["id"] = unique_rule_id(normalized["id"], existing_ids)
|
||||
existing_ids.add(normalized["id"])
|
||||
normalized_rows.append(normalized)
|
||||
|
||||
if normalized_rows:
|
||||
with path.open("a", encoding="utf-8", newline="\n") as f:
|
||||
for row in normalized_rows:
|
||||
f.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
return [KnowledgeRule.from_dict(row) for row in normalized_rows]
|
||||
|
||||
|
||||
def append_import_record(record: dict[str, Any], path: Path = IMPORTS_PATH) -> None:
|
||||
ensure_knowledge_store()
|
||||
payload = {
|
||||
"created_at": datetime.now().isoformat(timespec="seconds"),
|
||||
**record,
|
||||
}
|
||||
with path.open("a", encoding="utf-8", newline="\n") as f:
|
||||
f.write(json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def rules_for_task(task_id: str, path: Path = RULES_PATH) -> list[KnowledgeRule]:
|
||||
task_id = task_id.strip()
|
||||
return [
|
||||
rule
|
||||
for rule in load_rules(path)
|
||||
if rule.status == "active" and task_id in rule.task_ids
|
||||
]
|
||||
|
||||
|
||||
def search_rules(query: str, limit: int = 10, path: Path = RULES_PATH) -> list[SearchResult]:
|
||||
query_terms = tokenize(query)
|
||||
if not query_terms:
|
||||
return []
|
||||
|
||||
results: list[SearchResult] = []
|
||||
for rule in load_rules(path):
|
||||
if rule.status != "active":
|
||||
continue
|
||||
haystack = searchable_text(rule)
|
||||
score = sum(haystack.count(term) for term in query_terms)
|
||||
if rule.topic.casefold() in query.casefold():
|
||||
score += 3
|
||||
for task_id in rule.task_ids:
|
||||
if task_id.casefold() in query.casefold():
|
||||
score += 3
|
||||
if score > 0:
|
||||
results.append(SearchResult(rule=rule, score=score))
|
||||
|
||||
results.sort(key=lambda item: (-item.score, item.rule.topic, item.rule.id))
|
||||
return results[: max(limit, 0)]
|
||||
|
||||
|
||||
def store_summary(path: Path = RULES_PATH) -> dict[str, int]:
|
||||
rules = load_rules(path)
|
||||
topics = {rule.topic for rule in rules if rule.topic}
|
||||
task_ids = {task_id for rule in rules for task_id in rule.task_ids}
|
||||
suggested_task_ids = {
|
||||
task_id
|
||||
for rule in rules
|
||||
for task_id in rule.suggested_task_ids
|
||||
if task_id not in rule.task_ids
|
||||
}
|
||||
sources = {rule.source for rule in rules if rule.source}
|
||||
by_status = {status: sum(1 for rule in rules if rule.status == status) for status in ALLOWED_STATUSES}
|
||||
return {
|
||||
"rules": len(rules),
|
||||
"active_rules": by_status.get("active", 0),
|
||||
"draft_rules": by_status.get("draft", 0),
|
||||
"archived_rules": by_status.get("archived", 0),
|
||||
"duplicate_rules": by_status.get("duplicate", 0),
|
||||
"topics": len(topics),
|
||||
"task_ids": len(task_ids),
|
||||
"suggested_task_ids": len(suggested_task_ids),
|
||||
"suggestions": sum(
|
||||
1
|
||||
for rule in rules
|
||||
for task_id in rule.suggested_task_ids
|
||||
if task_id not in rule.task_ids
|
||||
),
|
||||
"sources": len(sources),
|
||||
}
|
||||
|
||||
|
||||
def list_rules(
|
||||
topic: str | None = None,
|
||||
task_id: str | None = None,
|
||||
status: str | None = None,
|
||||
source: str | None = None,
|
||||
path: Path = RULES_PATH,
|
||||
) -> list[KnowledgeRule]:
|
||||
rules = load_rules(path)
|
||||
if topic:
|
||||
normalized_topic = slugify(topic)
|
||||
rules = [rule for rule in rules if rule.topic == normalized_topic]
|
||||
if task_id:
|
||||
normalized_task_id = slugify(task_id)
|
||||
rules = [rule for rule in rules if normalized_task_id in rule.task_ids]
|
||||
if status:
|
||||
normalized_status = normalize_status(status)
|
||||
rules = [rule for rule in rules if rule.status == normalized_status]
|
||||
if source:
|
||||
normalized_source = source.casefold()
|
||||
rules = [rule for rule in rules if normalized_source in rule.source.casefold()]
|
||||
return sorted(rules, key=lambda rule: (rule.topic, rule.id))
|
||||
|
||||
|
||||
def update_rule_status(
|
||||
rule_id: str,
|
||||
status: str,
|
||||
duplicate_of: str = "",
|
||||
path: Path = RULES_PATH,
|
||||
) -> KnowledgeRule:
|
||||
normalized_rule_id = rule_id.strip()
|
||||
normalized_status = normalize_status(status)
|
||||
if normalized_status not in ALLOWED_STATUSES:
|
||||
raise ValueError(f"Nieprawidlowy status: {status}")
|
||||
|
||||
rows = read_rule_rows(path)
|
||||
updated_rule: KnowledgeRule | None = None
|
||||
now = now_iso()
|
||||
for row in rows:
|
||||
if str(row.get("id") or "").strip() != normalized_rule_id:
|
||||
continue
|
||||
row["status"] = normalized_status
|
||||
row["updated_at"] = now
|
||||
if duplicate_of:
|
||||
row["duplicate_of"] = duplicate_of.strip()
|
||||
elif normalized_status != "duplicate":
|
||||
row["duplicate_of"] = ""
|
||||
updated_rule = KnowledgeRule.from_dict(row)
|
||||
break
|
||||
|
||||
if not updated_rule:
|
||||
raise ValueError(f"Nie znaleziono reguly: {normalized_rule_id}")
|
||||
|
||||
write_rule_rows(rows, path)
|
||||
return updated_rule
|
||||
|
||||
|
||||
def tokenize(value: str) -> list[str]:
|
||||
return [
|
||||
token.casefold()
|
||||
for token in re.findall(r"\w+", value or "", flags=re.UNICODE)
|
||||
if len(token) >= 2
|
||||
]
|
||||
|
||||
|
||||
def searchable_text(rule: KnowledgeRule) -> str:
|
||||
values = [
|
||||
rule.id,
|
||||
rule.status,
|
||||
rule.topic,
|
||||
" ".join(rule.task_ids),
|
||||
" ".join(rule.suggested_task_ids),
|
||||
rule.rule_type,
|
||||
rule.condition,
|
||||
rule.recommendation,
|
||||
rule.risk,
|
||||
rule.source,
|
||||
rule.source_file,
|
||||
rule.confidence,
|
||||
rule.text,
|
||||
]
|
||||
return " ".join(values).casefold()
|
||||
|
||||
|
||||
def normalize_rule_row(data: dict[str, Any]) -> dict[str, Any]:
|
||||
task_ids = normalize_task_id_list(data.get("task_ids") or [])
|
||||
suggested_task_ids = normalize_task_id_list(data.get("suggested_task_ids") or [])
|
||||
created_at = clean_field(data.get("created_at")) or now_iso()
|
||||
updated_at = clean_field(data.get("updated_at")) or created_at
|
||||
text = clean_field(data.get("text"))
|
||||
condition = clean_field(data.get("condition"))
|
||||
recommendation = clean_field(data.get("recommendation"))
|
||||
risk = clean_field(data.get("risk"))
|
||||
if not text:
|
||||
text = " ".join(item for item in [condition, recommendation, risk] if item)
|
||||
row = {
|
||||
"id": slugify(clean_field(data.get("id")) or text or recommendation or condition or "knowledge_rule"),
|
||||
"status": normalize_status(data.get("status")),
|
||||
"topic": slugify(clean_field(data.get("topic")) or "inne"),
|
||||
"task_ids": task_ids,
|
||||
"suggested_task_ids": [item for item in suggested_task_ids if item not in task_ids],
|
||||
"rule_type": slugify(clean_field(data.get("rule_type")) or "recommendation"),
|
||||
"condition": condition,
|
||||
"recommendation": recommendation,
|
||||
"risk": risk,
|
||||
"source": clean_field(data.get("source")),
|
||||
"source_file": clean_field(data.get("source_file")),
|
||||
"confidence": slugify(clean_field(data.get("confidence")) or "medium"),
|
||||
"created_at": created_at,
|
||||
"updated_at": updated_at,
|
||||
"duplicate_of": clean_field(data.get("duplicate_of")),
|
||||
"supersedes": normalize_string_list(data.get("supersedes") or []),
|
||||
"text": text,
|
||||
}
|
||||
validate_rule_row(row)
|
||||
return row
|
||||
|
||||
|
||||
def validate_rule_row(row: dict[str, Any]) -> None:
|
||||
required = [
|
||||
"id",
|
||||
"status",
|
||||
"topic",
|
||||
"task_ids",
|
||||
"suggested_task_ids",
|
||||
"rule_type",
|
||||
"condition",
|
||||
"recommendation",
|
||||
"risk",
|
||||
"source",
|
||||
"source_file",
|
||||
"confidence",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"duplicate_of",
|
||||
"supersedes",
|
||||
"text",
|
||||
]
|
||||
missing = [key for key in required if key not in row]
|
||||
if missing:
|
||||
raise ValueError(f"Regula nie ma wymaganych pol: {', '.join(missing)}")
|
||||
if not isinstance(row["task_ids"], list):
|
||||
raise ValueError("Pole task_ids musi byc lista.")
|
||||
if not isinstance(row["suggested_task_ids"], list):
|
||||
raise ValueError("Pole suggested_task_ids musi byc lista.")
|
||||
if not isinstance(row["supersedes"], list):
|
||||
raise ValueError("Pole supersedes musi byc lista.")
|
||||
if not row["id"]:
|
||||
raise ValueError("Regula musi miec id.")
|
||||
if row["status"] not in ALLOWED_STATUSES:
|
||||
raise ValueError(f"Nieprawidlowy status reguly: {row['status']}")
|
||||
if not row["topic"]:
|
||||
raise ValueError("Regula musi miec topic.")
|
||||
if not row["source"]:
|
||||
raise ValueError("Regula musi miec source.")
|
||||
if not row["text"] and not row["recommendation"]:
|
||||
raise ValueError("Regula musi miec text albo recommendation.")
|
||||
|
||||
|
||||
def clean_field(value: Any) -> str:
|
||||
return " ".join(str(value or "").replace("\r", "\n").split())
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def normalize_status(value: Any) -> str:
|
||||
status = slugify(clean_field(value) or DEFAULT_STATUS)
|
||||
if status not in ALLOWED_STATUSES:
|
||||
return DEFAULT_STATUS
|
||||
return status
|
||||
|
||||
|
||||
def normalize_task_id_list(value: Any) -> list[str]:
|
||||
if isinstance(value, str):
|
||||
value = [value]
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [slugify(str(item)) for item in value if str(item).strip()]
|
||||
|
||||
|
||||
def normalize_string_list(value: Any) -> list[str]:
|
||||
if isinstance(value, str):
|
||||
value = [value]
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [clean_field(item) for item in value if clean_field(item)]
|
||||
|
||||
|
||||
def slugify(value: str, max_length: int = 96) -> str:
|
||||
value = value.casefold()
|
||||
replacements = {"\u0142": "l"}
|
||||
for source, target in replacements.items():
|
||||
value = value.replace(source, target)
|
||||
value = unicodedata.normalize("NFKD", value)
|
||||
value = "".join(char for char in value if not unicodedata.combining(char))
|
||||
value = re.sub(r"[^a-z0-9]+", "_", value).strip("_")
|
||||
value = re.sub(r"_+", "_", value)
|
||||
return (value or "knowledge_rule")[:max_length].strip("_") or "knowledge_rule"
|
||||
|
||||
|
||||
def unique_rule_id(base_id: str, existing_ids: set[str]) -> str:
|
||||
candidate = base_id
|
||||
suffix = 2
|
||||
while candidate in existing_ids:
|
||||
candidate = f"{base_id}_{suffix}"
|
||||
suffix += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def pending_task_suggestions(path: Path = RULES_PATH) -> list[dict[str, Any]]:
|
||||
suggestions: list[dict[str, Any]] = []
|
||||
for rule in load_rules(path):
|
||||
if rule.status != "active":
|
||||
continue
|
||||
for task_id in rule.suggested_task_ids:
|
||||
if task_id in rule.task_ids:
|
||||
continue
|
||||
suggestions.append({"rule": rule, "task_id": task_id})
|
||||
return suggestions
|
||||
|
||||
|
||||
def approve_task_suggestion(rule_id: str, task_id: str, path: Path = RULES_PATH) -> KnowledgeRule:
|
||||
return update_task_suggestion(rule_id, task_id, approve=True, path=path)
|
||||
|
||||
|
||||
def reject_task_suggestion(rule_id: str, task_id: str, path: Path = RULES_PATH) -> KnowledgeRule:
|
||||
return update_task_suggestion(rule_id, task_id, approve=False, path=path)
|
||||
|
||||
|
||||
def update_task_suggestion(
|
||||
rule_id: str,
|
||||
task_id: str,
|
||||
approve: bool,
|
||||
path: Path = RULES_PATH,
|
||||
) -> KnowledgeRule:
|
||||
normalized_rule_id = rule_id.strip()
|
||||
normalized_task_id = slugify(task_id)
|
||||
rows = read_rule_rows(path)
|
||||
updated_rule: KnowledgeRule | None = None
|
||||
|
||||
for row in rows:
|
||||
if str(row.get("id") or "").strip() != normalized_rule_id:
|
||||
continue
|
||||
task_ids = normalize_task_id_list(row.get("task_ids") or [])
|
||||
suggested_task_ids = normalize_task_id_list(row.get("suggested_task_ids") or [])
|
||||
if normalized_task_id not in suggested_task_ids:
|
||||
raise ValueError(
|
||||
f"Regula {normalized_rule_id} nie ma oczekujacej propozycji dla zadania {normalized_task_id}."
|
||||
)
|
||||
if approve and normalized_task_id not in task_ids:
|
||||
task_ids.append(normalized_task_id)
|
||||
row["task_ids"] = task_ids
|
||||
row["suggested_task_ids"] = [item for item in suggested_task_ids if item != normalized_task_id]
|
||||
row["updated_at"] = now_iso()
|
||||
updated_rule = KnowledgeRule.from_dict(row)
|
||||
break
|
||||
|
||||
if not updated_rule:
|
||||
raise ValueError(f"Nie znaleziono reguly: {normalized_rule_id}")
|
||||
|
||||
write_rule_rows(rows, path)
|
||||
return updated_rule
|
||||
|
||||
|
||||
def assign_rule_to_task(rule_id: str, task_id: str, path: Path = RULES_PATH) -> KnowledgeRule:
|
||||
normalized_rule_id = rule_id.strip()
|
||||
normalized_task_id = slugify(task_id)
|
||||
rows = read_rule_rows(path)
|
||||
updated_rule: KnowledgeRule | None = None
|
||||
|
||||
for row in rows:
|
||||
if str(row.get("id") or "").strip() != normalized_rule_id:
|
||||
continue
|
||||
task_ids = normalize_task_id_list(row.get("task_ids") or [])
|
||||
suggested_task_ids = normalize_task_id_list(row.get("suggested_task_ids") or [])
|
||||
if normalized_task_id not in task_ids:
|
||||
task_ids.append(normalized_task_id)
|
||||
row["task_ids"] = task_ids
|
||||
row["suggested_task_ids"] = [item for item in suggested_task_ids if item != normalized_task_id]
|
||||
row["updated_at"] = now_iso()
|
||||
updated_rule = KnowledgeRule.from_dict(row)
|
||||
break
|
||||
|
||||
if not updated_rule:
|
||||
raise ValueError(f"Nie znaleziono reguly: {normalized_rule_id}")
|
||||
|
||||
write_rule_rows(rows, path)
|
||||
return updated_rule
|
||||
|
||||
|
||||
def delete_rule(rule_id: str, path: Path = RULES_PATH) -> KnowledgeRule:
|
||||
normalized_rule_id = rule_id.strip()
|
||||
rows = read_rule_rows(path)
|
||||
remaining_rows: list[dict[str, Any]] = []
|
||||
deleted_rule: KnowledgeRule | None = None
|
||||
|
||||
for row in rows:
|
||||
if str(row.get("id") or "").strip() == normalized_rule_id and deleted_rule is None:
|
||||
deleted_rule = KnowledgeRule.from_dict(row)
|
||||
continue
|
||||
remaining_rows.append(row)
|
||||
|
||||
if not deleted_rule:
|
||||
raise ValueError(f"Nie znaleziono reguly: {normalized_rule_id}")
|
||||
|
||||
write_rule_rows(remaining_rows, path)
|
||||
return deleted_rule
|
||||
|
||||
|
||||
def unassigned_rules(path: Path = RULES_PATH) -> list[KnowledgeRule]:
|
||||
return [
|
||||
rule
|
||||
for rule in load_rules(path)
|
||||
if rule.status == "active" and not rule.task_ids
|
||||
]
|
||||
|
||||
|
||||
def load_review_state(path: Path = REVIEW_STATE_PATH) -> dict[str, Any]:
|
||||
ensure_knowledge_store()
|
||||
if not path.exists():
|
||||
return {}
|
||||
raw = path.read_text(encoding="utf-8").strip()
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def save_review_state(state: dict[str, Any], path: Path = REVIEW_STATE_PATH) -> None:
|
||||
ensure_knowledge_store()
|
||||
path.write_text(json.dumps(state, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
|
||||
|
||||
|
||||
def reset_review_state(queue_name: str = "unassigned") -> None:
|
||||
state = load_review_state()
|
||||
state.pop(queue_name, None)
|
||||
save_review_state(state)
|
||||
|
||||
|
||||
def review_start_index(rules: list[KnowledgeRule], queue_name: str = "unassigned") -> int:
|
||||
state = load_review_state()
|
||||
queue_state = state.get(queue_name) or {}
|
||||
last_rule_id = queue_state.get("last_rule_id", "")
|
||||
if not last_rule_id:
|
||||
return 0
|
||||
for index, rule in enumerate(rules):
|
||||
if rule.id == last_rule_id:
|
||||
return min(index + 1, len(rules))
|
||||
last_queue_index = queue_state.get("last_queue_index")
|
||||
if isinstance(last_queue_index, int):
|
||||
return min(max(last_queue_index, 0), len(rules))
|
||||
last_sort_key = queue_state.get("last_sort_key")
|
||||
if isinstance(last_sort_key, list) and len(last_sort_key) == 2:
|
||||
normalized_last_sort_key = (str(last_sort_key[0]), str(last_sort_key[1]))
|
||||
for index, rule in enumerate(rules):
|
||||
if (rule.topic, rule.id) > normalized_last_sort_key:
|
||||
return index
|
||||
return len(rules)
|
||||
return 0
|
||||
|
||||
|
||||
def mark_review_progress(
|
||||
rule_id: str,
|
||||
queue_name: str = "unassigned",
|
||||
sort_key: list[str] | None = None,
|
||||
queue_index: int | None = None,
|
||||
) -> None:
|
||||
state = load_review_state()
|
||||
queue_state = {
|
||||
"last_rule_id": rule_id,
|
||||
"updated_at": now_iso(),
|
||||
}
|
||||
if sort_key:
|
||||
queue_state["last_sort_key"] = [str(sort_key[0]), str(sort_key[1])]
|
||||
if queue_index is not None:
|
||||
queue_state["last_queue_index"] = queue_index
|
||||
state[queue_name] = queue_state
|
||||
save_review_state(state)
|
||||
|
||||
|
||||
def rule_preview(rule: KnowledgeRule, max_length: int = 90) -> str:
|
||||
value = rule.recommendation or rule.text or rule.condition or rule.risk
|
||||
value = " ".join(value.split())
|
||||
if len(value) <= max_length:
|
||||
return value
|
||||
return value[: max_length - 3].rstrip() + "..."
|
||||
211
src/gads_v2/knowledge/vector_index.py
Normal file
211
src/gads_v2/knowledge/vector_index.py
Normal file
@@ -0,0 +1,211 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import lancedb
|
||||
import requests
|
||||
|
||||
from .store import LANCEDB_DIR, KnowledgeRule, ensure_knowledge_store, load_rules, now_iso
|
||||
|
||||
|
||||
TABLE_NAME = "rules"
|
||||
INDEX_META_PATH = LANCEDB_DIR / "index_meta.json"
|
||||
DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small"
|
||||
EMBEDDING_BATCH_SIZE = 64
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IndexBuildResult:
|
||||
table_path: Path
|
||||
model: str
|
||||
rules_count: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SemanticSearchResult:
|
||||
id: str
|
||||
topic: str
|
||||
task_ids: str
|
||||
source: str
|
||||
text: str
|
||||
distance: float
|
||||
|
||||
|
||||
def build_vector_index(model: str | None = None) -> IndexBuildResult:
|
||||
ensure_knowledge_store()
|
||||
storage_dir = lancedb_storage_dir()
|
||||
storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
selected_model = embedding_model(model)
|
||||
rules = [rule for rule in load_rules() if rule.status == "active"]
|
||||
if not rules:
|
||||
raise RuntimeError("Brak aktywnych regul do zaindeksowania.")
|
||||
|
||||
texts = [rule_search_text(rule) for rule in rules]
|
||||
vectors = embed_texts(texts, selected_model)
|
||||
rows = [rule_to_lancedb_row(rule, text, vector) for rule, text, vector in zip(rules, texts, vectors)]
|
||||
|
||||
db = lancedb.connect(str(storage_dir))
|
||||
db.create_table(TABLE_NAME, data=rows, mode="overwrite")
|
||||
meta = {
|
||||
"created_at": now_iso(),
|
||||
"model": selected_model,
|
||||
"rules_count": len(rows),
|
||||
"table": TABLE_NAME,
|
||||
"storage_dir": str(storage_dir),
|
||||
"table_path": str(storage_dir / f"{TABLE_NAME}.lance"),
|
||||
"rule_ids": [rule.id for rule in rules],
|
||||
}
|
||||
INDEX_META_PATH.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return IndexBuildResult(
|
||||
table_path=storage_dir / f"{TABLE_NAME}.lance",
|
||||
model=selected_model,
|
||||
rules_count=len(rows),
|
||||
)
|
||||
|
||||
|
||||
def semantic_search(query: str, limit: int = 10, model: str | None = None) -> list[SemanticSearchResult]:
|
||||
ensure_knowledge_store()
|
||||
query = query.strip()
|
||||
if not query:
|
||||
return []
|
||||
selected_model = embedding_model(model)
|
||||
storage_dir = lancedb_storage_dir()
|
||||
db = lancedb.connect(str(storage_dir))
|
||||
if TABLE_NAME not in table_names(db):
|
||||
raise RuntimeError("Brak indeksu LanceDB. Uruchom: python gads.py wiedza indeksuj")
|
||||
|
||||
query_vector = embed_texts([query], selected_model)[0]
|
||||
table = db.open_table(TABLE_NAME)
|
||||
rows = table.search(query_vector).limit(max(limit, 0)).to_list()
|
||||
return [
|
||||
SemanticSearchResult(
|
||||
id=str(row.get("id") or ""),
|
||||
topic=str(row.get("topic") or ""),
|
||||
task_ids=str(row.get("task_ids") or ""),
|
||||
source=str(row.get("source") or ""),
|
||||
text=str(row.get("text") or ""),
|
||||
distance=float(row.get("_distance") or 0.0),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def vector_index_summary() -> dict[str, Any]:
|
||||
ensure_knowledge_store()
|
||||
if not INDEX_META_PATH.exists():
|
||||
return {}
|
||||
meta = json.loads(INDEX_META_PATH.read_text(encoding="utf-8"))
|
||||
table_path = Path(str(meta.get("table_path") or ""))
|
||||
current_table_path = lancedb_storage_dir() / f"{TABLE_NAME}.lance"
|
||||
meta["available"] = table_path.exists()
|
||||
meta["current_storage_dir"] = str(lancedb_storage_dir())
|
||||
meta["current_table_path"] = str(current_table_path)
|
||||
meta["current_available"] = current_table_path.exists()
|
||||
return meta
|
||||
|
||||
|
||||
def lancedb_storage_dir() -> Path:
|
||||
configured = os.environ.get("KNOWLEDGE_LANCEDB_DIR")
|
||||
if configured:
|
||||
return Path(configured)
|
||||
local_app_data = os.environ.get("LOCALAPPDATA")
|
||||
if local_app_data:
|
||||
return Path(local_app_data) / "google-ads-ver2-knowledge-lancedb"
|
||||
return LANCEDB_DIR
|
||||
|
||||
|
||||
def embedding_model(model: str | None = None) -> str:
|
||||
return (
|
||||
model
|
||||
or os.environ.get("KNOWLEDGE_EMBEDDING_MODEL")
|
||||
or DEFAULT_EMBEDDING_MODEL
|
||||
)
|
||||
|
||||
|
||||
def embed_texts(texts: list[str], model: str) -> list[list[float]]:
|
||||
api_key = os.environ.get("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
raise RuntimeError("Brak OPENAI_API_KEY. Dodaj klucz do .env.")
|
||||
|
||||
vectors: list[list[float]] = []
|
||||
for start in range(0, len(texts), EMBEDDING_BATCH_SIZE):
|
||||
batch = texts[start : start + EMBEDDING_BATCH_SIZE]
|
||||
vectors.extend(call_embeddings_api(api_key, model, batch))
|
||||
if len(vectors) != len(texts):
|
||||
raise RuntimeError("Liczba embeddingow nie zgadza sie z liczba tekstow.")
|
||||
return vectors
|
||||
|
||||
|
||||
def call_embeddings_api(api_key: str, model: str, texts: list[str]) -> list[list[float]]:
|
||||
base_url = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1").rstrip("/")
|
||||
response = requests.post(
|
||||
f"{base_url}/embeddings",
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": model,
|
||||
"input": texts,
|
||||
"encoding_format": "float",
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise RuntimeError(f"OpenAI embeddings API zwrocilo blad {response.status_code}: {response.text}")
|
||||
payload = response.json()
|
||||
data = payload.get("data") or []
|
||||
data = sorted(data, key=lambda item: int(item.get("index", 0)))
|
||||
vectors = [item.get("embedding") for item in data]
|
||||
if not all(isinstance(vector, list) and vector for vector in vectors):
|
||||
raise RuntimeError("OpenAI embeddings API nie zwrocilo poprawnych wektorow.")
|
||||
return vectors
|
||||
|
||||
|
||||
def rule_to_lancedb_row(rule: KnowledgeRule, search_text: str, vector: list[float]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": rule.id,
|
||||
"status": rule.status,
|
||||
"topic": rule.topic,
|
||||
"task_ids": ", ".join(rule.task_ids),
|
||||
"suggested_task_ids": ", ".join(rule.suggested_task_ids),
|
||||
"rule_type": rule.rule_type,
|
||||
"condition": rule.condition,
|
||||
"recommendation": rule.recommendation,
|
||||
"risk": rule.risk,
|
||||
"source": rule.source,
|
||||
"source_file": rule.source_file,
|
||||
"confidence": rule.confidence,
|
||||
"created_at": rule.created_at,
|
||||
"updated_at": rule.updated_at,
|
||||
"duplicate_of": rule.duplicate_of,
|
||||
"supersedes": ", ".join(rule.supersedes),
|
||||
"text": rule.text,
|
||||
"search_text": search_text,
|
||||
"vector": vector,
|
||||
}
|
||||
|
||||
|
||||
def rule_search_text(rule: KnowledgeRule) -> str:
|
||||
values = [
|
||||
f"ID: {rule.id}",
|
||||
f"Temat: {rule.topic}",
|
||||
f"Typ: {rule.rule_type}",
|
||||
f"Warunek: {rule.condition}",
|
||||
f"Rekomendacja: {rule.recommendation}",
|
||||
f"Ryzyko: {rule.risk}",
|
||||
f"Tekst: {rule.text}",
|
||||
f"Zadania: {', '.join(rule.task_ids)}",
|
||||
f"Zrodlo: {rule.source}",
|
||||
]
|
||||
return "\n".join(value for value in values if value.strip())
|
||||
|
||||
|
||||
def table_names(db: Any) -> list[str]:
|
||||
if hasattr(db, "table_names"):
|
||||
return list(db.table_names())
|
||||
return list(db.list_tables())
|
||||
197
src/gads_v2/reminders.py
Normal file
197
src/gads_v2/reminders.py
Normal file
@@ -0,0 +1,197 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from .config import ROOT, client_dir
|
||||
from .history import now_local
|
||||
from .table import print_table
|
||||
|
||||
|
||||
GLOBAL_REMINDERS_PATH = ROOT / "reminders.jsonl"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Reminder:
|
||||
id: str
|
||||
created_at: str
|
||||
due_date: str
|
||||
text: str
|
||||
client: str
|
||||
status: str
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "Reminder":
|
||||
return cls(
|
||||
id=str(data.get("id", "")),
|
||||
created_at=str(data.get("created_at", "")),
|
||||
due_date=str(data.get("due_date", "")),
|
||||
text=str(data.get("text", "")),
|
||||
client=str(data.get("client", "")),
|
||||
status=str(data.get("status", "active")),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"created_at": self.created_at,
|
||||
"due_date": self.due_date,
|
||||
"text": self.text,
|
||||
"client": self.client,
|
||||
"status": self.status,
|
||||
}
|
||||
|
||||
|
||||
def normalize_text(value: str) -> str:
|
||||
normalized = unicodedata.normalize("NFKD", value)
|
||||
without_marks = "".join(ch for ch in normalized if not unicodedata.combining(ch))
|
||||
return without_marks.lower()
|
||||
|
||||
|
||||
def reminder_path(domain: str | None = None) -> Path:
|
||||
if domain:
|
||||
base = client_dir(domain)
|
||||
return base / "reminders.jsonl"
|
||||
return GLOBAL_REMINDERS_PATH
|
||||
|
||||
|
||||
def load_reminders(domain: str | None = None, include_global: bool = False) -> list[Reminder]:
|
||||
paths = []
|
||||
if include_global:
|
||||
paths.append(GLOBAL_REMINDERS_PATH)
|
||||
paths.append(reminder_path(domain))
|
||||
reminders: list[Reminder] = []
|
||||
for path in paths:
|
||||
if not path.exists():
|
||||
continue
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
reminders.append(Reminder.from_dict(json.loads(line)))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
reminders.sort(key=lambda item: (item.due_date, item.created_at, item.id))
|
||||
return reminders
|
||||
|
||||
|
||||
def parse_reminder_text(raw_text: str) -> tuple[date, str]:
|
||||
text = " ".join(raw_text.split()).strip()
|
||||
if not text:
|
||||
raise ValueError("Brak tresci przypomnienia.")
|
||||
|
||||
today = now_local().date()
|
||||
normalized = normalize_text(text)
|
||||
due = today
|
||||
matched_prefix = ""
|
||||
|
||||
patterns = [
|
||||
(r"^za\s+(\d+)\s+dni(?:\s+|$)", "days"),
|
||||
(r"^za\s+(\d+)\s+dzien(?:\s+|$)", "days"),
|
||||
(r"^za\s+(\d+)\s+tygodnie(?:\s+|$)", "weeks"),
|
||||
(r"^za\s+(\d+)\s+tygodni(?:\s+|$)", "weeks"),
|
||||
(r"^za\s+(\d+)\s+tydzien(?:\s+|$)", "weeks"),
|
||||
(r"^za\s+(\d+)\s+miesiace(?:\s+|$)", "months"),
|
||||
(r"^za\s+(\d+)\s+miesiecy(?:\s+|$)", "months"),
|
||||
(r"^za\s+(\d+)\s+miesiac(?:\s+|$)", "months"),
|
||||
]
|
||||
for pattern, unit in patterns:
|
||||
match = re.search(pattern, normalized)
|
||||
if not match:
|
||||
continue
|
||||
amount = int(match.group(1))
|
||||
if unit == "days":
|
||||
due = today + timedelta(days=amount)
|
||||
elif unit == "weeks":
|
||||
due = today + timedelta(weeks=amount)
|
||||
else:
|
||||
due = today + timedelta(days=amount * 30)
|
||||
matched_prefix = text[: match.end()].strip()
|
||||
break
|
||||
|
||||
if not matched_prefix:
|
||||
if normalized.startswith("jutro"):
|
||||
due = today + timedelta(days=1)
|
||||
matched_prefix = text[:5].strip()
|
||||
elif normalized.startswith("dzisiaj"):
|
||||
due = today
|
||||
matched_prefix = text[:7].strip()
|
||||
|
||||
note = text[len(matched_prefix) :].strip() if matched_prefix else text
|
||||
note_normalized = normalize_text(note)
|
||||
for prefix in [
|
||||
"przypomnij mi o ",
|
||||
"przypomnij o ",
|
||||
"przypomnienie o ",
|
||||
"o ",
|
||||
]:
|
||||
if note_normalized.startswith(prefix):
|
||||
note = note[len(prefix) :].strip()
|
||||
break
|
||||
if not note:
|
||||
note = text
|
||||
return due, note
|
||||
|
||||
|
||||
def add_reminder(raw_text: str, domain: str | None = None) -> Reminder:
|
||||
due, note = parse_reminder_text(raw_text)
|
||||
ts = now_local()
|
||||
reminder = Reminder(
|
||||
id=f"rem_{ts.strftime('%Y%m%d%H%M%S')}",
|
||||
created_at=ts.isoformat(timespec="seconds"),
|
||||
due_date=due.isoformat(),
|
||||
text=note,
|
||||
client=domain or "",
|
||||
status="active",
|
||||
)
|
||||
path = reminder_path(domain)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(reminder.to_dict(), ensure_ascii=False) + "\n")
|
||||
return reminder
|
||||
|
||||
|
||||
def active_reminders_for_client(domain: str) -> list[Reminder]:
|
||||
return [item for item in load_reminders(domain, include_global=True) if item.status == "active"]
|
||||
|
||||
|
||||
def reminder_status_label(reminder: Reminder) -> str:
|
||||
today = now_local().date()
|
||||
try:
|
||||
due = date.fromisoformat(reminder.due_date)
|
||||
except ValueError:
|
||||
return "brak daty"
|
||||
days = (due - today).days
|
||||
if days < 0:
|
||||
return f"po terminie {abs(days)} dni"
|
||||
if days == 0:
|
||||
return "dzisiaj"
|
||||
if days == 1:
|
||||
return "jutro"
|
||||
return f"za {days} dni"
|
||||
|
||||
|
||||
def print_client_reminders(domain: str, limit: int = 12) -> None:
|
||||
reminders = active_reminders_for_client(domain)
|
||||
if not reminders:
|
||||
return
|
||||
print("\nPrzypomnienia")
|
||||
shown = reminders[:limit]
|
||||
print_table(
|
||||
["Termin", "Status", "Zakres", "Notatka"],
|
||||
[
|
||||
[
|
||||
reminder.due_date,
|
||||
reminder_status_label(reminder),
|
||||
reminder.client or "globalne",
|
||||
reminder.text,
|
||||
]
|
||||
for reminder in shown
|
||||
],
|
||||
)
|
||||
if len(reminders) > len(shown):
|
||||
print(f"... oraz {len(reminders) - len(shown)} kolejnych przypomnien")
|
||||
25
src/gads_v2/table.py
Normal file
25
src/gads_v2/table.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def print_table(headers: list[str], rows: list[list[str]]) -> None:
|
||||
widths = [len(header) for header in headers]
|
||||
for row in rows:
|
||||
for index, value in enumerate(row):
|
||||
widths[index] = max(widths[index], len(str(value)))
|
||||
|
||||
def border(left: str, middle: str, right: str) -> str:
|
||||
return left + middle.join("─" * (width + 2) for width in widths) + right
|
||||
|
||||
def line(values: list[str]) -> str:
|
||||
cells = [f" {str(value):<{widths[index]}} " for index, value in enumerate(values)]
|
||||
return "│" + "│".join(cells) + "│"
|
||||
|
||||
print(border("┌", "┬", "┐"))
|
||||
print(line(headers))
|
||||
print(border("├", "┼", "┤"))
|
||||
for index, row in enumerate(rows):
|
||||
print(line([str(value) for value in row]))
|
||||
if index != len(rows) - 1:
|
||||
print(border("├", "┼", "┤"))
|
||||
print(border("└", "┴", "┘"))
|
||||
|
||||
126
src/gads_v2/task_catalog.py
Normal file
126
src/gads_v2/task_catalog.py
Normal file
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .config import ROOT
|
||||
from .table import print_table
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Task:
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
group_id: str
|
||||
group_name: str
|
||||
number: int
|
||||
group_number: int
|
||||
index_in_group: int
|
||||
|
||||
@property
|
||||
def selection(self) -> str:
|
||||
return f"{self.group_number}.{self.index_in_group}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskGroup:
|
||||
id: str
|
||||
name: str
|
||||
number: int
|
||||
|
||||
|
||||
def load_task_config() -> dict:
|
||||
path = ROOT / "config" / "tasks.toml"
|
||||
return tomllib.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def load_groups() -> list[TaskGroup]:
|
||||
data = load_task_config()
|
||||
groups = []
|
||||
for index, group in enumerate(data.get("groups", []), 1):
|
||||
groups.append(TaskGroup(id=group["id"], name=group["name"], number=index))
|
||||
return groups
|
||||
|
||||
|
||||
def load_tasks() -> list[Task]:
|
||||
data = load_task_config()
|
||||
tasks: list[Task] = []
|
||||
number = 1
|
||||
for group_number, group in enumerate(data.get("groups", []), 1):
|
||||
for index_in_group, row in enumerate(group.get("tasks", []), 1):
|
||||
tasks.append(
|
||||
Task(
|
||||
id=row["id"],
|
||||
name=row["name"],
|
||||
description=row.get("description", ""),
|
||||
group_id=group["id"],
|
||||
group_name=group["name"],
|
||||
number=number,
|
||||
group_number=group_number,
|
||||
index_in_group=index_in_group,
|
||||
)
|
||||
)
|
||||
number += 1
|
||||
return tasks
|
||||
|
||||
|
||||
def task_by_number(tasks: list[Task], number: int) -> Task | None:
|
||||
for task in tasks:
|
||||
if task.number == number:
|
||||
return task
|
||||
return None
|
||||
|
||||
|
||||
def task_by_selection(tasks: list[Task], selection: str) -> Task | None:
|
||||
normalized = selection.strip().lower()
|
||||
for task in tasks:
|
||||
if task.selection.lower() == normalized:
|
||||
return task
|
||||
return None
|
||||
|
||||
|
||||
def tasks_by_selection_group(tasks: list[Task], groups: list[TaskGroup], selection: str) -> list[Task]:
|
||||
normalized = selection.strip().lower()
|
||||
if not normalized.endswith(".0"):
|
||||
return []
|
||||
try:
|
||||
group_number = int(normalized.split(".", 1)[0])
|
||||
except ValueError:
|
||||
return []
|
||||
return tasks_by_group_number(tasks, groups, group_number)
|
||||
|
||||
|
||||
def tasks_by_group_number(tasks: list[Task], groups: list[TaskGroup], number: int) -> list[Task]:
|
||||
group = next((item for item in groups if item.number == number), None)
|
||||
if not group:
|
||||
return []
|
||||
return [task for task in tasks if task.group_id == group.id]
|
||||
|
||||
|
||||
def print_task_list(tasks: list[Task]) -> None:
|
||||
groups = load_groups()
|
||||
for group in groups:
|
||||
group_tasks = [task for task in tasks if task.group_id == group.id]
|
||||
if not group_tasks:
|
||||
continue
|
||||
print()
|
||||
print("=" * 72)
|
||||
print(f"GRUPA {group.number}: {group.name.upper()}")
|
||||
print("=" * 72)
|
||||
print_table(
|
||||
["Nr", "Zadanie", "Opis"],
|
||||
[[item.selection, item.name, item.description] for item in group_tasks],
|
||||
)
|
||||
|
||||
print()
|
||||
print("Opcje zbiorcze")
|
||||
group_rows = [
|
||||
[f"{group.number}.0", f"Wszystkie zadania z grupy: {group.name}"]
|
||||
for group in groups
|
||||
if any(task.group_id == group.id for task in tasks)
|
||||
]
|
||||
print_table(
|
||||
["Nr", "Zakres"],
|
||||
group_rows + [["ALL", "Wszystkie zadania ze wszystkich grup"]],
|
||||
)
|
||||
1
src/gads_v2/tasks/__init__.py
Normal file
1
src/gads_v2/tasks/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
949
src/gads_v2/tasks/account_anomaly_check.py
Normal file
949
src/gads_v2/tasks/account_anomaly_check.py
Normal file
@@ -0,0 +1,949 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..config import ClientConfig, client_dir
|
||||
from ..google_ads import get_google_ads_client, run_query
|
||||
from ..history import append_change_markdown, append_history, now_local
|
||||
from ..knowledge.store import rules_for_task
|
||||
from ..table import print_table
|
||||
|
||||
|
||||
TASK_ID = "check_account_anomalies"
|
||||
TASK_NAME = "Sprawdzenie anomalii konta"
|
||||
|
||||
|
||||
SCOPE = [
|
||||
{
|
||||
"area": "Okres porownania",
|
||||
"check": "Porownaj ostatnie 7 zakonczonych dni z poprzednimi 7 dniami, bez uzywania niepelnych danych z dzisiaj.",
|
||||
},
|
||||
{
|
||||
"area": "Metryki kampanii",
|
||||
"check": "Sprawdz koszt, klikniecia, wyswietlenia, konwersje, wartosc konwersji, CTR, CPC i ROAS na poziomie aktywnych kampanii.",
|
||||
},
|
||||
{
|
||||
"area": "Nagly spadek",
|
||||
"check": "Oznacz kampanie, w ktorych spadl ruch, koszt, konwersje, wartosc konwersji albo ROAS.",
|
||||
},
|
||||
{
|
||||
"area": "Nagly wzrost",
|
||||
"check": "Oznacz kampanie, w ktorych koszt, CPC albo ruch wzrosly szybciej niz wyniki.",
|
||||
},
|
||||
{
|
||||
"area": "Priorytet reakcji",
|
||||
"check": "Nadaj anomaliom poziom waznosci, aby agent mogl szybko zdecydowac, ktore kampanie sprawdzic jako pierwsze.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
OUT_OF_SCOPE = [
|
||||
"zmiany budzetow i ocena pacingu budzetu",
|
||||
"zmiany strategii stawek oraz celow Docelowy ROAS/Docelowy CPA",
|
||||
"analiza zapytan uzytkownikow oraz wykluczen",
|
||||
"analiza reklam RSA, zasobow i kreacji",
|
||||
"wdrazanie zmian na koncie Google Ads",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AccountAnomalyPlan:
|
||||
currency_code: str
|
||||
recent_period: dict
|
||||
previous_period: dict
|
||||
account_summary: list[dict]
|
||||
campaigns: list[dict]
|
||||
anomalies: list[dict]
|
||||
scope: list[dict]
|
||||
out_of_scope: list[str]
|
||||
knowledge_rules: list[dict]
|
||||
warnings: list[str]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"task": TASK_ID,
|
||||
"task_name": TASK_NAME,
|
||||
"currency_code": self.currency_code,
|
||||
"recent_period": self.recent_period,
|
||||
"previous_period": self.previous_period,
|
||||
"account_summary": self.account_summary,
|
||||
"campaigns": self.campaigns,
|
||||
"anomalies": self.anomalies,
|
||||
"scope": self.scope,
|
||||
"out_of_scope": self.out_of_scope,
|
||||
"knowledge_rules": self.knowledge_rules,
|
||||
"warnings": self.warnings,
|
||||
"changes": [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "AccountAnomalyPlan":
|
||||
return cls(
|
||||
currency_code=data.get("currency_code", ""),
|
||||
recent_period=data.get("recent_period", {}),
|
||||
previous_period=data.get("previous_period", {}),
|
||||
account_summary=data.get("account_summary", []),
|
||||
campaigns=data.get("campaigns", []),
|
||||
anomalies=data.get("anomalies", []),
|
||||
scope=data.get("scope", []),
|
||||
out_of_scope=data.get("out_of_scope", []),
|
||||
knowledge_rules=data.get("knowledge_rules", []),
|
||||
warnings=data.get("warnings", []),
|
||||
)
|
||||
|
||||
|
||||
def enum_name(value: Any) -> str:
|
||||
name = getattr(value, "name", None)
|
||||
if name:
|
||||
return name
|
||||
return str(value)
|
||||
|
||||
|
||||
def md_cell(value: Any) -> str:
|
||||
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
|
||||
|
||||
|
||||
def micros_to_amount(value: int | float) -> float:
|
||||
return round(float(value or 0) / 1_000_000, 2)
|
||||
|
||||
|
||||
def format_money_micros(value: int | float, currency_code: str) -> str:
|
||||
suffix = f" {currency_code}" if currency_code else ""
|
||||
return f"{micros_to_amount(value):.2f}{suffix}"
|
||||
|
||||
|
||||
def format_money_amount(value: int | float, currency_code: str) -> str:
|
||||
suffix = f" {currency_code}" if currency_code else ""
|
||||
return f"{float(value or 0):.2f}{suffix}"
|
||||
|
||||
|
||||
def format_number(value: int | float, decimals: int = 0) -> str:
|
||||
if decimals <= 0:
|
||||
return str(int(round(float(value or 0))))
|
||||
return f"{float(value or 0):.{decimals}f}"
|
||||
|
||||
|
||||
def format_percent_value(value: int | float) -> str:
|
||||
return f"{float(value or 0):.1f}%"
|
||||
|
||||
|
||||
def format_change(value: float | None) -> str:
|
||||
if value is None:
|
||||
return "nowe dane"
|
||||
return f"{value:+.1f}%"
|
||||
|
||||
|
||||
def pct_change(previous: float, recent: float) -> float | None:
|
||||
if previous == 0:
|
||||
return None
|
||||
return round(((recent - previous) / previous) * 100, 1)
|
||||
|
||||
|
||||
def percent(numerator: int | float, denominator: int | float) -> float:
|
||||
if not denominator:
|
||||
return 0.0
|
||||
return round((float(numerator) / float(denominator)) * 100, 2)
|
||||
|
||||
|
||||
def empty_metrics() -> dict:
|
||||
return {
|
||||
"cost_micros": 0,
|
||||
"clicks": 0,
|
||||
"impressions": 0,
|
||||
"conversions": 0.0,
|
||||
"conversion_value": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def add_metrics(target: dict, metrics: Any) -> None:
|
||||
target["cost_micros"] += int(metrics.cost_micros or 0)
|
||||
target["clicks"] += int(metrics.clicks or 0)
|
||||
target["impressions"] += int(metrics.impressions or 0)
|
||||
target["conversions"] += float(metrics.conversions or 0)
|
||||
target["conversion_value"] += float(metrics.conversions_value or 0)
|
||||
|
||||
|
||||
def derived_metrics(metrics: dict) -> dict:
|
||||
cost_amount = micros_to_amount(metrics["cost_micros"])
|
||||
clicks = metrics["clicks"]
|
||||
impressions = metrics["impressions"]
|
||||
conversions = metrics["conversions"]
|
||||
conversion_value = metrics["conversion_value"]
|
||||
return {
|
||||
**metrics,
|
||||
"cost_amount": cost_amount,
|
||||
"ctr_percent": percent(clicks, impressions),
|
||||
"avg_cpc_micros": int(metrics["cost_micros"] / clicks) if clicks else 0,
|
||||
"conversion_rate_percent": percent(conversions, clicks),
|
||||
"roas": round(conversion_value / cost_amount, 2) if cost_amount else 0.0,
|
||||
}
|
||||
|
||||
|
||||
def period_labels() -> tuple[dict, dict]:
|
||||
today = now_local().date()
|
||||
recent_end = today - timedelta(days=1)
|
||||
recent_start = recent_end - timedelta(days=6)
|
||||
previous_end = recent_start - timedelta(days=1)
|
||||
previous_start = previous_end - timedelta(days=6)
|
||||
return (
|
||||
{
|
||||
"label": "ostatnie 7 zakonczonych dni",
|
||||
"start": recent_start.isoformat(),
|
||||
"end": recent_end.isoformat(),
|
||||
},
|
||||
{
|
||||
"label": "poprzednie 7 dni",
|
||||
"start": previous_start.isoformat(),
|
||||
"end": previous_end.isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def fetch_currency_code(google_client, customer_id: str) -> str:
|
||||
rows = run_query(
|
||||
google_client,
|
||||
customer_id,
|
||||
"""
|
||||
SELECT
|
||||
customer.currency_code
|
||||
FROM customer
|
||||
""",
|
||||
)
|
||||
if not rows:
|
||||
return ""
|
||||
return str(rows[0].customer.currency_code or "")
|
||||
|
||||
|
||||
def fetch_campaign_period_metrics(client_config: ClientConfig) -> tuple[str, dict, dict, list[dict]]:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
customer_id = client_config.safe_customer_id
|
||||
currency_code = fetch_currency_code(google_client, customer_id)
|
||||
recent_period, previous_period = period_labels()
|
||||
rows = run_query(
|
||||
google_client,
|
||||
customer_id,
|
||||
f"""
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign.name,
|
||||
campaign.status,
|
||||
campaign.advertising_channel_type,
|
||||
segments.date,
|
||||
metrics.cost_micros,
|
||||
metrics.clicks,
|
||||
metrics.impressions,
|
||||
metrics.conversions,
|
||||
metrics.conversions_value
|
||||
FROM campaign
|
||||
WHERE campaign.status = 'ENABLED'
|
||||
AND segments.date BETWEEN '{previous_period["start"]}' AND '{recent_period["end"]}'
|
||||
""",
|
||||
)
|
||||
|
||||
campaigns: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
campaign = row.campaign
|
||||
campaign_id = str(campaign.id)
|
||||
record = campaigns.setdefault(
|
||||
campaign_id,
|
||||
{
|
||||
"campaign_id": campaign_id,
|
||||
"campaign_name": campaign.name,
|
||||
"status": enum_name(campaign.status),
|
||||
"channel_type": enum_name(campaign.advertising_channel_type),
|
||||
"recent": empty_metrics(),
|
||||
"previous": empty_metrics(),
|
||||
},
|
||||
)
|
||||
row_date = str(row.segments.date)
|
||||
if recent_period["start"] <= row_date <= recent_period["end"]:
|
||||
add_metrics(record["recent"], row.metrics)
|
||||
elif previous_period["start"] <= row_date <= previous_period["end"]:
|
||||
add_metrics(record["previous"], row.metrics)
|
||||
|
||||
campaign_rows = []
|
||||
for record in campaigns.values():
|
||||
recent = derived_metrics(record["recent"])
|
||||
previous = derived_metrics(record["previous"])
|
||||
campaign_rows.append(
|
||||
{
|
||||
"campaign_id": record["campaign_id"],
|
||||
"campaign_name": record["campaign_name"],
|
||||
"status": record["status"],
|
||||
"channel_type": record["channel_type"],
|
||||
"recent": recent,
|
||||
"previous": previous,
|
||||
"changes": metric_changes(previous, recent),
|
||||
}
|
||||
)
|
||||
campaign_rows.sort(key=lambda row: (-row["recent"]["cost_micros"], row["campaign_name"]))
|
||||
return currency_code, recent_period, previous_period, campaign_rows
|
||||
|
||||
|
||||
def metric_changes(previous: dict, recent: dict) -> dict:
|
||||
return {
|
||||
"cost": pct_change(previous["cost_micros"], recent["cost_micros"]),
|
||||
"clicks": pct_change(previous["clicks"], recent["clicks"]),
|
||||
"impressions": pct_change(previous["impressions"], recent["impressions"]),
|
||||
"conversions": pct_change(previous["conversions"], recent["conversions"]),
|
||||
"conversion_value": pct_change(previous["conversion_value"], recent["conversion_value"]),
|
||||
"ctr": pct_change(previous["ctr_percent"], recent["ctr_percent"]),
|
||||
"avg_cpc": pct_change(previous["avg_cpc_micros"], recent["avg_cpc_micros"]),
|
||||
"roas": pct_change(previous["roas"], recent["roas"]),
|
||||
}
|
||||
|
||||
|
||||
def aggregate_account_summary(campaigns: list[dict], currency_code: str) -> list[dict]:
|
||||
recent = empty_metrics()
|
||||
previous = empty_metrics()
|
||||
for campaign in campaigns:
|
||||
for key in recent:
|
||||
recent[key] += campaign["recent"][key]
|
||||
previous[key] += campaign["previous"][key]
|
||||
recent = derived_metrics(recent)
|
||||
previous = derived_metrics(previous)
|
||||
return [
|
||||
summary_row("Koszt", previous["cost_micros"], recent["cost_micros"], "money_micros", currency_code),
|
||||
summary_row("Klikniecia", previous["clicks"], recent["clicks"], "number", currency_code),
|
||||
summary_row("Wyswietlenia", previous["impressions"], recent["impressions"], "number", currency_code),
|
||||
summary_row("Konwersje", previous["conversions"], recent["conversions"], "decimal", currency_code),
|
||||
summary_row(
|
||||
"Wartosc konwersji",
|
||||
previous["conversion_value"],
|
||||
recent["conversion_value"],
|
||||
"money_amount",
|
||||
currency_code,
|
||||
),
|
||||
summary_row("CTR", previous["ctr_percent"], recent["ctr_percent"], "percent", currency_code),
|
||||
summary_row("Sredni CPC", previous["avg_cpc_micros"], recent["avg_cpc_micros"], "money_micros", currency_code),
|
||||
summary_row("ROAS", previous["roas"], recent["roas"], "decimal", currency_code),
|
||||
]
|
||||
|
||||
|
||||
def summary_row(label: str, previous: float, recent: float, value_type: str, currency_code: str) -> dict:
|
||||
return {
|
||||
"metric": label,
|
||||
"previous": display_metric(previous, value_type, currency_code),
|
||||
"recent": display_metric(recent, value_type, currency_code),
|
||||
"change_percent": format_change(pct_change(previous, recent)),
|
||||
}
|
||||
|
||||
|
||||
def display_metric(value: float, value_type: str, currency_code: str) -> str:
|
||||
if value_type == "money_micros":
|
||||
return format_money_micros(value, currency_code)
|
||||
if value_type == "money_amount":
|
||||
return format_money_amount(value, currency_code)
|
||||
if value_type == "percent":
|
||||
return format_percent_value(value)
|
||||
if value_type == "decimal":
|
||||
return format_number(value, 2)
|
||||
return format_number(value)
|
||||
|
||||
|
||||
def anomaly(
|
||||
campaign: dict,
|
||||
metric: str,
|
||||
severity: str,
|
||||
previous_value: str,
|
||||
recent_value: str,
|
||||
change_percent: str,
|
||||
reason: str,
|
||||
recommendation: str,
|
||||
) -> dict:
|
||||
return {
|
||||
"campaign_id": campaign["campaign_id"],
|
||||
"campaign_name": campaign["campaign_name"],
|
||||
"channel_type": campaign["channel_type"],
|
||||
"status": campaign["status"],
|
||||
"metric": metric,
|
||||
"severity": severity,
|
||||
"previous_value": previous_value,
|
||||
"recent_value": recent_value,
|
||||
"change_percent": change_percent,
|
||||
"reason": reason,
|
||||
"recommendation": recommendation,
|
||||
}
|
||||
|
||||
|
||||
def build_campaign_anomalies(campaign: dict, currency_code: str) -> list[dict]:
|
||||
recent = campaign["recent"]
|
||||
previous = campaign["previous"]
|
||||
changes = campaign["changes"]
|
||||
found = []
|
||||
|
||||
if previous["cost_micros"] >= 50_000_000 and recent["cost_micros"] == 0:
|
||||
found.append(
|
||||
anomaly(
|
||||
campaign,
|
||||
"koszt",
|
||||
"wysokie",
|
||||
format_money_micros(previous["cost_micros"], currency_code),
|
||||
format_money_micros(recent["cost_micros"], currency_code),
|
||||
"-100.0%",
|
||||
"kampania miala koszt w poprzednim okresie i nie ma kosztu w ostatnich 7 dniach",
|
||||
"sprawdz status kampanii, budzet, odrzucenia, pomiar i ostatnie zmiany",
|
||||
)
|
||||
)
|
||||
elif previous["cost_micros"] == 0 and recent["cost_micros"] >= 50_000_000:
|
||||
found.append(
|
||||
anomaly(
|
||||
campaign,
|
||||
"koszt",
|
||||
"niskie",
|
||||
format_money_micros(previous["cost_micros"], currency_code),
|
||||
format_money_micros(recent["cost_micros"], currency_code),
|
||||
"nowe dane",
|
||||
"kampania zaczela wydawac srodki po okresie bez kosztu",
|
||||
"sprawdz, czy start kampanii byl planowany i czy wyniki sa akceptowalne",
|
||||
)
|
||||
)
|
||||
|
||||
if changes["cost"] is not None and previous["cost_micros"] >= 50_000_000:
|
||||
if changes["cost"] >= 60:
|
||||
found.append(
|
||||
anomaly(
|
||||
campaign,
|
||||
"koszt",
|
||||
"srednie",
|
||||
format_money_micros(previous["cost_micros"], currency_code),
|
||||
format_money_micros(recent["cost_micros"], currency_code),
|
||||
format_change(changes["cost"]),
|
||||
"koszt wzrosl szybciej niz typowy tygodniowy prog alarmowy",
|
||||
"sprawdz budzet, strategie stawek i zmiany ruchu w osobnych zadaniach",
|
||||
)
|
||||
)
|
||||
elif changes["cost"] <= -50:
|
||||
found.append(
|
||||
anomaly(
|
||||
campaign,
|
||||
"koszt",
|
||||
"srednie",
|
||||
format_money_micros(previous["cost_micros"], currency_code),
|
||||
format_money_micros(recent["cost_micros"], currency_code),
|
||||
format_change(changes["cost"]),
|
||||
"koszt spadl o co najmniej polowe tydzien do tygodnia",
|
||||
"sprawdz, czy spadek wynika z decyzji, limitow, odrzucen albo utraty ruchu",
|
||||
)
|
||||
)
|
||||
|
||||
if changes["clicks"] is not None and previous["clicks"] >= 20:
|
||||
if changes["clicks"] <= -45:
|
||||
found.append(
|
||||
anomaly(
|
||||
campaign,
|
||||
"klikniecia",
|
||||
"srednie",
|
||||
format_number(previous["clicks"]),
|
||||
format_number(recent["clicks"]),
|
||||
format_change(changes["clicks"]),
|
||||
"klikniecia spadly mocno przy sensownym wolumenie bazowym",
|
||||
"sprawdz aukcje, status kampanii, budzet i zapytania w zadaniach szczegolowych",
|
||||
)
|
||||
)
|
||||
elif changes["clicks"] >= 80:
|
||||
found.append(
|
||||
anomaly(
|
||||
campaign,
|
||||
"klikniecia",
|
||||
"niskie",
|
||||
format_number(previous["clicks"]),
|
||||
format_number(recent["clicks"]),
|
||||
format_change(changes["clicks"]),
|
||||
"klikniecia wzrosly bardzo mocno tydzien do tygodnia",
|
||||
"sprawdz, czy wzrost jest jakosciowy i nie wynika z niepasujacego ruchu",
|
||||
)
|
||||
)
|
||||
|
||||
if changes["conversions"] is not None and previous["conversions"] >= 3:
|
||||
if recent["conversions"] == 0:
|
||||
severity = "wysokie"
|
||||
else:
|
||||
severity = "srednie"
|
||||
if changes["conversions"] <= -50:
|
||||
found.append(
|
||||
anomaly(
|
||||
campaign,
|
||||
"konwersje",
|
||||
severity,
|
||||
format_number(previous["conversions"], 2),
|
||||
format_number(recent["conversions"], 2),
|
||||
format_change(changes["conversions"]),
|
||||
"konwersje spadly o co najmniej polowe",
|
||||
"sprawdz pomiar konwersji, ruch i ostatnie zmiany w kampanii",
|
||||
)
|
||||
)
|
||||
elif changes["conversions"] >= 80:
|
||||
found.append(
|
||||
anomaly(
|
||||
campaign,
|
||||
"konwersje",
|
||||
"niskie",
|
||||
format_number(previous["conversions"], 2),
|
||||
format_number(recent["conversions"], 2),
|
||||
format_change(changes["conversions"]),
|
||||
"konwersje wzrosly bardzo mocno tydzien do tygodnia",
|
||||
"sprawdz, czy wzrost jest rzeczywisty i czy nie zmienil sie pomiar",
|
||||
)
|
||||
)
|
||||
|
||||
if changes["conversion_value"] is not None and previous["conversion_value"] >= 100:
|
||||
if changes["conversion_value"] <= -50:
|
||||
found.append(
|
||||
anomaly(
|
||||
campaign,
|
||||
"wartosc konwersji",
|
||||
"wysokie",
|
||||
format_money_amount(previous["conversion_value"], currency_code),
|
||||
format_money_amount(recent["conversion_value"], currency_code),
|
||||
format_change(changes["conversion_value"]),
|
||||
"wartosc konwersji spadla o co najmniej polowe",
|
||||
"sprawdz pomiar, koszyk, kampanie produktowe i jakosc ruchu",
|
||||
)
|
||||
)
|
||||
elif changes["conversion_value"] >= 100:
|
||||
found.append(
|
||||
anomaly(
|
||||
campaign,
|
||||
"wartosc konwersji",
|
||||
"niskie",
|
||||
format_money_amount(previous["conversion_value"], currency_code),
|
||||
format_money_amount(recent["conversion_value"], currency_code),
|
||||
format_change(changes["conversion_value"]),
|
||||
"wartosc konwersji wzrosla ponad dwukrotnie",
|
||||
"sprawdz, czy wzrost wynika z realnej sprzedazy, a nie zmiany pomiaru",
|
||||
)
|
||||
)
|
||||
|
||||
if changes["ctr"] is not None and previous["impressions"] >= 500 and previous["ctr_percent"] >= 1:
|
||||
if changes["ctr"] <= -35:
|
||||
found.append(
|
||||
anomaly(
|
||||
campaign,
|
||||
"CTR",
|
||||
"srednie",
|
||||
format_percent_value(previous["ctr_percent"]),
|
||||
format_percent_value(recent["ctr_percent"]),
|
||||
format_change(changes["ctr"]),
|
||||
"CTR spadl przy wystarczajacej liczbie wyswietlen",
|
||||
"sprawdz dopasowanie ruchu, reklamy i zmiany konkurencji",
|
||||
)
|
||||
)
|
||||
|
||||
if changes["avg_cpc"] is not None and previous["clicks"] >= 20:
|
||||
if changes["avg_cpc"] >= 60:
|
||||
found.append(
|
||||
anomaly(
|
||||
campaign,
|
||||
"sredni CPC",
|
||||
"srednie",
|
||||
format_money_micros(previous["avg_cpc_micros"], currency_code),
|
||||
format_money_micros(recent["avg_cpc_micros"], currency_code),
|
||||
format_change(changes["avg_cpc"]),
|
||||
"sredni CPC wzrosl mocno tydzien do tygodnia",
|
||||
"sprawdz strategie stawek, aukcje i segmenty ruchu",
|
||||
)
|
||||
)
|
||||
|
||||
if changes["roas"] is not None and previous["cost_micros"] >= 50_000_000 and previous["roas"] >= 1:
|
||||
if changes["roas"] <= -40:
|
||||
found.append(
|
||||
anomaly(
|
||||
campaign,
|
||||
"ROAS",
|
||||
"wysokie",
|
||||
format_number(previous["roas"], 2),
|
||||
format_number(recent["roas"], 2),
|
||||
format_change(changes["roas"]),
|
||||
"ROAS spadl przy istotnym koszcie bazowym",
|
||||
"sprawdz rentownosc, pomiar, produkt/feed i strategie stawek w zadaniach szczegolowych",
|
||||
)
|
||||
)
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def build_anomalies(campaigns: list[dict], currency_code: str) -> list[dict]:
|
||||
anomalies = []
|
||||
for campaign in campaigns:
|
||||
anomalies.extend(build_campaign_anomalies(campaign, currency_code))
|
||||
severity_order = {"wysokie": 0, "srednie": 1, "niskie": 2}
|
||||
anomalies.sort(
|
||||
key=lambda row: (
|
||||
severity_order.get(row["severity"], 9),
|
||||
row["campaign_name"],
|
||||
row["metric"],
|
||||
)
|
||||
)
|
||||
return anomalies
|
||||
|
||||
|
||||
def compact_campaign_row(campaign: dict, currency_code: str) -> dict:
|
||||
previous = campaign["previous"]
|
||||
recent = campaign["recent"]
|
||||
return {
|
||||
"campaign_id": campaign["campaign_id"],
|
||||
"campaign_name": campaign["campaign_name"],
|
||||
"status": campaign["status"],
|
||||
"channel_type": campaign["channel_type"],
|
||||
"previous_cost": format_money_micros(previous["cost_micros"], currency_code),
|
||||
"recent_cost": format_money_micros(recent["cost_micros"], currency_code),
|
||||
"cost_change": format_change(campaign["changes"]["cost"]),
|
||||
"previous_clicks": format_number(previous["clicks"]),
|
||||
"recent_clicks": format_number(recent["clicks"]),
|
||||
"clicks_change": format_change(campaign["changes"]["clicks"]),
|
||||
"previous_conversions": format_number(previous["conversions"], 2),
|
||||
"recent_conversions": format_number(recent["conversions"], 2),
|
||||
"conversions_change": format_change(campaign["changes"]["conversions"]),
|
||||
"previous_roas": format_number(previous["roas"], 2),
|
||||
"recent_roas": format_number(recent["roas"], 2),
|
||||
"roas_change": format_change(campaign["changes"]["roas"]),
|
||||
}
|
||||
|
||||
|
||||
def build_account_anomaly_plan(client_config: ClientConfig) -> AccountAnomalyPlan:
|
||||
warnings = []
|
||||
recent_period, previous_period = period_labels()
|
||||
try:
|
||||
currency_code, recent_period, previous_period, campaigns_raw = fetch_campaign_period_metrics(client_config)
|
||||
except Exception as exc:
|
||||
currency_code = ""
|
||||
campaigns_raw = []
|
||||
warnings.append(f"Nie udalo sie pobrac metryk kampanii z Google Ads API: {exc}")
|
||||
|
||||
if not campaigns_raw:
|
||||
warnings.append("Nie znaleziono kampanii z danymi w analizowanym okresie albo nie udalo sie ich pobrac.")
|
||||
|
||||
anomalies = build_anomalies(campaigns_raw, currency_code)
|
||||
if not anomalies and campaigns_raw:
|
||||
warnings.append("Nie wykryto anomalii wedlug obecnych progow. Wyniki nadal warto porownac z kontekstem klienta.")
|
||||
|
||||
knowledge_rules = [
|
||||
{
|
||||
"id": rule.id,
|
||||
"topic": rule.topic,
|
||||
"rule_type": rule.rule_type,
|
||||
"condition": rule.condition,
|
||||
"recommendation": rule.recommendation,
|
||||
"risk": rule.risk,
|
||||
"source": rule.source,
|
||||
}
|
||||
for rule in rules_for_task(TASK_ID)
|
||||
]
|
||||
if not knowledge_rules:
|
||||
warnings.append(
|
||||
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
|
||||
"Reguly dotyczace anomalii i alertow bedziemy dopisywac osobno po akceptacji uzytkownika."
|
||||
)
|
||||
|
||||
campaigns = [compact_campaign_row(campaign, currency_code) for campaign in campaigns_raw]
|
||||
return AccountAnomalyPlan(
|
||||
currency_code=currency_code,
|
||||
recent_period=recent_period,
|
||||
previous_period=previous_period,
|
||||
account_summary=aggregate_account_summary(campaigns_raw, currency_code),
|
||||
campaigns=campaigns,
|
||||
anomalies=anomalies,
|
||||
scope=SCOPE,
|
||||
out_of_scope=OUT_OF_SCOPE,
|
||||
knowledge_rules=knowledge_rules,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def save_account_anomaly_plan(domain: str, plan: AccountAnomalyPlan) -> tuple[Path, Path]:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "plans"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
|
||||
json_path = base / f"{stem}.json"
|
||||
md_path = base / f"{stem}.md"
|
||||
payload = {
|
||||
"created_at": ts.isoformat(timespec="seconds"),
|
||||
"client": domain,
|
||||
**plan.to_dict(),
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Plan: Sprawdzenie anomalii konta",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Okresy",
|
||||
"",
|
||||
f"- Ostatnie 7 zakonczonych dni: {plan.recent_period.get('start', '')} - {plan.recent_period.get('end', '')}",
|
||||
f"- Poprzednie 7 dni: {plan.previous_period.get('start', '')} - {plan.previous_period.get('end', '')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Kampanie z danymi: {len(plan.campaigns)}",
|
||||
f"- Wykryte anomalie: {len(plan.anomalies)}",
|
||||
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
|
||||
"- Zmiany do wdrozenia: 0",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {item}" for item in plan.warnings)
|
||||
lines.append("")
|
||||
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
|
||||
for row in plan.scope:
|
||||
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
|
||||
lines.append("")
|
||||
lines.extend(["## Poza zakresem tego zadania", ""])
|
||||
lines.extend(f"- {item}" for item in plan.out_of_scope)
|
||||
lines.append("")
|
||||
if plan.account_summary:
|
||||
lines.extend(
|
||||
[
|
||||
"## Podsumowanie konta",
|
||||
"",
|
||||
"| Metryka | Poprzednie 7 dni | Ostatnie 7 dni | Zmiana |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for row in plan.account_summary:
|
||||
lines.append(f"| {row['metric']} | {row['previous']} | {row['recent']} | {row['change_percent']} |")
|
||||
lines.append("")
|
||||
if plan.anomalies:
|
||||
lines.extend(
|
||||
[
|
||||
"## Wykryte anomalie",
|
||||
"",
|
||||
"| Waznosc | Kampania | Metryka | Poprzednio | Teraz | Zmiana | Powod | Rekomendacja |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for item in plan.anomalies:
|
||||
lines.append(
|
||||
f"| {item['severity']} | {md_cell(item['campaign_name'])} | {item['metric']} | "
|
||||
f"{item['previous_value']} | {item['recent_value']} | {item['change_percent']} | "
|
||||
f"{md_cell(item['reason'])} | {md_cell(item['recommendation'])} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.campaigns:
|
||||
lines.extend(
|
||||
[
|
||||
"## Porownanie kampanii",
|
||||
"",
|
||||
"| Kampania | Typ | Status | Koszt poprzednio | Koszt teraz | Zmiana kosztu | Konwersje poprzednio | Konwersje teraz | Zmiana konwersji | ROAS poprzednio | ROAS teraz | Zmiana ROAS |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for campaign in plan.campaigns:
|
||||
lines.append(
|
||||
f"| {md_cell(campaign['campaign_name'])} | {campaign['channel_type']} | {campaign['status']} | "
|
||||
f"{campaign['previous_cost']} | {campaign['recent_cost']} | {campaign['cost_change']} | "
|
||||
f"{campaign['previous_conversions']} | {campaign['recent_conversions']} | {campaign['conversions_change']} | "
|
||||
f"{campaign['previous_roas']} | {campaign['recent_roas']} | {campaign['roas_change']} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.knowledge_rules:
|
||||
lines.extend(
|
||||
[
|
||||
"## Reguly z bazy wiedzy",
|
||||
"",
|
||||
"| ID | Temat | Rekomendacja | Ryzyko |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for rule in plan.knowledge_rules:
|
||||
lines.append(
|
||||
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
|
||||
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
|
||||
)
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def print_account_anomaly_plan(plan: AccountAnomalyPlan) -> None:
|
||||
print("\nPlan sprawdzenia anomalii konta")
|
||||
print_table(
|
||||
["Metryka", "Liczba"],
|
||||
[
|
||||
["Kampanie z danymi", str(len(plan.campaigns))],
|
||||
["Wykryte anomalie", str(len(plan.anomalies))],
|
||||
["Reguly wiedzy", str(len(plan.knowledge_rules))],
|
||||
["Zmiany do wdrozenia", "0"],
|
||||
],
|
||||
)
|
||||
print("\nOkresy porownania")
|
||||
print_table(
|
||||
["Okres", "Od", "Do"],
|
||||
[
|
||||
[plan.previous_period.get("label", "poprzednie 7 dni"), plan.previous_period.get("start", ""), plan.previous_period.get("end", "")],
|
||||
[plan.recent_period.get("label", "ostatnie 7 zakonczonych dni"), plan.recent_period.get("start", ""), plan.recent_period.get("end", "")],
|
||||
],
|
||||
)
|
||||
if plan.warnings:
|
||||
print("\nUwagi")
|
||||
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
|
||||
print("\nZakres zadania")
|
||||
print_table(
|
||||
["Nr", "Obszar", "Co sprawdzic"],
|
||||
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
|
||||
)
|
||||
print("\nPoza zakresem")
|
||||
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
|
||||
if plan.account_summary:
|
||||
print("\nPodsumowanie konta")
|
||||
print_table(
|
||||
["Metryka", "Poprzednie 7 dni", "Ostatnie 7 dni", "Zmiana"],
|
||||
[[row["metric"], row["previous"], row["recent"], row["change_percent"]] for row in plan.account_summary],
|
||||
)
|
||||
if plan.anomalies:
|
||||
print("\nWykryte anomalie")
|
||||
print_table(
|
||||
["Nr", "Waznosc", "Kampania", "Metryka", "Poprzednio", "Teraz", "Zmiana", "Powod"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
item["severity"],
|
||||
item["campaign_name"],
|
||||
item["metric"],
|
||||
item["previous_value"],
|
||||
item["recent_value"],
|
||||
item["change_percent"],
|
||||
item["reason"],
|
||||
]
|
||||
for index, item in enumerate(plan.anomalies[:30], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.anomalies) > 30:
|
||||
print(f"... oraz {len(plan.anomalies) - 30} kolejnych anomalii w pliku planu")
|
||||
if plan.campaigns:
|
||||
print("\nPorownanie kampanii")
|
||||
print_table(
|
||||
["Nr", "Kampania", "Typ", "Koszt poprz.", "Koszt teraz", "Zmiana", "Konw. poprz.", "Konw. teraz", "ROAS teraz"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
campaign["campaign_name"],
|
||||
campaign["channel_type"],
|
||||
campaign["previous_cost"],
|
||||
campaign["recent_cost"],
|
||||
campaign["cost_change"],
|
||||
campaign["previous_conversions"],
|
||||
campaign["recent_conversions"],
|
||||
campaign["recent_roas"],
|
||||
]
|
||||
for index, campaign in enumerate(plan.campaigns[:30], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.campaigns) > 30:
|
||||
print(f"... oraz {len(plan.campaigns) - 30} kolejnych kampanii w pliku planu")
|
||||
if plan.knowledge_rules:
|
||||
print("\nReguly z bazy wiedzy")
|
||||
print_table(
|
||||
["Nr", "ID", "Temat", "Rekomendacja"],
|
||||
[
|
||||
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
|
||||
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.knowledge_rules) > 10:
|
||||
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
|
||||
|
||||
|
||||
def print_next_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def apply_account_anomaly_plan(
|
||||
client_config: ClientConfig,
|
||||
plan: AccountAnomalyPlan,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
print("\nTo zadanie jest audytem anomalii i nie wdraza zmian na koncie Google Ads.")
|
||||
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "audyt oznaczony jako wykonany",
|
||||
"campaign": ", ".join(item["campaign_name"] for item in plan.anomalies[:10]),
|
||||
"summary": {
|
||||
"campaigns": len(plan.campaigns),
|
||||
"anomalies": len(plan.anomalies),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def run_check_account_anomalies(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
_ = global_rules
|
||||
if apply_plan_path:
|
||||
if confirm_apply != "TAK":
|
||||
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
|
||||
if plan_data.get("client") != client_config.domain:
|
||||
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan = AccountAnomalyPlan.from_dict(plan_data)
|
||||
print_account_anomaly_plan(plan)
|
||||
apply_account_anomaly_plan(client_config, plan, show_navigation=show_navigation)
|
||||
return
|
||||
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print("Przygotowuje plan sprawdzenia anomalii konta...")
|
||||
plan = build_account_anomaly_plan(client_config)
|
||||
print_account_anomaly_plan(plan)
|
||||
json_path, md_path = save_account_anomaly_plan(client_config.domain, plan)
|
||||
print(f"\nPlan JSON: {json_path}")
|
||||
print(f"Plan Markdown: {md_path}")
|
||||
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "plan przygotowany",
|
||||
"campaign": ", ".join(item["campaign_name"] for item in plan.anomalies[:10]),
|
||||
"summary": {
|
||||
"campaigns": len(plan.campaigns),
|
||||
"anomalies": len(plan.anomalies),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if plan_only:
|
||||
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu anomalii.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
627
src/gads_v2/tasks/ad_asset_status_check.py
Normal file
627
src/gads_v2/tasks/ad_asset_status_check.py
Normal file
@@ -0,0 +1,627 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..config import ClientConfig, client_dir
|
||||
from ..google_ads import get_google_ads_client, run_query
|
||||
from ..history import append_change_markdown, append_history, now_local
|
||||
from ..knowledge.store import rules_for_task
|
||||
from ..table import print_table
|
||||
|
||||
|
||||
TASK_ID = "check_ad_asset_statuses"
|
||||
TASK_NAME = "Sprawdzenie statusow reklam i zasobow"
|
||||
|
||||
|
||||
SCOPE = [
|
||||
{
|
||||
"area": "Reklamy",
|
||||
"check": "Pokaz status reklamy, typ reklamy i status zatwierdzenia polityk dla aktywnych grup reklam.",
|
||||
},
|
||||
{
|
||||
"area": "Odrzucenia i ograniczenia",
|
||||
"check": "Oznacz reklamy odrzucone, ograniczone, oczekujace albo wstrzymane jako elementy do szybkiej reakcji.",
|
||||
},
|
||||
{
|
||||
"area": "Zasoby PMax",
|
||||
"check": "Pokaz status zasobow w asset group, jezeli Google Ads API udostepnia te dane dla konta.",
|
||||
},
|
||||
{
|
||||
"area": "Audyt techniczny",
|
||||
"check": "Oddziel dostepnosc emisji od oceny jakosci tekstow, naglowkow i kreacji.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
OUT_OF_SCOPE = [
|
||||
"analiza jakosci tekstow reklam i kreacji",
|
||||
"budzety i wykorzystanie budzetu",
|
||||
"strategie stawek oraz cele Docelowy ROAS/Docelowy CPA",
|
||||
"zapytania uzytkownikow oraz wykluczenia",
|
||||
"wdrazanie zmian reklam albo zasobow na koncie Google Ads",
|
||||
]
|
||||
|
||||
|
||||
PROBLEM_APPROVAL_STATUSES = {
|
||||
"DISAPPROVED",
|
||||
"AREA_OF_INTEREST_ONLY",
|
||||
"APPROVED_LIMITED",
|
||||
"UNDER_REVIEW",
|
||||
"UNKNOWN",
|
||||
"UNSPECIFIED",
|
||||
}
|
||||
|
||||
|
||||
PROBLEM_ENTITY_STATUSES = {
|
||||
"PAUSED",
|
||||
"DISABLED",
|
||||
"UNKNOWN",
|
||||
"UNSPECIFIED",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdAssetStatusPlan:
|
||||
ads: list[dict]
|
||||
assets: list[dict]
|
||||
ad_status_summary: list[dict]
|
||||
approval_summary: list[dict]
|
||||
asset_status_summary: list[dict]
|
||||
problem_items: list[dict]
|
||||
scope: list[dict]
|
||||
out_of_scope: list[str]
|
||||
knowledge_rules: list[dict]
|
||||
warnings: list[str]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"task": TASK_ID,
|
||||
"task_name": TASK_NAME,
|
||||
"ads": self.ads,
|
||||
"assets": self.assets,
|
||||
"ad_status_summary": self.ad_status_summary,
|
||||
"approval_summary": self.approval_summary,
|
||||
"asset_status_summary": self.asset_status_summary,
|
||||
"problem_items": self.problem_items,
|
||||
"scope": self.scope,
|
||||
"out_of_scope": self.out_of_scope,
|
||||
"knowledge_rules": self.knowledge_rules,
|
||||
"warnings": self.warnings,
|
||||
"changes": [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "AdAssetStatusPlan":
|
||||
return cls(
|
||||
ads=data.get("ads", []),
|
||||
assets=data.get("assets", []),
|
||||
ad_status_summary=data.get("ad_status_summary", []),
|
||||
approval_summary=data.get("approval_summary", []),
|
||||
asset_status_summary=data.get("asset_status_summary", []),
|
||||
problem_items=data.get("problem_items", []),
|
||||
scope=data.get("scope", []),
|
||||
out_of_scope=data.get("out_of_scope", []),
|
||||
knowledge_rules=data.get("knowledge_rules", []),
|
||||
warnings=data.get("warnings", []),
|
||||
)
|
||||
|
||||
|
||||
def enum_name(value: Any) -> str:
|
||||
name = getattr(value, "name", None)
|
||||
if name:
|
||||
return name
|
||||
return str(value)
|
||||
|
||||
|
||||
def md_cell(value: Any) -> str:
|
||||
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
|
||||
|
||||
|
||||
def ad_label(row: dict) -> str:
|
||||
if row.get("ad_name"):
|
||||
return row["ad_name"]
|
||||
return f"{row['ad_type']} {row['ad_id']}"
|
||||
|
||||
|
||||
def item_severity(status: str, approval_status: str) -> str:
|
||||
if approval_status == "DISAPPROVED":
|
||||
return "wysokie"
|
||||
if status in PROBLEM_ENTITY_STATUSES:
|
||||
return "srednie"
|
||||
if approval_status in {"APPROVED_LIMITED", "AREA_OF_INTEREST_ONLY"}:
|
||||
return "srednie"
|
||||
if approval_status == "UNDER_REVIEW":
|
||||
return "niskie"
|
||||
if approval_status in {"UNKNOWN", "UNSPECIFIED"}:
|
||||
return "niskie"
|
||||
return "ok"
|
||||
|
||||
|
||||
def item_flags(status: str, approval_status: str) -> list[str]:
|
||||
flags = []
|
||||
if status in PROBLEM_ENTITY_STATUSES:
|
||||
flags.append(f"status: {status}")
|
||||
if approval_status in PROBLEM_APPROVAL_STATUSES:
|
||||
flags.append(f"polityka: {approval_status}")
|
||||
return flags or ["ok"]
|
||||
|
||||
|
||||
def fetch_ads(client_config: ClientConfig) -> list[dict]:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
rows = run_query(
|
||||
google_client,
|
||||
client_config.safe_customer_id,
|
||||
"""
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign.name,
|
||||
campaign.status,
|
||||
campaign.advertising_channel_type,
|
||||
ad_group.id,
|
||||
ad_group.name,
|
||||
ad_group.status,
|
||||
ad_group_ad.ad.id,
|
||||
ad_group_ad.ad.name,
|
||||
ad_group_ad.ad.type,
|
||||
ad_group_ad.status,
|
||||
ad_group_ad.policy_summary.approval_status
|
||||
FROM ad_group_ad
|
||||
WHERE campaign.status != 'REMOVED'
|
||||
AND ad_group.status != 'REMOVED'
|
||||
AND ad_group_ad.status != 'REMOVED'
|
||||
""",
|
||||
)
|
||||
|
||||
ads = []
|
||||
for row in rows:
|
||||
ad = row.ad_group_ad.ad
|
||||
status = enum_name(row.ad_group_ad.status)
|
||||
approval_status = enum_name(row.ad_group_ad.policy_summary.approval_status)
|
||||
record = {
|
||||
"item_type": "reklama",
|
||||
"campaign_id": str(row.campaign.id),
|
||||
"campaign_name": row.campaign.name,
|
||||
"campaign_status": enum_name(row.campaign.status),
|
||||
"channel_type": enum_name(row.campaign.advertising_channel_type),
|
||||
"ad_group_id": str(row.ad_group.id),
|
||||
"ad_group_name": row.ad_group.name,
|
||||
"ad_group_status": enum_name(row.ad_group.status),
|
||||
"ad_id": str(ad.id),
|
||||
"ad_name": str(ad.name or ""),
|
||||
"ad_type": enum_name(ad.type),
|
||||
"status": status,
|
||||
"approval_status": approval_status,
|
||||
}
|
||||
record["label"] = ad_label(record)
|
||||
record["severity"] = item_severity(status, approval_status)
|
||||
record["flags"] = item_flags(status, approval_status)
|
||||
ads.append(record)
|
||||
ads.sort(key=lambda row: (row["severity"], row["campaign_name"], row["ad_group_name"], row["ad_type"]))
|
||||
return ads
|
||||
|
||||
|
||||
def fetch_asset_group_assets(client_config: ClientConfig) -> list[dict]:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
rows = run_query(
|
||||
google_client,
|
||||
client_config.safe_customer_id,
|
||||
"""
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign.name,
|
||||
campaign.status,
|
||||
asset_group.id,
|
||||
asset_group.name,
|
||||
asset.id,
|
||||
asset.name,
|
||||
asset.type,
|
||||
asset_group_asset.field_type,
|
||||
asset_group_asset.status,
|
||||
asset_group_asset.policy_summary.approval_status
|
||||
FROM asset_group_asset
|
||||
WHERE campaign.status != 'REMOVED'
|
||||
AND asset_group_asset.status != 'REMOVED'
|
||||
""",
|
||||
)
|
||||
|
||||
assets = []
|
||||
for row in rows:
|
||||
status = enum_name(row.asset_group_asset.status)
|
||||
approval_status = enum_name(row.asset_group_asset.policy_summary.approval_status)
|
||||
record = {
|
||||
"item_type": "zasob",
|
||||
"campaign_id": str(row.campaign.id),
|
||||
"campaign_name": row.campaign.name,
|
||||
"campaign_status": enum_name(row.campaign.status),
|
||||
"asset_group_id": str(row.asset_group.id),
|
||||
"asset_group_name": row.asset_group.name,
|
||||
"asset_id": str(row.asset.id),
|
||||
"asset_name": str(row.asset.name or ""),
|
||||
"asset_type": enum_name(row.asset.type),
|
||||
"field_type": enum_name(row.asset_group_asset.field_type),
|
||||
"status": status,
|
||||
"approval_status": approval_status,
|
||||
}
|
||||
record["label"] = record["asset_name"] or f"{record['asset_type']} {record['asset_id']}"
|
||||
record["severity"] = item_severity(status, approval_status)
|
||||
record["flags"] = item_flags(status, approval_status)
|
||||
assets.append(record)
|
||||
assets.sort(key=lambda row: (row["severity"], row["campaign_name"], row["asset_group_name"], row["field_type"]))
|
||||
return assets
|
||||
|
||||
|
||||
def build_counter_summary(rows: list[dict], field: str, label: str) -> list[dict]:
|
||||
counter = Counter(row.get(field, "") or "(brak)" for row in rows)
|
||||
return [{label: key, "count": value} for key, value in counter.most_common()]
|
||||
|
||||
|
||||
def build_problem_items(ads: list[dict], assets: list[dict]) -> list[dict]:
|
||||
items = []
|
||||
for ad in ads:
|
||||
if ad["flags"] == ["ok"]:
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"item_type": "reklama",
|
||||
"severity": ad["severity"],
|
||||
"campaign_name": ad["campaign_name"],
|
||||
"container": ad["ad_group_name"],
|
||||
"label": ad["label"],
|
||||
"status": ad["status"],
|
||||
"approval_status": ad["approval_status"],
|
||||
"flags": ad["flags"],
|
||||
"recommendation": "sprawdz przyczyne ograniczenia lub odrzucenia w Google Ads",
|
||||
}
|
||||
)
|
||||
for asset in assets:
|
||||
if asset["flags"] == ["ok"]:
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"item_type": "zasob",
|
||||
"severity": asset["severity"],
|
||||
"campaign_name": asset["campaign_name"],
|
||||
"container": asset["asset_group_name"],
|
||||
"label": asset["label"],
|
||||
"status": asset["status"],
|
||||
"approval_status": asset["approval_status"],
|
||||
"flags": asset["flags"],
|
||||
"recommendation": "sprawdz status zasobu i polityki w asset group",
|
||||
}
|
||||
)
|
||||
severity_order = {"wysokie": 0, "srednie": 1, "niskie": 2, "ok": 9}
|
||||
items.sort(key=lambda row: (severity_order.get(row["severity"], 9), row["campaign_name"], row["item_type"]))
|
||||
return items
|
||||
|
||||
|
||||
def build_ad_asset_status_plan(client_config: ClientConfig) -> AdAssetStatusPlan:
|
||||
warnings = []
|
||||
try:
|
||||
ads = fetch_ads(client_config)
|
||||
except Exception as exc:
|
||||
ads = []
|
||||
warnings.append(f"Nie udalo sie pobrac reklam z Google Ads API: {exc}")
|
||||
|
||||
try:
|
||||
assets = fetch_asset_group_assets(client_config)
|
||||
except Exception as exc:
|
||||
assets = []
|
||||
warnings.append(f"Nie udalo sie pobrac zasobow PMax z Google Ads API: {exc}")
|
||||
|
||||
if not ads:
|
||||
warnings.append("Nie znaleziono reklam albo nie udalo sie ich pobrac.")
|
||||
if not assets:
|
||||
warnings.append("Nie znaleziono zasobow asset group albo API nie udostepnilo ich dla tego konta.")
|
||||
|
||||
knowledge_rules = [
|
||||
{
|
||||
"id": rule.id,
|
||||
"topic": rule.topic,
|
||||
"rule_type": rule.rule_type,
|
||||
"condition": rule.condition,
|
||||
"recommendation": rule.recommendation,
|
||||
"risk": rule.risk,
|
||||
"source": rule.source,
|
||||
}
|
||||
for rule in rules_for_task(TASK_ID)
|
||||
]
|
||||
if not knowledge_rules:
|
||||
warnings.append(
|
||||
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
|
||||
"Reguly dotyczace odrzucen reklam i zasobow bedziemy dopisywac osobno po akceptacji uzytkownika."
|
||||
)
|
||||
|
||||
return AdAssetStatusPlan(
|
||||
ads=ads,
|
||||
assets=assets,
|
||||
ad_status_summary=build_counter_summary(ads, "status", "status"),
|
||||
approval_summary=build_counter_summary(ads + assets, "approval_status", "approval_status"),
|
||||
asset_status_summary=build_counter_summary(assets, "status", "status"),
|
||||
problem_items=build_problem_items(ads, assets),
|
||||
scope=SCOPE,
|
||||
out_of_scope=OUT_OF_SCOPE,
|
||||
knowledge_rules=knowledge_rules,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def save_ad_asset_status_plan(domain: str, plan: AdAssetStatusPlan) -> tuple[Path, Path]:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "plans"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
|
||||
json_path = base / f"{stem}.json"
|
||||
md_path = base / f"{stem}.md"
|
||||
payload = {
|
||||
"created_at": ts.isoformat(timespec="seconds"),
|
||||
"client": domain,
|
||||
**plan.to_dict(),
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Plan: Sprawdzenie statusow reklam i zasobow",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Reklamy: {len(plan.ads)}",
|
||||
f"- Zasoby asset group: {len(plan.assets)}",
|
||||
f"- Elementy do oceny: {len(plan.problem_items)}",
|
||||
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
|
||||
"- Zmiany do wdrozenia: 0",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {item}" for item in plan.warnings)
|
||||
lines.append("")
|
||||
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
|
||||
for row in plan.scope:
|
||||
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
|
||||
lines.append("")
|
||||
lines.extend(["## Poza zakresem tego zadania", ""])
|
||||
lines.extend(f"- {item}" for item in plan.out_of_scope)
|
||||
lines.append("")
|
||||
if plan.problem_items:
|
||||
lines.extend(
|
||||
[
|
||||
"## Elementy do oceny",
|
||||
"",
|
||||
"| Waznosc | Typ | Kampania | Kontener | Element | Status | Polityka | Flagi | Rekomendacja |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for item in plan.problem_items:
|
||||
lines.append(
|
||||
f"| {item['severity']} | {item['item_type']} | {md_cell(item['campaign_name'])} | "
|
||||
f"{md_cell(item['container'])} | {md_cell(item['label'])} | {item['status']} | "
|
||||
f"{item['approval_status']} | {md_cell(', '.join(item['flags']))} | {md_cell(item['recommendation'])} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.ad_status_summary:
|
||||
lines.extend(["## Statusy reklam", "", "| Status | Liczba |", "| --- | --- |"])
|
||||
for row in plan.ad_status_summary:
|
||||
lines.append(f"| {row['status']} | {row['count']} |")
|
||||
lines.append("")
|
||||
if plan.approval_summary:
|
||||
lines.extend(["## Statusy zatwierdzenia", "", "| Status polityki | Liczba |", "| --- | --- |"])
|
||||
for row in plan.approval_summary:
|
||||
lines.append(f"| {row['approval_status']} | {row['count']} |")
|
||||
lines.append("")
|
||||
if plan.ads:
|
||||
lines.extend(
|
||||
[
|
||||
"## Reklamy",
|
||||
"",
|
||||
"| Kampania | Grupa reklam | Typ | Status | Polityka | Flagi |",
|
||||
"| --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for ad in plan.ads:
|
||||
lines.append(
|
||||
f"| {md_cell(ad['campaign_name'])} | {md_cell(ad['ad_group_name'])} | {ad['ad_type']} | "
|
||||
f"{ad['status']} | {ad['approval_status']} | {md_cell(', '.join(ad['flags']))} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.assets:
|
||||
lines.extend(
|
||||
[
|
||||
"## Zasoby asset group",
|
||||
"",
|
||||
"| Kampania | Asset group | Typ | Pole | Status | Polityka | Flagi |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for asset in plan.assets:
|
||||
lines.append(
|
||||
f"| {md_cell(asset['campaign_name'])} | {md_cell(asset['asset_group_name'])} | {asset['asset_type']} | "
|
||||
f"{asset['field_type']} | {asset['status']} | {asset['approval_status']} | {md_cell(', '.join(asset['flags']))} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.knowledge_rules:
|
||||
lines.extend(
|
||||
[
|
||||
"## Reguly z bazy wiedzy",
|
||||
"",
|
||||
"| ID | Temat | Rekomendacja | Ryzyko |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for rule in plan.knowledge_rules:
|
||||
lines.append(
|
||||
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
|
||||
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
|
||||
)
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def print_ad_asset_status_plan(plan: AdAssetStatusPlan) -> None:
|
||||
print("\nPlan sprawdzenia statusow reklam i zasobow")
|
||||
print_table(
|
||||
["Metryka", "Liczba"],
|
||||
[
|
||||
["Reklamy", str(len(plan.ads))],
|
||||
["Zasoby asset group", str(len(plan.assets))],
|
||||
["Elementy do oceny", str(len(plan.problem_items))],
|
||||
["Reguly wiedzy", str(len(plan.knowledge_rules))],
|
||||
["Zmiany do wdrozenia", "0"],
|
||||
],
|
||||
)
|
||||
if plan.warnings:
|
||||
print("\nUwagi")
|
||||
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
|
||||
print("\nZakres zadania")
|
||||
print_table(
|
||||
["Nr", "Obszar", "Co sprawdzic"],
|
||||
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
|
||||
)
|
||||
print("\nPoza zakresem")
|
||||
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
|
||||
if plan.problem_items:
|
||||
print("\nElementy do oceny")
|
||||
print_table(
|
||||
["Nr", "Waznosc", "Typ", "Kampania", "Kontener", "Status", "Polityka", "Flagi"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
item["severity"],
|
||||
item["item_type"],
|
||||
item["campaign_name"],
|
||||
item["container"],
|
||||
item["status"],
|
||||
item["approval_status"],
|
||||
", ".join(item["flags"]),
|
||||
]
|
||||
for index, item in enumerate(plan.problem_items[:30], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.problem_items) > 30:
|
||||
print(f"... oraz {len(plan.problem_items) - 30} kolejnych elementow w pliku planu")
|
||||
if plan.ad_status_summary:
|
||||
print("\nStatusy reklam")
|
||||
print_table(["Status", "Liczba"], [[row["status"], str(row["count"])] for row in plan.ad_status_summary])
|
||||
if plan.approval_summary:
|
||||
print("\nStatusy zatwierdzenia")
|
||||
print_table(
|
||||
["Status polityki", "Liczba"],
|
||||
[[row["approval_status"], str(row["count"])] for row in plan.approval_summary],
|
||||
)
|
||||
if plan.knowledge_rules:
|
||||
print("\nReguly z bazy wiedzy")
|
||||
print_table(
|
||||
["Nr", "ID", "Temat", "Rekomendacja"],
|
||||
[
|
||||
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
|
||||
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.knowledge_rules) > 10:
|
||||
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
|
||||
|
||||
|
||||
def print_next_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def apply_ad_asset_status_plan(
|
||||
client_config: ClientConfig,
|
||||
plan: AdAssetStatusPlan,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
print("\nTo zadanie jest audytem statusow reklam i zasobow i nie wdraza zmian na koncie Google Ads.")
|
||||
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "audyt oznaczony jako wykonany",
|
||||
"campaign": ", ".join(item["campaign_name"] for item in plan.problem_items[:10]),
|
||||
"summary": {
|
||||
"ads": len(plan.ads),
|
||||
"assets": len(plan.assets),
|
||||
"problem_items": len(plan.problem_items),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def run_check_ad_asset_statuses(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
_ = global_rules
|
||||
if apply_plan_path:
|
||||
if confirm_apply != "TAK":
|
||||
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
|
||||
if plan_data.get("client") != client_config.domain:
|
||||
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan = AdAssetStatusPlan.from_dict(plan_data)
|
||||
print_ad_asset_status_plan(plan)
|
||||
apply_ad_asset_status_plan(client_config, plan, show_navigation=show_navigation)
|
||||
return
|
||||
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print("Przygotowuje plan sprawdzenia statusow reklam i zasobow...")
|
||||
plan = build_ad_asset_status_plan(client_config)
|
||||
print_ad_asset_status_plan(plan)
|
||||
json_path, md_path = save_ad_asset_status_plan(client_config.domain, plan)
|
||||
print(f"\nPlan JSON: {json_path}")
|
||||
print(f"Plan Markdown: {md_path}")
|
||||
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "plan przygotowany",
|
||||
"campaign": ", ".join(item["campaign_name"] for item in plan.problem_items[:10]),
|
||||
"summary": {
|
||||
"ads": len(plan.ads),
|
||||
"assets": len(plan.assets),
|
||||
"problem_items": len(plan.problem_items),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if plan_only:
|
||||
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu statusow reklam i zasobow.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
564
src/gads_v2/tasks/ad_schedule_check.py
Normal file
564
src/gads_v2/tasks/ad_schedule_check.py
Normal file
@@ -0,0 +1,564 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..config import ClientConfig, client_dir
|
||||
from ..google_ads import get_google_ads_client, run_query
|
||||
from ..history import append_change_markdown, append_history, now_local
|
||||
from ..knowledge.store import rules_for_task
|
||||
from ..table import print_table
|
||||
|
||||
|
||||
TASK_ID = "check_ad_schedules"
|
||||
TASK_NAME = "Sprawdzenie harmonogramu reklam"
|
||||
|
||||
|
||||
SCOPE = [
|
||||
{
|
||||
"area": "Harmonogram 24/7",
|
||||
"check": "Oznacz kampanie bez jawnego harmonogramu jako dzialajace caly tydzien i caly dzien.",
|
||||
},
|
||||
{
|
||||
"area": "Niestandardowe godziny",
|
||||
"check": "Wypisz kampanie z ustawionymi przedzialami dni i godzin emisji.",
|
||||
},
|
||||
{
|
||||
"area": "Typ kampanii",
|
||||
"check": "Pokaz harmonogram razem z typem kampanii, zeby osobno oceniac Search, Shopping i PMax.",
|
||||
},
|
||||
{
|
||||
"area": "Audyt ustawien",
|
||||
"check": "Przygotuj szybki przeglad harmonogramow, ktory mozna wykonywac rzadziej niz budzety i anomalie.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
OUT_OF_SCOPE = [
|
||||
"budzety i wykorzystanie budzetu",
|
||||
"strategie stawek oraz cele Docelowy ROAS/Docelowy CPA",
|
||||
"zapytania uzytkownikow oraz wykluczenia",
|
||||
"reklamy RSA, assety i kreacje",
|
||||
"wdrazanie zmian harmonogramu na koncie Google Ads",
|
||||
]
|
||||
|
||||
|
||||
DAY_ORDER = {
|
||||
"MONDAY": 1,
|
||||
"TUESDAY": 2,
|
||||
"WEDNESDAY": 3,
|
||||
"THURSDAY": 4,
|
||||
"FRIDAY": 5,
|
||||
"SATURDAY": 6,
|
||||
"SUNDAY": 7,
|
||||
}
|
||||
|
||||
|
||||
DAY_LABELS = {
|
||||
"MONDAY": "poniedzialek",
|
||||
"TUESDAY": "wtorek",
|
||||
"WEDNESDAY": "sroda",
|
||||
"THURSDAY": "czwartek",
|
||||
"FRIDAY": "piatek",
|
||||
"SATURDAY": "sobota",
|
||||
"SUNDAY": "niedziela",
|
||||
}
|
||||
|
||||
|
||||
MINUTE_LABELS = {
|
||||
"ZERO": "00",
|
||||
"FIFTEEN": "15",
|
||||
"THIRTY": "30",
|
||||
"FORTY_FIVE": "45",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdSchedulePlan:
|
||||
campaigns: list[dict]
|
||||
schedule_summary: list[dict]
|
||||
channel_summary: list[dict]
|
||||
scope: list[dict]
|
||||
out_of_scope: list[str]
|
||||
knowledge_rules: list[dict]
|
||||
warnings: list[str]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"task": TASK_ID,
|
||||
"task_name": TASK_NAME,
|
||||
"campaigns": self.campaigns,
|
||||
"schedule_summary": self.schedule_summary,
|
||||
"channel_summary": self.channel_summary,
|
||||
"scope": self.scope,
|
||||
"out_of_scope": self.out_of_scope,
|
||||
"knowledge_rules": self.knowledge_rules,
|
||||
"warnings": self.warnings,
|
||||
"changes": [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "AdSchedulePlan":
|
||||
return cls(
|
||||
campaigns=data.get("campaigns", []),
|
||||
schedule_summary=data.get("schedule_summary", []),
|
||||
channel_summary=data.get("channel_summary", []),
|
||||
scope=data.get("scope", []),
|
||||
out_of_scope=data.get("out_of_scope", []),
|
||||
knowledge_rules=data.get("knowledge_rules", []),
|
||||
warnings=data.get("warnings", []),
|
||||
)
|
||||
|
||||
|
||||
def enum_name(value: Any) -> str:
|
||||
name = getattr(value, "name", None)
|
||||
if name:
|
||||
return name
|
||||
return str(value)
|
||||
|
||||
|
||||
def md_cell(value: Any) -> str:
|
||||
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
|
||||
|
||||
|
||||
def minute_label(value: Any) -> str:
|
||||
raw = enum_name(value)
|
||||
return MINUTE_LABELS.get(raw, raw)
|
||||
|
||||
|
||||
def hour_label(hour: int, minute: str) -> str:
|
||||
return f"{int(hour):02d}:{minute}"
|
||||
|
||||
|
||||
def schedule_label(schedule: dict) -> str:
|
||||
day = DAY_LABELS.get(schedule["day_of_week"], schedule["day_of_week"])
|
||||
start = hour_label(schedule["start_hour"], schedule["start_minute"])
|
||||
end = hour_label(schedule["end_hour"], schedule["end_minute"])
|
||||
return f"{day} {start}-{end}"
|
||||
|
||||
|
||||
def join_schedule_labels(schedules: list[dict], limit: int = 8) -> str:
|
||||
if not schedules:
|
||||
return "24/7 (brak jawnego harmonogramu)"
|
||||
labels = [schedule["label"] for schedule in schedules]
|
||||
shown = labels[:limit]
|
||||
if len(labels) > limit:
|
||||
shown.append(f"... +{len(labels) - limit}")
|
||||
return ", ".join(shown)
|
||||
|
||||
|
||||
def campaign_flags(campaign: dict) -> list[str]:
|
||||
schedules = campaign["schedules"]
|
||||
flags = []
|
||||
if not schedules:
|
||||
flags.append("dziala 24/7")
|
||||
if len(schedules) == 7 and all(
|
||||
item["start_hour"] == 0
|
||||
and item["start_minute"] == "00"
|
||||
and item["end_hour"] == 24
|
||||
and item["end_minute"] == "00"
|
||||
for item in schedules
|
||||
):
|
||||
flags.append("harmonogram rowny 24/7")
|
||||
if schedules and len(schedules) < 5:
|
||||
flags.append("malo dni emisji")
|
||||
if any(item["start_hour"] < 6 or item["end_hour"] > 22 for item in schedules):
|
||||
flags.append("emisja nocna do oceny")
|
||||
return flags or ["ok"]
|
||||
|
||||
|
||||
def fetch_campaigns(client_config: ClientConfig) -> list[dict]:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
rows = run_query(
|
||||
google_client,
|
||||
client_config.safe_customer_id,
|
||||
"""
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign.name,
|
||||
campaign.status,
|
||||
campaign.advertising_channel_type
|
||||
FROM campaign
|
||||
WHERE campaign.status != 'REMOVED'
|
||||
""",
|
||||
)
|
||||
|
||||
campaigns = []
|
||||
for row in rows:
|
||||
campaign = row.campaign
|
||||
campaigns.append(
|
||||
{
|
||||
"campaign_id": str(campaign.id),
|
||||
"campaign_name": campaign.name,
|
||||
"status": enum_name(campaign.status),
|
||||
"channel_type": enum_name(campaign.advertising_channel_type),
|
||||
"schedules": [],
|
||||
}
|
||||
)
|
||||
return campaigns
|
||||
|
||||
|
||||
def fetch_ad_schedules(client_config: ClientConfig) -> dict[str, list[dict]]:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
rows = run_query(
|
||||
google_client,
|
||||
client_config.safe_customer_id,
|
||||
"""
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign_criterion.criterion_id,
|
||||
campaign_criterion.status,
|
||||
campaign_criterion.ad_schedule.day_of_week,
|
||||
campaign_criterion.ad_schedule.start_hour,
|
||||
campaign_criterion.ad_schedule.start_minute,
|
||||
campaign_criterion.ad_schedule.end_hour,
|
||||
campaign_criterion.ad_schedule.end_minute
|
||||
FROM campaign_criterion
|
||||
WHERE campaign.status != 'REMOVED'
|
||||
AND campaign_criterion.status != 'REMOVED'
|
||||
AND campaign_criterion.type = 'AD_SCHEDULE'
|
||||
""",
|
||||
)
|
||||
|
||||
schedules_by_campaign: dict[str, list[dict]] = {}
|
||||
for row in rows:
|
||||
campaign_id = str(row.campaign.id)
|
||||
criterion = row.campaign_criterion
|
||||
schedule = criterion.ad_schedule
|
||||
record = {
|
||||
"criterion_id": str(criterion.criterion_id),
|
||||
"status": enum_name(criterion.status),
|
||||
"day_of_week": enum_name(schedule.day_of_week),
|
||||
"start_hour": int(schedule.start_hour or 0),
|
||||
"start_minute": minute_label(schedule.start_minute),
|
||||
"end_hour": int(schedule.end_hour or 0),
|
||||
"end_minute": minute_label(schedule.end_minute),
|
||||
}
|
||||
record["label"] = schedule_label(record)
|
||||
schedules_by_campaign.setdefault(campaign_id, []).append(record)
|
||||
|
||||
for schedules in schedules_by_campaign.values():
|
||||
schedules.sort(key=lambda item: (DAY_ORDER.get(item["day_of_week"], 99), item["start_hour"], item["start_minute"]))
|
||||
return schedules_by_campaign
|
||||
|
||||
|
||||
def attach_schedules(campaigns: list[dict], schedules_by_campaign: dict[str, list[dict]]) -> list[dict]:
|
||||
for campaign in campaigns:
|
||||
campaign["schedules"] = schedules_by_campaign.get(campaign["campaign_id"], [])
|
||||
campaign["schedule_count"] = len(campaign["schedules"])
|
||||
campaign["schedule_label"] = join_schedule_labels(campaign["schedules"])
|
||||
campaign["flags"] = campaign_flags(campaign)
|
||||
campaigns.sort(key=lambda row: (row["channel_type"], row["campaign_name"]))
|
||||
return campaigns
|
||||
|
||||
|
||||
def build_schedule_summary(campaigns: list[dict]) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"metric": "Kampanie",
|
||||
"count": len(campaigns),
|
||||
},
|
||||
{
|
||||
"metric": "Kampanie bez jawnego harmonogramu",
|
||||
"count": sum(1 for campaign in campaigns if campaign["schedule_count"] == 0),
|
||||
},
|
||||
{
|
||||
"metric": "Kampanie z harmonogramem",
|
||||
"count": sum(1 for campaign in campaigns if campaign["schedule_count"] > 0),
|
||||
},
|
||||
{
|
||||
"metric": "Kampanie z emisja nocna do oceny",
|
||||
"count": sum(1 for campaign in campaigns if "emisja nocna do oceny" in campaign["flags"]),
|
||||
},
|
||||
{
|
||||
"metric": "Kampanie z malą liczba dni emisji",
|
||||
"count": sum(1 for campaign in campaigns if "malo dni emisji" in campaign["flags"]),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def build_channel_summary(campaigns: list[dict]) -> list[dict]:
|
||||
counter = Counter(row["channel_type"] for row in campaigns)
|
||||
return [{"channel_type": key, "count": value} for key, value in counter.most_common()]
|
||||
|
||||
|
||||
def build_ad_schedule_plan(client_config: ClientConfig) -> AdSchedulePlan:
|
||||
warnings = []
|
||||
try:
|
||||
campaigns = fetch_campaigns(client_config)
|
||||
schedules_by_campaign = fetch_ad_schedules(client_config)
|
||||
campaigns = attach_schedules(campaigns, schedules_by_campaign)
|
||||
except Exception as exc:
|
||||
campaigns = []
|
||||
warnings.append(f"Nie udalo sie pobrac harmonogramow reklam z Google Ads API: {exc}")
|
||||
|
||||
if not campaigns:
|
||||
warnings.append("Nie znaleziono kampanii albo nie udalo sie pobrac harmonogramow.")
|
||||
|
||||
knowledge_rules = [
|
||||
{
|
||||
"id": rule.id,
|
||||
"topic": rule.topic,
|
||||
"rule_type": rule.rule_type,
|
||||
"condition": rule.condition,
|
||||
"recommendation": rule.recommendation,
|
||||
"risk": rule.risk,
|
||||
"source": rule.source,
|
||||
}
|
||||
for rule in rules_for_task(TASK_ID)
|
||||
]
|
||||
if not knowledge_rules:
|
||||
warnings.append(
|
||||
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
|
||||
"Reguly dotyczace harmonogramow reklam bedziemy dopisywac osobno po akceptacji uzytkownika."
|
||||
)
|
||||
|
||||
return AdSchedulePlan(
|
||||
campaigns=campaigns,
|
||||
schedule_summary=build_schedule_summary(campaigns),
|
||||
channel_summary=build_channel_summary(campaigns),
|
||||
scope=SCOPE,
|
||||
out_of_scope=OUT_OF_SCOPE,
|
||||
knowledge_rules=knowledge_rules,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def save_ad_schedule_plan(domain: str, plan: AdSchedulePlan) -> tuple[Path, Path]:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "plans"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
|
||||
json_path = base / f"{stem}.json"
|
||||
md_path = base / f"{stem}.md"
|
||||
payload = {
|
||||
"created_at": ts.isoformat(timespec="seconds"),
|
||||
"client": domain,
|
||||
**plan.to_dict(),
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Plan: Sprawdzenie harmonogramu reklam",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Kampanie: {len(plan.campaigns)}",
|
||||
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
|
||||
"- Zmiany do wdrozenia: 0",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {item}" for item in plan.warnings)
|
||||
lines.append("")
|
||||
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
|
||||
for row in plan.scope:
|
||||
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
|
||||
lines.append("")
|
||||
lines.extend(["## Poza zakresem tego zadania", ""])
|
||||
lines.extend(f"- {item}" for item in plan.out_of_scope)
|
||||
lines.append("")
|
||||
if plan.schedule_summary:
|
||||
lines.extend(["## Podsumowanie harmonogramow", "", "| Metryka | Liczba |", "| --- | --- |"])
|
||||
for row in plan.schedule_summary:
|
||||
lines.append(f"| {md_cell(row['metric'])} | {row['count']} |")
|
||||
lines.append("")
|
||||
if plan.channel_summary:
|
||||
lines.extend(["## Podsumowanie po typach kampanii", "", "| Typ | Liczba |", "| --- | --- |"])
|
||||
for row in plan.channel_summary:
|
||||
lines.append(f"| {row['channel_type']} | {row['count']} |")
|
||||
lines.append("")
|
||||
if plan.campaigns:
|
||||
lines.extend(
|
||||
[
|
||||
"## Kampanie",
|
||||
"",
|
||||
"| Kampania | Typ | Status | Harmonogram | Flagi |",
|
||||
"| --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for campaign in plan.campaigns:
|
||||
lines.append(
|
||||
f"| {md_cell(campaign['campaign_name'])} | {campaign['channel_type']} | {campaign['status']} | "
|
||||
f"{md_cell(campaign['schedule_label'])} | {md_cell(', '.join(campaign['flags']))} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.knowledge_rules:
|
||||
lines.extend(
|
||||
[
|
||||
"## Reguly z bazy wiedzy",
|
||||
"",
|
||||
"| ID | Temat | Rekomendacja | Ryzyko |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for rule in plan.knowledge_rules:
|
||||
lines.append(
|
||||
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
|
||||
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
|
||||
)
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def print_ad_schedule_plan(plan: AdSchedulePlan) -> None:
|
||||
print("\nPlan sprawdzenia harmonogramu reklam")
|
||||
print_table(
|
||||
["Metryka", "Liczba"],
|
||||
[
|
||||
["Kampanie", str(len(plan.campaigns))],
|
||||
["Reguly wiedzy", str(len(plan.knowledge_rules))],
|
||||
["Zmiany do wdrozenia", "0"],
|
||||
],
|
||||
)
|
||||
if plan.warnings:
|
||||
print("\nUwagi")
|
||||
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
|
||||
print("\nZakres zadania")
|
||||
print_table(
|
||||
["Nr", "Obszar", "Co sprawdzic"],
|
||||
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
|
||||
)
|
||||
print("\nPoza zakresem")
|
||||
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
|
||||
if plan.schedule_summary:
|
||||
print("\nPodsumowanie harmonogramow")
|
||||
print_table(["Metryka", "Liczba"], [[row["metric"], str(row["count"])] for row in plan.schedule_summary])
|
||||
if plan.channel_summary:
|
||||
print("\nPodsumowanie po typach kampanii")
|
||||
print_table(
|
||||
["Typ", "Liczba"],
|
||||
[[row["channel_type"], str(row["count"])] for row in plan.channel_summary],
|
||||
)
|
||||
if plan.campaigns:
|
||||
print("\nKampanie")
|
||||
print_table(
|
||||
["Nr", "Kampania", "Typ", "Liczba przedz.", "Harmonogram", "Flagi"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
campaign["campaign_name"],
|
||||
campaign["channel_type"],
|
||||
str(campaign["schedule_count"]),
|
||||
campaign["schedule_label"],
|
||||
", ".join(campaign["flags"]),
|
||||
]
|
||||
for index, campaign in enumerate(plan.campaigns[:30], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.campaigns) > 30:
|
||||
print(f"... oraz {len(plan.campaigns) - 30} kolejnych kampanii w pliku planu")
|
||||
if plan.knowledge_rules:
|
||||
print("\nReguly z bazy wiedzy")
|
||||
print_table(
|
||||
["Nr", "ID", "Temat", "Rekomendacja"],
|
||||
[
|
||||
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
|
||||
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.knowledge_rules) > 10:
|
||||
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
|
||||
|
||||
|
||||
def print_next_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def apply_ad_schedule_plan(
|
||||
client_config: ClientConfig,
|
||||
plan: AdSchedulePlan,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
print("\nTo zadanie jest audytem harmonogramu reklam i nie wdraza zmian na koncie Google Ads.")
|
||||
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "audyt oznaczony jako wykonany",
|
||||
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
|
||||
"summary": {
|
||||
"campaigns": len(plan.campaigns),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def run_check_ad_schedules(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
_ = global_rules
|
||||
if apply_plan_path:
|
||||
if confirm_apply != "TAK":
|
||||
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
|
||||
if plan_data.get("client") != client_config.domain:
|
||||
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan = AdSchedulePlan.from_dict(plan_data)
|
||||
print_ad_schedule_plan(plan)
|
||||
apply_ad_schedule_plan(client_config, plan, show_navigation=show_navigation)
|
||||
return
|
||||
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print("Przygotowuje plan sprawdzenia harmonogramu reklam...")
|
||||
plan = build_ad_schedule_plan(client_config)
|
||||
print_ad_schedule_plan(plan)
|
||||
json_path, md_path = save_ad_schedule_plan(client_config.domain, plan)
|
||||
print(f"\nPlan JSON: {json_path}")
|
||||
print(f"Plan Markdown: {md_path}")
|
||||
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "plan przygotowany",
|
||||
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
|
||||
"summary": {
|
||||
"campaigns": len(plan.campaigns),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if plan_only:
|
||||
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu harmonogramu reklam.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
994
src/gads_v2/tasks/additional_audits.py
Normal file
994
src/gads_v2/tasks/additional_audits.py
Normal file
@@ -0,0 +1,994 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from ..config import ClientConfig, client_dir
|
||||
from ..google_ads import get_google_ads_client, run_query
|
||||
from ..history import append_change_markdown, append_history, now_local
|
||||
from ..knowledge.store import rules_for_task
|
||||
from ..table import print_table
|
||||
|
||||
|
||||
OUT_OF_SCOPE_COMMON = [
|
||||
"wdrazanie zmian na koncie Google Ads",
|
||||
"automatyczne zmiany budzetow",
|
||||
"automatyczne zmiany stawek albo strategii ustalania stawek",
|
||||
"automatyczne wylaczanie kampanii, grup reklam, slow kluczowych, reklam albo produktow",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuditDefinition:
|
||||
task_id: str
|
||||
task_name: str
|
||||
intro: str
|
||||
query: str
|
||||
row_builder: Callable[[Any], dict]
|
||||
summary_fields: list[str]
|
||||
table_fields: list[str]
|
||||
scope: list[dict]
|
||||
out_of_scope: list[str]
|
||||
sort_key: Callable[[dict], tuple]
|
||||
finding_builder: Callable[[dict], list[str]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class GenericAuditPlan:
|
||||
task: str
|
||||
task_name: str
|
||||
rows: list[dict]
|
||||
findings: list[dict]
|
||||
summary: list[dict]
|
||||
scope: list[dict]
|
||||
out_of_scope: list[str]
|
||||
knowledge_rules: list[dict]
|
||||
warnings: list[str]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"task": self.task,
|
||||
"task_name": self.task_name,
|
||||
"rows": self.rows,
|
||||
"findings": self.findings,
|
||||
"summary": self.summary,
|
||||
"scope": self.scope,
|
||||
"out_of_scope": self.out_of_scope,
|
||||
"knowledge_rules": self.knowledge_rules,
|
||||
"warnings": self.warnings,
|
||||
"changes": [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "GenericAuditPlan":
|
||||
return cls(
|
||||
task=data.get("task", ""),
|
||||
task_name=data.get("task_name", ""),
|
||||
rows=data.get("rows", []),
|
||||
findings=data.get("findings", []),
|
||||
summary=data.get("summary", []),
|
||||
scope=data.get("scope", []),
|
||||
out_of_scope=data.get("out_of_scope", []),
|
||||
knowledge_rules=data.get("knowledge_rules", []),
|
||||
warnings=data.get("warnings", []),
|
||||
)
|
||||
|
||||
|
||||
def enum_name(value: Any) -> str:
|
||||
name = getattr(value, "name", None)
|
||||
if name:
|
||||
return name
|
||||
return str(value)
|
||||
|
||||
|
||||
def md_cell(value: Any) -> str:
|
||||
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
|
||||
|
||||
|
||||
def micros_to_amount(value: int | float) -> float:
|
||||
return round(float(value or 0) / 1_000_000, 2)
|
||||
|
||||
|
||||
def money_micros(value: int | float, currency_code: str) -> str:
|
||||
suffix = f" {currency_code}" if currency_code else ""
|
||||
return f"{micros_to_amount(value):.2f}{suffix}"
|
||||
|
||||
|
||||
def money_amount(value: int | float, currency_code: str) -> str:
|
||||
suffix = f" {currency_code}" if currency_code else ""
|
||||
return f"{float(value or 0):.2f}{suffix}"
|
||||
|
||||
|
||||
def decimal(value: int | float, places: int = 2) -> str:
|
||||
return f"{float(value or 0):.{places}f}"
|
||||
|
||||
|
||||
def percent(numerator: int | float, denominator: int | float) -> float:
|
||||
if not denominator:
|
||||
return 0.0
|
||||
return round((float(numerator) / float(denominator)) * 100, 1)
|
||||
|
||||
|
||||
def rate(numerator: int | float, denominator: int | float) -> float:
|
||||
if not denominator:
|
||||
return 0.0
|
||||
return round((float(numerator) / float(denominator)) * 100, 2)
|
||||
|
||||
|
||||
def roas(conversion_value: float, cost_micros: int) -> float:
|
||||
cost = micros_to_amount(cost_micros)
|
||||
if not cost:
|
||||
return 0.0
|
||||
return round(float(conversion_value or 0) / cost, 2)
|
||||
|
||||
|
||||
def cpa(cost_micros: int, conversions: float) -> float:
|
||||
if not conversions:
|
||||
return 0.0
|
||||
return round(micros_to_amount(cost_micros) / float(conversions), 2)
|
||||
|
||||
|
||||
def safe_int(value: Any) -> int:
|
||||
try:
|
||||
return int(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def safe_float(value: Any) -> float:
|
||||
try:
|
||||
return float(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def fetch_currency_code(google_client, customer_id: str) -> str:
|
||||
rows = run_query(
|
||||
google_client,
|
||||
customer_id,
|
||||
"""
|
||||
SELECT
|
||||
customer.currency_code
|
||||
FROM customer
|
||||
""",
|
||||
)
|
||||
if not rows:
|
||||
return ""
|
||||
return str(rows[0].customer.currency_code or "")
|
||||
|
||||
|
||||
def base_metrics(row: Any, currency_code: str) -> dict:
|
||||
metrics = row.metrics
|
||||
cost_micros = safe_int(metrics.cost_micros)
|
||||
conversions = safe_float(metrics.conversions)
|
||||
conversion_value = safe_float(metrics.conversions_value)
|
||||
clicks = safe_int(metrics.clicks)
|
||||
impressions = safe_int(metrics.impressions)
|
||||
return {
|
||||
"impressions": impressions,
|
||||
"clicks": clicks,
|
||||
"cost": money_micros(cost_micros, currency_code),
|
||||
"cost_micros": cost_micros,
|
||||
"conversions": round(conversions, 2),
|
||||
"conversion_value": money_amount(conversion_value, currency_code),
|
||||
"conversion_value_raw": round(conversion_value, 2),
|
||||
"ctr": f"{rate(clicks, impressions):.2f}%",
|
||||
"avg_cpc": money_micros(int(cost_micros / clicks) if clicks else 0, currency_code),
|
||||
"conversion_rate": f"{rate(conversions, clicks):.2f}%",
|
||||
"roas": roas(conversion_value, cost_micros),
|
||||
"cpa": money_amount(cpa(cost_micros, conversions), currency_code),
|
||||
}
|
||||
|
||||
|
||||
def row_campaign_day(row: Any, currency_code: str) -> dict:
|
||||
return {
|
||||
"campaign": row.campaign.name,
|
||||
"channel": enum_name(row.campaign.advertising_channel_type),
|
||||
"day_of_week": enum_name(row.segments.day_of_week),
|
||||
**base_metrics(row, currency_code),
|
||||
}
|
||||
|
||||
|
||||
def row_campaign_hour(row: Any, currency_code: str) -> dict:
|
||||
return {
|
||||
"campaign": row.campaign.name,
|
||||
"channel": enum_name(row.campaign.advertising_channel_type),
|
||||
"hour": str(row.segments.hour),
|
||||
**base_metrics(row, currency_code),
|
||||
}
|
||||
|
||||
|
||||
def row_campaign_network(row: Any, currency_code: str) -> dict:
|
||||
return {
|
||||
"campaign": row.campaign.name,
|
||||
"channel": enum_name(row.campaign.advertising_channel_type),
|
||||
"network": enum_name(row.segments.ad_network_type),
|
||||
**base_metrics(row, currency_code),
|
||||
}
|
||||
|
||||
|
||||
def row_ad_group(row: Any, currency_code: str) -> dict:
|
||||
return {
|
||||
"campaign": row.campaign.name,
|
||||
"ad_group": row.ad_group.name,
|
||||
"status": enum_name(row.ad_group.status),
|
||||
"type": enum_name(row.ad_group.type_),
|
||||
**base_metrics(row, currency_code),
|
||||
}
|
||||
|
||||
|
||||
def row_keyword_quality(row: Any, currency_code: str) -> dict:
|
||||
criterion = row.ad_group_criterion
|
||||
quality = criterion.quality_info
|
||||
return {
|
||||
"campaign": row.campaign.name,
|
||||
"ad_group": row.ad_group.name,
|
||||
"keyword": criterion.keyword.text,
|
||||
"match_type": enum_name(criterion.keyword.match_type),
|
||||
"status": enum_name(criterion.status),
|
||||
"quality_score": safe_int(quality.quality_score),
|
||||
"creative_quality": enum_name(quality.creative_quality_score),
|
||||
"landing_page_quality": enum_name(quality.post_click_quality_score),
|
||||
"expected_ctr": enum_name(quality.search_predicted_ctr),
|
||||
**base_metrics(row, currency_code),
|
||||
}
|
||||
|
||||
|
||||
def row_landing_page(row: Any, currency_code: str) -> dict:
|
||||
return {
|
||||
"landing_page": row.landing_page_view.unexpanded_final_url,
|
||||
**base_metrics(row, currency_code),
|
||||
}
|
||||
|
||||
|
||||
def row_conversion_action(row: Any, currency_code: str) -> dict:
|
||||
return {
|
||||
"campaign": row.campaign.name,
|
||||
"conversion_action": str(row.segments.conversion_action_name or ""),
|
||||
"conversion_category": enum_name(row.segments.conversion_action_category),
|
||||
**base_metrics(row, currency_code),
|
||||
}
|
||||
|
||||
|
||||
def row_shopping_product(row: Any, currency_code: str) -> dict:
|
||||
return {
|
||||
"campaign": row.campaign.name,
|
||||
"ad_group": row.ad_group.name,
|
||||
"product_title": row.segments.product_title,
|
||||
"item_id": row.segments.product_item_id,
|
||||
"brand": row.segments.product_brand,
|
||||
"category_l1": row.segments.product_category_level1,
|
||||
**base_metrics(row, currency_code),
|
||||
}
|
||||
|
||||
|
||||
def row_gender(row: Any, currency_code: str) -> dict:
|
||||
return {
|
||||
"campaign": row.campaign.name,
|
||||
"ad_group": row.ad_group.name,
|
||||
"gender": enum_name(row.ad_group_criterion.gender.type_),
|
||||
**base_metrics(row, currency_code),
|
||||
}
|
||||
|
||||
|
||||
def row_age_range(row: Any, currency_code: str) -> dict:
|
||||
return {
|
||||
"campaign": row.campaign.name,
|
||||
"ad_group": row.ad_group.name,
|
||||
"age_range": enum_name(row.ad_group_criterion.age_range.type_),
|
||||
**base_metrics(row, currency_code),
|
||||
}
|
||||
|
||||
|
||||
def generic_findings(row: dict) -> list[str]:
|
||||
flags = []
|
||||
if row.get("cost_micros", 0) >= 100_000_000 and row.get("conversions", 0) == 0:
|
||||
flags.append("istotny koszt bez konwersji")
|
||||
if row.get("clicks", 0) >= 50 and row.get("conversions", 0) == 0:
|
||||
flags.append("wiele klikniec bez konwersji")
|
||||
if row.get("roas", 0) > 0 and row.get("roas", 0) < 1 and row.get("cost_micros", 0) >= 100_000_000:
|
||||
flags.append("niski ROAS przy istotnym koszcie")
|
||||
if row.get("impressions", 0) >= 1000 and row.get("clicks", 0) == 0:
|
||||
flags.append("wyswietlenia bez klikniec")
|
||||
return flags
|
||||
|
||||
|
||||
def quality_findings(row: dict) -> list[str]:
|
||||
flags = generic_findings(row)
|
||||
if 0 < row.get("quality_score", 0) <= 3:
|
||||
flags.append("niski Wynik Jakosci")
|
||||
if row.get("landing_page_quality") in {"BELOW_AVERAGE", "BELOW AVERAGE"}:
|
||||
flags.append("slaba jakosc strony docelowej")
|
||||
if row.get("expected_ctr") in {"BELOW_AVERAGE", "BELOW AVERAGE"}:
|
||||
flags.append("niski przewidywany CTR")
|
||||
return flags
|
||||
|
||||
|
||||
def landing_page_findings(row: dict) -> list[str]:
|
||||
flags = generic_findings(row)
|
||||
if not row.get("landing_page"):
|
||||
flags.append("brak widocznego URL strony docelowej")
|
||||
return flags
|
||||
|
||||
|
||||
def build_summary(rows: list[dict], summary_fields: list[str], currency_code: str) -> list[dict]:
|
||||
summary = []
|
||||
for field in summary_fields:
|
||||
buckets: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
key = str(row.get(field, "") or "(brak)")
|
||||
bucket = buckets.setdefault(
|
||||
key,
|
||||
{
|
||||
"segment": key,
|
||||
"rows": 0,
|
||||
"cost_micros": 0,
|
||||
"clicks": 0,
|
||||
"impressions": 0,
|
||||
"conversions": 0.0,
|
||||
"conversion_value_raw": 0.0,
|
||||
},
|
||||
)
|
||||
bucket["rows"] += 1
|
||||
bucket["cost_micros"] += safe_int(row.get("cost_micros"))
|
||||
bucket["clicks"] += safe_int(row.get("clicks"))
|
||||
bucket["impressions"] += safe_int(row.get("impressions"))
|
||||
bucket["conversions"] += safe_float(row.get("conversions"))
|
||||
bucket["conversion_value_raw"] += safe_float(row.get("conversion_value_raw"))
|
||||
for bucket in buckets.values():
|
||||
summary.append(
|
||||
{
|
||||
"group_by": field,
|
||||
"segment": bucket["segment"],
|
||||
"rows": bucket["rows"],
|
||||
"cost": money_micros(bucket["cost_micros"], currency_code),
|
||||
"clicks": bucket["clicks"],
|
||||
"conversions": round(bucket["conversions"], 2),
|
||||
"conversion_value": money_amount(bucket["conversion_value_raw"], currency_code),
|
||||
"roas": roas(bucket["conversion_value_raw"], bucket["cost_micros"]),
|
||||
}
|
||||
)
|
||||
summary.sort(key=lambda row: (row["group_by"], -safe_float(str(row["cost"]).split()[0].replace(",", "."))))
|
||||
return summary
|
||||
|
||||
|
||||
def build_plan(definition: AuditDefinition, client_config: ClientConfig) -> GenericAuditPlan:
|
||||
warnings = []
|
||||
currency_code = ""
|
||||
rows: list[dict] = []
|
||||
try:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
currency_code = fetch_currency_code(google_client, client_config.safe_customer_id)
|
||||
api_rows = run_query(google_client, client_config.safe_customer_id, definition.query)
|
||||
rows = [definition.row_builder(row, currency_code) for row in api_rows]
|
||||
except Exception as exc:
|
||||
warnings.append(f"Nie udalo sie pobrac danych dla zadania {definition.task_name}: {compact_error(exc)}")
|
||||
|
||||
rows.sort(key=definition.sort_key)
|
||||
findings = []
|
||||
for row in rows:
|
||||
flags = definition.finding_builder(row)
|
||||
if flags:
|
||||
findings.append(
|
||||
{
|
||||
**{key: row.get(key, "") for key in definition.table_fields if key in row},
|
||||
"flags": flags,
|
||||
"recommendation": "sprawdz ten segment przed decyzja optymalizacyjna",
|
||||
}
|
||||
)
|
||||
|
||||
if not rows:
|
||||
warnings.append("Nie znaleziono danych w analizowanym zakresie albo API nie zwrocilo wynikow.")
|
||||
|
||||
knowledge_rules = [
|
||||
{
|
||||
"id": rule.id,
|
||||
"topic": rule.topic,
|
||||
"rule_type": rule.rule_type,
|
||||
"condition": rule.condition,
|
||||
"recommendation": rule.recommendation,
|
||||
"risk": rule.risk,
|
||||
"source": rule.source,
|
||||
}
|
||||
for rule in rules_for_task(definition.task_id)
|
||||
]
|
||||
if not knowledge_rules:
|
||||
warnings.append(
|
||||
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
|
||||
"Reguly bedziemy dopisywac osobno po akceptacji uzytkownika."
|
||||
)
|
||||
|
||||
return GenericAuditPlan(
|
||||
task=definition.task_id,
|
||||
task_name=definition.task_name,
|
||||
rows=rows,
|
||||
findings=findings,
|
||||
summary=build_summary(rows, definition.summary_fields, currency_code),
|
||||
scope=definition.scope,
|
||||
out_of_scope=definition.out_of_scope,
|
||||
knowledge_rules=knowledge_rules,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def compact_error(exc: Exception) -> str:
|
||||
message = str(exc)
|
||||
if "PROHIBITED" in message:
|
||||
return "Google Ads API odrzucilo kombinacje pol w zapytaniu. Szczegoly sa w logu requestu."
|
||||
if "UNRECOGNIZED_FIELD" in message:
|
||||
return "Google Ads API nie rozpoznalo jednego z pol zapytania."
|
||||
if "PERMISSION_DENIED" in message:
|
||||
return "brak uprawnien do pobrania tych danych."
|
||||
return message.splitlines()[0][:500]
|
||||
|
||||
|
||||
def save_plan(domain: str, definition: AuditDefinition, plan: GenericAuditPlan) -> tuple[Path, Path]:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "plans"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{definition.task_id}"
|
||||
json_path = base / f"{stem}.json"
|
||||
md_path = base / f"{stem}.md"
|
||||
payload = {
|
||||
"created_at": ts.isoformat(timespec="seconds"),
|
||||
"client": domain,
|
||||
**plan.to_dict(),
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
f"# Plan: {definition.task_name}",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Wiersze danych: {len(plan.rows)}",
|
||||
f"- Elementy do oceny: {len(plan.findings)}",
|
||||
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
|
||||
"- Zmiany do wdrozenia: 0",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {item}" for item in plan.warnings)
|
||||
lines.append("")
|
||||
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
|
||||
for row in plan.scope:
|
||||
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
|
||||
lines.append("")
|
||||
lines.extend(["## Poza zakresem tego zadania", ""])
|
||||
lines.extend(f"- {item}" for item in plan.out_of_scope)
|
||||
lines.append("")
|
||||
if plan.summary:
|
||||
lines.extend(["## Podsumowanie segmentow", "", "| Grupowanie | Segment | Wiersze | Koszt | Klikniecia | Konwersje | Wartosc | ROAS |", "| --- | --- | --- | --- | --- | --- | --- | --- |"])
|
||||
for row in plan.summary:
|
||||
lines.append(
|
||||
f"| {md_cell(row['group_by'])} | {md_cell(row['segment'])} | {row['rows']} | {row['cost']} | "
|
||||
f"{row['clicks']} | {row['conversions']} | {row['conversion_value']} | {row['roas']} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.findings:
|
||||
fields = [field for field in definition.table_fields if any(field in row for row in plan.findings)]
|
||||
lines.extend(["## Elementy do oceny", "", "| " + " | ".join(fields + ["Flagi", "Rekomendacja"]) + " |", "| " + " | ".join("---" for _ in fields + ["Flagi", "Rekomendacja"]) + " |"])
|
||||
for row in plan.findings:
|
||||
values = [md_cell(row.get(field, "")) for field in fields]
|
||||
values.extend([md_cell(", ".join(row.get("flags", []))), md_cell(row.get("recommendation", ""))])
|
||||
lines.append("| " + " | ".join(values) + " |")
|
||||
lines.append("")
|
||||
if plan.rows:
|
||||
fields = [field for field in definition.table_fields if any(field in row for row in plan.rows)]
|
||||
lines.extend(["## Dane", "", "| " + " | ".join(fields) + " |", "| " + " | ".join("---" for _ in fields) + " |"])
|
||||
for row in plan.rows:
|
||||
lines.append("| " + " | ".join(md_cell(row.get(field, "")) for field in fields) + " |")
|
||||
lines.append("")
|
||||
if plan.knowledge_rules:
|
||||
lines.extend(["## Reguly z bazy wiedzy", "", "| ID | Temat | Rekomendacja | Ryzyko |", "| --- | --- | --- | --- |"])
|
||||
for rule in plan.knowledge_rules:
|
||||
lines.append(
|
||||
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
|
||||
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
|
||||
)
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def print_plan(definition: AuditDefinition, plan: GenericAuditPlan) -> None:
|
||||
print(f"\nPlan: {definition.task_name}")
|
||||
print_table(
|
||||
["Metryka", "Liczba"],
|
||||
[
|
||||
["Wiersze danych", str(len(plan.rows))],
|
||||
["Elementy do oceny", str(len(plan.findings))],
|
||||
["Reguly wiedzy", str(len(plan.knowledge_rules))],
|
||||
["Zmiany do wdrozenia", "0"],
|
||||
],
|
||||
)
|
||||
if plan.warnings:
|
||||
print("\nUwagi")
|
||||
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
|
||||
print("\nZakres zadania")
|
||||
print_table(
|
||||
["Nr", "Obszar", "Co sprawdzic"],
|
||||
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
|
||||
)
|
||||
print("\nPoza zakresem")
|
||||
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
|
||||
if plan.summary:
|
||||
print("\nPodsumowanie segmentow")
|
||||
print_table(
|
||||
["Grupowanie", "Segment", "Wiersze", "Koszt", "Klikniecia", "Konw.", "Wartosc", "ROAS"],
|
||||
[
|
||||
[row["group_by"], row["segment"], str(row["rows"]), row["cost"], str(row["clicks"]), str(row["conversions"]), row["conversion_value"], str(row["roas"])]
|
||||
for row in plan.summary[:30]
|
||||
],
|
||||
)
|
||||
if len(plan.summary) > 30:
|
||||
print(f"... oraz {len(plan.summary) - 30} kolejnych segmentow w pliku planu")
|
||||
if plan.findings:
|
||||
fields = [field for field in definition.table_fields if any(field in row for row in plan.findings)]
|
||||
rows = []
|
||||
for index, row in enumerate(plan.findings[:30], 1):
|
||||
rows.append([str(index)] + [row.get(field, "") for field in fields] + [", ".join(row.get("flags", []))])
|
||||
print("\nElementy do oceny")
|
||||
print_table(["Nr"] + fields + ["Flagi"], rows)
|
||||
if len(plan.findings) > 30:
|
||||
print(f"... oraz {len(plan.findings) - 30} kolejnych elementow w pliku planu")
|
||||
elif plan.rows:
|
||||
fields = [field for field in definition.table_fields if any(field in row for row in plan.rows)]
|
||||
print("\nDane")
|
||||
print_table(
|
||||
["Nr"] + fields,
|
||||
[[str(index)] + [row.get(field, "") for field in fields] for index, row in enumerate(plan.rows[:30], 1)],
|
||||
)
|
||||
if len(plan.rows) > 30:
|
||||
print(f"... oraz {len(plan.rows) - 30} kolejnych wierszy w pliku planu")
|
||||
|
||||
|
||||
def print_next_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def apply_plan(client_config: ClientConfig, plan: GenericAuditPlan, show_navigation: bool = True) -> None:
|
||||
print(f"\nTo zadanie jest audytem: {plan.task_name}. Nie wdraza zmian na koncie Google Ads.")
|
||||
changes_path = append_change_markdown(client_config.domain, plan.task_name, [])
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": plan.task_name,
|
||||
"status": "audyt oznaczony jako wykonany",
|
||||
"summary": {
|
||||
"rows": len(plan.rows),
|
||||
"findings": len(plan.findings),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def run_generic_audit(
|
||||
definition: AuditDefinition,
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
_ = global_rules
|
||||
if apply_plan_path:
|
||||
if confirm_apply != "TAK":
|
||||
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
|
||||
if plan_data.get("client") != client_config.domain:
|
||||
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan = GenericAuditPlan.from_dict(plan_data)
|
||||
print_plan(definition, plan)
|
||||
apply_plan(client_config, plan, show_navigation=show_navigation)
|
||||
return
|
||||
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print(definition.intro)
|
||||
plan = build_plan(definition, client_config)
|
||||
print_plan(definition, plan)
|
||||
json_path, md_path = save_plan(client_config.domain, definition, plan)
|
||||
print(f"\nPlan JSON: {json_path}")
|
||||
print(f"Plan Markdown: {md_path}")
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": definition.task_name,
|
||||
"status": "plan przygotowany",
|
||||
"summary": {
|
||||
"rows": len(plan.rows),
|
||||
"findings": len(plan.findings),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if plan_only:
|
||||
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def scope(*items: tuple[str, str]) -> list[dict]:
|
||||
return [{"area": area, "check": check} for area, check in items]
|
||||
|
||||
|
||||
def query_campaign_segment(segment_field: str) -> str:
|
||||
return f"""
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign.name,
|
||||
campaign.status,
|
||||
campaign.advertising_channel_type,
|
||||
{segment_field},
|
||||
metrics.impressions,
|
||||
metrics.clicks,
|
||||
metrics.cost_micros,
|
||||
metrics.conversions,
|
||||
metrics.conversions_value
|
||||
FROM campaign
|
||||
WHERE campaign.status != 'REMOVED'
|
||||
AND segments.date DURING LAST_30_DAYS
|
||||
"""
|
||||
|
||||
|
||||
QUERY_AD_GROUP = """
|
||||
SELECT
|
||||
campaign.name,
|
||||
ad_group.name,
|
||||
ad_group.status,
|
||||
ad_group.type,
|
||||
metrics.impressions,
|
||||
metrics.clicks,
|
||||
metrics.cost_micros,
|
||||
metrics.conversions,
|
||||
metrics.conversions_value
|
||||
FROM ad_group
|
||||
WHERE campaign.status != 'REMOVED'
|
||||
AND ad_group.status != 'REMOVED'
|
||||
AND segments.date DURING LAST_30_DAYS
|
||||
"""
|
||||
|
||||
|
||||
QUERY_KEYWORD_QUALITY = """
|
||||
SELECT
|
||||
campaign.name,
|
||||
ad_group.name,
|
||||
ad_group_criterion.status,
|
||||
ad_group_criterion.keyword.text,
|
||||
ad_group_criterion.keyword.match_type,
|
||||
ad_group_criterion.quality_info.quality_score,
|
||||
ad_group_criterion.quality_info.creative_quality_score,
|
||||
ad_group_criterion.quality_info.post_click_quality_score,
|
||||
ad_group_criterion.quality_info.search_predicted_ctr,
|
||||
metrics.impressions,
|
||||
metrics.clicks,
|
||||
metrics.cost_micros,
|
||||
metrics.conversions,
|
||||
metrics.conversions_value
|
||||
FROM keyword_view
|
||||
WHERE campaign.status != 'REMOVED'
|
||||
AND ad_group.status != 'REMOVED'
|
||||
AND ad_group_criterion.status != 'REMOVED'
|
||||
AND segments.date DURING LAST_30_DAYS
|
||||
"""
|
||||
|
||||
|
||||
QUERY_LANDING_PAGE = """
|
||||
SELECT
|
||||
landing_page_view.unexpanded_final_url,
|
||||
metrics.impressions,
|
||||
metrics.clicks,
|
||||
metrics.cost_micros,
|
||||
metrics.conversions,
|
||||
metrics.conversions_value
|
||||
FROM landing_page_view
|
||||
WHERE segments.date DURING LAST_30_DAYS
|
||||
"""
|
||||
|
||||
|
||||
QUERY_CONVERSION_ACTION = """
|
||||
SELECT
|
||||
campaign.name,
|
||||
segments.conversion_action_name,
|
||||
segments.conversion_action_category,
|
||||
metrics.conversions,
|
||||
metrics.conversions_value
|
||||
FROM campaign
|
||||
WHERE campaign.status != 'REMOVED'
|
||||
AND segments.date DURING LAST_30_DAYS
|
||||
"""
|
||||
|
||||
|
||||
QUERY_SHOPPING_PRODUCT = """
|
||||
SELECT
|
||||
campaign.name,
|
||||
ad_group.name,
|
||||
segments.product_title,
|
||||
segments.product_item_id,
|
||||
segments.product_brand,
|
||||
segments.product_category_level1,
|
||||
metrics.impressions,
|
||||
metrics.clicks,
|
||||
metrics.cost_micros,
|
||||
metrics.conversions,
|
||||
metrics.conversions_value
|
||||
FROM shopping_performance_view
|
||||
WHERE segments.date DURING LAST_30_DAYS
|
||||
LIMIT 500
|
||||
"""
|
||||
|
||||
|
||||
QUERY_GENDER = """
|
||||
SELECT
|
||||
campaign.name,
|
||||
ad_group.name,
|
||||
ad_group_criterion.gender.type,
|
||||
metrics.impressions,
|
||||
metrics.clicks,
|
||||
metrics.cost_micros,
|
||||
metrics.conversions,
|
||||
metrics.conversions_value
|
||||
FROM gender_view
|
||||
WHERE segments.date DURING LAST_30_DAYS
|
||||
"""
|
||||
|
||||
|
||||
QUERY_AGE_RANGE = """
|
||||
SELECT
|
||||
campaign.name,
|
||||
ad_group.name,
|
||||
ad_group_criterion.age_range.type,
|
||||
metrics.impressions,
|
||||
metrics.clicks,
|
||||
metrics.cost_micros,
|
||||
metrics.conversions,
|
||||
metrics.conversions_value
|
||||
FROM age_range_view
|
||||
WHERE segments.date DURING LAST_30_DAYS
|
||||
"""
|
||||
|
||||
|
||||
DEFINITIONS: dict[str, AuditDefinition] = {
|
||||
"check_day_of_week_performance": AuditDefinition(
|
||||
task_id="check_day_of_week_performance",
|
||||
task_name="Sprawdzenie dni tygodnia",
|
||||
intro="Przygotowuje plan sprawdzenia wynikow wedlug dni tygodnia...",
|
||||
query=query_campaign_segment("segments.day_of_week"),
|
||||
row_builder=row_campaign_day,
|
||||
summary_fields=["day_of_week"],
|
||||
table_fields=["campaign", "channel", "day_of_week", "cost", "clicks", "conversions", "conversion_value", "roas", "cpa", "ctr"],
|
||||
scope=scope(
|
||||
("Dni tygodnia", "Porownaj koszt, klikniecia, konwersje, wartosc konwersji, ROAS i CPA wedlug dnia tygodnia."),
|
||||
("Kontekst kampanii", "Pokaz dane w podziale na kampanie, aby nie mieszac roznych typow ruchu."),
|
||||
("Decyzja reczna", "Zaznacz segmenty do oceny bez zmiany harmonogramu reklam."),
|
||||
),
|
||||
out_of_scope=OUT_OF_SCOPE_COMMON + ["zmiany harmonogramu reklam"],
|
||||
sort_key=lambda row: (row["campaign"], row["day_of_week"]),
|
||||
finding_builder=generic_findings,
|
||||
),
|
||||
"check_hour_of_day_performance": AuditDefinition(
|
||||
task_id="check_hour_of_day_performance",
|
||||
task_name="Sprawdzenie godzin dnia",
|
||||
intro="Przygotowuje plan sprawdzenia wynikow wedlug godzin dnia...",
|
||||
query=query_campaign_segment("segments.hour"),
|
||||
row_builder=row_campaign_hour,
|
||||
summary_fields=["hour"],
|
||||
table_fields=["campaign", "channel", "hour", "cost", "clicks", "conversions", "conversion_value", "roas", "cpa", "ctr"],
|
||||
scope=scope(
|
||||
("Godziny dnia", "Porownaj wyniki wedlug godzin z ostatnich 30 dni."),
|
||||
("Efektywnosc", "Pokaz ROAS, CPA i konwersje dla godzin, w ktorych realnie wydawany jest budzet."),
|
||||
("Granica zadania", "Nie zmieniaj harmonogramu reklam ani stawek godzinowych w tym zadaniu."),
|
||||
),
|
||||
out_of_scope=OUT_OF_SCOPE_COMMON + ["zmiany harmonogramu reklam"],
|
||||
sort_key=lambda row: (safe_int(row["hour"]), row["campaign"]),
|
||||
finding_builder=generic_findings,
|
||||
),
|
||||
"check_network_performance": AuditDefinition(
|
||||
task_id="check_network_performance",
|
||||
task_name="Sprawdzenie efektywnosci sieci",
|
||||
intro="Przygotowuje plan sprawdzenia wynikow wedlug sieci...",
|
||||
query=query_campaign_segment("segments.ad_network_type"),
|
||||
row_builder=row_campaign_network,
|
||||
summary_fields=["network", "channel"],
|
||||
table_fields=["campaign", "channel", "network", "cost", "clicks", "conversions", "conversion_value", "roas", "cpa", "ctr"],
|
||||
scope=scope(
|
||||
("Sieci", "Porownaj wyniki Google Search, partnerow, Display, Shopping i innych sieci zwracanych przez API."),
|
||||
("Jakosc ruchu", "Oznacz sieci z kosztem bez konwersji albo slabym ROAS."),
|
||||
("Granica zadania", "Nie zmieniaj ustawien sieci w tym audycie."),
|
||||
),
|
||||
out_of_scope=OUT_OF_SCOPE_COMMON + ["zmiany ustawien sieci kampanii"],
|
||||
sort_key=lambda row: (row["campaign"], row["network"]),
|
||||
finding_builder=generic_findings,
|
||||
),
|
||||
"check_ad_group_performance": AuditDefinition(
|
||||
task_id="check_ad_group_performance",
|
||||
task_name="Sprawdzenie grup reklam",
|
||||
intro="Przygotowuje plan sprawdzenia wynikow grup reklam...",
|
||||
query=QUERY_AD_GROUP,
|
||||
row_builder=row_ad_group,
|
||||
summary_fields=["campaign", "type"],
|
||||
table_fields=["campaign", "ad_group", "status", "type", "cost", "clicks", "conversions", "conversion_value", "roas", "cpa", "ctr"],
|
||||
scope=scope(
|
||||
("Grupy reklam", "Pokaz koszt, konwersje, wartosc, ROAS i CPA na poziomie grupy reklam."),
|
||||
("Priorytet oceny", "Oznacz grupy z kosztem bez konwersji, wieloma kliknieciami bez konwersji albo slabym ROAS."),
|
||||
("Granica zadania", "Nie wstrzymuj grup reklam automatycznie."),
|
||||
),
|
||||
out_of_scope=OUT_OF_SCOPE_COMMON + ["zmiany struktury grup reklam"],
|
||||
sort_key=lambda row: (-row["cost_micros"], row["campaign"], row["ad_group"]),
|
||||
finding_builder=generic_findings,
|
||||
),
|
||||
"check_keyword_quality_score": AuditDefinition(
|
||||
task_id="check_keyword_quality_score",
|
||||
task_name="Sprawdzenie Wyniku Jakosci slow kluczowych",
|
||||
intro="Przygotowuje plan sprawdzenia Wyniku Jakosci slow kluczowych...",
|
||||
query=QUERY_KEYWORD_QUALITY,
|
||||
row_builder=row_keyword_quality,
|
||||
summary_fields=["quality_score", "match_type"],
|
||||
table_fields=["campaign", "ad_group", "keyword", "match_type", "quality_score", "creative_quality", "landing_page_quality", "expected_ctr", "cost", "clicks", "conversions", "roas"],
|
||||
scope=scope(
|
||||
("Wynik Jakosci", "Sprawdz quality score oraz skladowe: reklama, strona docelowa i przewidywany CTR."),
|
||||
("Koszt i wynik", "Polacz ocene jakosci z kosztem, kliknieciami, konwersjami i ROAS."),
|
||||
("Granica zadania", "Nie pauzuj slow kluczowych i nie zmieniaj stawek."),
|
||||
),
|
||||
out_of_scope=OUT_OF_SCOPE_COMMON + ["dodawanie albo usuwanie slow kluczowych"],
|
||||
sort_key=lambda row: (row.get("quality_score", 0) or 99, -row["cost_micros"], row["keyword"]),
|
||||
finding_builder=quality_findings,
|
||||
),
|
||||
"check_landing_page_performance": AuditDefinition(
|
||||
task_id="check_landing_page_performance",
|
||||
task_name="Sprawdzenie stron docelowych",
|
||||
intro="Przygotowuje plan sprawdzenia stron docelowych...",
|
||||
query=QUERY_LANDING_PAGE,
|
||||
row_builder=row_landing_page,
|
||||
summary_fields=[],
|
||||
table_fields=["landing_page", "cost", "clicks", "conversions", "conversion_value", "roas", "cpa", "ctr", "conversion_rate"],
|
||||
scope=scope(
|
||||
("Strony docelowe", "Pokaz wyniki URL-i docelowych z ostatnich 30 dni."),
|
||||
("Efektywnosc", "Oznacz strony z kosztem, kliknieciami i brakiem konwersji albo niskim ROAS."),
|
||||
("Granica zadania", "Nie zmieniaj URL-i ani reklam automatycznie."),
|
||||
),
|
||||
out_of_scope=OUT_OF_SCOPE_COMMON + ["zmiany URL-i docelowych"],
|
||||
sort_key=lambda row: (-row["cost_micros"], row["landing_page"]),
|
||||
finding_builder=landing_page_findings,
|
||||
),
|
||||
"check_conversion_action_performance": AuditDefinition(
|
||||
task_id="check_conversion_action_performance",
|
||||
task_name="Sprawdzenie akcji konwersji",
|
||||
intro="Przygotowuje plan sprawdzenia akcji konwersji...",
|
||||
query=QUERY_CONVERSION_ACTION,
|
||||
row_builder=row_conversion_action,
|
||||
summary_fields=["conversion_action", "conversion_category"],
|
||||
table_fields=["campaign", "conversion_action", "conversion_category", "cost", "clicks", "conversions", "conversion_value", "roas", "cpa"],
|
||||
scope=scope(
|
||||
("Akcje konwersji", "Pokaz, ktore akcje konwersji generuja wynik w kampaniach."),
|
||||
("Jakosc pomiaru", "Pomoz wychwycic kampanie optymalizowane na niewlasciwy albo podejrzany typ konwersji."),
|
||||
("Granica zadania", "Nie zmieniaj ustawien konwersji ani celow kampanii."),
|
||||
),
|
||||
out_of_scope=OUT_OF_SCOPE_COMMON + ["zmiany ustawien konwersji"],
|
||||
sort_key=lambda row: (row["campaign"], row["conversion_action"]),
|
||||
finding_builder=generic_findings,
|
||||
),
|
||||
"check_shopping_product_performance": AuditDefinition(
|
||||
task_id="check_shopping_product_performance",
|
||||
task_name="Sprawdzenie wynikow produktow Shopping",
|
||||
intro="Przygotowuje plan sprawdzenia wynikow produktow Shopping...",
|
||||
query=QUERY_SHOPPING_PRODUCT,
|
||||
row_builder=row_shopping_product,
|
||||
summary_fields=["brand", "category_l1"],
|
||||
table_fields=["campaign", "ad_group", "product_title", "item_id", "brand", "category_l1", "cost", "clicks", "conversions", "conversion_value", "roas", "cpa"],
|
||||
scope=scope(
|
||||
("Produkty", "Pokaz produkty z kosztem, kliknieciami, konwersjami, wartoscia i ROAS."),
|
||||
("Priorytet feedu", "Wskaz produkty, ktore moga wymagac analizy ceny, tytulu, feedu albo dostepnosci."),
|
||||
("Granica zadania", "Nie zmieniaj feedu, stawek ani struktury kampanii."),
|
||||
),
|
||||
out_of_scope=OUT_OF_SCOPE_COMMON + ["optymalizacja tytulow, kategorii Google i unit pricing"],
|
||||
sort_key=lambda row: (-row["cost_micros"], row["product_title"]),
|
||||
finding_builder=generic_findings,
|
||||
),
|
||||
"check_gender_performance": AuditDefinition(
|
||||
task_id="check_gender_performance",
|
||||
task_name="Sprawdzenie plci odbiorcow",
|
||||
intro="Przygotowuje plan sprawdzenia wynikow wedlug plci odbiorcow...",
|
||||
query=QUERY_GENDER,
|
||||
row_builder=row_gender,
|
||||
summary_fields=["gender"],
|
||||
table_fields=["campaign", "ad_group", "gender", "cost", "clicks", "conversions", "conversion_value", "roas", "cpa", "ctr"],
|
||||
scope=scope(
|
||||
("Plec odbiorcow", "Porownaj wyniki segmentow plci z ostatnich 30 dni."),
|
||||
("Efektywnosc", "Pokaz koszt, konwersje, wartosc, ROAS i CPA."),
|
||||
("Granica zadania", "Nie dodawaj wykluczen demograficznych ani korekt stawek."),
|
||||
),
|
||||
out_of_scope=OUT_OF_SCOPE_COMMON + ["zmiany kierowania demograficznego"],
|
||||
sort_key=lambda row: (row["campaign"], row["gender"]),
|
||||
finding_builder=generic_findings,
|
||||
),
|
||||
"check_age_performance": AuditDefinition(
|
||||
task_id="check_age_performance",
|
||||
task_name="Sprawdzenie wieku odbiorcow",
|
||||
intro="Przygotowuje plan sprawdzenia wynikow wedlug wieku odbiorcow...",
|
||||
query=QUERY_AGE_RANGE,
|
||||
row_builder=row_age_range,
|
||||
summary_fields=["age_range"],
|
||||
table_fields=["campaign", "ad_group", "age_range", "cost", "clicks", "conversions", "conversion_value", "roas", "cpa", "ctr"],
|
||||
scope=scope(
|
||||
("Wiek odbiorcow", "Porownaj wyniki przedzialow wieku z ostatnich 30 dni."),
|
||||
("Efektywnosc", "Pokaz koszt, konwersje, wartosc, ROAS i CPA."),
|
||||
("Granica zadania", "Nie dodawaj wykluczen demograficznych ani korekt stawek."),
|
||||
),
|
||||
out_of_scope=OUT_OF_SCOPE_COMMON + ["zmiany kierowania demograficznego"],
|
||||
sort_key=lambda row: (row["campaign"], row["age_range"]),
|
||||
finding_builder=generic_findings,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def run_check_day_of_week_performance(*args, **kwargs) -> None:
|
||||
run_generic_audit(DEFINITIONS["check_day_of_week_performance"], *args, **kwargs)
|
||||
|
||||
|
||||
def run_check_hour_of_day_performance(*args, **kwargs) -> None:
|
||||
run_generic_audit(DEFINITIONS["check_hour_of_day_performance"], *args, **kwargs)
|
||||
|
||||
|
||||
def run_check_network_performance(*args, **kwargs) -> None:
|
||||
run_generic_audit(DEFINITIONS["check_network_performance"], *args, **kwargs)
|
||||
|
||||
|
||||
def run_check_ad_group_performance(*args, **kwargs) -> None:
|
||||
run_generic_audit(DEFINITIONS["check_ad_group_performance"], *args, **kwargs)
|
||||
|
||||
|
||||
def run_check_keyword_quality_score(*args, **kwargs) -> None:
|
||||
run_generic_audit(DEFINITIONS["check_keyword_quality_score"], *args, **kwargs)
|
||||
|
||||
|
||||
def run_check_landing_page_performance(*args, **kwargs) -> None:
|
||||
run_generic_audit(DEFINITIONS["check_landing_page_performance"], *args, **kwargs)
|
||||
|
||||
|
||||
def run_check_conversion_action_performance(*args, **kwargs) -> None:
|
||||
run_generic_audit(DEFINITIONS["check_conversion_action_performance"], *args, **kwargs)
|
||||
|
||||
|
||||
def run_check_shopping_product_performance(*args, **kwargs) -> None:
|
||||
run_generic_audit(DEFINITIONS["check_shopping_product_performance"], *args, **kwargs)
|
||||
|
||||
|
||||
def run_check_gender_performance(*args, **kwargs) -> None:
|
||||
run_generic_audit(DEFINITIONS["check_gender_performance"], *args, **kwargs)
|
||||
|
||||
|
||||
def run_check_age_performance(*args, **kwargs) -> None:
|
||||
run_generic_audit(DEFINITIONS["check_age_performance"], *args, **kwargs)
|
||||
544
src/gads_v2/tasks/auction_insights_check.py
Normal file
544
src/gads_v2/tasks/auction_insights_check.py
Normal file
@@ -0,0 +1,544 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..config import ClientConfig, client_dir
|
||||
from ..google_ads import get_google_ads_client, run_query
|
||||
from ..history import append_change_markdown, append_history, now_local
|
||||
from ..knowledge.store import rules_for_task
|
||||
from ..table import print_table
|
||||
|
||||
|
||||
TASK_ID = "check_auction_insights"
|
||||
TASK_NAME = "Sprawdzenie Auction Insights"
|
||||
|
||||
|
||||
SCOPE = [
|
||||
{
|
||||
"area": "Konkurenci w aukcji",
|
||||
"check": "Pobierz domeny konkurentow z segmentu Auction Insights, jezeli developer token ma dostep do tych metryk.",
|
||||
},
|
||||
{
|
||||
"area": "Overlap i outranking",
|
||||
"check": "Pokaz overlap rate, position above rate i outranking share dla konkurentow.",
|
||||
},
|
||||
{
|
||||
"area": "Widocznosc top",
|
||||
"check": "Pokaz top rate i absolute top rate jako sygnal presji konkurencyjnej.",
|
||||
},
|
||||
{
|
||||
"area": "Granica zadania",
|
||||
"check": "Nie podejmuj tutaj decyzji o budzecie, stawkach ani strategiach; to tylko diagnoza aukcji.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
OUT_OF_SCOPE = [
|
||||
"zmiany budzetow",
|
||||
"zmiany stawek i strategii ustalania stawek",
|
||||
"decyzje o zmianie Docelowego ROAS albo Docelowego CPA",
|
||||
"analiza zapytan uzytkownikow",
|
||||
"wdrazanie zmian na koncie Google Ads",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuctionInsightsPlan:
|
||||
competitors: list[dict]
|
||||
campaign_summary: list[dict]
|
||||
domain_summary: list[dict]
|
||||
scope: list[dict]
|
||||
out_of_scope: list[str]
|
||||
knowledge_rules: list[dict]
|
||||
warnings: list[str]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"task": TASK_ID,
|
||||
"task_name": TASK_NAME,
|
||||
"competitors": self.competitors,
|
||||
"campaign_summary": self.campaign_summary,
|
||||
"domain_summary": self.domain_summary,
|
||||
"scope": self.scope,
|
||||
"out_of_scope": self.out_of_scope,
|
||||
"knowledge_rules": self.knowledge_rules,
|
||||
"warnings": self.warnings,
|
||||
"changes": [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "AuctionInsightsPlan":
|
||||
return cls(
|
||||
competitors=data.get("competitors", []),
|
||||
campaign_summary=data.get("campaign_summary", []),
|
||||
domain_summary=data.get("domain_summary", []),
|
||||
scope=data.get("scope", []),
|
||||
out_of_scope=data.get("out_of_scope", []),
|
||||
knowledge_rules=data.get("knowledge_rules", []),
|
||||
warnings=data.get("warnings", []),
|
||||
)
|
||||
|
||||
|
||||
def md_cell(value: Any) -> str:
|
||||
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
|
||||
|
||||
|
||||
def enum_name(value: Any) -> str:
|
||||
name = getattr(value, "name", None)
|
||||
if name:
|
||||
return name
|
||||
return str(value)
|
||||
|
||||
|
||||
def percent(value: float | int | None) -> float:
|
||||
if value is None:
|
||||
return 0.0
|
||||
return round(float(value or 0) * 100, 1)
|
||||
|
||||
|
||||
def percent_label(value: float | int | None) -> str:
|
||||
return f"{percent(value):.1f}%"
|
||||
|
||||
|
||||
def compact_api_error(exc: Exception) -> str:
|
||||
message = str(exc)
|
||||
request_id = ""
|
||||
if "request_id:" in message:
|
||||
request_id = message.split("request_id:", 1)[1].splitlines()[0].strip().strip('"')
|
||||
if "METRIC_ACCESS_DENIED" in message:
|
||||
base = "METRIC_ACCESS_DENIED: developer token nie ma dostepu do metryk Auction Insights"
|
||||
elif "PERMISSION_DENIED" in message:
|
||||
base = "PERMISSION_DENIED: brak uprawnien do pobrania danych Auction Insights"
|
||||
else:
|
||||
base = "Nie udalo sie pobrac danych Auction Insights z Google Ads API"
|
||||
if request_id:
|
||||
return f"{base}. Request ID: {request_id}."
|
||||
return f"{base}."
|
||||
|
||||
|
||||
def auction_severity(row: dict) -> str:
|
||||
if row["position_above_rate"] >= 0.5 or row["outranking_share"] <= 0.2:
|
||||
return "wysokie"
|
||||
if row["overlap_rate"] >= 0.5 or row["position_above_rate"] >= 0.3:
|
||||
return "srednie"
|
||||
return "niskie"
|
||||
|
||||
|
||||
def auction_flags(row: dict) -> list[str]:
|
||||
flags = []
|
||||
if row["overlap_rate"] >= 0.5:
|
||||
flags.append("wysoki overlap")
|
||||
if row["position_above_rate"] >= 0.5:
|
||||
flags.append("konkurent czesto wyzej")
|
||||
elif row["position_above_rate"] >= 0.3:
|
||||
flags.append("pozycja konkurenta do oceny")
|
||||
if row["outranking_share"] <= 0.2:
|
||||
flags.append("niski outranking share")
|
||||
return flags or ["ok"]
|
||||
|
||||
|
||||
def fetch_auction_insights(client_config: ClientConfig) -> list[dict]:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
rows = run_query(
|
||||
google_client,
|
||||
client_config.safe_customer_id,
|
||||
"""
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign.name,
|
||||
campaign.status,
|
||||
campaign.advertising_channel_type,
|
||||
segments.auction_insight_domain,
|
||||
metrics.auction_insight_search_impression_share,
|
||||
metrics.auction_insight_search_overlap_rate,
|
||||
metrics.auction_insight_search_position_above_rate,
|
||||
metrics.auction_insight_search_outranking_share,
|
||||
metrics.auction_insight_search_top_impression_percentage,
|
||||
metrics.auction_insight_search_absolute_top_impression_percentage
|
||||
FROM campaign
|
||||
WHERE campaign.status != 'REMOVED'
|
||||
AND segments.date DURING LAST_30_DAYS
|
||||
LIMIT 1000
|
||||
""",
|
||||
)
|
||||
|
||||
competitors = []
|
||||
for row in rows:
|
||||
domain = str(row.segments.auction_insight_domain or "").strip()
|
||||
if not domain:
|
||||
continue
|
||||
metrics = row.metrics
|
||||
record = {
|
||||
"campaign_id": str(row.campaign.id),
|
||||
"campaign_name": row.campaign.name,
|
||||
"status": enum_name(row.campaign.status),
|
||||
"channel_type": enum_name(row.campaign.advertising_channel_type),
|
||||
"domain": domain,
|
||||
"impression_share": float(metrics.auction_insight_search_impression_share or 0),
|
||||
"overlap_rate": float(metrics.auction_insight_search_overlap_rate or 0),
|
||||
"position_above_rate": float(metrics.auction_insight_search_position_above_rate or 0),
|
||||
"outranking_share": float(metrics.auction_insight_search_outranking_share or 0),
|
||||
"top_rate": float(metrics.auction_insight_search_top_impression_percentage or 0),
|
||||
"absolute_top_rate": float(metrics.auction_insight_search_absolute_top_impression_percentage or 0),
|
||||
}
|
||||
record["severity"] = auction_severity(record)
|
||||
record["flags"] = auction_flags(record)
|
||||
competitors.append(record)
|
||||
severity_order = {"wysokie": 0, "srednie": 1, "niskie": 2, "ok": 9}
|
||||
competitors.sort(
|
||||
key=lambda row: (
|
||||
severity_order.get(row["severity"], 9),
|
||||
row["campaign_name"],
|
||||
-row["overlap_rate"],
|
||||
row["domain"],
|
||||
)
|
||||
)
|
||||
return competitors
|
||||
|
||||
|
||||
def build_campaign_summary(competitors: list[dict]) -> list[dict]:
|
||||
buckets: dict[str, dict] = {}
|
||||
for item in competitors:
|
||||
row = buckets.setdefault(
|
||||
item["campaign_name"],
|
||||
{
|
||||
"campaign_name": item["campaign_name"],
|
||||
"channel_type": item["channel_type"],
|
||||
"competitors": 0,
|
||||
"high_risk": 0,
|
||||
"max_overlap_rate": 0.0,
|
||||
"max_position_above_rate": 0.0,
|
||||
},
|
||||
)
|
||||
row["competitors"] += 1
|
||||
if item["severity"] == "wysokie":
|
||||
row["high_risk"] += 1
|
||||
row["max_overlap_rate"] = max(row["max_overlap_rate"], item["overlap_rate"])
|
||||
row["max_position_above_rate"] = max(row["max_position_above_rate"], item["position_above_rate"])
|
||||
return sorted(buckets.values(), key=lambda row: (-row["high_risk"], -row["competitors"], row["campaign_name"]))
|
||||
|
||||
|
||||
def build_domain_summary(competitors: list[dict]) -> list[dict]:
|
||||
counter = Counter(item["domain"] for item in competitors)
|
||||
return [{"domain": key, "campaigns": value} for key, value in counter.most_common()]
|
||||
|
||||
|
||||
def build_auction_insights_plan(client_config: ClientConfig) -> AuctionInsightsPlan:
|
||||
warnings = []
|
||||
try:
|
||||
competitors = fetch_auction_insights(client_config)
|
||||
except Exception as exc:
|
||||
competitors = []
|
||||
message = str(exc)
|
||||
if "METRIC_ACCESS_DENIED" in message or "auction_insight" in message:
|
||||
warnings.append(
|
||||
"Google Ads API zwrocilo brak dostepu do metryk Auction Insights dla obecnego developer tokena. "
|
||||
"Zadanie jest gotowe, ale zacznie zwracac konkurentow dopiero po odblokowaniu tych metryk."
|
||||
)
|
||||
warnings.append(compact_api_error(exc))
|
||||
|
||||
if not competitors:
|
||||
warnings.append("Nie znaleziono danych Auction Insights albo nie udalo sie ich pobrac.")
|
||||
warnings.append(
|
||||
"To zadanie tylko diagnozuje presje konkurencji w aukcji. Decyzje o budzetach, stawkach i strategiach pozostaja w osobnych zadaniach."
|
||||
)
|
||||
|
||||
knowledge_rules = [
|
||||
{
|
||||
"id": rule.id,
|
||||
"topic": rule.topic,
|
||||
"rule_type": rule.rule_type,
|
||||
"condition": rule.condition,
|
||||
"recommendation": rule.recommendation,
|
||||
"risk": rule.risk,
|
||||
"source": rule.source,
|
||||
}
|
||||
for rule in rules_for_task(TASK_ID)
|
||||
]
|
||||
if not knowledge_rules:
|
||||
warnings.append(
|
||||
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
|
||||
"Reguly dotyczace Auction Insights bedziemy dopisywac osobno po akceptacji uzytkownika."
|
||||
)
|
||||
|
||||
return AuctionInsightsPlan(
|
||||
competitors=competitors,
|
||||
campaign_summary=build_campaign_summary(competitors),
|
||||
domain_summary=build_domain_summary(competitors),
|
||||
scope=SCOPE,
|
||||
out_of_scope=OUT_OF_SCOPE,
|
||||
knowledge_rules=knowledge_rules,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def save_auction_insights_plan(domain: str, plan: AuctionInsightsPlan) -> tuple[Path, Path]:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "plans"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
|
||||
json_path = base / f"{stem}.json"
|
||||
md_path = base / f"{stem}.md"
|
||||
payload = {
|
||||
"created_at": ts.isoformat(timespec="seconds"),
|
||||
"client": domain,
|
||||
**plan.to_dict(),
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Plan: Sprawdzenie Auction Insights",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Rekordy konkurentow: {len(plan.competitors)}",
|
||||
f"- Kampanie z konkurentami: {len(plan.campaign_summary)}",
|
||||
f"- Domeny konkurentow: {len(plan.domain_summary)}",
|
||||
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
|
||||
"- Zmiany do wdrozenia: 0",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {item}" for item in plan.warnings)
|
||||
lines.append("")
|
||||
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
|
||||
for row in plan.scope:
|
||||
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
|
||||
lines.append("")
|
||||
lines.extend(["## Poza zakresem tego zadania", ""])
|
||||
lines.extend(f"- {item}" for item in plan.out_of_scope)
|
||||
lines.append("")
|
||||
if plan.campaign_summary:
|
||||
lines.extend(
|
||||
[
|
||||
"## Podsumowanie po kampaniach",
|
||||
"",
|
||||
"| Kampania | Typ | Konkurenci | Wysokie ryzyko | Max overlap | Max position above |",
|
||||
"| --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for row in plan.campaign_summary:
|
||||
lines.append(
|
||||
f"| {md_cell(row['campaign_name'])} | {row['channel_type']} | {row['competitors']} | "
|
||||
f"{row['high_risk']} | {percent_label(row['max_overlap_rate'])} | {percent_label(row['max_position_above_rate'])} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.domain_summary:
|
||||
lines.extend(["## Domeny konkurentow", "", "| Domena | Kampanie |", "| --- | --- |"])
|
||||
for row in plan.domain_summary:
|
||||
lines.append(f"| {md_cell(row['domain'])} | {row['campaigns']} |")
|
||||
lines.append("")
|
||||
if plan.competitors:
|
||||
lines.extend(
|
||||
[
|
||||
"## Auction Insights",
|
||||
"",
|
||||
"| Waznosc | Kampania | Konkurent | Impression share | Overlap | Position above | Outranking | Top | Abs. top | Flagi |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for item in plan.competitors:
|
||||
lines.append(
|
||||
f"| {item['severity']} | {md_cell(item['campaign_name'])} | {md_cell(item['domain'])} | "
|
||||
f"{percent_label(item['impression_share'])} | {percent_label(item['overlap_rate'])} | "
|
||||
f"{percent_label(item['position_above_rate'])} | {percent_label(item['outranking_share'])} | "
|
||||
f"{percent_label(item['top_rate'])} | {percent_label(item['absolute_top_rate'])} | "
|
||||
f"{md_cell(', '.join(item['flags']))} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.knowledge_rules:
|
||||
lines.extend(
|
||||
[
|
||||
"## Reguly z bazy wiedzy",
|
||||
"",
|
||||
"| ID | Temat | Rekomendacja | Ryzyko |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for rule in plan.knowledge_rules:
|
||||
lines.append(
|
||||
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
|
||||
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
|
||||
)
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def print_auction_insights_plan(plan: AuctionInsightsPlan) -> None:
|
||||
print("\nPlan sprawdzenia Auction Insights")
|
||||
print_table(
|
||||
["Metryka", "Liczba"],
|
||||
[
|
||||
["Rekordy konkurentow", str(len(plan.competitors))],
|
||||
["Kampanie z konkurentami", str(len(plan.campaign_summary))],
|
||||
["Domeny konkurentow", str(len(plan.domain_summary))],
|
||||
["Reguly wiedzy", str(len(plan.knowledge_rules))],
|
||||
["Zmiany do wdrozenia", "0"],
|
||||
],
|
||||
)
|
||||
if plan.warnings:
|
||||
print("\nUwagi")
|
||||
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
|
||||
print("\nZakres zadania")
|
||||
print_table(
|
||||
["Nr", "Obszar", "Co sprawdzic"],
|
||||
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
|
||||
)
|
||||
print("\nPoza zakresem")
|
||||
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
|
||||
if plan.campaign_summary:
|
||||
print("\nPodsumowanie po kampaniach")
|
||||
print_table(
|
||||
["Kampania", "Typ", "Konkurenci", "Wysokie", "Max overlap", "Max above"],
|
||||
[
|
||||
[
|
||||
row["campaign_name"],
|
||||
row["channel_type"],
|
||||
str(row["competitors"]),
|
||||
str(row["high_risk"]),
|
||||
percent_label(row["max_overlap_rate"]),
|
||||
percent_label(row["max_position_above_rate"]),
|
||||
]
|
||||
for row in plan.campaign_summary
|
||||
],
|
||||
)
|
||||
if plan.domain_summary:
|
||||
print("\nDomeny konkurentow")
|
||||
print_table(["Domena", "Kampanie"], [[row["domain"], str(row["campaigns"])] for row in plan.domain_summary[:30]])
|
||||
if plan.competitors:
|
||||
print("\nAuction Insights")
|
||||
print_table(
|
||||
["Nr", "Waznosc", "Kampania", "Konkurent", "Overlap", "Above", "Outranking", "Flagi"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
item["severity"],
|
||||
item["campaign_name"],
|
||||
item["domain"],
|
||||
percent_label(item["overlap_rate"]),
|
||||
percent_label(item["position_above_rate"]),
|
||||
percent_label(item["outranking_share"]),
|
||||
", ".join(item["flags"]),
|
||||
]
|
||||
for index, item in enumerate(plan.competitors[:30], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.competitors) > 30:
|
||||
print(f"... oraz {len(plan.competitors) - 30} kolejnych rekordow w pliku planu")
|
||||
if plan.knowledge_rules:
|
||||
print("\nReguly z bazy wiedzy")
|
||||
print_table(
|
||||
["Nr", "ID", "Temat", "Rekomendacja"],
|
||||
[
|
||||
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
|
||||
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.knowledge_rules) > 10:
|
||||
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
|
||||
|
||||
|
||||
def print_next_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def apply_auction_insights_plan(
|
||||
client_config: ClientConfig,
|
||||
plan: AuctionInsightsPlan,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
print("\nTo zadanie jest audytem Auction Insights i nie wdraza zmian na koncie Google Ads.")
|
||||
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "audyt oznaczony jako wykonany",
|
||||
"campaign": ", ".join(row["campaign_name"] for row in plan.campaign_summary[:10]),
|
||||
"summary": {
|
||||
"competitors": len(plan.competitors),
|
||||
"campaigns": len(plan.campaign_summary),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def run_check_auction_insights(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
_ = global_rules
|
||||
if apply_plan_path:
|
||||
if confirm_apply != "TAK":
|
||||
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
|
||||
if plan_data.get("client") != client_config.domain:
|
||||
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan = AuctionInsightsPlan.from_dict(plan_data)
|
||||
print_auction_insights_plan(plan)
|
||||
apply_auction_insights_plan(client_config, plan, show_navigation=show_navigation)
|
||||
return
|
||||
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print("Przygotowuje plan sprawdzenia Auction Insights...")
|
||||
plan = build_auction_insights_plan(client_config)
|
||||
print_auction_insights_plan(plan)
|
||||
json_path, md_path = save_auction_insights_plan(client_config.domain, plan)
|
||||
print(f"\nPlan JSON: {json_path}")
|
||||
print(f"Plan Markdown: {md_path}")
|
||||
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "plan przygotowany",
|
||||
"campaign": ", ".join(row["campaign_name"] for row in plan.campaign_summary[:10]),
|
||||
"summary": {
|
||||
"competitors": len(plan.competitors),
|
||||
"campaigns": len(plan.campaign_summary),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if plan_only:
|
||||
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu Auction Insights.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
929
src/gads_v2/tasks/bidding_strategy_check.py
Normal file
929
src/gads_v2/tasks/bidding_strategy_check.py
Normal file
@@ -0,0 +1,929 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from google.protobuf import field_mask_pb2
|
||||
|
||||
from ..config import ClientConfig, client_dir
|
||||
from ..google_ads import get_google_ads_client, run_query
|
||||
from ..history import append_change_markdown, append_history, now_local
|
||||
from ..knowledge.store import rules_for_task
|
||||
from ..table import print_table
|
||||
|
||||
|
||||
TASK_ID = "check_bidding_strategies"
|
||||
TASK_NAME = "Sprawdzenie strategii stawek"
|
||||
|
||||
|
||||
SCOPE = [
|
||||
{
|
||||
"area": "Typ strategii",
|
||||
"check": "Pokaz typ strategii ustalania stawek dla aktywnych kampanii.",
|
||||
},
|
||||
{
|
||||
"area": "Cele strategii",
|
||||
"check": "Pokaz aktualny Docelowy ROAS albo Docelowy CPA, jezeli kampania go uzywa.",
|
||||
},
|
||||
{
|
||||
"area": "Wolumen konwersji",
|
||||
"check": "Sprawdz liczbe konwersji z ostatnich 30 dni jako kontekst dla automatycznych strategii.",
|
||||
},
|
||||
{
|
||||
"area": "Stabilnosc decyzji",
|
||||
"check": "Oznacz kampanie, gdzie malo danych zwieksza ryzyko pochopnej zmiany strategii albo celu.",
|
||||
},
|
||||
{
|
||||
"area": "Ocena celu",
|
||||
"check": "Porownaj rzeczywisty ROAS/CPA z aktualnym celem i oznacz cele zbyt niskie albo zbyt wysokie.",
|
||||
},
|
||||
{
|
||||
"area": "Kontekst budzetu",
|
||||
"check": "Uwzglednij wykorzystanie budzetu i utrate wyswietlania przez budzet przed rekomendacja zmiany strategii.",
|
||||
},
|
||||
{
|
||||
"area": "Zmiany po budzecie",
|
||||
"check": "Jesli budzet byl niedawno zmieniany, rekomenduj odczekanie przed zmiana strategii albo celu.",
|
||||
},
|
||||
{
|
||||
"area": "Dopasowanie strategii",
|
||||
"check": "Sprawdz, czy strategia pasuje do typu kampanii i dostepnego wolumenu danych.",
|
||||
},
|
||||
{
|
||||
"area": "Rekomendacja",
|
||||
"check": "Pokaz konkretna rekomendacje decyzyjna bez automatycznego wdrazania zmian strategii.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
OUT_OF_SCOPE = [
|
||||
"budzety i pacing budzetu",
|
||||
"podstawowe ustawienia kampanii, np. lokalizacje i sieci",
|
||||
"zapytania uzytkownikow oraz wykluczenia",
|
||||
"reklamy, zasoby i kreacje",
|
||||
"automatyczne wdrazanie zmian strategii stawek",
|
||||
]
|
||||
|
||||
|
||||
LOW_CONVERSION_THRESHOLD = 15
|
||||
STABLE_CONVERSION_THRESHOLD = 30
|
||||
TARGET_ROAS_TOO_LOW_RATIO = 1.5
|
||||
TARGET_ROAS_TOO_HIGH_RATIO = 0.75
|
||||
BUDGET_LOST_THRESHOLD = 0.15
|
||||
HIGH_BUDGET_LOST_THRESHOLD = 0.3
|
||||
RECENT_BUDGET_CHANGE_DAYS = 7
|
||||
|
||||
|
||||
@dataclass
|
||||
class BiddingStrategyPlan:
|
||||
currency_code: str
|
||||
campaigns: list[dict]
|
||||
scope: list[dict]
|
||||
out_of_scope: list[str]
|
||||
knowledge_rules: list[dict]
|
||||
warnings: list[str]
|
||||
target_changes: list[dict] | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"task": TASK_ID,
|
||||
"task_name": TASK_NAME,
|
||||
"currency_code": self.currency_code,
|
||||
"campaigns": self.campaigns,
|
||||
"scope": self.scope,
|
||||
"out_of_scope": self.out_of_scope,
|
||||
"knowledge_rules": self.knowledge_rules,
|
||||
"warnings": self.warnings,
|
||||
"target_changes": self.target_changes or [],
|
||||
"changes": self.target_changes or [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "BiddingStrategyPlan":
|
||||
return cls(
|
||||
currency_code=data.get("currency_code", ""),
|
||||
campaigns=data.get("campaigns", []),
|
||||
scope=data.get("scope", []),
|
||||
out_of_scope=data.get("out_of_scope", []),
|
||||
knowledge_rules=data.get("knowledge_rules", []),
|
||||
warnings=data.get("warnings", []),
|
||||
target_changes=data.get("target_changes", data.get("changes", [])),
|
||||
)
|
||||
|
||||
|
||||
def enum_name(value: Any) -> str:
|
||||
name = getattr(value, "name", None)
|
||||
if name:
|
||||
return name
|
||||
return str(value)
|
||||
|
||||
|
||||
def micros_to_amount(value: int | float) -> float:
|
||||
return round(float(value or 0) / 1_000_000, 2)
|
||||
|
||||
|
||||
def format_money(value: int | float, currency_code: str) -> str:
|
||||
suffix = f" {currency_code}" if currency_code else ""
|
||||
return f"{micros_to_amount(value):.2f}{suffix}"
|
||||
|
||||
|
||||
def format_decimal(value: int | float) -> str:
|
||||
return f"{float(value or 0):.2f}"
|
||||
|
||||
|
||||
def percent_label(value: int | float | None) -> str:
|
||||
if value is None:
|
||||
return "-"
|
||||
return f"{float(value) * 100:.2f}%"
|
||||
|
||||
|
||||
def safe_int(value: Any) -> int:
|
||||
try:
|
||||
return int(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def safe_float(value: Any) -> float:
|
||||
try:
|
||||
return float(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def target_roas_label(value: float) -> str:
|
||||
if value <= 0:
|
||||
return ""
|
||||
return f"Docelowy ROAS {value * 100:.0f}%"
|
||||
|
||||
|
||||
def target_cpa_label(value_micros: int, currency_code: str) -> str:
|
||||
if value_micros <= 0:
|
||||
return ""
|
||||
return f"Docelowy CPA {format_money(value_micros, currency_code)}"
|
||||
|
||||
|
||||
def strategy_target_label(row: dict, currency_code: str) -> str:
|
||||
labels = [
|
||||
target_roas_label(row.get("target_roas", 0)),
|
||||
target_roas_label(row.get("maximize_conversion_value_target_roas", 0)),
|
||||
target_cpa_label(row.get("target_cpa_micros", 0), currency_code),
|
||||
target_cpa_label(row.get("maximize_conversions_target_cpa_micros", 0), currency_code),
|
||||
]
|
||||
return ", ".join(label for label in labels if label) or "brak jawnego celu"
|
||||
|
||||
|
||||
def risk_label(strategy_type: str, conversions_30d: float) -> str:
|
||||
strategy = strategy_type.upper()
|
||||
automated = {
|
||||
"MAXIMIZE_CONVERSIONS",
|
||||
"MAXIMIZE_CONVERSION_VALUE",
|
||||
"TARGET_CPA",
|
||||
"TARGET_ROAS",
|
||||
}
|
||||
if conversions_30d <= 0:
|
||||
return "brak konwersji w 30 dni"
|
||||
if strategy in automated and conversions_30d < LOW_CONVERSION_THRESHOLD:
|
||||
return "malo konwersji dla automatyzacji"
|
||||
return "dane do oceny"
|
||||
|
||||
|
||||
def actual_roas(cost_micros: int, conversion_value: float) -> float:
|
||||
cost = micros_to_amount(cost_micros)
|
||||
if cost <= 0:
|
||||
return 0.0
|
||||
return round(float(conversion_value or 0) / cost, 2)
|
||||
|
||||
|
||||
def primary_target_roas(row: dict) -> float:
|
||||
return safe_float(row.get("target_roas")) or safe_float(row.get("maximize_conversion_value_target_roas"))
|
||||
|
||||
|
||||
def primary_target_cpa_micros(row: dict) -> int:
|
||||
return safe_int(row.get("target_cpa_micros")) or safe_int(row.get("maximize_conversions_target_cpa_micros"))
|
||||
|
||||
|
||||
def target_assessment(row: dict) -> str:
|
||||
target_roas = primary_target_roas(row)
|
||||
roas = safe_float(row.get("actual_roas"))
|
||||
conversions = safe_float(row.get("conversions_30d"))
|
||||
if target_roas > 0 and roas > 0:
|
||||
if conversions < LOW_CONVERSION_THRESHOLD:
|
||||
return "za malo danych do oceny celu ROAS"
|
||||
if roas >= target_roas * TARGET_ROAS_TOO_LOW_RATIO:
|
||||
return "Docelowy ROAS prawdopodobnie za niski"
|
||||
if roas <= target_roas * TARGET_ROAS_TOO_HIGH_RATIO:
|
||||
return "Docelowy ROAS prawdopodobnie za wysoki"
|
||||
return "Docelowy ROAS zgodny z wynikiem"
|
||||
if primary_target_cpa_micros(row) > 0:
|
||||
if conversions < LOW_CONVERSION_THRESHOLD:
|
||||
return "za malo danych do oceny celu CPA"
|
||||
return "Docelowy CPA do oceny po koszcie na konwersje"
|
||||
return "brak jawnego celu do oceny"
|
||||
|
||||
|
||||
def stability_label(row: dict) -> str:
|
||||
conversions = safe_float(row.get("conversions_30d"))
|
||||
conversion_value = safe_float(row.get("conversion_value_30d"))
|
||||
cost = safe_int(row.get("cost_30d_micros"))
|
||||
if conversions <= 0:
|
||||
return "niestabilne: brak konwersji"
|
||||
if conversions < LOW_CONVERSION_THRESHOLD:
|
||||
return "niestabilne: malo konwersji"
|
||||
if conversions < STABLE_CONVERSION_THRESHOLD:
|
||||
return "umiarkowane: dane do ostroznej decyzji"
|
||||
if conversion_value <= 0 or cost <= 0:
|
||||
return "niestabilne: brak wartosci albo kosztu"
|
||||
return "stabilne"
|
||||
|
||||
|
||||
def budget_context(row: dict, recent_budget_changes: set[str]) -> str:
|
||||
if row["campaign_id"] in recent_budget_changes or row["campaign_name"] in recent_budget_changes:
|
||||
return f"budzet zmieniony w ostatnich {RECENT_BUDGET_CHANGE_DAYS} dniach"
|
||||
lost_budget = safe_float(row.get("search_budget_lost_impression_share"))
|
||||
usage = safe_float(row.get("budget_usage_percent"))
|
||||
if lost_budget >= HIGH_BUDGET_LOST_THRESHOLD:
|
||||
return "mocne ograniczenie budzetem"
|
||||
if lost_budget >= BUDGET_LOST_THRESHOLD:
|
||||
return "ograniczenie budzetem do oceny"
|
||||
if usage >= 95:
|
||||
return "budzet blisko limitu"
|
||||
if usage and usage < 30:
|
||||
return "niskie wykorzystanie budzetu"
|
||||
return "brak silnego sygnalu budzetowego"
|
||||
|
||||
|
||||
def strategy_fit(row: dict) -> str:
|
||||
strategy = row.get("bidding_strategy_type", "").upper()
|
||||
channel = row.get("channel_type", "").upper()
|
||||
conversions = safe_float(row.get("conversions_30d"))
|
||||
if strategy == "TARGET_IMPRESSION_SHARE" and channel != "SEARCH":
|
||||
return "do sprawdzenia: udzial w wyswietleniach poza Search"
|
||||
if strategy == "TARGET_IMPRESSION_SHARE":
|
||||
return "pasuje do kampanii brand/search, nie optymalizuje bezposrednio wartosci konwersji"
|
||||
if strategy in {"TARGET_ROAS", "MAXIMIZE_CONVERSION_VALUE"}:
|
||||
if conversions < LOW_CONVERSION_THRESHOLD:
|
||||
return "ryzykowne: za malo konwersji dla strategii wartosci"
|
||||
return "pasuje do kampanii e-commerce z wartoscia konwersji"
|
||||
if strategy in {"TARGET_CPA", "MAXIMIZE_CONVERSIONS"}:
|
||||
if conversions < LOW_CONVERSION_THRESHOLD:
|
||||
return "ryzykowne: za malo konwersji dla automatyzacji"
|
||||
return "pasuje do celu pozyskiwania konwersji"
|
||||
return "do oceny recznej"
|
||||
|
||||
|
||||
def bidding_recommendation(row: dict) -> dict:
|
||||
if "budzet zmieniony" in row.get("budget_context", ""):
|
||||
return {
|
||||
"level": "czekaj",
|
||||
"action": "odczekaj przed zmiana strategii",
|
||||
"reason": "budzet byl niedawno zmieniony; najpierw zbierz nowe dane po zmianie",
|
||||
}
|
||||
if row.get("budget_context") in {"mocne ograniczenie budzetem", "ograniczenie budzetem do oceny"}:
|
||||
return {
|
||||
"level": "czekaj",
|
||||
"action": "najpierw rozwiaz ograniczenie budzetem",
|
||||
"reason": "zmiana celu strategii przy ograniczeniu budzetem moze zaciemnic efekt decyzji",
|
||||
}
|
||||
if row.get("stability_label", "").startswith("niestabilne"):
|
||||
return {
|
||||
"level": "ostroznie",
|
||||
"action": "nie zmieniaj strategii",
|
||||
"reason": row["stability_label"],
|
||||
}
|
||||
assessment = row.get("target_assessment", "")
|
||||
if "za niski" in assessment:
|
||||
return {
|
||||
"level": "do decyzji",
|
||||
"action": "rozwaz stopniowe podniesienie Docelowego ROAS",
|
||||
"reason": "rzeczywisty ROAS jest wyraznie wyzszy niz cel i dane sa wystarczajace",
|
||||
}
|
||||
if "za wysoki" in assessment:
|
||||
return {
|
||||
"level": "do decyzji",
|
||||
"action": "rozwaz obnizenie Docelowego ROAS albo analize rentownosci",
|
||||
"reason": "rzeczywisty ROAS jest wyraznie ponizej celu",
|
||||
}
|
||||
return {
|
||||
"level": "ok",
|
||||
"action": "bez zmiany strategii",
|
||||
"reason": "brak mocnego sygnalu do zmiany celu albo strategii",
|
||||
}
|
||||
|
||||
|
||||
def recent_budget_change_campaigns(domain: str) -> set[str]:
|
||||
history_dir = client_dir(domain) / "history"
|
||||
changed: set[str] = set()
|
||||
if not history_dir.exists():
|
||||
return changed
|
||||
cutoff = now_local() - timedelta(days=RECENT_BUDGET_CHANGE_DAYS)
|
||||
for path in history_dir.glob("*.jsonl"):
|
||||
try:
|
||||
for line in path.read_text(encoding="utf-8-sig").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
row = json.loads(line)
|
||||
timestamp = row.get("timestamp")
|
||||
if timestamp:
|
||||
try:
|
||||
if datetime.fromisoformat(timestamp) < cutoff:
|
||||
continue
|
||||
except ValueError:
|
||||
pass
|
||||
summary = row.get("summary", {})
|
||||
if not summary.get("budget_changes"):
|
||||
continue
|
||||
campaign_text = str(row.get("campaign", ""))
|
||||
for name in campaign_text.split(","):
|
||||
name = name.strip()
|
||||
if name:
|
||||
changed.add(name)
|
||||
except Exception:
|
||||
continue
|
||||
return changed
|
||||
|
||||
|
||||
def bidding_recommendations(campaigns: list[dict]) -> list[dict]:
|
||||
return [
|
||||
campaign
|
||||
for campaign in campaigns
|
||||
if campaign.get("bidding_recommendation", {}).get("level") not in {"ok", None}
|
||||
]
|
||||
|
||||
|
||||
def roas_target_field(row: dict) -> str:
|
||||
strategy = row.get("bidding_strategy_type", "").upper()
|
||||
if strategy == "TARGET_ROAS" and safe_float(row.get("target_roas")) > 0:
|
||||
return "target_roas.target_roas"
|
||||
if strategy == "MAXIMIZE_CONVERSION_VALUE" and safe_float(row.get("maximize_conversion_value_target_roas")) > 0:
|
||||
return "maximize_conversion_value.target_roas"
|
||||
return ""
|
||||
|
||||
|
||||
def suggested_target_roas_change(row: dict) -> dict | None:
|
||||
field = roas_target_field(row)
|
||||
if not field:
|
||||
return None
|
||||
recommendation = row.get("bidding_recommendation", {})
|
||||
if recommendation.get("level") != "do decyzji":
|
||||
return None
|
||||
|
||||
current = primary_target_roas(row)
|
||||
actual = safe_float(row.get("actual_roas"))
|
||||
if current <= 0 or actual <= 0:
|
||||
return None
|
||||
|
||||
assessment = row.get("target_assessment", "")
|
||||
if "za niski" in assessment:
|
||||
target = min(current * 1.10, actual * 0.80)
|
||||
direction = "podniesienie"
|
||||
elif "za wysoki" in assessment:
|
||||
target = max(current * 0.90, actual * 1.20)
|
||||
direction = "obnizenie"
|
||||
else:
|
||||
return None
|
||||
|
||||
target = round(target, 2)
|
||||
if abs(target - current) < 0.01:
|
||||
return None
|
||||
|
||||
return {
|
||||
"campaign_id": row["campaign_id"],
|
||||
"campaign_name": row["campaign_name"],
|
||||
"strategy": row["bidding_strategy_type"],
|
||||
"field": field,
|
||||
"current_target_roas": round(current, 2),
|
||||
"target_roas": target,
|
||||
"current_label": f"{current * 100:.0f}%",
|
||||
"target_label": f"{target * 100:.0f}%",
|
||||
"actual_roas": actual,
|
||||
"direction": direction,
|
||||
"reason": recommendation.get("reason", ""),
|
||||
}
|
||||
|
||||
|
||||
def target_change_candidates(campaigns: list[dict]) -> list[dict]:
|
||||
changes = []
|
||||
for campaign in campaigns:
|
||||
change = suggested_target_roas_change(campaign)
|
||||
if change:
|
||||
changes.append(change)
|
||||
return changes
|
||||
|
||||
|
||||
def fetch_bidding_targets(google_client, customer_id: str, warnings: list[str]) -> dict[str, dict]:
|
||||
try:
|
||||
rows = run_query(
|
||||
google_client,
|
||||
customer_id,
|
||||
"""
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign.target_cpa.target_cpa_micros,
|
||||
campaign.target_roas.target_roas,
|
||||
campaign.maximize_conversions.target_cpa_micros,
|
||||
campaign.maximize_conversion_value.target_roas
|
||||
FROM campaign
|
||||
WHERE campaign.status = 'ENABLED'
|
||||
""",
|
||||
)
|
||||
except Exception as exc:
|
||||
warnings.append(f"Nie udalo sie pobrac celow Docelowy ROAS/CPA: {exc}")
|
||||
return {}
|
||||
|
||||
targets: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
campaign = row.campaign
|
||||
targets[str(campaign.id)] = {
|
||||
"target_cpa_micros": safe_int(campaign.target_cpa.target_cpa_micros),
|
||||
"target_roas": safe_float(campaign.target_roas.target_roas),
|
||||
"maximize_conversions_target_cpa_micros": safe_int(
|
||||
campaign.maximize_conversions.target_cpa_micros
|
||||
),
|
||||
"maximize_conversion_value_target_roas": safe_float(
|
||||
campaign.maximize_conversion_value.target_roas
|
||||
),
|
||||
}
|
||||
return targets
|
||||
|
||||
|
||||
def fetch_bidding_campaigns(client_config: ClientConfig) -> tuple[str, list[dict], list[str]]:
|
||||
warnings: list[str] = []
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
customer_id = client_config.safe_customer_id
|
||||
targets_by_campaign = fetch_bidding_targets(google_client, customer_id, warnings)
|
||||
rows = run_query(
|
||||
google_client,
|
||||
customer_id,
|
||||
"""
|
||||
SELECT
|
||||
customer.currency_code,
|
||||
campaign.id,
|
||||
campaign.name,
|
||||
campaign.status,
|
||||
campaign.advertising_channel_type,
|
||||
campaign.bidding_strategy_type,
|
||||
campaign.bidding_strategy,
|
||||
campaign_budget.id,
|
||||
campaign_budget.name,
|
||||
campaign_budget.amount_micros,
|
||||
metrics.cost_micros,
|
||||
metrics.conversions,
|
||||
metrics.conversions_value,
|
||||
metrics.search_impression_share,
|
||||
metrics.search_budget_lost_impression_share,
|
||||
metrics.search_rank_lost_impression_share
|
||||
FROM campaign
|
||||
WHERE campaign.status = 'ENABLED'
|
||||
AND segments.date DURING LAST_30_DAYS
|
||||
""",
|
||||
)
|
||||
|
||||
currency_code = ""
|
||||
campaigns = []
|
||||
for row in rows:
|
||||
currency_code = currency_code or str(row.customer.currency_code or "")
|
||||
campaign = row.campaign
|
||||
campaign_id = str(campaign.id)
|
||||
record = {
|
||||
"campaign_id": campaign_id,
|
||||
"campaign_name": campaign.name,
|
||||
"status": enum_name(campaign.status),
|
||||
"channel_type": enum_name(campaign.advertising_channel_type),
|
||||
"bidding_strategy_type": enum_name(campaign.bidding_strategy_type),
|
||||
"bidding_strategy_resource": str(campaign.bidding_strategy or ""),
|
||||
"budget_id": str(row.campaign_budget.id),
|
||||
"budget_name": row.campaign_budget.name,
|
||||
"daily_budget_micros": safe_int(row.campaign_budget.amount_micros),
|
||||
"cost_30d_micros": safe_int(row.metrics.cost_micros),
|
||||
"conversions_30d": safe_float(row.metrics.conversions),
|
||||
"conversion_value_30d": safe_float(row.metrics.conversions_value),
|
||||
"search_impression_share": safe_float(row.metrics.search_impression_share),
|
||||
"search_budget_lost_impression_share": safe_float(row.metrics.search_budget_lost_impression_share),
|
||||
"search_rank_lost_impression_share": safe_float(row.metrics.search_rank_lost_impression_share),
|
||||
}
|
||||
expected_30d_micros = record["daily_budget_micros"] * 30
|
||||
record["budget_usage_percent"] = (
|
||||
round((record["cost_30d_micros"] / expected_30d_micros) * 100, 1)
|
||||
if expected_30d_micros
|
||||
else 0.0
|
||||
)
|
||||
record["actual_roas"] = actual_roas(record["cost_30d_micros"], record["conversion_value_30d"])
|
||||
record.update(targets_by_campaign.get(campaign_id, {}))
|
||||
record["target_label"] = strategy_target_label(record, currency_code)
|
||||
record["risk_label"] = risk_label(record["bidding_strategy_type"], record["conversions_30d"])
|
||||
campaigns.append(record)
|
||||
return currency_code, campaigns, warnings
|
||||
|
||||
|
||||
def build_bidding_strategy_plan(client_config: ClientConfig) -> BiddingStrategyPlan:
|
||||
warnings = []
|
||||
try:
|
||||
currency_code, campaigns, fetch_warnings = fetch_bidding_campaigns(client_config)
|
||||
warnings.extend(fetch_warnings)
|
||||
except Exception as exc:
|
||||
currency_code = ""
|
||||
campaigns = []
|
||||
warnings.append(f"Nie udalo sie pobrac strategii stawek z Google Ads API: {exc}")
|
||||
|
||||
if not campaigns:
|
||||
warnings.append("Nie znaleziono aktywnych kampanii z danymi z ostatnich 30 dni albo nie udalo sie ich pobrac.")
|
||||
|
||||
recent_budget_changes = recent_budget_change_campaigns(client_config.domain)
|
||||
for campaign in campaigns:
|
||||
campaign["target_assessment"] = target_assessment(campaign)
|
||||
campaign["stability_label"] = stability_label(campaign)
|
||||
campaign["budget_context"] = budget_context(campaign, recent_budget_changes)
|
||||
campaign["strategy_fit"] = strategy_fit(campaign)
|
||||
campaign["bidding_recommendation"] = bidding_recommendation(campaign)
|
||||
|
||||
rules = rules_for_task(TASK_ID)
|
||||
knowledge_rules = [
|
||||
{
|
||||
"id": rule.id,
|
||||
"topic": rule.topic,
|
||||
"rule_type": rule.rule_type,
|
||||
"condition": rule.condition,
|
||||
"recommendation": rule.recommendation,
|
||||
"risk": rule.risk,
|
||||
"source": rule.source,
|
||||
}
|
||||
for rule in rules
|
||||
]
|
||||
if not knowledge_rules:
|
||||
warnings.append(
|
||||
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
|
||||
"Reguly dotyczace strategii stawek bedziemy dopisywac osobno po akceptacji uzytkownika."
|
||||
)
|
||||
|
||||
recommendation_order = {"czekaj": 0, "do decyzji": 1, "ostroznie": 2, "ok": 9}
|
||||
campaigns.sort(
|
||||
key=lambda row: (
|
||||
recommendation_order.get(row.get("bidding_recommendation", {}).get("level"), 9),
|
||||
row["risk_label"],
|
||||
row["campaign_name"],
|
||||
)
|
||||
)
|
||||
target_changes = target_change_candidates(campaigns)
|
||||
return BiddingStrategyPlan(
|
||||
currency_code=currency_code,
|
||||
campaigns=campaigns,
|
||||
scope=SCOPE,
|
||||
out_of_scope=OUT_OF_SCOPE,
|
||||
knowledge_rules=knowledge_rules,
|
||||
warnings=warnings,
|
||||
target_changes=target_changes,
|
||||
)
|
||||
|
||||
|
||||
def save_bidding_strategy_plan(domain: str, plan: BiddingStrategyPlan) -> tuple[Path, Path]:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "plans"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
|
||||
json_path = base / f"{stem}.json"
|
||||
md_path = base / f"{stem}.md"
|
||||
payload = {
|
||||
"created_at": ts.isoformat(timespec="seconds"),
|
||||
"client": domain,
|
||||
**plan.to_dict(),
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Plan: Sprawdzenie strategii stawek",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Kampanie aktywne z danymi 30 dni: {len(plan.campaigns)}",
|
||||
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
|
||||
f"- Rekomendacje strategii do decyzji: {len(bidding_recommendations(plan.campaigns))}",
|
||||
f"- Zmiany celu do wdrozenia po akceptacji: {len(plan.target_changes or [])}",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {item}" for item in plan.warnings)
|
||||
lines.append("")
|
||||
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
|
||||
for row in plan.scope:
|
||||
lines.append(f"| {row.get('area', '')} | {row.get('check', '')} |")
|
||||
lines.append("")
|
||||
lines.extend(["## Poza zakresem tego zadania", ""])
|
||||
lines.extend(f"- {item}" for item in plan.out_of_scope)
|
||||
lines.append("")
|
||||
if plan.campaigns:
|
||||
lines.extend(
|
||||
[
|
||||
"## Strategie stawek z ostatnich 30 dni",
|
||||
"",
|
||||
"| Kampania | Typ | Strategia | Cel | Koszt | Konwersje | Wartosc konwersji | ROAS | Utrata budzet | Ocena celu | Stabilnosc | Budzet |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for campaign in plan.campaigns:
|
||||
lines.append(
|
||||
f"| {campaign['campaign_name']} | {campaign['channel_type']} | "
|
||||
f"{campaign['bidding_strategy_type']} | {campaign['target_label']} | "
|
||||
f"{format_money(campaign['cost_30d_micros'], plan.currency_code)} | "
|
||||
f"{format_decimal(campaign['conversions_30d'])} | "
|
||||
f"{format_decimal(campaign['conversion_value_30d'])} | "
|
||||
f"{format_decimal(campaign.get('actual_roas', 0))} | "
|
||||
f"{percent_label(campaign.get('search_budget_lost_impression_share'))} | "
|
||||
f"{campaign.get('target_assessment', '')} | "
|
||||
f"{campaign.get('stability_label', '')} | "
|
||||
f"{campaign.get('budget_context', '')} |"
|
||||
)
|
||||
lines.append("")
|
||||
recommendations = bidding_recommendations(plan.campaigns)
|
||||
if recommendations:
|
||||
lines.extend(
|
||||
[
|
||||
"## Rekomendacje strategii do decyzji",
|
||||
"",
|
||||
"| Kampania | Waznosc | Rekomendacja | Powod | Dopasowanie strategii |",
|
||||
"| --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for campaign in recommendations:
|
||||
recommendation = campaign["bidding_recommendation"]
|
||||
lines.append(
|
||||
f"| {campaign['campaign_name']} | {recommendation['level']} | "
|
||||
f"{recommendation['action']} | {recommendation['reason']} | "
|
||||
f"{campaign.get('strategy_fit', '')} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.target_changes:
|
||||
lines.extend(
|
||||
[
|
||||
"## Zmiany celu strategii do akceptacji",
|
||||
"",
|
||||
"| Kampania | Strategia | Kierunek | Obecnie | Docelowo | Rzeczywisty ROAS | Powod |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for change in plan.target_changes:
|
||||
lines.append(
|
||||
f"| {change['campaign_name']} | {change['strategy']} | {change['direction']} | "
|
||||
f"{change['current_label']} | {change['target_label']} | "
|
||||
f"{format_decimal(change['actual_roas'])} | {change['reason']} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.knowledge_rules:
|
||||
lines.extend(
|
||||
[
|
||||
"## Reguly z bazy wiedzy",
|
||||
"",
|
||||
"| ID | Temat | Rekomendacja | Ryzyko |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for rule in plan.knowledge_rules:
|
||||
lines.append(
|
||||
f"| {rule.get('id', '')} | {rule.get('topic', '')} | "
|
||||
f"{rule.get('recommendation', '')} | {rule.get('risk', '')} |"
|
||||
)
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def print_bidding_strategy_plan(plan: BiddingStrategyPlan) -> None:
|
||||
print("\nPlan sprawdzenia strategii stawek")
|
||||
print_table(
|
||||
["Metryka", "Liczba"],
|
||||
[
|
||||
["Kampanie z danymi 30 dni", str(len(plan.campaigns))],
|
||||
["Reguly wiedzy", str(len(plan.knowledge_rules))],
|
||||
["Rekomendacje strategii", str(len(bidding_recommendations(plan.campaigns)))],
|
||||
["Zmiany celu do wdrozenia", str(len(plan.target_changes or []))],
|
||||
],
|
||||
)
|
||||
if plan.warnings:
|
||||
print("\nUwagi")
|
||||
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
|
||||
print("\nZakres zadania")
|
||||
print_table(
|
||||
["Nr", "Obszar", "Co sprawdzic"],
|
||||
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
|
||||
)
|
||||
print("\nPoza zakresem")
|
||||
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
|
||||
if plan.campaigns:
|
||||
print("\nStrategie stawek z ostatnich 30 dni")
|
||||
print_table(
|
||||
["Nr", "Kampania", "Typ", "Strategia", "Cel", "Konw.", "ROAS", "Utrata budz.", "Ocena celu", "Budzet"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
campaign["campaign_name"],
|
||||
campaign["channel_type"],
|
||||
campaign["bidding_strategy_type"],
|
||||
campaign["target_label"],
|
||||
format_decimal(campaign["conversions_30d"]),
|
||||
format_decimal(campaign.get("actual_roas", 0)),
|
||||
percent_label(campaign.get("search_budget_lost_impression_share")),
|
||||
campaign.get("target_assessment", ""),
|
||||
campaign.get("budget_context", ""),
|
||||
]
|
||||
for index, campaign in enumerate(plan.campaigns, 1)
|
||||
],
|
||||
)
|
||||
if plan.target_changes:
|
||||
print("\nZmiany celu strategii do akceptacji")
|
||||
print_table(
|
||||
["Nr", "Kampania", "Strategia", "Kierunek", "Obecnie", "Docelowo", "ROAS", "Powod"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
change["campaign_name"],
|
||||
change["strategy"],
|
||||
change["direction"],
|
||||
change["current_label"],
|
||||
change["target_label"],
|
||||
format_decimal(change["actual_roas"]),
|
||||
change["reason"],
|
||||
]
|
||||
for index, change in enumerate(plan.target_changes, 1)
|
||||
],
|
||||
)
|
||||
recommendations = bidding_recommendations(plan.campaigns)
|
||||
if recommendations:
|
||||
print("\nRekomendacje strategii do decyzji")
|
||||
print_table(
|
||||
["Nr", "Kampania", "Waznosc", "Rekomendacja", "Powod", "Dopasowanie"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
campaign["campaign_name"],
|
||||
campaign["bidding_recommendation"]["level"],
|
||||
campaign["bidding_recommendation"]["action"],
|
||||
campaign["bidding_recommendation"]["reason"],
|
||||
campaign.get("strategy_fit", ""),
|
||||
]
|
||||
for index, campaign in enumerate(recommendations, 1)
|
||||
],
|
||||
)
|
||||
if plan.knowledge_rules:
|
||||
print("\nReguly z bazy wiedzy")
|
||||
print_table(
|
||||
["Nr", "ID", "Temat", "Rekomendacja"],
|
||||
[
|
||||
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
|
||||
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.knowledge_rules) > 10:
|
||||
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
|
||||
|
||||
|
||||
def print_next_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def apply_bidding_strategy_plan(
|
||||
client_config: ClientConfig,
|
||||
plan: BiddingStrategyPlan,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
target_changes = plan.target_changes or []
|
||||
changed = 0
|
||||
errors = []
|
||||
if target_changes:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
customer_id = client_config.safe_customer_id
|
||||
service = google_client.get_service("CampaignService")
|
||||
operations = []
|
||||
for change in target_changes:
|
||||
op = google_client.get_type("CampaignOperation")
|
||||
campaign = op.update
|
||||
campaign.resource_name = service.campaign_path(customer_id, change["campaign_id"])
|
||||
field = change["field"]
|
||||
if field == "target_roas.target_roas":
|
||||
campaign.target_roas.target_roas = float(change["target_roas"])
|
||||
elif field == "maximize_conversion_value.target_roas":
|
||||
campaign.maximize_conversion_value.target_roas = float(change["target_roas"])
|
||||
else:
|
||||
errors.append(f"Nieobslugiwane pole celu: {field}")
|
||||
continue
|
||||
op.update_mask = field_mask_pb2.FieldMask(paths=[field])
|
||||
operations.append(op)
|
||||
|
||||
if operations:
|
||||
try:
|
||||
response = service.mutate_campaigns(customer_id=customer_id, operations=operations)
|
||||
changed = len(response.results)
|
||||
except Exception as exc:
|
||||
errors.append(str(exc))
|
||||
|
||||
if target_changes:
|
||||
print("\nWynik wdrozenia zmian celu strategii")
|
||||
print(f"Zmieniono kampanii: {changed}")
|
||||
print(f"Bledy: {len(errors)}")
|
||||
for error in errors:
|
||||
print(f"Blad: {error}")
|
||||
else:
|
||||
print("\nTo zadanie jest audytem strategii stawek i nie ma zmian celu do wdrozenia.")
|
||||
|
||||
rows = [
|
||||
{
|
||||
"klient": client_config.domain,
|
||||
"kampania": change.get("campaign_name", ""),
|
||||
"czynnosc": f"Zmien Docelowy ROAS: {change.get('current_label', '')} -> {change.get('target_label', '')}",
|
||||
"grupa reklam": "",
|
||||
"produkt": change.get("reason", ""),
|
||||
}
|
||||
for change in target_changes
|
||||
]
|
||||
changes_path = append_change_markdown(client_config.domain, TASK_NAME, rows)
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "wdrozono zmiany celu strategii" if target_changes and not errors else "audyt oznaczony jako wykonany",
|
||||
"campaign": ", ".join(change.get("campaign_name", "") for change in target_changes)
|
||||
or ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
|
||||
"summary": {
|
||||
"campaigns": len(plan.campaigns),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"target_changes": len(target_changes),
|
||||
"changed": changed,
|
||||
"errors": len(errors),
|
||||
},
|
||||
},
|
||||
)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def run_check_bidding_strategies(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
_ = global_rules
|
||||
if apply_plan_path:
|
||||
if confirm_apply != "TAK":
|
||||
print("Do wdrozenia planu wymagane jest --confirm-apply TAK.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
|
||||
if plan_data.get("client") != client_config.domain:
|
||||
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan = BiddingStrategyPlan.from_dict(plan_data)
|
||||
print_bidding_strategy_plan(plan)
|
||||
apply_bidding_strategy_plan(client_config, plan, show_navigation=show_navigation)
|
||||
return
|
||||
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print("Przygotowuje plan sprawdzenia strategii stawek...")
|
||||
plan = build_bidding_strategy_plan(client_config)
|
||||
print_bidding_strategy_plan(plan)
|
||||
json_path, md_path = save_bidding_strategy_plan(client_config.domain, plan)
|
||||
print(f"\nPlan JSON: {json_path}")
|
||||
print(f"Plan Markdown: {md_path}")
|
||||
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "plan przygotowany",
|
||||
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
|
||||
"summary": {
|
||||
"campaigns": len(plan.campaigns),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"target_changes": len(plan.target_changes or []),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if plan_only:
|
||||
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
print("\nBrak automatycznego wdrozenia. Uzyj zapisanego planu i potwierdzenia, aby wdrozyc zmiany celu.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
804
src/gads_v2/tasks/budget_usage_check.py
Normal file
804
src/gads_v2/tasks/budget_usage_check.py
Normal file
@@ -0,0 +1,804 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from google.protobuf import field_mask_pb2
|
||||
|
||||
from ..config import ClientConfig, client_dir
|
||||
from ..google_ads import get_google_ads_client, run_query
|
||||
from ..history import append_change_markdown, append_history, now_local
|
||||
from ..knowledge.store import rules_for_task
|
||||
from ..table import print_table
|
||||
|
||||
|
||||
TASK_ID = "check_budget_usage"
|
||||
TASK_NAME = "Sprawdzenie wykorzystania budzetu"
|
||||
|
||||
|
||||
SCOPE = [
|
||||
{
|
||||
"area": "Wydatki 7 dni",
|
||||
"check": "Porownaj koszt z ostatnich 7 dni z oczekiwanym wydatkiem wynikajacym z budzetu dziennego.",
|
||||
},
|
||||
{
|
||||
"area": "Pacing",
|
||||
"check": "Oznacz kampanie, ktore wydaja bardzo malo albo prawie caly tygodniowy limit budzetu.",
|
||||
},
|
||||
{
|
||||
"area": "Utrata wyswietlania przez budzet",
|
||||
"check": "Polacz wykorzystanie budzetu z utrata udzialu w wyswietleniach przez budzet i rentownoscia kampanii.",
|
||||
},
|
||||
{
|
||||
"area": "Brak wydatkow",
|
||||
"check": "Wskaz aktywne kampanie z budzetem, ktore nie wydaly srodkow w ostatnich 7 dniach.",
|
||||
},
|
||||
{
|
||||
"area": "Budzet wspoldzielony",
|
||||
"check": "Pokaz nazwe budzetu, zeby latwiej wychwycic kampanie korzystajace z tego samego budzetu.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
OUT_OF_SCOPE = [
|
||||
"zmiany stawek i strategii ustalania stawek",
|
||||
"ocena Docelowego ROAS albo Docelowego CPA",
|
||||
"analiza zapytan, wykluczen i jakosci ruchu",
|
||||
"wdrazanie zmian budzetowych na koncie",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class BudgetUsagePlan:
|
||||
currency_code: str
|
||||
campaigns: list[dict]
|
||||
scope: list[dict]
|
||||
out_of_scope: list[str]
|
||||
knowledge_rules: list[dict]
|
||||
warnings: list[str]
|
||||
budget_changes: list[dict] | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"task": TASK_ID,
|
||||
"task_name": TASK_NAME,
|
||||
"currency_code": self.currency_code,
|
||||
"campaigns": self.campaigns,
|
||||
"scope": self.scope,
|
||||
"out_of_scope": self.out_of_scope,
|
||||
"knowledge_rules": self.knowledge_rules,
|
||||
"warnings": self.warnings,
|
||||
"budget_changes": self.budget_changes or [],
|
||||
"changes": self.budget_changes or [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "BudgetUsagePlan":
|
||||
return cls(
|
||||
currency_code=data.get("currency_code", ""),
|
||||
campaigns=data.get("campaigns", []),
|
||||
scope=data.get("scope", []),
|
||||
out_of_scope=data.get("out_of_scope", []),
|
||||
knowledge_rules=data.get("knowledge_rules", []),
|
||||
warnings=data.get("warnings", []),
|
||||
budget_changes=data.get("budget_changes", data.get("changes", [])),
|
||||
)
|
||||
|
||||
|
||||
def enum_name(value) -> str:
|
||||
name = getattr(value, "name", None)
|
||||
if name:
|
||||
return name
|
||||
return str(value)
|
||||
|
||||
|
||||
def micros_to_amount(value: int | float) -> float:
|
||||
return round(float(value or 0) / 1_000_000, 2)
|
||||
|
||||
|
||||
def percent(value: int | float, total: int | float) -> float:
|
||||
if not total:
|
||||
return 0.0
|
||||
return round((float(value) / float(total)) * 100, 1)
|
||||
|
||||
|
||||
def format_money(value: int | float, currency_code: str) -> str:
|
||||
suffix = f" {currency_code}" if currency_code else ""
|
||||
return f"{micros_to_amount(value):.2f}{suffix}"
|
||||
|
||||
|
||||
def percent_label(value: int | float | None) -> str:
|
||||
if value is None:
|
||||
return "-"
|
||||
return f"{float(value) * 100:.2f}%"
|
||||
|
||||
|
||||
def roas_label(value: int | float) -> str:
|
||||
if not value:
|
||||
return "-"
|
||||
return f"{float(value):.2f}"
|
||||
|
||||
|
||||
def days_since_label(value) -> str:
|
||||
if value is None:
|
||||
return "brak danych"
|
||||
if value == 0:
|
||||
return "dzis"
|
||||
if value == 1:
|
||||
return "1 dzien temu"
|
||||
return f"{value} dni temu"
|
||||
|
||||
|
||||
def budget_recommendations(campaigns: list[dict]) -> list[dict]:
|
||||
return [
|
||||
campaign
|
||||
for campaign in campaigns
|
||||
if campaign.get("budget_recommendation", {}).get("level") not in {"ok", None}
|
||||
]
|
||||
|
||||
|
||||
def pacing_label(cost_7d_micros: int, expected_7d_micros: int) -> str:
|
||||
if expected_7d_micros <= 0:
|
||||
return "brak budzetu"
|
||||
usage = percent(cost_7d_micros, expected_7d_micros)
|
||||
if cost_7d_micros <= 0:
|
||||
return "brak wydatkow"
|
||||
if usage < 30:
|
||||
return "niskie wykorzystanie"
|
||||
if usage > 95:
|
||||
return "blisko limitu"
|
||||
return "w normie"
|
||||
|
||||
|
||||
def last_budget_change_date(domain: str, campaign_id: str, campaign_name: str) -> datetime | None:
|
||||
"""Najnowsza data wdrozonej zmiany budzetu dla kampanii, z plikow historii."""
|
||||
base = client_dir(domain) / "history"
|
||||
if not base.exists():
|
||||
return None
|
||||
latest: datetime | None = None
|
||||
for path in sorted(base.glob("*.jsonl")):
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8-sig")
|
||||
except OSError:
|
||||
continue
|
||||
for raw in content.splitlines():
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
event = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if event.get("status") != "wdrozono zmiany budzetu":
|
||||
continue
|
||||
changes = event.get("budget_changes") or []
|
||||
if changes:
|
||||
matched = any(str(item.get("campaign_id")) == str(campaign_id) for item in changes)
|
||||
else:
|
||||
# starsze wpisy bez szczegolow - dopasowanie po nazwie kampanii
|
||||
matched = (
|
||||
event.get("task") == TASK_NAME
|
||||
and bool(campaign_name)
|
||||
and campaign_name in (event.get("campaign") or "")
|
||||
)
|
||||
if not matched:
|
||||
continue
|
||||
try:
|
||||
ts = datetime.fromisoformat(event["timestamp"])
|
||||
except (KeyError, ValueError):
|
||||
continue
|
||||
if latest is None or ts > latest:
|
||||
latest = ts
|
||||
return latest
|
||||
|
||||
|
||||
def build_budget_recommendation(campaign: dict, min_days_between_budget_changes: int = 0) -> dict:
|
||||
usage = float(campaign.get("usage_percent") or 0)
|
||||
lost_budget = float(campaign.get("search_budget_lost_impression_share") or 0)
|
||||
cost_micros = int(campaign.get("cost_7d_micros") or 0)
|
||||
daily_budget_micros = int(campaign.get("daily_budget_micros") or 0)
|
||||
conversion_value = float(campaign.get("conversions_value") or 0)
|
||||
roas = float(campaign.get("roas") or 0)
|
||||
days_since = campaign.get("days_since_budget_change")
|
||||
|
||||
increase_percent = 0
|
||||
if lost_budget >= 0.5:
|
||||
increase_percent = 30
|
||||
elif lost_budget >= 0.3:
|
||||
increase_percent = 25
|
||||
elif lost_budget >= 0.15:
|
||||
increase_percent = 15
|
||||
|
||||
recommended_budget_micros = daily_budget_micros
|
||||
if increase_percent:
|
||||
recommended_budget_micros = int(round(daily_budget_micros * (1 + increase_percent / 100)))
|
||||
budget_delta_micros = recommended_budget_micros - daily_budget_micros
|
||||
|
||||
if usage >= 90 and lost_budget >= 0.3 and conversion_value > 0 and roas >= 2:
|
||||
recommendation = {
|
||||
"level": "wysokie",
|
||||
"action": "rozważ podniesienie budżetu",
|
||||
"reason": "kampania prawie wykorzystuje budzet, traci duzo wyswietlen przez budzet i ma dodatnia rentownosc",
|
||||
"suggested_budget_change_percent": increase_percent,
|
||||
"current_daily_budget_micros": daily_budget_micros,
|
||||
"recommended_daily_budget_micros": recommended_budget_micros,
|
||||
"budget_delta_micros": budget_delta_micros,
|
||||
}
|
||||
elif usage >= 90 and lost_budget >= 0.15 and conversion_value > 0 and roas >= 1:
|
||||
recommendation = {
|
||||
"level": "srednie",
|
||||
"action": "sprawdz mozliwosc podniesienia budżetu",
|
||||
"reason": "kampania wykorzystuje budzet i traci czesc wyswietlen przez budzet",
|
||||
"suggested_budget_change_percent": increase_percent,
|
||||
"current_daily_budget_micros": daily_budget_micros,
|
||||
"recommended_daily_budget_micros": recommended_budget_micros,
|
||||
"budget_delta_micros": budget_delta_micros,
|
||||
}
|
||||
elif usage >= 90 and lost_budget >= 0.15:
|
||||
recommendation = {
|
||||
"level": "ostroznie",
|
||||
"action": "nie podnoś budżetu bez oceny rentowności",
|
||||
"reason": "widac utrate przez budzet, ale brakuje wystarczajacej wartosci konwersji",
|
||||
"suggested_budget_change_percent": 0,
|
||||
"current_daily_budget_micros": daily_budget_micros,
|
||||
"recommended_daily_budget_micros": daily_budget_micros,
|
||||
"budget_delta_micros": 0,
|
||||
}
|
||||
elif cost_micros <= 0:
|
||||
recommendation = {
|
||||
"level": "do sprawdzenia",
|
||||
"action": "sprawdz brak wydatkow",
|
||||
"reason": "aktywna kampania nie wydala srodkow w ostatnich 7 dniach",
|
||||
"suggested_budget_change_percent": 0,
|
||||
"current_daily_budget_micros": daily_budget_micros,
|
||||
"recommended_daily_budget_micros": daily_budget_micros,
|
||||
"budget_delta_micros": 0,
|
||||
}
|
||||
else:
|
||||
recommendation = {
|
||||
"level": "ok",
|
||||
"action": "bez zmiany budzetu",
|
||||
"reason": "brak jednoczesnego sygnalu wysokiego wykorzystania i utraty przez budzet",
|
||||
"suggested_budget_change_percent": 0,
|
||||
"current_daily_budget_micros": daily_budget_micros,
|
||||
"recommended_daily_budget_micros": daily_budget_micros,
|
||||
"budget_delta_micros": 0,
|
||||
}
|
||||
|
||||
recommendation["days_since_budget_change"] = days_since
|
||||
recommendation["min_days_between_budget_changes"] = min_days_between_budget_changes
|
||||
|
||||
# Wstrzymaj rekomendacje podniesienia budzetu, jesli budzet zmieniono zbyt niedawno.
|
||||
if (
|
||||
recommendation["level"] in {"wysokie", "srednie"}
|
||||
and min_days_between_budget_changes > 0
|
||||
and days_since is not None
|
||||
and days_since < min_days_between_budget_changes
|
||||
):
|
||||
recommendation = {
|
||||
"level": "wstrzymane",
|
||||
"action": "nie zmieniaj budzetu jeszcze",
|
||||
"reason": (
|
||||
f"budzet zmieniony {days_since} dni temu, minimum {min_days_between_budget_changes}; "
|
||||
f"pierwotna rekomendacja: {recommendation['level']}"
|
||||
),
|
||||
"suggested_budget_change_percent": 0,
|
||||
"current_daily_budget_micros": daily_budget_micros,
|
||||
"recommended_daily_budget_micros": daily_budget_micros,
|
||||
"budget_delta_micros": 0,
|
||||
"days_since_budget_change": days_since,
|
||||
"min_days_between_budget_changes": min_days_between_budget_changes,
|
||||
}
|
||||
|
||||
return recommendation
|
||||
|
||||
|
||||
def fetch_currency_code(google_client, customer_id: str) -> str:
|
||||
rows = run_query(
|
||||
google_client,
|
||||
customer_id,
|
||||
"""
|
||||
SELECT
|
||||
customer.currency_code
|
||||
FROM customer
|
||||
""",
|
||||
)
|
||||
if not rows:
|
||||
return ""
|
||||
return str(rows[0].customer.currency_code or "")
|
||||
|
||||
|
||||
def fetch_budget_campaigns(
|
||||
client_config: ClientConfig,
|
||||
min_days_between_budget_changes: int = 0,
|
||||
) -> tuple[str, list[dict]]:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
customer_id = client_config.safe_customer_id
|
||||
currency_code = fetch_currency_code(google_client, customer_id)
|
||||
rows = run_query(
|
||||
google_client,
|
||||
customer_id,
|
||||
"""
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign.name,
|
||||
campaign.status,
|
||||
campaign.advertising_channel_type,
|
||||
campaign_budget.id,
|
||||
campaign_budget.name,
|
||||
campaign_budget.amount_micros,
|
||||
campaign_budget.delivery_method,
|
||||
metrics.cost_micros,
|
||||
metrics.conversions,
|
||||
metrics.conversions_value,
|
||||
metrics.search_impression_share,
|
||||
metrics.search_budget_lost_impression_share,
|
||||
metrics.search_rank_lost_impression_share
|
||||
FROM campaign
|
||||
WHERE campaign.status = 'ENABLED'
|
||||
AND segments.date DURING LAST_7_DAYS
|
||||
""",
|
||||
)
|
||||
|
||||
campaigns = []
|
||||
for row in rows:
|
||||
campaign = row.campaign
|
||||
budget = row.campaign_budget
|
||||
daily_budget_micros = int(budget.amount_micros or 0)
|
||||
metrics = row.metrics
|
||||
cost_7d_micros = int(metrics.cost_micros or 0)
|
||||
expected_7d_micros = daily_budget_micros * 7
|
||||
conversions_value = float(metrics.conversions_value or 0)
|
||||
cost = micros_to_amount(cost_7d_micros)
|
||||
record = {
|
||||
"campaign_id": str(campaign.id),
|
||||
"campaign_name": campaign.name,
|
||||
"status": enum_name(campaign.status),
|
||||
"channel_type": enum_name(campaign.advertising_channel_type),
|
||||
"budget_id": str(budget.id),
|
||||
"budget_name": budget.name,
|
||||
"budget_delivery_method": enum_name(budget.delivery_method),
|
||||
"daily_budget_micros": daily_budget_micros,
|
||||
"expected_7d_micros": expected_7d_micros,
|
||||
"cost_7d_micros": cost_7d_micros,
|
||||
"avg_daily_cost_micros": int(cost_7d_micros / 7),
|
||||
"usage_percent": percent(cost_7d_micros, expected_7d_micros),
|
||||
"pacing_label": pacing_label(cost_7d_micros, expected_7d_micros),
|
||||
"conversions": round(float(metrics.conversions or 0), 2),
|
||||
"conversions_value": round(conversions_value, 2),
|
||||
"roas": round(conversions_value / cost, 2) if cost else 0,
|
||||
"search_impression_share": float(metrics.search_impression_share or 0),
|
||||
"search_budget_lost_impression_share": float(metrics.search_budget_lost_impression_share or 0),
|
||||
"search_rank_lost_impression_share": float(metrics.search_rank_lost_impression_share or 0),
|
||||
}
|
||||
last_change = last_budget_change_date(
|
||||
client_config.domain, record["campaign_id"], record["campaign_name"]
|
||||
)
|
||||
record["days_since_budget_change"] = (
|
||||
(now_local() - last_change).days if last_change else None
|
||||
)
|
||||
record["budget_recommendation"] = build_budget_recommendation(
|
||||
record, min_days_between_budget_changes
|
||||
)
|
||||
campaigns.append(record)
|
||||
return currency_code, campaigns
|
||||
|
||||
|
||||
def build_budget_usage_plan(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict | None = None,
|
||||
) -> BudgetUsagePlan:
|
||||
warnings = []
|
||||
budget_rules = client_config.effective_rules(global_rules or {}, "budget_usage")
|
||||
min_days_between_budget_changes = int(
|
||||
budget_rules.get("min_days_between_budget_changes", 0) or 0
|
||||
)
|
||||
try:
|
||||
currency_code, campaigns = fetch_budget_campaigns(
|
||||
client_config, min_days_between_budget_changes
|
||||
)
|
||||
except Exception as exc:
|
||||
currency_code = ""
|
||||
campaigns = []
|
||||
warnings.append(f"Nie udalo sie pobrac budzetow z Google Ads API: {exc}")
|
||||
|
||||
if not campaigns:
|
||||
warnings.append("Nie znaleziono aktywnych kampanii z danymi kosztow z ostatnich 7 dni albo nie udalo sie ich pobrac.")
|
||||
|
||||
rules = rules_for_task(TASK_ID)
|
||||
knowledge_rules = [
|
||||
{
|
||||
"id": rule.id,
|
||||
"topic": rule.topic,
|
||||
"rule_type": rule.rule_type,
|
||||
"condition": rule.condition,
|
||||
"recommendation": rule.recommendation,
|
||||
"risk": rule.risk,
|
||||
"source": rule.source,
|
||||
}
|
||||
for rule in rules
|
||||
]
|
||||
if not knowledge_rules:
|
||||
warnings.append(
|
||||
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
|
||||
"Reguly budzetowe bedziemy dopisywac osobno po akceptacji uzytkownika."
|
||||
)
|
||||
|
||||
campaigns.sort(key=lambda row: (-row["usage_percent"], row["campaign_name"]))
|
||||
return BudgetUsagePlan(
|
||||
currency_code=currency_code,
|
||||
campaigns=campaigns,
|
||||
scope=SCOPE,
|
||||
out_of_scope=OUT_OF_SCOPE,
|
||||
knowledge_rules=knowledge_rules,
|
||||
warnings=warnings,
|
||||
budget_changes=[],
|
||||
)
|
||||
|
||||
|
||||
def save_budget_usage_plan(domain: str, plan: BudgetUsagePlan) -> tuple[Path, Path]:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "plans"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
|
||||
json_path = base / f"{stem}.json"
|
||||
md_path = base / f"{stem}.md"
|
||||
payload = {
|
||||
"created_at": ts.isoformat(timespec="seconds"),
|
||||
"client": domain,
|
||||
**plan.to_dict(),
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Plan: Sprawdzenie wykorzystania budzetu",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Kampanie aktywne z danymi 7 dni: {len(plan.campaigns)}",
|
||||
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
|
||||
f"- Rekomendacje budzetowe do decyzji: {len(budget_recommendations(plan.campaigns))}",
|
||||
f"- Zmiany budzetu do wdrozenia: {len(plan.budget_changes or [])}",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {item}" for item in plan.warnings)
|
||||
lines.append("")
|
||||
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
|
||||
for row in plan.scope:
|
||||
lines.append(f"| {row.get('area', '')} | {row.get('check', '')} |")
|
||||
lines.append("")
|
||||
lines.extend(["## Poza zakresem tego zadania", ""])
|
||||
lines.extend(f"- {item}" for item in plan.out_of_scope)
|
||||
lines.append("")
|
||||
if plan.campaigns:
|
||||
lines.extend(
|
||||
[
|
||||
"## Wykorzystanie budzetu z ostatnich 7 dni",
|
||||
"",
|
||||
"| Kampania | Typ | Budzet dzienny | Koszt 7 dni | Uzycie 7 dni | Utrata przez budzet | ROAS | Status | Ost. zmiana budzetu | Budzet |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for campaign in plan.campaigns:
|
||||
lines.append(
|
||||
f"| {campaign['campaign_name']} | {campaign['channel_type']} | "
|
||||
f"{format_money(campaign['daily_budget_micros'], plan.currency_code)} | "
|
||||
f"{format_money(campaign['cost_7d_micros'], plan.currency_code)} | "
|
||||
f"{campaign['usage_percent']:.1f}% | "
|
||||
f"{percent_label(campaign.get('search_budget_lost_impression_share'))} | "
|
||||
f"{roas_label(campaign.get('roas', 0))} | "
|
||||
f"{campaign['pacing_label']} | "
|
||||
f"{days_since_label(campaign.get('days_since_budget_change'))} | "
|
||||
f"{campaign['budget_name']} |"
|
||||
)
|
||||
lines.append("")
|
||||
recommendations = budget_recommendations(plan.campaigns)
|
||||
if recommendations:
|
||||
lines.extend(
|
||||
[
|
||||
"## Rekomendacje budzetowe do decyzji",
|
||||
"",
|
||||
"| Kampania | Waznosc | Obecnie | Propozycja | Zmiana | Powod |",
|
||||
"| --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for campaign in recommendations:
|
||||
recommendation = campaign["budget_recommendation"]
|
||||
lines.append(
|
||||
f"| {campaign['campaign_name']} | {recommendation['level']} | "
|
||||
f"{format_money(recommendation['current_daily_budget_micros'], plan.currency_code)} | "
|
||||
f"{format_money(recommendation['recommended_daily_budget_micros'], plan.currency_code)} | "
|
||||
f"+{recommendation['suggested_budget_change_percent']}% "
|
||||
f"({format_money(recommendation['budget_delta_micros'], plan.currency_code)}) | "
|
||||
f"{recommendation['reason']} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.budget_changes:
|
||||
lines.extend(
|
||||
[
|
||||
"## Zmiany budzetu do wdrozenia",
|
||||
"",
|
||||
"| Kampania | Budzet | Obecnie | Docelowo | Zmiana | Powod |",
|
||||
"| --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for change in plan.budget_changes:
|
||||
lines.append(
|
||||
f"| {change.get('campaign_name', '')} | {change.get('budget_name', '')} | "
|
||||
f"{format_money(change.get('current_daily_budget_micros', 0), plan.currency_code)} | "
|
||||
f"{format_money(change.get('target_daily_budget_micros', 0), plan.currency_code)} | "
|
||||
f"{format_money(change.get('delta_micros', 0), plan.currency_code)} | "
|
||||
f"{change.get('reason', '')} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.knowledge_rules:
|
||||
lines.extend(
|
||||
[
|
||||
"## Reguly z bazy wiedzy",
|
||||
"",
|
||||
"| ID | Temat | Rekomendacja | Ryzyko |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for rule in plan.knowledge_rules:
|
||||
lines.append(
|
||||
f"| {rule.get('id', '')} | {rule.get('topic', '')} | "
|
||||
f"{rule.get('recommendation', '')} | {rule.get('risk', '')} |"
|
||||
)
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def print_budget_usage_plan(plan: BudgetUsagePlan) -> None:
|
||||
print("\nPlan sprawdzenia wykorzystania budzetu")
|
||||
print_table(
|
||||
["Metryka", "Liczba"],
|
||||
[
|
||||
["Kampanie z danymi 7 dni", str(len(plan.campaigns))],
|
||||
["Reguly wiedzy", str(len(plan.knowledge_rules))],
|
||||
["Rekomendacje budzetowe", str(len(budget_recommendations(plan.campaigns)))],
|
||||
["Zmiany budzetu do wdrozenia", str(len(plan.budget_changes or []))],
|
||||
],
|
||||
)
|
||||
if plan.warnings:
|
||||
print("\nUwagi")
|
||||
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
|
||||
print("\nZakres zadania")
|
||||
print_table(
|
||||
["Nr", "Obszar", "Co sprawdzic"],
|
||||
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
|
||||
)
|
||||
print("\nPoza zakresem")
|
||||
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
|
||||
if plan.campaigns:
|
||||
print("\nWykorzystanie budzetu z ostatnich 7 dni")
|
||||
print_table(
|
||||
["Nr", "Kampania", "Typ", "Budzet dzienny", "Koszt 7 dni", "Uzycie", "Utrata budz.", "ROAS", "Status", "Ost. zm. budz."],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
campaign["campaign_name"],
|
||||
campaign["channel_type"],
|
||||
format_money(campaign["daily_budget_micros"], plan.currency_code),
|
||||
format_money(campaign["cost_7d_micros"], plan.currency_code),
|
||||
f"{campaign['usage_percent']:.1f}%",
|
||||
percent_label(campaign.get("search_budget_lost_impression_share")),
|
||||
roas_label(campaign.get("roas", 0)),
|
||||
campaign["pacing_label"],
|
||||
days_since_label(campaign.get("days_since_budget_change")),
|
||||
]
|
||||
for index, campaign in enumerate(plan.campaigns, 1)
|
||||
],
|
||||
)
|
||||
if plan.budget_changes:
|
||||
print("\nZmiany budzetu do wdrozenia")
|
||||
print_table(
|
||||
["Nr", "Kampania", "Budzet", "Obecnie", "Docelowo", "Zmiana", "Powod"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
change.get("campaign_name", ""),
|
||||
change.get("budget_name", ""),
|
||||
format_money(change.get("current_daily_budget_micros", 0), plan.currency_code),
|
||||
format_money(change.get("target_daily_budget_micros", 0), plan.currency_code),
|
||||
format_money(change.get("delta_micros", 0), plan.currency_code),
|
||||
change.get("reason", ""),
|
||||
]
|
||||
for index, change in enumerate(plan.budget_changes, 1)
|
||||
],
|
||||
)
|
||||
recommendations = budget_recommendations(plan.campaigns)
|
||||
if recommendations:
|
||||
print("\nRekomendacje budzetowe do decyzji")
|
||||
print_table(
|
||||
["Nr", "Kampania", "Waznosc", "Obecnie", "Propozycja", "Zmiana", "Powod"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
campaign["campaign_name"],
|
||||
campaign["budget_recommendation"]["level"],
|
||||
format_money(campaign["budget_recommendation"]["current_daily_budget_micros"], plan.currency_code),
|
||||
format_money(
|
||||
campaign["budget_recommendation"]["recommended_daily_budget_micros"],
|
||||
plan.currency_code,
|
||||
),
|
||||
(
|
||||
f"+{campaign['budget_recommendation']['suggested_budget_change_percent']}% "
|
||||
f"({format_money(campaign['budget_recommendation']['budget_delta_micros'], plan.currency_code)})"
|
||||
),
|
||||
campaign["budget_recommendation"]["reason"],
|
||||
]
|
||||
for index, campaign in enumerate(recommendations, 1)
|
||||
],
|
||||
)
|
||||
if plan.knowledge_rules:
|
||||
print("\nReguly z bazy wiedzy")
|
||||
print_table(
|
||||
["Nr", "ID", "Temat", "Rekomendacja"],
|
||||
[
|
||||
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
|
||||
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.knowledge_rules) > 10:
|
||||
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
|
||||
|
||||
|
||||
def print_next_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def apply_budget_usage_plan(
|
||||
client_config: ClientConfig,
|
||||
plan: BudgetUsagePlan,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
budget_changes = plan.budget_changes or []
|
||||
changed = 0
|
||||
errors = []
|
||||
if budget_changes:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
customer_id = client_config.safe_customer_id
|
||||
service = google_client.get_service("CampaignBudgetService")
|
||||
operations = []
|
||||
for change in budget_changes:
|
||||
op = google_client.get_type("CampaignBudgetOperation")
|
||||
budget = op.update
|
||||
budget.resource_name = service.campaign_budget_path(customer_id, change["budget_id"])
|
||||
budget.amount_micros = int(change["target_daily_budget_micros"])
|
||||
op.update_mask = field_mask_pb2.FieldMask(paths=["amount_micros"])
|
||||
operations.append(op)
|
||||
|
||||
if operations:
|
||||
try:
|
||||
response = service.mutate_campaign_budgets(customer_id=customer_id, operations=operations)
|
||||
changed = len(response.results)
|
||||
except Exception as exc:
|
||||
errors.append(str(exc))
|
||||
|
||||
if budget_changes:
|
||||
print("\nWynik wdrozenia zmian budzetu")
|
||||
print(f"Zmieniono budzetow: {changed}")
|
||||
print(f"Bledy: {len(errors)}")
|
||||
for error in errors:
|
||||
print(f"Blad: {error}")
|
||||
else:
|
||||
print("\nTo zadanie jest audytem budzetow i nie ma zmian budzetu do wdrozenia.")
|
||||
|
||||
rows = [
|
||||
{
|
||||
"klient": client_config.domain,
|
||||
"kampania": change.get("campaign_name", ""),
|
||||
"czynnosc": "Zmien budzet dzienny",
|
||||
"grupa reklam": "",
|
||||
"produkt": (
|
||||
f"{format_money(change.get('current_daily_budget_micros', 0), plan.currency_code)} -> "
|
||||
f"{format_money(change.get('target_daily_budget_micros', 0), plan.currency_code)}"
|
||||
),
|
||||
}
|
||||
for change in budget_changes
|
||||
]
|
||||
changes_path = append_change_markdown(client_config.domain, TASK_NAME, rows)
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "wdrozono zmiany budzetu" if budget_changes and not errors else "audyt oznaczony jako wykonany",
|
||||
"campaign": ", ".join(
|
||||
change.get("campaign_name", "") for change in budget_changes
|
||||
)
|
||||
or ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
|
||||
"budget_changes": [
|
||||
{
|
||||
"campaign_id": change.get("campaign_id", ""),
|
||||
"campaign_name": change.get("campaign_name", ""),
|
||||
"budget_id": change.get("budget_id", ""),
|
||||
"target_daily_budget_micros": change.get("target_daily_budget_micros", 0),
|
||||
}
|
||||
for change in budget_changes
|
||||
],
|
||||
"summary": {
|
||||
"campaigns": len(plan.campaigns),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"budget_changes": len(budget_changes),
|
||||
"changed": changed,
|
||||
"errors": len(errors),
|
||||
},
|
||||
},
|
||||
)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def run_check_budget_usage(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
if apply_plan_path:
|
||||
if confirm_apply != "TAK":
|
||||
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
|
||||
if plan_data.get("client") != client_config.domain:
|
||||
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan = BudgetUsagePlan.from_dict(plan_data)
|
||||
print_budget_usage_plan(plan)
|
||||
apply_budget_usage_plan(client_config, plan, show_navigation=show_navigation)
|
||||
return
|
||||
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print("Przygotowuje plan sprawdzenia wykorzystania budzetu...")
|
||||
plan = build_budget_usage_plan(client_config, global_rules)
|
||||
print_budget_usage_plan(plan)
|
||||
json_path, md_path = save_budget_usage_plan(client_config.domain, plan)
|
||||
print(f"\nPlan JSON: {json_path}")
|
||||
print(f"Plan Markdown: {md_path}")
|
||||
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "plan przygotowany",
|
||||
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
|
||||
"summary": {
|
||||
"campaigns": len(plan.campaigns),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if plan_only:
|
||||
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu budzetow.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
567
src/gads_v2/tasks/campaign_language_check.py
Normal file
567
src/gads_v2/tasks/campaign_language_check.py
Normal file
@@ -0,0 +1,567 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..config import ClientConfig, client_dir
|
||||
from ..google_ads import get_google_ads_client, run_query
|
||||
from ..history import append_change_markdown, append_history, now_local
|
||||
from ..knowledge.store import rules_for_task
|
||||
from ..table import print_table
|
||||
|
||||
|
||||
TASK_ID = "check_campaign_languages"
|
||||
TASK_NAME = "Sprawdzenie jezykow kampanii"
|
||||
|
||||
|
||||
SCOPE = [
|
||||
{
|
||||
"area": "Jezyki kampanii",
|
||||
"check": "Wypisz jezyki przypisane do kampanii i oznacz kampanie bez jawnych kryteriow jezykowych.",
|
||||
},
|
||||
{
|
||||
"area": "Rynek klienta",
|
||||
"check": "Oznacz ustawienia wymagajace recznej oceny zgodnosci z rynkiem klienta.",
|
||||
},
|
||||
{
|
||||
"area": "Typ kampanii",
|
||||
"check": "Pokaz jezyki razem z typem kampanii, zeby osobno oceniac Search, Shopping i PMax.",
|
||||
},
|
||||
{
|
||||
"area": "Audyt miesieczny",
|
||||
"check": "Przygotuj szybki przeglad ustawien jezykowych, ktory mozna wykonywac rzadziej niz budzety i anomalie.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
OUT_OF_SCOPE = [
|
||||
"budzety i wykorzystanie budzetu",
|
||||
"strategie stawek oraz cele Docelowy ROAS/Docelowy CPA",
|
||||
"zapytania uzytkownikow oraz wykluczenia",
|
||||
"reklamy RSA, assety i kreacje",
|
||||
"wdrazanie zmian jezykow na koncie Google Ads",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CampaignLanguagePlan:
|
||||
campaigns: list[dict]
|
||||
language_summary: list[dict]
|
||||
channel_summary: list[dict]
|
||||
scope: list[dict]
|
||||
out_of_scope: list[str]
|
||||
knowledge_rules: list[dict]
|
||||
warnings: list[str]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"task": TASK_ID,
|
||||
"task_name": TASK_NAME,
|
||||
"campaigns": self.campaigns,
|
||||
"language_summary": self.language_summary,
|
||||
"channel_summary": self.channel_summary,
|
||||
"scope": self.scope,
|
||||
"out_of_scope": self.out_of_scope,
|
||||
"knowledge_rules": self.knowledge_rules,
|
||||
"warnings": self.warnings,
|
||||
"changes": [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "CampaignLanguagePlan":
|
||||
return cls(
|
||||
campaigns=data.get("campaigns", []),
|
||||
language_summary=data.get("language_summary", []),
|
||||
channel_summary=data.get("channel_summary", []),
|
||||
scope=data.get("scope", []),
|
||||
out_of_scope=data.get("out_of_scope", []),
|
||||
knowledge_rules=data.get("knowledge_rules", []),
|
||||
warnings=data.get("warnings", []),
|
||||
)
|
||||
|
||||
|
||||
def enum_name(value: Any) -> str:
|
||||
name = getattr(value, "name", None)
|
||||
if name:
|
||||
return name
|
||||
return str(value)
|
||||
|
||||
|
||||
def md_cell(value: Any) -> str:
|
||||
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
|
||||
|
||||
|
||||
def language_label(resource_name: str, language_constants: dict[str, dict]) -> str:
|
||||
language = language_constants.get(resource_name)
|
||||
if not language:
|
||||
return resource_name
|
||||
code = language.get("code", "")
|
||||
name = language.get("name", "")
|
||||
if code:
|
||||
return f"{name} ({code})" if name else code
|
||||
return name or resource_name
|
||||
|
||||
|
||||
def campaign_flags(campaign: dict) -> list[str]:
|
||||
flags = []
|
||||
language_count = campaign["languages_count"]
|
||||
labels = " ".join(language["label"].casefold() for language in campaign["languages"])
|
||||
if language_count == 0:
|
||||
flags.append("brak jawnych jezykow")
|
||||
if language_count > 3:
|
||||
flags.append("wiele jezykow do oceny")
|
||||
if "polish" not in labels and "polski" not in labels and "pl" not in labels:
|
||||
flags.append("brak oczywistego jezyka polskiego")
|
||||
if campaign["channel_type"] in {"PERFORMANCE_MAX", "SHOPPING"}:
|
||||
flags.append("sprawdz razem z feedem i rynkiem")
|
||||
return flags or ["ok"]
|
||||
|
||||
|
||||
def fetch_campaign_language_settings(client_config: ClientConfig) -> list[dict]:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
rows = run_query(
|
||||
google_client,
|
||||
client_config.safe_customer_id,
|
||||
"""
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign.name,
|
||||
campaign.status,
|
||||
campaign.advertising_channel_type
|
||||
FROM campaign
|
||||
WHERE campaign.status != 'REMOVED'
|
||||
""",
|
||||
)
|
||||
|
||||
campaigns = []
|
||||
for row in rows:
|
||||
campaign = row.campaign
|
||||
campaigns.append(
|
||||
{
|
||||
"campaign_id": str(campaign.id),
|
||||
"campaign_name": campaign.name,
|
||||
"status": enum_name(campaign.status),
|
||||
"channel_type": enum_name(campaign.advertising_channel_type),
|
||||
"languages": [],
|
||||
}
|
||||
)
|
||||
return campaigns
|
||||
|
||||
|
||||
def fetch_language_criteria(client_config: ClientConfig) -> tuple[dict[str, list[dict]], dict[str, dict], list[str]]:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
warnings = []
|
||||
rows = run_query(
|
||||
google_client,
|
||||
client_config.safe_customer_id,
|
||||
"""
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign_criterion.criterion_id,
|
||||
campaign_criterion.status,
|
||||
campaign_criterion.language.language_constant
|
||||
FROM campaign_criterion
|
||||
WHERE campaign.status != 'REMOVED'
|
||||
AND campaign_criterion.status != 'REMOVED'
|
||||
AND campaign_criterion.type = 'LANGUAGE'
|
||||
""",
|
||||
)
|
||||
|
||||
languages_by_campaign: dict[str, list[dict]] = {}
|
||||
resource_names = set()
|
||||
for row in rows:
|
||||
campaign_id = str(row.campaign.id)
|
||||
criterion = row.campaign_criterion
|
||||
resource_name = str(criterion.language.language_constant or "")
|
||||
if resource_name:
|
||||
resource_names.add(resource_name)
|
||||
languages_by_campaign.setdefault(campaign_id, []).append(
|
||||
{
|
||||
"criterion_id": str(criterion.criterion_id),
|
||||
"resource_name": resource_name,
|
||||
"status": enum_name(criterion.status),
|
||||
}
|
||||
)
|
||||
|
||||
language_constants = fetch_language_constant_names(
|
||||
google_client,
|
||||
client_config.safe_customer_id,
|
||||
sorted(resource_names),
|
||||
warnings,
|
||||
)
|
||||
return languages_by_campaign, language_constants, warnings
|
||||
|
||||
|
||||
def fetch_language_constant_names(
|
||||
google_client,
|
||||
customer_id: str,
|
||||
resource_names: list[str],
|
||||
warnings: list[str],
|
||||
) -> dict[str, dict]:
|
||||
if not resource_names:
|
||||
return {}
|
||||
language_constants: dict[str, dict] = {}
|
||||
chunk_size = 200
|
||||
for start in range(0, len(resource_names), chunk_size):
|
||||
chunk = resource_names[start : start + chunk_size]
|
||||
quoted = ", ".join(f"'{name}'" for name in chunk)
|
||||
try:
|
||||
rows = run_query(
|
||||
google_client,
|
||||
customer_id,
|
||||
f"""
|
||||
SELECT
|
||||
language_constant.resource_name,
|
||||
language_constant.name,
|
||||
language_constant.code,
|
||||
language_constant.targetable
|
||||
FROM language_constant
|
||||
WHERE language_constant.resource_name IN ({quoted})
|
||||
""",
|
||||
)
|
||||
except Exception as exc:
|
||||
warnings.append(f"Nie udalo sie pobrac nazw language_constant: {exc}")
|
||||
return language_constants
|
||||
for row in rows:
|
||||
language = row.language_constant
|
||||
language_constants[str(language.resource_name)] = {
|
||||
"resource_name": str(language.resource_name),
|
||||
"name": str(language.name or ""),
|
||||
"code": str(language.code or ""),
|
||||
"targetable": bool(language.targetable),
|
||||
}
|
||||
return language_constants
|
||||
|
||||
|
||||
def attach_languages(
|
||||
campaigns: list[dict],
|
||||
languages_by_campaign: dict[str, list[dict]],
|
||||
language_constants: dict[str, dict],
|
||||
) -> list[dict]:
|
||||
for campaign in campaigns:
|
||||
campaign_id = campaign["campaign_id"]
|
||||
campaign["languages"] = [
|
||||
{
|
||||
**item,
|
||||
"label": language_label(item["resource_name"], language_constants),
|
||||
"code": language_constants.get(item["resource_name"], {}).get("code", ""),
|
||||
"name": language_constants.get(item["resource_name"], {}).get("name", ""),
|
||||
}
|
||||
for item in languages_by_campaign.get(campaign_id, [])
|
||||
]
|
||||
campaign["languages_count"] = len(campaign["languages"])
|
||||
campaign["flags"] = campaign_flags(campaign)
|
||||
campaigns.sort(key=lambda row: (row["channel_type"], row["campaign_name"]))
|
||||
return campaigns
|
||||
|
||||
|
||||
def build_language_summary(campaigns: list[dict]) -> list[dict]:
|
||||
counter: Counter[str] = Counter()
|
||||
for campaign in campaigns:
|
||||
if not campaign["languages"]:
|
||||
counter["(brak jawnych jezykow)"] += 1
|
||||
continue
|
||||
for language in campaign["languages"]:
|
||||
counter[language.get("label") or language.get("resource_name") or "(brak nazwy)"] += 1
|
||||
return [{"language": key, "campaigns": value} for key, value in counter.most_common()]
|
||||
|
||||
|
||||
def build_channel_summary(campaigns: list[dict]) -> list[dict]:
|
||||
counter = Counter(row["channel_type"] for row in campaigns)
|
||||
return [{"channel_type": key, "count": value} for key, value in counter.most_common()]
|
||||
|
||||
|
||||
def join_language_labels(languages: list[dict], limit: int = 8) -> str:
|
||||
if not languages:
|
||||
return "(brak)"
|
||||
labels = [item.get("label") or item.get("resource_name") or "" for item in languages]
|
||||
shown = labels[:limit]
|
||||
if len(labels) > limit:
|
||||
shown.append(f"... +{len(labels) - limit}")
|
||||
return ", ".join(shown)
|
||||
|
||||
|
||||
def build_campaign_language_plan(client_config: ClientConfig) -> CampaignLanguagePlan:
|
||||
warnings = []
|
||||
try:
|
||||
campaigns = fetch_campaign_language_settings(client_config)
|
||||
languages_by_campaign, language_constants, language_warnings = fetch_language_criteria(client_config)
|
||||
warnings.extend(language_warnings)
|
||||
campaigns = attach_languages(campaigns, languages_by_campaign, language_constants)
|
||||
except Exception as exc:
|
||||
campaigns = []
|
||||
warnings.append(f"Nie udalo sie pobrac ustawien jezykow z Google Ads API: {exc}")
|
||||
|
||||
if not campaigns:
|
||||
warnings.append("Nie znaleziono kampanii albo nie udalo sie pobrac ustawien jezykow.")
|
||||
|
||||
knowledge_rules = [
|
||||
{
|
||||
"id": rule.id,
|
||||
"topic": rule.topic,
|
||||
"rule_type": rule.rule_type,
|
||||
"condition": rule.condition,
|
||||
"recommendation": rule.recommendation,
|
||||
"risk": rule.risk,
|
||||
"source": rule.source,
|
||||
}
|
||||
for rule in rules_for_task(TASK_ID)
|
||||
]
|
||||
if not knowledge_rules:
|
||||
warnings.append(
|
||||
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
|
||||
"Reguly dotyczace jezykow kampanii bedziemy dopisywac osobno po akceptacji uzytkownika."
|
||||
)
|
||||
|
||||
return CampaignLanguagePlan(
|
||||
campaigns=campaigns,
|
||||
language_summary=build_language_summary(campaigns),
|
||||
channel_summary=build_channel_summary(campaigns),
|
||||
scope=SCOPE,
|
||||
out_of_scope=OUT_OF_SCOPE,
|
||||
knowledge_rules=knowledge_rules,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def save_campaign_language_plan(domain: str, plan: CampaignLanguagePlan) -> tuple[Path, Path]:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "plans"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
|
||||
json_path = base / f"{stem}.json"
|
||||
md_path = base / f"{stem}.md"
|
||||
payload = {
|
||||
"created_at": ts.isoformat(timespec="seconds"),
|
||||
"client": domain,
|
||||
**plan.to_dict(),
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Plan: Sprawdzenie jezykow kampanii",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Kampanie: {len(plan.campaigns)}",
|
||||
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
|
||||
"- Zmiany do wdrozenia: 0",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {item}" for item in plan.warnings)
|
||||
lines.append("")
|
||||
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
|
||||
for row in plan.scope:
|
||||
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
|
||||
lines.append("")
|
||||
lines.extend(["## Poza zakresem tego zadania", ""])
|
||||
lines.extend(f"- {item}" for item in plan.out_of_scope)
|
||||
lines.append("")
|
||||
if plan.language_summary:
|
||||
lines.extend(["## Podsumowanie jezykow", "", "| Jezyk | Kampanie |", "| --- | --- |"])
|
||||
for row in plan.language_summary:
|
||||
lines.append(f"| {md_cell(row['language'])} | {row['campaigns']} |")
|
||||
lines.append("")
|
||||
if plan.channel_summary:
|
||||
lines.extend(["## Podsumowanie po typach kampanii", "", "| Typ | Liczba |", "| --- | --- |"])
|
||||
for row in plan.channel_summary:
|
||||
lines.append(f"| {row['channel_type']} | {row['count']} |")
|
||||
lines.append("")
|
||||
if plan.campaigns:
|
||||
lines.extend(
|
||||
[
|
||||
"## Kampanie",
|
||||
"",
|
||||
"| Kampania | Typ | Status | Jezyki | Flagi |",
|
||||
"| --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for campaign in plan.campaigns:
|
||||
lines.append(
|
||||
f"| {md_cell(campaign['campaign_name'])} | {campaign['channel_type']} | {campaign['status']} | "
|
||||
f"{md_cell(join_language_labels(campaign['languages']))} | "
|
||||
f"{md_cell(', '.join(campaign['flags']))} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.knowledge_rules:
|
||||
lines.extend(
|
||||
[
|
||||
"## Reguly z bazy wiedzy",
|
||||
"",
|
||||
"| ID | Temat | Rekomendacja | Ryzyko |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for rule in plan.knowledge_rules:
|
||||
lines.append(
|
||||
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
|
||||
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
|
||||
)
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def print_campaign_language_plan(plan: CampaignLanguagePlan) -> None:
|
||||
print("\nPlan sprawdzenia jezykow kampanii")
|
||||
print_table(
|
||||
["Metryka", "Liczba"],
|
||||
[
|
||||
["Kampanie", str(len(plan.campaigns))],
|
||||
["Reguly wiedzy", str(len(plan.knowledge_rules))],
|
||||
["Zmiany do wdrozenia", "0"],
|
||||
],
|
||||
)
|
||||
if plan.warnings:
|
||||
print("\nUwagi")
|
||||
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
|
||||
print("\nZakres zadania")
|
||||
print_table(
|
||||
["Nr", "Obszar", "Co sprawdzic"],
|
||||
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
|
||||
)
|
||||
print("\nPoza zakresem")
|
||||
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
|
||||
if plan.language_summary:
|
||||
print("\nPodsumowanie jezykow")
|
||||
print_table(
|
||||
["Jezyk", "Kampanie"],
|
||||
[[row["language"], str(row["campaigns"])] for row in plan.language_summary],
|
||||
)
|
||||
if plan.channel_summary:
|
||||
print("\nPodsumowanie po typach kampanii")
|
||||
print_table(
|
||||
["Typ", "Liczba"],
|
||||
[[row["channel_type"], str(row["count"])] for row in plan.channel_summary],
|
||||
)
|
||||
if plan.campaigns:
|
||||
print("\nKampanie")
|
||||
print_table(
|
||||
["Nr", "Kampania", "Typ", "Jezyki", "Flagi"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
campaign["campaign_name"],
|
||||
campaign["channel_type"],
|
||||
join_language_labels(campaign["languages"]),
|
||||
", ".join(campaign["flags"]),
|
||||
]
|
||||
for index, campaign in enumerate(plan.campaigns[:30], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.campaigns) > 30:
|
||||
print(f"... oraz {len(plan.campaigns) - 30} kolejnych kampanii w pliku planu")
|
||||
if plan.knowledge_rules:
|
||||
print("\nReguly z bazy wiedzy")
|
||||
print_table(
|
||||
["Nr", "ID", "Temat", "Rekomendacja"],
|
||||
[
|
||||
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
|
||||
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.knowledge_rules) > 10:
|
||||
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
|
||||
|
||||
|
||||
def print_next_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def apply_campaign_language_plan(
|
||||
client_config: ClientConfig,
|
||||
plan: CampaignLanguagePlan,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
print("\nTo zadanie jest audytem jezykow kampanii i nie wdraza zmian na koncie Google Ads.")
|
||||
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "audyt oznaczony jako wykonany",
|
||||
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
|
||||
"summary": {
|
||||
"campaigns": len(plan.campaigns),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def run_check_campaign_languages(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
_ = global_rules
|
||||
if apply_plan_path:
|
||||
if confirm_apply != "TAK":
|
||||
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
|
||||
if plan_data.get("client") != client_config.domain:
|
||||
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan = CampaignLanguagePlan.from_dict(plan_data)
|
||||
print_campaign_language_plan(plan)
|
||||
apply_campaign_language_plan(client_config, plan, show_navigation=show_navigation)
|
||||
return
|
||||
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print("Przygotowuje plan sprawdzenia jezykow kampanii...")
|
||||
plan = build_campaign_language_plan(client_config)
|
||||
print_campaign_language_plan(plan)
|
||||
json_path, md_path = save_campaign_language_plan(client_config.domain, plan)
|
||||
print(f"\nPlan JSON: {json_path}")
|
||||
print(f"Plan Markdown: {md_path}")
|
||||
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "plan przygotowany",
|
||||
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
|
||||
"summary": {
|
||||
"campaigns": len(plan.campaigns),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if plan_only:
|
||||
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu jezykow kampanii.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
599
src/gads_v2/tasks/campaign_location_check.py
Normal file
599
src/gads_v2/tasks/campaign_location_check.py
Normal file
@@ -0,0 +1,599 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..config import ClientConfig, client_dir
|
||||
from ..google_ads import get_google_ads_client, run_query
|
||||
from ..history import append_change_markdown, append_history, now_local
|
||||
from ..knowledge.store import rules_for_task
|
||||
from ..table import print_table
|
||||
|
||||
|
||||
TASK_ID = "check_campaign_locations"
|
||||
TASK_NAME = "Sprawdzenie lokalizacji kampanii"
|
||||
|
||||
|
||||
SCOPE = [
|
||||
{
|
||||
"area": "Tryb kierowania",
|
||||
"check": "Pokaz pozytywny i negatywny tryb kierowania lokalizacja dla kazdej kampanii.",
|
||||
},
|
||||
{
|
||||
"area": "Lokalizacje docelowe",
|
||||
"check": "Wypisz lokalizacje dodane do kampanii i oznacz kampanie bez jawnych lokalizacji.",
|
||||
},
|
||||
{
|
||||
"area": "Wykluczone lokalizacje",
|
||||
"check": "Wypisz lokalizacje wykluczone i oznacz kampanie, w ktorych brakuje wykluczen do recznej oceny.",
|
||||
},
|
||||
{
|
||||
"area": "Sprawnosc miesieczna",
|
||||
"check": "Przygotuj szybki przeglad ustawien, ktory mozna wykonywac rzadziej niz budzety i anomalie.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
OUT_OF_SCOPE = [
|
||||
"budzety i wykorzystanie budzetu",
|
||||
"strategie stawek oraz cele Docelowy ROAS/Docelowy CPA",
|
||||
"zapytania uzytkownikow i wykluczenia slow kluczowych",
|
||||
"reklamy, assety i kreacje",
|
||||
"wdrazanie zmian lokalizacji na koncie Google Ads",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CampaignLocationPlan:
|
||||
campaigns: list[dict]
|
||||
geo_type_summary: list[dict]
|
||||
location_summary: list[dict]
|
||||
scope: list[dict]
|
||||
out_of_scope: list[str]
|
||||
knowledge_rules: list[dict]
|
||||
warnings: list[str]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"task": TASK_ID,
|
||||
"task_name": TASK_NAME,
|
||||
"campaigns": self.campaigns,
|
||||
"geo_type_summary": self.geo_type_summary,
|
||||
"location_summary": self.location_summary,
|
||||
"scope": self.scope,
|
||||
"out_of_scope": self.out_of_scope,
|
||||
"knowledge_rules": self.knowledge_rules,
|
||||
"warnings": self.warnings,
|
||||
"changes": [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "CampaignLocationPlan":
|
||||
return cls(
|
||||
campaigns=data.get("campaigns", []),
|
||||
geo_type_summary=data.get("geo_type_summary", []),
|
||||
location_summary=data.get("location_summary", []),
|
||||
scope=data.get("scope", []),
|
||||
out_of_scope=data.get("out_of_scope", []),
|
||||
knowledge_rules=data.get("knowledge_rules", []),
|
||||
warnings=data.get("warnings", []),
|
||||
)
|
||||
|
||||
|
||||
def enum_name(value: Any) -> str:
|
||||
name = getattr(value, "name", None)
|
||||
if name:
|
||||
return name
|
||||
return str(value)
|
||||
|
||||
|
||||
def md_cell(value: Any) -> str:
|
||||
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
|
||||
|
||||
|
||||
def human_geo(value: str) -> str:
|
||||
return {
|
||||
"PRESENCE": "Obecnosc",
|
||||
"PRESENCE_OR_INTEREST": "Obecnosc lub zainteresowanie",
|
||||
"SEARCH_INTEREST": "Zainteresowanie wyszukiwaniem",
|
||||
"UNKNOWN": "Nieznane",
|
||||
"UNSPECIFIED": "Nieokreslone",
|
||||
}.get(value, value)
|
||||
|
||||
|
||||
def location_label(resource_name: str, geo_targets: dict[str, dict]) -> str:
|
||||
target = geo_targets.get(resource_name)
|
||||
if not target:
|
||||
return resource_name
|
||||
parts = [target.get("name", "")]
|
||||
country_code = target.get("country_code", "")
|
||||
target_type = target.get("target_type", "")
|
||||
suffix = ", ".join(part for part in [country_code, target_type] if part)
|
||||
if suffix:
|
||||
parts.append(f"({suffix})")
|
||||
return " ".join(part for part in parts if part).strip() or resource_name
|
||||
|
||||
|
||||
def campaign_flags(campaign: dict) -> list[str]:
|
||||
flags = []
|
||||
if campaign["positive_geo_target_type"] != "PRESENCE":
|
||||
flags.append("kierowanie nie tylko na obecnosc")
|
||||
if campaign["positive_locations_count"] == 0:
|
||||
flags.append("brak jawnych lokalizacji")
|
||||
if campaign["negative_locations_count"] == 0:
|
||||
flags.append("brak wykluczen lokalizacji")
|
||||
if campaign["negative_geo_target_type"] in {"UNKNOWN", "UNSPECIFIED", ""}:
|
||||
flags.append("nieznany tryb wykluczen")
|
||||
return flags or ["ok"]
|
||||
|
||||
|
||||
def fetch_campaign_geo_settings(client_config: ClientConfig) -> list[dict]:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
rows = run_query(
|
||||
google_client,
|
||||
client_config.safe_customer_id,
|
||||
"""
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign.name,
|
||||
campaign.status,
|
||||
campaign.advertising_channel_type,
|
||||
campaign.geo_target_type_setting.positive_geo_target_type,
|
||||
campaign.geo_target_type_setting.negative_geo_target_type
|
||||
FROM campaign
|
||||
WHERE campaign.status != 'REMOVED'
|
||||
""",
|
||||
)
|
||||
|
||||
campaigns = []
|
||||
for row in rows:
|
||||
campaign = row.campaign
|
||||
positive_geo = enum_name(campaign.geo_target_type_setting.positive_geo_target_type)
|
||||
negative_geo = enum_name(campaign.geo_target_type_setting.negative_geo_target_type)
|
||||
campaigns.append(
|
||||
{
|
||||
"campaign_id": str(campaign.id),
|
||||
"campaign_name": campaign.name,
|
||||
"status": enum_name(campaign.status),
|
||||
"channel_type": enum_name(campaign.advertising_channel_type),
|
||||
"positive_geo_target_type": positive_geo,
|
||||
"positive_geo_target_type_label": human_geo(positive_geo),
|
||||
"negative_geo_target_type": negative_geo,
|
||||
"negative_geo_target_type_label": human_geo(negative_geo),
|
||||
"positive_locations": [],
|
||||
"negative_locations": [],
|
||||
}
|
||||
)
|
||||
return campaigns
|
||||
|
||||
|
||||
def fetch_location_criteria(client_config: ClientConfig) -> tuple[dict[str, list[dict]], dict[str, list[dict]], dict[str, dict], list[str]]:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
warnings = []
|
||||
rows = run_query(
|
||||
google_client,
|
||||
client_config.safe_customer_id,
|
||||
"""
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign_criterion.criterion_id,
|
||||
campaign_criterion.negative,
|
||||
campaign_criterion.status,
|
||||
campaign_criterion.location.geo_target_constant
|
||||
FROM campaign_criterion
|
||||
WHERE campaign.status != 'REMOVED'
|
||||
AND campaign_criterion.status != 'REMOVED'
|
||||
AND campaign_criterion.type = 'LOCATION'
|
||||
""",
|
||||
)
|
||||
|
||||
positive: dict[str, list[dict]] = {}
|
||||
negative: dict[str, list[dict]] = {}
|
||||
resource_names = set()
|
||||
for row in rows:
|
||||
campaign_id = str(row.campaign.id)
|
||||
criterion = row.campaign_criterion
|
||||
resource_name = str(criterion.location.geo_target_constant or "")
|
||||
if resource_name:
|
||||
resource_names.add(resource_name)
|
||||
item = {
|
||||
"criterion_id": str(criterion.criterion_id),
|
||||
"resource_name": resource_name,
|
||||
"status": enum_name(criterion.status),
|
||||
"negative": bool(criterion.negative),
|
||||
}
|
||||
target = negative if criterion.negative else positive
|
||||
target.setdefault(campaign_id, []).append(item)
|
||||
|
||||
geo_targets = fetch_geo_target_names(google_client, client_config.safe_customer_id, sorted(resource_names), warnings)
|
||||
return positive, negative, geo_targets, warnings
|
||||
|
||||
|
||||
def fetch_geo_target_names(google_client, customer_id: str, resource_names: list[str], warnings: list[str]) -> dict[str, dict]:
|
||||
if not resource_names:
|
||||
return {}
|
||||
geo_targets: dict[str, dict] = {}
|
||||
chunk_size = 200
|
||||
for start in range(0, len(resource_names), chunk_size):
|
||||
chunk = resource_names[start : start + chunk_size]
|
||||
quoted = ", ".join(f"'{name}'" for name in chunk)
|
||||
try:
|
||||
rows = run_query(
|
||||
google_client,
|
||||
customer_id,
|
||||
f"""
|
||||
SELECT
|
||||
geo_target_constant.resource_name,
|
||||
geo_target_constant.name,
|
||||
geo_target_constant.country_code,
|
||||
geo_target_constant.target_type,
|
||||
geo_target_constant.status
|
||||
FROM geo_target_constant
|
||||
WHERE geo_target_constant.resource_name IN ({quoted})
|
||||
""",
|
||||
)
|
||||
except Exception as exc:
|
||||
warnings.append(f"Nie udalo sie pobrac nazw lokalizacji geo_target_constant: {exc}")
|
||||
return geo_targets
|
||||
for row in rows:
|
||||
target = row.geo_target_constant
|
||||
geo_targets[str(target.resource_name)] = {
|
||||
"resource_name": str(target.resource_name),
|
||||
"name": str(target.name or ""),
|
||||
"country_code": str(target.country_code or ""),
|
||||
"target_type": str(target.target_type or ""),
|
||||
"status": enum_name(target.status),
|
||||
}
|
||||
return geo_targets
|
||||
|
||||
|
||||
def attach_locations(
|
||||
campaigns: list[dict],
|
||||
positive: dict[str, list[dict]],
|
||||
negative: dict[str, list[dict]],
|
||||
geo_targets: dict[str, dict],
|
||||
) -> list[dict]:
|
||||
for campaign in campaigns:
|
||||
campaign_id = campaign["campaign_id"]
|
||||
campaign["positive_locations"] = [
|
||||
{
|
||||
**item,
|
||||
"label": location_label(item["resource_name"], geo_targets),
|
||||
}
|
||||
for item in positive.get(campaign_id, [])
|
||||
]
|
||||
campaign["negative_locations"] = [
|
||||
{
|
||||
**item,
|
||||
"label": location_label(item["resource_name"], geo_targets),
|
||||
}
|
||||
for item in negative.get(campaign_id, [])
|
||||
]
|
||||
campaign["positive_locations_count"] = len(campaign["positive_locations"])
|
||||
campaign["negative_locations_count"] = len(campaign["negative_locations"])
|
||||
campaign["flags"] = campaign_flags(campaign)
|
||||
campaigns.sort(key=lambda row: (row["channel_type"], row["campaign_name"]))
|
||||
return campaigns
|
||||
|
||||
|
||||
def build_geo_type_summary(campaigns: list[dict]) -> list[dict]:
|
||||
counter = Counter(campaign["positive_geo_target_type_label"] for campaign in campaigns)
|
||||
return [{"positive_geo_target_type": key, "count": value} for key, value in counter.most_common()]
|
||||
|
||||
|
||||
def build_location_summary(campaigns: list[dict]) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"metric": "Kampanie",
|
||||
"count": len(campaigns),
|
||||
},
|
||||
{
|
||||
"metric": "Kampanie bez jawnych lokalizacji",
|
||||
"count": sum(1 for campaign in campaigns if campaign["positive_locations_count"] == 0),
|
||||
},
|
||||
{
|
||||
"metric": "Kampanie bez wykluczen lokalizacji",
|
||||
"count": sum(1 for campaign in campaigns if campaign["negative_locations_count"] == 0),
|
||||
},
|
||||
{
|
||||
"metric": "Kampanie z kierowaniem innym niz Obecnosc",
|
||||
"count": sum(1 for campaign in campaigns if campaign["positive_geo_target_type"] != "PRESENCE"),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def build_campaign_location_plan(client_config: ClientConfig) -> CampaignLocationPlan:
|
||||
warnings = []
|
||||
try:
|
||||
campaigns = fetch_campaign_geo_settings(client_config)
|
||||
positive, negative, geo_targets, location_warnings = fetch_location_criteria(client_config)
|
||||
warnings.extend(location_warnings)
|
||||
campaigns = attach_locations(campaigns, positive, negative, geo_targets)
|
||||
except Exception as exc:
|
||||
campaigns = []
|
||||
warnings.append(f"Nie udalo sie pobrac ustawien lokalizacji z Google Ads API: {exc}")
|
||||
|
||||
if not campaigns:
|
||||
warnings.append("Nie znaleziono kampanii albo nie udalo sie pobrac ustawien lokalizacji.")
|
||||
|
||||
knowledge_rules = [
|
||||
{
|
||||
"id": rule.id,
|
||||
"topic": rule.topic,
|
||||
"rule_type": rule.rule_type,
|
||||
"condition": rule.condition,
|
||||
"recommendation": rule.recommendation,
|
||||
"risk": rule.risk,
|
||||
"source": rule.source,
|
||||
}
|
||||
for rule in rules_for_task(TASK_ID)
|
||||
]
|
||||
if not knowledge_rules:
|
||||
warnings.append(
|
||||
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
|
||||
"Reguly dotyczace lokalizacji bedziemy dopisywac osobno po akceptacji uzytkownika."
|
||||
)
|
||||
|
||||
return CampaignLocationPlan(
|
||||
campaigns=campaigns,
|
||||
geo_type_summary=build_geo_type_summary(campaigns),
|
||||
location_summary=build_location_summary(campaigns),
|
||||
scope=SCOPE,
|
||||
out_of_scope=OUT_OF_SCOPE,
|
||||
knowledge_rules=knowledge_rules,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def join_location_labels(locations: list[dict], limit: int = 6) -> str:
|
||||
if not locations:
|
||||
return "(brak)"
|
||||
labels = [item.get("label") or item.get("resource_name") or "" for item in locations]
|
||||
shown = labels[:limit]
|
||||
if len(labels) > limit:
|
||||
shown.append(f"... +{len(labels) - limit}")
|
||||
return ", ".join(shown)
|
||||
|
||||
|
||||
def save_campaign_location_plan(domain: str, plan: CampaignLocationPlan) -> tuple[Path, Path]:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "plans"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
|
||||
json_path = base / f"{stem}.json"
|
||||
md_path = base / f"{stem}.md"
|
||||
payload = {
|
||||
"created_at": ts.isoformat(timespec="seconds"),
|
||||
"client": domain,
|
||||
**plan.to_dict(),
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Plan: Sprawdzenie lokalizacji kampanii",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Kampanie: {len(plan.campaigns)}",
|
||||
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
|
||||
"- Zmiany do wdrozenia: 0",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {item}" for item in plan.warnings)
|
||||
lines.append("")
|
||||
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
|
||||
for row in plan.scope:
|
||||
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
|
||||
lines.append("")
|
||||
lines.extend(["## Poza zakresem tego zadania", ""])
|
||||
lines.extend(f"- {item}" for item in plan.out_of_scope)
|
||||
lines.append("")
|
||||
if plan.location_summary:
|
||||
lines.extend(["## Podsumowanie lokalizacji", "", "| Metryka | Liczba |", "| --- | --- |"])
|
||||
for row in plan.location_summary:
|
||||
lines.append(f"| {row['metric']} | {row['count']} |")
|
||||
lines.append("")
|
||||
if plan.geo_type_summary:
|
||||
lines.extend(["## Tryby kierowania lokalizacja", "", "| Tryb | Liczba kampanii |", "| --- | --- |"])
|
||||
for row in plan.geo_type_summary:
|
||||
lines.append(f"| {row['positive_geo_target_type']} | {row['count']} |")
|
||||
lines.append("")
|
||||
if plan.campaigns:
|
||||
lines.extend(
|
||||
[
|
||||
"## Kampanie",
|
||||
"",
|
||||
"| Kampania | Typ | Status | Tryb lokalizacji | Tryb wykluczen | Lokalizacje | Wykluczenia | Flagi |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for campaign in plan.campaigns:
|
||||
lines.append(
|
||||
f"| {md_cell(campaign['campaign_name'])} | {campaign['channel_type']} | {campaign['status']} | "
|
||||
f"{campaign['positive_geo_target_type_label']} | {campaign['negative_geo_target_type_label']} | "
|
||||
f"{md_cell(join_location_labels(campaign['positive_locations']))} | "
|
||||
f"{md_cell(join_location_labels(campaign['negative_locations']))} | "
|
||||
f"{md_cell(', '.join(campaign['flags']))} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.knowledge_rules:
|
||||
lines.extend(
|
||||
[
|
||||
"## Reguly z bazy wiedzy",
|
||||
"",
|
||||
"| ID | Temat | Rekomendacja | Ryzyko |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for rule in plan.knowledge_rules:
|
||||
lines.append(
|
||||
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
|
||||
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
|
||||
)
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def print_campaign_location_plan(plan: CampaignLocationPlan) -> None:
|
||||
print("\nPlan sprawdzenia lokalizacji kampanii")
|
||||
print_table(
|
||||
["Metryka", "Liczba"],
|
||||
[
|
||||
["Kampanie", str(len(plan.campaigns))],
|
||||
["Reguly wiedzy", str(len(plan.knowledge_rules))],
|
||||
["Zmiany do wdrozenia", "0"],
|
||||
],
|
||||
)
|
||||
if plan.warnings:
|
||||
print("\nUwagi")
|
||||
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
|
||||
print("\nZakres zadania")
|
||||
print_table(
|
||||
["Nr", "Obszar", "Co sprawdzic"],
|
||||
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
|
||||
)
|
||||
print("\nPoza zakresem")
|
||||
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
|
||||
if plan.location_summary:
|
||||
print("\nPodsumowanie lokalizacji")
|
||||
print_table(["Metryka", "Liczba"], [[row["metric"], str(row["count"])] for row in plan.location_summary])
|
||||
if plan.geo_type_summary:
|
||||
print("\nTryby kierowania lokalizacja")
|
||||
print_table(
|
||||
["Tryb", "Kampanie"],
|
||||
[[row["positive_geo_target_type"], str(row["count"])] for row in plan.geo_type_summary],
|
||||
)
|
||||
if plan.campaigns:
|
||||
print("\nKampanie")
|
||||
print_table(
|
||||
["Nr", "Kampania", "Typ", "Tryb", "Lok.", "Wykl.", "Flagi"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
campaign["campaign_name"],
|
||||
campaign["channel_type"],
|
||||
campaign["positive_geo_target_type_label"],
|
||||
str(campaign["positive_locations_count"]),
|
||||
str(campaign["negative_locations_count"]),
|
||||
", ".join(campaign["flags"]),
|
||||
]
|
||||
for index, campaign in enumerate(plan.campaigns[:30], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.campaigns) > 30:
|
||||
print(f"... oraz {len(plan.campaigns) - 30} kolejnych kampanii w pliku planu")
|
||||
if plan.knowledge_rules:
|
||||
print("\nReguly z bazy wiedzy")
|
||||
print_table(
|
||||
["Nr", "ID", "Temat", "Rekomendacja"],
|
||||
[
|
||||
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
|
||||
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.knowledge_rules) > 10:
|
||||
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
|
||||
|
||||
|
||||
def print_next_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def apply_campaign_location_plan(
|
||||
client_config: ClientConfig,
|
||||
plan: CampaignLocationPlan,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
print("\nTo zadanie jest audytem lokalizacji i nie wdraza zmian na koncie Google Ads.")
|
||||
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "audyt oznaczony jako wykonany",
|
||||
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
|
||||
"summary": {
|
||||
"campaigns": len(plan.campaigns),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def run_check_campaign_locations(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
_ = global_rules
|
||||
if apply_plan_path:
|
||||
if confirm_apply != "TAK":
|
||||
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
|
||||
if plan_data.get("client") != client_config.domain:
|
||||
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan = CampaignLocationPlan.from_dict(plan_data)
|
||||
print_campaign_location_plan(plan)
|
||||
apply_campaign_location_plan(client_config, plan, show_navigation=show_navigation)
|
||||
return
|
||||
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print("Przygotowuje plan sprawdzenia lokalizacji kampanii...")
|
||||
plan = build_campaign_location_plan(client_config)
|
||||
print_campaign_location_plan(plan)
|
||||
json_path, md_path = save_campaign_location_plan(client_config.domain, plan)
|
||||
print(f"\nPlan JSON: {json_path}")
|
||||
print(f"Plan Markdown: {md_path}")
|
||||
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "plan przygotowany",
|
||||
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
|
||||
"summary": {
|
||||
"campaigns": len(plan.campaigns),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if plan_only:
|
||||
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu lokalizacji.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
470
src/gads_v2/tasks/campaign_network_check.py
Normal file
470
src/gads_v2/tasks/campaign_network_check.py
Normal file
@@ -0,0 +1,470 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..config import ClientConfig, client_dir
|
||||
from ..google_ads import get_google_ads_client, run_query
|
||||
from ..history import append_change_markdown, append_history, now_local
|
||||
from ..knowledge.store import rules_for_task
|
||||
from ..table import print_table
|
||||
|
||||
|
||||
TASK_ID = "check_campaign_networks"
|
||||
TASK_NAME = "Sprawdzenie sieci kampanii"
|
||||
|
||||
|
||||
SCOPE = [
|
||||
{
|
||||
"area": "Search",
|
||||
"check": "Sprawdz, czy kampanie Search maja wlaczony Google Search i czy partnerzy wyszukiwania sa ustawieni swiadomie.",
|
||||
},
|
||||
{
|
||||
"area": "Siec reklamowa",
|
||||
"check": "Oznacz kampanie Search z wlaczona siecia reklamowa jako ustawienie wymagajace recznej oceny.",
|
||||
},
|
||||
{
|
||||
"area": "Typ kampanii",
|
||||
"check": "Pokaz ustawienia sieci razem z typem kampanii, zeby nie mieszac Search, Shopping i PMax.",
|
||||
},
|
||||
{
|
||||
"area": "Audyt ustawien",
|
||||
"check": "Przygotuj szybki przeglad ustawien sieci, ktory mozna wykonywac rzadziej niz budzety i anomalie.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
OUT_OF_SCOPE = [
|
||||
"budzety i wykorzystanie budzetu",
|
||||
"strategie stawek oraz cele Docelowy ROAS/Docelowy CPA",
|
||||
"zapytania uzytkownikow oraz wykluczenia",
|
||||
"reklamy RSA, assety i kreacje",
|
||||
"wdrazanie zmian ustawien sieci na koncie Google Ads",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CampaignNetworkPlan:
|
||||
campaigns: list[dict]
|
||||
channel_summary: list[dict]
|
||||
network_summary: list[dict]
|
||||
scope: list[dict]
|
||||
out_of_scope: list[str]
|
||||
knowledge_rules: list[dict]
|
||||
warnings: list[str]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"task": TASK_ID,
|
||||
"task_name": TASK_NAME,
|
||||
"campaigns": self.campaigns,
|
||||
"channel_summary": self.channel_summary,
|
||||
"network_summary": self.network_summary,
|
||||
"scope": self.scope,
|
||||
"out_of_scope": self.out_of_scope,
|
||||
"knowledge_rules": self.knowledge_rules,
|
||||
"warnings": self.warnings,
|
||||
"changes": [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "CampaignNetworkPlan":
|
||||
return cls(
|
||||
campaigns=data.get("campaigns", []),
|
||||
channel_summary=data.get("channel_summary", []),
|
||||
network_summary=data.get("network_summary", []),
|
||||
scope=data.get("scope", []),
|
||||
out_of_scope=data.get("out_of_scope", []),
|
||||
knowledge_rules=data.get("knowledge_rules", []),
|
||||
warnings=data.get("warnings", []),
|
||||
)
|
||||
|
||||
|
||||
def enum_name(value: Any) -> str:
|
||||
name = getattr(value, "name", None)
|
||||
if name:
|
||||
return name
|
||||
return str(value)
|
||||
|
||||
|
||||
def md_cell(value: Any) -> str:
|
||||
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
|
||||
|
||||
|
||||
def yes_no(value: bool) -> str:
|
||||
return "TAK" if value else "NIE"
|
||||
|
||||
|
||||
def campaign_flags(campaign: dict) -> list[str]:
|
||||
flags = []
|
||||
if campaign["channel_type"] == "SEARCH":
|
||||
if not campaign["target_google_search"]:
|
||||
flags.append("Search bez Google Search")
|
||||
if campaign["target_content_network"]:
|
||||
flags.append("Search z wlaczona siecia reklamowa")
|
||||
if campaign["target_partner_search_network"]:
|
||||
flags.append("partnerzy wyszukiwania do oceny")
|
||||
if campaign["channel_type"] == "SHOPPING" and campaign["target_content_network"]:
|
||||
flags.append("Shopping z siecia reklamowa do oceny")
|
||||
if campaign["channel_type"] in {"PERFORMANCE_MAX", "DISPLAY", "VIDEO", "DEMAND_GEN"}:
|
||||
flags.append("sieci sterowane przez typ kampanii")
|
||||
return flags or ["ok"]
|
||||
|
||||
|
||||
def fetch_campaign_networks(client_config: ClientConfig) -> list[dict]:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
rows = run_query(
|
||||
google_client,
|
||||
client_config.safe_customer_id,
|
||||
"""
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign.name,
|
||||
campaign.status,
|
||||
campaign.advertising_channel_type,
|
||||
campaign.network_settings.target_google_search,
|
||||
campaign.network_settings.target_search_network,
|
||||
campaign.network_settings.target_partner_search_network,
|
||||
campaign.network_settings.target_content_network
|
||||
FROM campaign
|
||||
WHERE campaign.status != 'REMOVED'
|
||||
""",
|
||||
)
|
||||
|
||||
campaigns = []
|
||||
for row in rows:
|
||||
campaign = row.campaign
|
||||
record = {
|
||||
"campaign_id": str(campaign.id),
|
||||
"campaign_name": campaign.name,
|
||||
"status": enum_name(campaign.status),
|
||||
"channel_type": enum_name(campaign.advertising_channel_type),
|
||||
"target_google_search": bool(campaign.network_settings.target_google_search),
|
||||
"target_search_network": bool(campaign.network_settings.target_search_network),
|
||||
"target_partner_search_network": bool(campaign.network_settings.target_partner_search_network),
|
||||
"target_content_network": bool(campaign.network_settings.target_content_network),
|
||||
}
|
||||
record["flags"] = campaign_flags(record)
|
||||
campaigns.append(record)
|
||||
campaigns.sort(key=lambda row: (row["channel_type"], row["campaign_name"]))
|
||||
return campaigns
|
||||
|
||||
|
||||
def build_channel_summary(campaigns: list[dict]) -> list[dict]:
|
||||
counter = Counter(row["channel_type"] for row in campaigns)
|
||||
return [{"channel_type": key, "count": value} for key, value in counter.most_common()]
|
||||
|
||||
|
||||
def build_network_summary(campaigns: list[dict]) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"metric": "Kampanie",
|
||||
"count": len(campaigns),
|
||||
},
|
||||
{
|
||||
"metric": "Google Search wlaczony",
|
||||
"count": sum(1 for campaign in campaigns if campaign["target_google_search"]),
|
||||
},
|
||||
{
|
||||
"metric": "Search Network wlaczony",
|
||||
"count": sum(1 for campaign in campaigns if campaign["target_search_network"]),
|
||||
},
|
||||
{
|
||||
"metric": "Partnerzy wyszukiwania wlaczeni",
|
||||
"count": sum(1 for campaign in campaigns if campaign["target_partner_search_network"]),
|
||||
},
|
||||
{
|
||||
"metric": "Siec reklamowa wlaczona",
|
||||
"count": sum(1 for campaign in campaigns if campaign["target_content_network"]),
|
||||
},
|
||||
{
|
||||
"metric": "Kampanie z flagami do oceny",
|
||||
"count": sum(1 for campaign in campaigns if campaign["flags"] != ["ok"]),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def build_campaign_network_plan(client_config: ClientConfig) -> CampaignNetworkPlan:
|
||||
warnings = []
|
||||
try:
|
||||
campaigns = fetch_campaign_networks(client_config)
|
||||
except Exception as exc:
|
||||
campaigns = []
|
||||
warnings.append(f"Nie udalo sie pobrac ustawien sieci z Google Ads API: {exc}")
|
||||
|
||||
if not campaigns:
|
||||
warnings.append("Nie znaleziono kampanii albo nie udalo sie pobrac ustawien sieci.")
|
||||
|
||||
knowledge_rules = [
|
||||
{
|
||||
"id": rule.id,
|
||||
"topic": rule.topic,
|
||||
"rule_type": rule.rule_type,
|
||||
"condition": rule.condition,
|
||||
"recommendation": rule.recommendation,
|
||||
"risk": rule.risk,
|
||||
"source": rule.source,
|
||||
}
|
||||
for rule in rules_for_task(TASK_ID)
|
||||
]
|
||||
if not knowledge_rules:
|
||||
warnings.append(
|
||||
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
|
||||
"Reguly dotyczace sieci kampanii bedziemy dopisywac osobno po akceptacji uzytkownika."
|
||||
)
|
||||
|
||||
return CampaignNetworkPlan(
|
||||
campaigns=campaigns,
|
||||
channel_summary=build_channel_summary(campaigns),
|
||||
network_summary=build_network_summary(campaigns),
|
||||
scope=SCOPE,
|
||||
out_of_scope=OUT_OF_SCOPE,
|
||||
knowledge_rules=knowledge_rules,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def save_campaign_network_plan(domain: str, plan: CampaignNetworkPlan) -> tuple[Path, Path]:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "plans"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
|
||||
json_path = base / f"{stem}.json"
|
||||
md_path = base / f"{stem}.md"
|
||||
payload = {
|
||||
"created_at": ts.isoformat(timespec="seconds"),
|
||||
"client": domain,
|
||||
**plan.to_dict(),
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Plan: Sprawdzenie sieci kampanii",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Kampanie: {len(plan.campaigns)}",
|
||||
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
|
||||
"- Zmiany do wdrozenia: 0",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {item}" for item in plan.warnings)
|
||||
lines.append("")
|
||||
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
|
||||
for row in plan.scope:
|
||||
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
|
||||
lines.append("")
|
||||
lines.extend(["## Poza zakresem tego zadania", ""])
|
||||
lines.extend(f"- {item}" for item in plan.out_of_scope)
|
||||
lines.append("")
|
||||
if plan.network_summary:
|
||||
lines.extend(["## Podsumowanie sieci", "", "| Metryka | Liczba |", "| --- | --- |"])
|
||||
for row in plan.network_summary:
|
||||
lines.append(f"| {row['metric']} | {row['count']} |")
|
||||
lines.append("")
|
||||
if plan.channel_summary:
|
||||
lines.extend(["## Podsumowanie po typach kampanii", "", "| Typ | Liczba |", "| --- | --- |"])
|
||||
for row in plan.channel_summary:
|
||||
lines.append(f"| {row['channel_type']} | {row['count']} |")
|
||||
lines.append("")
|
||||
if plan.campaigns:
|
||||
lines.extend(
|
||||
[
|
||||
"## Kampanie",
|
||||
"",
|
||||
"| Kampania | Typ | Status | Google Search | Search Network | Partnerzy | Siec reklamowa | Flagi |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for campaign in plan.campaigns:
|
||||
lines.append(
|
||||
f"| {md_cell(campaign['campaign_name'])} | {campaign['channel_type']} | {campaign['status']} | "
|
||||
f"{yes_no(campaign['target_google_search'])} | "
|
||||
f"{yes_no(campaign['target_search_network'])} | "
|
||||
f"{yes_no(campaign['target_partner_search_network'])} | "
|
||||
f"{yes_no(campaign['target_content_network'])} | "
|
||||
f"{md_cell(', '.join(campaign['flags']))} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.knowledge_rules:
|
||||
lines.extend(
|
||||
[
|
||||
"## Reguly z bazy wiedzy",
|
||||
"",
|
||||
"| ID | Temat | Rekomendacja | Ryzyko |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for rule in plan.knowledge_rules:
|
||||
lines.append(
|
||||
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
|
||||
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
|
||||
)
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def print_campaign_network_plan(plan: CampaignNetworkPlan) -> None:
|
||||
print("\nPlan sprawdzenia sieci kampanii")
|
||||
print_table(
|
||||
["Metryka", "Liczba"],
|
||||
[
|
||||
["Kampanie", str(len(plan.campaigns))],
|
||||
["Reguly wiedzy", str(len(plan.knowledge_rules))],
|
||||
["Zmiany do wdrozenia", "0"],
|
||||
],
|
||||
)
|
||||
if plan.warnings:
|
||||
print("\nUwagi")
|
||||
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
|
||||
print("\nZakres zadania")
|
||||
print_table(
|
||||
["Nr", "Obszar", "Co sprawdzic"],
|
||||
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
|
||||
)
|
||||
print("\nPoza zakresem")
|
||||
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
|
||||
if plan.network_summary:
|
||||
print("\nPodsumowanie sieci")
|
||||
print_table(["Metryka", "Liczba"], [[row["metric"], str(row["count"])] for row in plan.network_summary])
|
||||
if plan.channel_summary:
|
||||
print("\nPodsumowanie po typach kampanii")
|
||||
print_table(
|
||||
["Typ", "Liczba"],
|
||||
[[row["channel_type"], str(row["count"])] for row in plan.channel_summary],
|
||||
)
|
||||
if plan.campaigns:
|
||||
print("\nKampanie")
|
||||
print_table(
|
||||
["Nr", "Kampania", "Typ", "Google", "Search net.", "Partnerzy", "Display", "Flagi"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
campaign["campaign_name"],
|
||||
campaign["channel_type"],
|
||||
yes_no(campaign["target_google_search"]),
|
||||
yes_no(campaign["target_search_network"]),
|
||||
yes_no(campaign["target_partner_search_network"]),
|
||||
yes_no(campaign["target_content_network"]),
|
||||
", ".join(campaign["flags"]),
|
||||
]
|
||||
for index, campaign in enumerate(plan.campaigns[:30], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.campaigns) > 30:
|
||||
print(f"... oraz {len(plan.campaigns) - 30} kolejnych kampanii w pliku planu")
|
||||
if plan.knowledge_rules:
|
||||
print("\nReguly z bazy wiedzy")
|
||||
print_table(
|
||||
["Nr", "ID", "Temat", "Rekomendacja"],
|
||||
[
|
||||
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
|
||||
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.knowledge_rules) > 10:
|
||||
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
|
||||
|
||||
|
||||
def print_next_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def apply_campaign_network_plan(
|
||||
client_config: ClientConfig,
|
||||
plan: CampaignNetworkPlan,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
print("\nTo zadanie jest audytem sieci kampanii i nie wdraza zmian na koncie Google Ads.")
|
||||
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "audyt oznaczony jako wykonany",
|
||||
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
|
||||
"summary": {
|
||||
"campaigns": len(plan.campaigns),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def run_check_campaign_networks(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
_ = global_rules
|
||||
if apply_plan_path:
|
||||
if confirm_apply != "TAK":
|
||||
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
|
||||
if plan_data.get("client") != client_config.domain:
|
||||
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan = CampaignNetworkPlan.from_dict(plan_data)
|
||||
print_campaign_network_plan(plan)
|
||||
apply_campaign_network_plan(client_config, plan, show_navigation=show_navigation)
|
||||
return
|
||||
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print("Przygotowuje plan sprawdzenia sieci kampanii...")
|
||||
plan = build_campaign_network_plan(client_config)
|
||||
print_campaign_network_plan(plan)
|
||||
json_path, md_path = save_campaign_network_plan(client_config.domain, plan)
|
||||
print(f"\nPlan JSON: {json_path}")
|
||||
print(f"Plan Markdown: {md_path}")
|
||||
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "plan przygotowany",
|
||||
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
|
||||
"summary": {
|
||||
"campaigns": len(plan.campaigns),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if plan_only:
|
||||
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu sieci kampanii.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
268
src/gads_v2/tasks/conversion_tracking_check.py
Normal file
268
src/gads_v2/tasks/conversion_tracking_check.py
Normal file
@@ -0,0 +1,268 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import ClientConfig, client_dir
|
||||
from ..history import append_change_markdown, append_history, now_local
|
||||
from ..knowledge.store import rules_for_task
|
||||
from ..table import print_table
|
||||
|
||||
|
||||
TASK_ID = "check_conversion_tracking"
|
||||
TASK_NAME = "Sprawdzenie pomiaru konwersji"
|
||||
|
||||
|
||||
DEFAULT_SCOPE = [
|
||||
{
|
||||
"area": "Konwersje Google Ads",
|
||||
"check": "Sprawdz, czy glowne konwersje sa aktywne i oznaczone jako cele uzywane do optymalizacji.",
|
||||
},
|
||||
{
|
||||
"area": "Duplikacja konwersji",
|
||||
"check": "Sprawdz, czy konto nie liczy tych samych zdarzen jednoczesnie z Google Ads, GA4 i importow.",
|
||||
},
|
||||
{
|
||||
"area": "E-commerce",
|
||||
"check": "Sprawdz, czy konwersje zakupowe przekazuja wartosc i walute.",
|
||||
},
|
||||
{
|
||||
"area": "Remarketing dynamiczny",
|
||||
"check": "Sprawdz, czy tagowanie e-commerce przekazuje identyfikatory produktow.",
|
||||
},
|
||||
{
|
||||
"area": "Enhanced Conversions",
|
||||
"check": "Sprawdz, czy rozszerzone konwersje sa skonfigurowane tam, gdzie ma to sens.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversionTrackingPlan:
|
||||
scope: list[dict]
|
||||
knowledge_rules: list[dict]
|
||||
warnings: list[str]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"task": TASK_ID,
|
||||
"task_name": TASK_NAME,
|
||||
"scope": self.scope,
|
||||
"knowledge_rules": self.knowledge_rules,
|
||||
"warnings": self.warnings,
|
||||
"changes": [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ConversionTrackingPlan":
|
||||
return cls(
|
||||
scope=data.get("scope", []),
|
||||
knowledge_rules=data.get("knowledge_rules", []),
|
||||
warnings=data.get("warnings", []),
|
||||
)
|
||||
|
||||
|
||||
def build_conversion_tracking_plan(client_config: ClientConfig) -> ConversionTrackingPlan:
|
||||
rules = rules_for_task(TASK_ID)
|
||||
knowledge_rules = [
|
||||
{
|
||||
"id": rule.id,
|
||||
"topic": rule.topic,
|
||||
"rule_type": rule.rule_type,
|
||||
"condition": rule.condition,
|
||||
"recommendation": rule.recommendation,
|
||||
"risk": rule.risk,
|
||||
"source": rule.source,
|
||||
}
|
||||
for rule in rules
|
||||
]
|
||||
warnings = []
|
||||
if not knowledge_rules:
|
||||
warnings.append(
|
||||
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
|
||||
"Uruchom `python gads.py wiedza przypisz --restart` i przypisz pasujace reguly do check_conversion_tracking."
|
||||
)
|
||||
if not client_config.google_ads_customer_id:
|
||||
warnings.append("Klient nie ma google_ads_customer_id w config/clients.toml.")
|
||||
return ConversionTrackingPlan(scope=DEFAULT_SCOPE, knowledge_rules=knowledge_rules, warnings=warnings)
|
||||
|
||||
|
||||
def save_conversion_tracking_plan(domain: str, plan: ConversionTrackingPlan) -> tuple[Path, Path]:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "plans"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
|
||||
json_path = base / f"{stem}.json"
|
||||
md_path = base / f"{stem}.md"
|
||||
payload = {
|
||||
"created_at": ts.isoformat(timespec="seconds"),
|
||||
"client": domain,
|
||||
**plan.to_dict(),
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Plan: Sprawdzenie pomiaru konwersji",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Obszary audytu: {len(plan.scope)}",
|
||||
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
|
||||
"- Zmiany do wdrozenia: 0",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {item}" for item in plan.warnings)
|
||||
lines.append("")
|
||||
lines.extend(["## Zakres audytu", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
|
||||
for row in plan.scope:
|
||||
lines.append(f"| {row.get('area', '')} | {row.get('check', '')} |")
|
||||
lines.append("")
|
||||
if plan.knowledge_rules:
|
||||
lines.extend(
|
||||
[
|
||||
"## Reguly z bazy wiedzy",
|
||||
"",
|
||||
"| ID | Temat | Rekomendacja | Ryzyko |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for rule in plan.knowledge_rules:
|
||||
lines.append(
|
||||
f"| {rule.get('id', '')} | {rule.get('topic', '')} | "
|
||||
f"{rule.get('recommendation', '')} | {rule.get('risk', '')} |"
|
||||
)
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def print_conversion_tracking_plan(plan: ConversionTrackingPlan) -> None:
|
||||
print("\nPlan sprawdzenia pomiaru konwersji")
|
||||
print_table(
|
||||
["Metryka", "Liczba"],
|
||||
[
|
||||
["Obszary audytu", str(len(plan.scope))],
|
||||
["Reguly wiedzy", str(len(plan.knowledge_rules))],
|
||||
["Zmiany do wdrozenia", "0"],
|
||||
],
|
||||
)
|
||||
if plan.warnings:
|
||||
print("\nUwagi")
|
||||
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
|
||||
print("\nZakres audytu")
|
||||
print_table(
|
||||
["Nr", "Obszar", "Co sprawdzic"],
|
||||
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
|
||||
)
|
||||
if plan.knowledge_rules:
|
||||
print("\nReguly z bazy wiedzy")
|
||||
print_table(
|
||||
["Nr", "ID", "Temat", "Rekomendacja"],
|
||||
[
|
||||
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
|
||||
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.knowledge_rules) > 10:
|
||||
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
|
||||
|
||||
|
||||
def print_next_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def apply_conversion_tracking_plan(
|
||||
client_config: ClientConfig,
|
||||
plan: ConversionTrackingPlan,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
print("\nTo zadanie jest audytem i nie wdraza zmian na koncie Google Ads.")
|
||||
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "audyt oznaczony jako wykonany",
|
||||
"campaign": "",
|
||||
"summary": {
|
||||
"scope_items": len(plan.scope),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def run_check_conversion_tracking(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
_ = global_rules
|
||||
if apply_plan_path:
|
||||
if confirm_apply != "TAK":
|
||||
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
|
||||
if plan_data.get("client") != client_config.domain:
|
||||
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan = ConversionTrackingPlan.from_dict(plan_data)
|
||||
print_conversion_tracking_plan(plan)
|
||||
apply_conversion_tracking_plan(client_config, plan, show_navigation=show_navigation)
|
||||
return
|
||||
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print("Przygotowuje plan sprawdzenia pomiaru konwersji...")
|
||||
plan = build_conversion_tracking_plan(client_config)
|
||||
print_conversion_tracking_plan(plan)
|
||||
json_path, md_path = save_conversion_tracking_plan(client_config.domain, plan)
|
||||
print(f"\nPlan JSON: {json_path}")
|
||||
print(f"Plan Markdown: {md_path}")
|
||||
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "plan przygotowany",
|
||||
"campaign": "",
|
||||
"summary": {
|
||||
"scope_items": len(plan.scope),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if plan_only:
|
||||
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
667
src/gads_v2/tasks/device_performance_check.py
Normal file
667
src/gads_v2/tasks/device_performance_check.py
Normal file
@@ -0,0 +1,667 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..config import ClientConfig, client_dir
|
||||
from ..google_ads import get_google_ads_client, run_query
|
||||
from ..history import append_change_markdown, append_history, now_local
|
||||
from ..knowledge.store import rules_for_task
|
||||
from ..table import print_table
|
||||
|
||||
|
||||
TASK_ID = "check_device_performance"
|
||||
TASK_NAME = "Sprawdzenie urzadzen"
|
||||
|
||||
|
||||
SCOPE = [
|
||||
{
|
||||
"area": "Segment urzadzenia",
|
||||
"check": "Porownaj wyniki kampanii z ostatnich 30 dni dla komputerow, telefonow i tabletow.",
|
||||
},
|
||||
{
|
||||
"area": "Rentownosc",
|
||||
"check": "Policz koszt, konwersje, wartosc konwersji, ROAS i CPA dla kazdego urzadzenia.",
|
||||
},
|
||||
{
|
||||
"area": "Udzial kosztu",
|
||||
"check": "Pokaz, jaka czesc kosztu kampanii przypada na dane urzadzenie.",
|
||||
},
|
||||
{
|
||||
"area": "Sygnaly do oceny",
|
||||
"check": "Oznacz urzadzenia z istotnym kosztem i slabymi wynikami albo wyraznie odmienna efektywnoscia.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
OUT_OF_SCOPE = [
|
||||
"zmiany korekt stawek dla urzadzen",
|
||||
"zmiany strategii ustalania stawek",
|
||||
"zmiany budzetow kampanii",
|
||||
"analiza zapytan uzytkownikow",
|
||||
"wdrazanie zmian na koncie Google Ads",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DevicePerformancePlan:
|
||||
currency_code: str
|
||||
device_summary: list[dict]
|
||||
campaign_device_rows: list[dict]
|
||||
findings: list[dict]
|
||||
scope: list[dict]
|
||||
out_of_scope: list[str]
|
||||
knowledge_rules: list[dict]
|
||||
warnings: list[str]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"task": TASK_ID,
|
||||
"task_name": TASK_NAME,
|
||||
"currency_code": self.currency_code,
|
||||
"device_summary": self.device_summary,
|
||||
"campaign_device_rows": self.campaign_device_rows,
|
||||
"findings": self.findings,
|
||||
"scope": self.scope,
|
||||
"out_of_scope": self.out_of_scope,
|
||||
"knowledge_rules": self.knowledge_rules,
|
||||
"warnings": self.warnings,
|
||||
"changes": [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "DevicePerformancePlan":
|
||||
return cls(
|
||||
currency_code=data.get("currency_code", ""),
|
||||
device_summary=data.get("device_summary", []),
|
||||
campaign_device_rows=data.get("campaign_device_rows", []),
|
||||
findings=data.get("findings", []),
|
||||
scope=data.get("scope", []),
|
||||
out_of_scope=data.get("out_of_scope", []),
|
||||
knowledge_rules=data.get("knowledge_rules", []),
|
||||
warnings=data.get("warnings", []),
|
||||
)
|
||||
|
||||
|
||||
def enum_name(value: Any) -> str:
|
||||
name = getattr(value, "name", None)
|
||||
if name:
|
||||
return name
|
||||
return str(value)
|
||||
|
||||
|
||||
def md_cell(value: Any) -> str:
|
||||
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
|
||||
|
||||
|
||||
def micros_to_amount(value: int | float) -> float:
|
||||
return round(float(value or 0) / 1_000_000, 2)
|
||||
|
||||
|
||||
def format_money_micros(value: int | float, currency_code: str) -> str:
|
||||
suffix = f" {currency_code}" if currency_code else ""
|
||||
return f"{micros_to_amount(value):.2f}{suffix}"
|
||||
|
||||
|
||||
def format_money_amount(value: int | float, currency_code: str) -> str:
|
||||
suffix = f" {currency_code}" if currency_code else ""
|
||||
return f"{float(value or 0):.2f}{suffix}"
|
||||
|
||||
|
||||
def format_number(value: int | float, decimals: int = 2) -> str:
|
||||
return f"{float(value or 0):.{decimals}f}"
|
||||
|
||||
|
||||
def percent(numerator: int | float, denominator: int | float) -> float:
|
||||
if not denominator:
|
||||
return 0.0
|
||||
return round((float(numerator) / float(denominator)) * 100, 1)
|
||||
|
||||
|
||||
def roas(conversion_value: float, cost_micros: int) -> float:
|
||||
cost = micros_to_amount(cost_micros)
|
||||
if not cost:
|
||||
return 0.0
|
||||
return round(float(conversion_value or 0) / cost, 2)
|
||||
|
||||
|
||||
def cpa(cost_micros: int, conversions: float) -> float:
|
||||
if not conversions:
|
||||
return 0.0
|
||||
return round(micros_to_amount(cost_micros) / float(conversions), 2)
|
||||
|
||||
|
||||
def empty_metrics() -> dict:
|
||||
return {
|
||||
"cost_micros": 0,
|
||||
"clicks": 0,
|
||||
"impressions": 0,
|
||||
"conversions": 0.0,
|
||||
"conversion_value": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def add_metrics(target: dict, metrics: Any) -> None:
|
||||
target["cost_micros"] += int(metrics.cost_micros or 0)
|
||||
target["clicks"] += int(metrics.clicks or 0)
|
||||
target["impressions"] += int(metrics.impressions or 0)
|
||||
target["conversions"] += float(metrics.conversions or 0)
|
||||
target["conversion_value"] += float(metrics.conversions_value or 0)
|
||||
|
||||
|
||||
def fetch_currency_code(google_client, customer_id: str) -> str:
|
||||
rows = run_query(
|
||||
google_client,
|
||||
customer_id,
|
||||
"""
|
||||
SELECT
|
||||
customer.currency_code
|
||||
FROM customer
|
||||
""",
|
||||
)
|
||||
if not rows:
|
||||
return ""
|
||||
return str(rows[0].customer.currency_code or "")
|
||||
|
||||
|
||||
def fetch_device_rows(client_config: ClientConfig) -> tuple[str, list[dict]]:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
customer_id = client_config.safe_customer_id
|
||||
currency_code = fetch_currency_code(google_client, customer_id)
|
||||
rows = run_query(
|
||||
google_client,
|
||||
customer_id,
|
||||
"""
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign.name,
|
||||
campaign.status,
|
||||
campaign.advertising_channel_type,
|
||||
segments.device,
|
||||
metrics.cost_micros,
|
||||
metrics.clicks,
|
||||
metrics.impressions,
|
||||
metrics.conversions,
|
||||
metrics.conversions_value
|
||||
FROM campaign
|
||||
WHERE campaign.status != 'REMOVED'
|
||||
AND segments.date DURING LAST_30_DAYS
|
||||
""",
|
||||
)
|
||||
|
||||
campaigns: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
campaign_id = str(row.campaign.id)
|
||||
campaign = campaigns.setdefault(
|
||||
campaign_id,
|
||||
{
|
||||
"campaign_id": campaign_id,
|
||||
"campaign_name": row.campaign.name,
|
||||
"status": enum_name(row.campaign.status),
|
||||
"channel_type": enum_name(row.campaign.advertising_channel_type),
|
||||
"total": empty_metrics(),
|
||||
"devices": defaultdict(empty_metrics),
|
||||
},
|
||||
)
|
||||
add_metrics(campaign["total"], row.metrics)
|
||||
add_metrics(campaign["devices"][enum_name(row.segments.device)], row.metrics)
|
||||
|
||||
result = []
|
||||
for campaign in campaigns.values():
|
||||
total_cost = campaign["total"]["cost_micros"]
|
||||
for device, metrics in campaign["devices"].items():
|
||||
record = {
|
||||
"campaign_id": campaign["campaign_id"],
|
||||
"campaign_name": campaign["campaign_name"],
|
||||
"status": campaign["status"],
|
||||
"channel_type": campaign["channel_type"],
|
||||
"device": device,
|
||||
"cost_micros": metrics["cost_micros"],
|
||||
"clicks": metrics["clicks"],
|
||||
"impressions": metrics["impressions"],
|
||||
"conversions": round(metrics["conversions"], 2),
|
||||
"conversion_value": round(metrics["conversion_value"], 2),
|
||||
"roas": roas(metrics["conversion_value"], metrics["cost_micros"]),
|
||||
"cpa": cpa(metrics["cost_micros"], metrics["conversions"]),
|
||||
"cost_share_percent": percent(metrics["cost_micros"], total_cost),
|
||||
}
|
||||
record["flags"] = device_flags(record)
|
||||
record["severity"] = device_severity(record)
|
||||
result.append(record)
|
||||
|
||||
result.sort(
|
||||
key=lambda row: (
|
||||
{"wysokie": 0, "srednie": 1, "niskie": 2, "ok": 9}.get(row["severity"], 9),
|
||||
row["campaign_name"],
|
||||
-row["cost_micros"],
|
||||
row["device"],
|
||||
)
|
||||
)
|
||||
return currency_code, result
|
||||
|
||||
|
||||
def device_flags(row: dict) -> list[str]:
|
||||
flags = []
|
||||
if row["cost_micros"] > 0 and row["conversions"] == 0 and row["cost_share_percent"] >= 20:
|
||||
flags.append("koszt bez konwersji")
|
||||
if row["cost_share_percent"] >= 50 and row["roas"] == 0:
|
||||
flags.append("duzy udzial kosztu bez wartosci")
|
||||
if row["clicks"] >= 30 and row["conversions"] == 0:
|
||||
flags.append("wiele klikniec bez konwersji")
|
||||
if row["roas"] > 0 and row["roas"] < 1 and row["cost_share_percent"] >= 20:
|
||||
flags.append("niski ROAS przy istotnym koszcie")
|
||||
if row["impressions"] == 0:
|
||||
flags.append("brak wyswietlen")
|
||||
return flags or ["ok"]
|
||||
|
||||
|
||||
def device_severity(row: dict) -> str:
|
||||
flags = set(row["flags"])
|
||||
if "duzy udzial kosztu bez wartosci" in flags or "wiele klikniec bez konwersji" in flags:
|
||||
return "wysokie"
|
||||
if "koszt bez konwersji" in flags or "niski ROAS przy istotnym koszcie" in flags:
|
||||
return "srednie"
|
||||
if flags == {"ok"}:
|
||||
return "ok"
|
||||
return "niskie"
|
||||
|
||||
|
||||
def build_device_summary(rows: list[dict]) -> list[dict]:
|
||||
buckets: dict[str, dict] = defaultdict(empty_metrics)
|
||||
total_cost = 0
|
||||
for row in rows:
|
||||
bucket = buckets[row["device"]]
|
||||
bucket["cost_micros"] += row["cost_micros"]
|
||||
bucket["clicks"] += row["clicks"]
|
||||
bucket["impressions"] += row["impressions"]
|
||||
bucket["conversions"] += row["conversions"]
|
||||
bucket["conversion_value"] += row["conversion_value"]
|
||||
total_cost += row["cost_micros"]
|
||||
|
||||
summary = []
|
||||
for device, metrics in buckets.items():
|
||||
summary.append(
|
||||
{
|
||||
"device": device,
|
||||
"cost_micros": metrics["cost_micros"],
|
||||
"clicks": metrics["clicks"],
|
||||
"impressions": metrics["impressions"],
|
||||
"conversions": round(metrics["conversions"], 2),
|
||||
"conversion_value": round(metrics["conversion_value"], 2),
|
||||
"roas": roas(metrics["conversion_value"], metrics["cost_micros"]),
|
||||
"cpa": cpa(metrics["cost_micros"], metrics["conversions"]),
|
||||
"cost_share_percent": percent(metrics["cost_micros"], total_cost),
|
||||
}
|
||||
)
|
||||
summary.sort(key=lambda row: -row["cost_micros"])
|
||||
return summary
|
||||
|
||||
|
||||
def build_findings(rows: list[dict]) -> list[dict]:
|
||||
findings = []
|
||||
for row in rows:
|
||||
if row["flags"] == ["ok"]:
|
||||
continue
|
||||
findings.append(
|
||||
{
|
||||
"severity": row["severity"],
|
||||
"campaign_name": row["campaign_name"],
|
||||
"channel_type": row["channel_type"],
|
||||
"device": row["device"],
|
||||
"cost_share_percent": row["cost_share_percent"],
|
||||
"cost_micros": row["cost_micros"],
|
||||
"conversions": row["conversions"],
|
||||
"conversion_value": row["conversion_value"],
|
||||
"roas": row["roas"],
|
||||
"cpa": row["cpa"],
|
||||
"flags": row["flags"],
|
||||
"recommendation": "sprawdz urzadzenie w kontekscie kampanii przed decyzja o korektach stawek albo strukturze",
|
||||
}
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def build_device_performance_plan(client_config: ClientConfig) -> DevicePerformancePlan:
|
||||
warnings = []
|
||||
try:
|
||||
currency_code, campaign_device_rows = fetch_device_rows(client_config)
|
||||
except Exception as exc:
|
||||
currency_code = ""
|
||||
campaign_device_rows = []
|
||||
warnings.append(f"Nie udalo sie pobrac segmentu urzadzen z Google Ads API: {exc}")
|
||||
|
||||
if not campaign_device_rows:
|
||||
warnings.append("Nie znaleziono danych wedlug urzadzen z ostatnich 30 dni albo nie udalo sie ich pobrac.")
|
||||
|
||||
knowledge_rules = [
|
||||
{
|
||||
"id": rule.id,
|
||||
"topic": rule.topic,
|
||||
"rule_type": rule.rule_type,
|
||||
"condition": rule.condition,
|
||||
"recommendation": rule.recommendation,
|
||||
"risk": rule.risk,
|
||||
"source": rule.source,
|
||||
}
|
||||
for rule in rules_for_task(TASK_ID)
|
||||
]
|
||||
if not knowledge_rules:
|
||||
warnings.append(
|
||||
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
|
||||
"Reguly dotyczace segmentow urzadzen bedziemy dopisywac osobno po akceptacji uzytkownika."
|
||||
)
|
||||
|
||||
return DevicePerformancePlan(
|
||||
currency_code=currency_code,
|
||||
device_summary=build_device_summary(campaign_device_rows),
|
||||
campaign_device_rows=campaign_device_rows,
|
||||
findings=build_findings(campaign_device_rows),
|
||||
scope=SCOPE,
|
||||
out_of_scope=OUT_OF_SCOPE,
|
||||
knowledge_rules=knowledge_rules,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def save_device_performance_plan(domain: str, plan: DevicePerformancePlan) -> tuple[Path, Path]:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "plans"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
|
||||
json_path = base / f"{stem}.json"
|
||||
md_path = base / f"{stem}.md"
|
||||
payload = {
|
||||
"created_at": ts.isoformat(timespec="seconds"),
|
||||
"client": domain,
|
||||
**plan.to_dict(),
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Plan: Sprawdzenie urzadzen",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Segmenty urzadzen w kampaniach: {len(plan.campaign_device_rows)}",
|
||||
f"- Elementy do oceny: {len(plan.findings)}",
|
||||
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
|
||||
"- Zmiany do wdrozenia: 0",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {item}" for item in plan.warnings)
|
||||
lines.append("")
|
||||
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
|
||||
for row in plan.scope:
|
||||
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
|
||||
lines.append("")
|
||||
lines.extend(["## Poza zakresem tego zadania", ""])
|
||||
lines.extend(f"- {item}" for item in plan.out_of_scope)
|
||||
lines.append("")
|
||||
if plan.device_summary:
|
||||
lines.extend(
|
||||
[
|
||||
"## Podsumowanie po urzadzeniach",
|
||||
"",
|
||||
"| Urzadzenie | Koszt | Udzial kosztu | Klikniecia | Konwersje | Wartosc konwersji | ROAS | CPA |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for row in plan.device_summary:
|
||||
lines.append(
|
||||
f"| {row['device']} | {format_money_micros(row['cost_micros'], plan.currency_code)} | "
|
||||
f"{row['cost_share_percent']:.1f}% | {row['clicks']} | {format_number(row['conversions'])} | "
|
||||
f"{format_money_amount(row['conversion_value'], plan.currency_code)} | {format_number(row['roas'])} | "
|
||||
f"{format_money_amount(row['cpa'], plan.currency_code)} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.findings:
|
||||
lines.extend(
|
||||
[
|
||||
"## Elementy do oceny",
|
||||
"",
|
||||
"| Waznosc | Kampania | Typ | Urzadzenie | Koszt | Udzial kosztu | Konwersje | ROAS | Flagi | Rekomendacja |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for item in plan.findings:
|
||||
lines.append(
|
||||
f"| {item['severity']} | {md_cell(item['campaign_name'])} | {item['channel_type']} | {item['device']} | "
|
||||
f"{format_money_micros(item['cost_micros'], plan.currency_code)} | {item['cost_share_percent']:.1f}% | "
|
||||
f"{format_number(item['conversions'])} | {format_number(item['roas'])} | "
|
||||
f"{md_cell(', '.join(item['flags']))} | {md_cell(item['recommendation'])} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.campaign_device_rows:
|
||||
lines.extend(
|
||||
[
|
||||
"## Kampanie i urzadzenia",
|
||||
"",
|
||||
"| Kampania | Typ | Urzadzenie | Koszt | Udzial kosztu | Klikniecia | Konwersje | Wartosc konwersji | ROAS | CPA | Flagi |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for row in plan.campaign_device_rows:
|
||||
lines.append(
|
||||
f"| {md_cell(row['campaign_name'])} | {row['channel_type']} | {row['device']} | "
|
||||
f"{format_money_micros(row['cost_micros'], plan.currency_code)} | {row['cost_share_percent']:.1f}% | "
|
||||
f"{row['clicks']} | {format_number(row['conversions'])} | "
|
||||
f"{format_money_amount(row['conversion_value'], plan.currency_code)} | {format_number(row['roas'])} | "
|
||||
f"{format_money_amount(row['cpa'], plan.currency_code)} | {md_cell(', '.join(row['flags']))} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.knowledge_rules:
|
||||
lines.extend(
|
||||
[
|
||||
"## Reguly z bazy wiedzy",
|
||||
"",
|
||||
"| ID | Temat | Rekomendacja | Ryzyko |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for rule in plan.knowledge_rules:
|
||||
lines.append(
|
||||
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
|
||||
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
|
||||
)
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def print_device_performance_plan(plan: DevicePerformancePlan) -> None:
|
||||
print("\nPlan sprawdzenia urzadzen")
|
||||
print_table(
|
||||
["Metryka", "Liczba"],
|
||||
[
|
||||
["Segmenty urzadzen w kampaniach", str(len(plan.campaign_device_rows))],
|
||||
["Elementy do oceny", str(len(plan.findings))],
|
||||
["Reguly wiedzy", str(len(plan.knowledge_rules))],
|
||||
["Zmiany do wdrozenia", "0"],
|
||||
],
|
||||
)
|
||||
if plan.warnings:
|
||||
print("\nUwagi")
|
||||
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
|
||||
print("\nZakres zadania")
|
||||
print_table(
|
||||
["Nr", "Obszar", "Co sprawdzic"],
|
||||
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
|
||||
)
|
||||
print("\nPoza zakresem")
|
||||
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
|
||||
if plan.device_summary:
|
||||
print("\nPodsumowanie po urzadzeniach")
|
||||
print_table(
|
||||
["Urzadzenie", "Koszt", "Udzial", "Klikniecia", "Konw.", "Wartosc", "ROAS", "CPA"],
|
||||
[
|
||||
[
|
||||
row["device"],
|
||||
format_money_micros(row["cost_micros"], plan.currency_code),
|
||||
f"{row['cost_share_percent']:.1f}%",
|
||||
str(row["clicks"]),
|
||||
format_number(row["conversions"]),
|
||||
format_money_amount(row["conversion_value"], plan.currency_code),
|
||||
format_number(row["roas"]),
|
||||
format_money_amount(row["cpa"], plan.currency_code),
|
||||
]
|
||||
for row in plan.device_summary
|
||||
],
|
||||
)
|
||||
if plan.findings:
|
||||
print("\nElementy do oceny")
|
||||
print_table(
|
||||
["Nr", "Waznosc", "Kampania", "Urzadzenie", "Koszt", "Udzial", "Konw.", "ROAS", "Flagi"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
item["severity"],
|
||||
item["campaign_name"],
|
||||
item["device"],
|
||||
format_money_micros(item["cost_micros"], plan.currency_code),
|
||||
f"{item['cost_share_percent']:.1f}%",
|
||||
format_number(item["conversions"]),
|
||||
format_number(item["roas"]),
|
||||
", ".join(item["flags"]),
|
||||
]
|
||||
for index, item in enumerate(plan.findings[:30], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.findings) > 30:
|
||||
print(f"... oraz {len(plan.findings) - 30} kolejnych elementow w pliku planu")
|
||||
if plan.campaign_device_rows:
|
||||
print("\nKampanie i urzadzenia")
|
||||
print_table(
|
||||
["Nr", "Kampania", "Typ", "Urzadzenie", "Koszt", "Udzial", "Konw.", "ROAS", "Flagi"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
row["campaign_name"],
|
||||
row["channel_type"],
|
||||
row["device"],
|
||||
format_money_micros(row["cost_micros"], plan.currency_code),
|
||||
f"{row['cost_share_percent']:.1f}%",
|
||||
format_number(row["conversions"]),
|
||||
format_number(row["roas"]),
|
||||
", ".join(row["flags"]),
|
||||
]
|
||||
for index, row in enumerate(plan.campaign_device_rows[:30], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.campaign_device_rows) > 30:
|
||||
print(f"... oraz {len(plan.campaign_device_rows) - 30} kolejnych wierszy w pliku planu")
|
||||
if plan.knowledge_rules:
|
||||
print("\nReguly z bazy wiedzy")
|
||||
print_table(
|
||||
["Nr", "ID", "Temat", "Rekomendacja"],
|
||||
[
|
||||
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
|
||||
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.knowledge_rules) > 10:
|
||||
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
|
||||
|
||||
|
||||
def print_next_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def apply_device_performance_plan(
|
||||
client_config: ClientConfig,
|
||||
plan: DevicePerformancePlan,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
print("\nTo zadanie jest audytem urzadzen i nie wdraza zmian na koncie Google Ads.")
|
||||
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "audyt oznaczony jako wykonany",
|
||||
"campaign": ", ".join(item["campaign_name"] for item in plan.findings[:10]),
|
||||
"summary": {
|
||||
"device_rows": len(plan.campaign_device_rows),
|
||||
"findings": len(plan.findings),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def run_check_device_performance(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
_ = global_rules
|
||||
if apply_plan_path:
|
||||
if confirm_apply != "TAK":
|
||||
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
|
||||
if plan_data.get("client") != client_config.domain:
|
||||
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan = DevicePerformancePlan.from_dict(plan_data)
|
||||
print_device_performance_plan(plan)
|
||||
apply_device_performance_plan(client_config, plan, show_navigation=show_navigation)
|
||||
return
|
||||
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print("Przygotowuje plan sprawdzenia urzadzen...")
|
||||
plan = build_device_performance_plan(client_config)
|
||||
print_device_performance_plan(plan)
|
||||
json_path, md_path = save_device_performance_plan(client_config.domain, plan)
|
||||
print(f"\nPlan JSON: {json_path}")
|
||||
print(f"Plan Markdown: {md_path}")
|
||||
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "plan przygotowany",
|
||||
"campaign": ", ".join(item["campaign_name"] for item in plan.findings[:10]),
|
||||
"summary": {
|
||||
"device_rows": len(plan.campaign_device_rows),
|
||||
"findings": len(plan.findings),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if plan_only:
|
||||
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu urzadzen.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
446
src/gads_v2/tasks/feed_merchant_quality_check.py
Normal file
446
src/gads_v2/tasks/feed_merchant_quality_check.py
Normal file
@@ -0,0 +1,446 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..config import ClientConfig, client_dir
|
||||
from ..history import append_change_markdown, append_history, now_local
|
||||
from ..knowledge.store import rules_for_task
|
||||
from ..table import print_table
|
||||
from .product_feed_optimization import (
|
||||
fetch_missing_category_products,
|
||||
fetch_missing_title_products,
|
||||
fetch_missing_unit_pricing_products,
|
||||
)
|
||||
|
||||
|
||||
TASK_ID = "check_feed_merchant_quality"
|
||||
TASK_NAME = "Sprawdzenie feedu i Merchant Center"
|
||||
DEFAULT_LIMIT = 50
|
||||
|
||||
|
||||
SCOPE = [
|
||||
{
|
||||
"area": "Atrybuty feedu",
|
||||
"check": "Sprawdz braki w danych produktowych widoczne w adsPRO, bez przygotowywania zmian produktow.",
|
||||
},
|
||||
{
|
||||
"area": "Ryzyka Merchant Center",
|
||||
"check": "Wypisz kontrole, ktore wymagaja pozniejszej integracji Merchant Center API.",
|
||||
},
|
||||
{
|
||||
"area": "Routing problemow",
|
||||
"check": "Rozdziel problemy feedu od zadan optymalizacji tytulow, kategorii Google i unit pricing.",
|
||||
},
|
||||
{
|
||||
"area": "Przygotowanie do Shopping/PMax",
|
||||
"check": "Zapisz plan audytu, ktory pozniej moze byc uzyty przez zadania Shopping i PMax.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
MERCHANT_CENTER_CHECKS = [
|
||||
"swiezosc ostatniego przetworzenia feedu",
|
||||
"liczba produktow active, warning i disapproved",
|
||||
"procent aktywnych produktow",
|
||||
"produkty odrzucone wedlug kodow problemow",
|
||||
"landing page errors",
|
||||
"broken images",
|
||||
"agregacja problemow Merchant Center po issue code",
|
||||
]
|
||||
|
||||
|
||||
OUT_OF_SCOPE = [
|
||||
"optymalizacja tytulow produktow",
|
||||
"wybor kategorii Google",
|
||||
"uzupelnianie unit pricing",
|
||||
"synchronizacja grup reklam PLA_CL1",
|
||||
"wdrazanie zmian w adsPRO albo Merchant Center",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeedMerchantQualityPlan:
|
||||
attribute_checks: list[dict]
|
||||
merchant_center_checks: list[str]
|
||||
scope: list[dict]
|
||||
out_of_scope: list[str]
|
||||
knowledge_rules: list[dict]
|
||||
warnings: list[str]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"task": TASK_ID,
|
||||
"task_name": TASK_NAME,
|
||||
"attribute_checks": self.attribute_checks,
|
||||
"merchant_center_checks": self.merchant_center_checks,
|
||||
"scope": self.scope,
|
||||
"out_of_scope": self.out_of_scope,
|
||||
"knowledge_rules": self.knowledge_rules,
|
||||
"warnings": self.warnings,
|
||||
"changes": [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "FeedMerchantQualityPlan":
|
||||
return cls(
|
||||
attribute_checks=data.get("attribute_checks", []),
|
||||
merchant_center_checks=data.get("merchant_center_checks", []),
|
||||
scope=data.get("scope", []),
|
||||
out_of_scope=data.get("out_of_scope", []),
|
||||
knowledge_rules=data.get("knowledge_rules", []),
|
||||
warnings=data.get("warnings", []),
|
||||
)
|
||||
|
||||
|
||||
def product_label(product: dict[str, Any]) -> str:
|
||||
return str(
|
||||
product.get("title")
|
||||
or product.get("default_name")
|
||||
or product.get("custom_title")
|
||||
or product.get("name")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
|
||||
def sample_products(products: list[dict], max_items: int = 8) -> list[dict]:
|
||||
sample = []
|
||||
for product in products[:max_items]:
|
||||
sample.append(
|
||||
{
|
||||
"offer_id": str(product.get("offer_id") or product.get("id") or "").strip(),
|
||||
"title": product_label(product),
|
||||
"brand": str(product.get("brand") or "").strip(),
|
||||
"google_product_category": str(
|
||||
product.get("google_product_category") or product.get("google_category") or ""
|
||||
).strip(),
|
||||
"custom_label_1": str(product.get("custom_label_1") or "").strip(),
|
||||
}
|
||||
)
|
||||
return sample
|
||||
|
||||
|
||||
def attribute_check(name: str, issue: str, products: list[dict], target_task: str, limit: int) -> dict:
|
||||
return {
|
||||
"name": name,
|
||||
"issue": issue,
|
||||
"count": len(products),
|
||||
"limit": limit,
|
||||
"is_limited": len(products) >= limit,
|
||||
"target_task": target_task,
|
||||
"sample": sample_products(products),
|
||||
}
|
||||
|
||||
|
||||
def fetch_attribute_checks(client_config: ClientConfig, limit: int) -> list[dict]:
|
||||
title_products = fetch_missing_title_products(client_config, limit)
|
||||
category_products = fetch_missing_category_products(client_config, limit)
|
||||
unit_pricing_products = fetch_missing_unit_pricing_products(client_config, limit)
|
||||
return [
|
||||
attribute_check(
|
||||
"Tytuly produktow",
|
||||
"Produkty bez zoptymalizowanego tytulu",
|
||||
title_products,
|
||||
"optimize_product_titles",
|
||||
limit,
|
||||
),
|
||||
attribute_check(
|
||||
"Kategorie Google",
|
||||
"Produkty bez kategorii Google",
|
||||
category_products,
|
||||
"optimize_product_categories",
|
||||
limit,
|
||||
),
|
||||
attribute_check(
|
||||
"Unit pricing",
|
||||
"Produkty bez unit pricing",
|
||||
unit_pricing_products,
|
||||
"fill_product_unit_pricing",
|
||||
limit,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def build_feed_merchant_quality_plan(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
) -> FeedMerchantQualityPlan:
|
||||
rules = client_config.effective_rules(global_rules, "feed_merchant_quality")
|
||||
limit = int(rules.get("limit", DEFAULT_LIMIT))
|
||||
warnings = []
|
||||
|
||||
try:
|
||||
attribute_checks = fetch_attribute_checks(client_config, limit)
|
||||
except Exception as exc:
|
||||
attribute_checks = []
|
||||
warnings.append(f"Nie udalo sie pobrac danych feedu z adsPRO: {exc}")
|
||||
|
||||
warnings.append(
|
||||
"Statusy active/warning/disapproved, issue codes, landing page errors i broken images wymagaja pozniejszej integracji Merchant Center API."
|
||||
)
|
||||
warnings.append(
|
||||
"To zadanie tylko wskazuje problemy feedu. Naprawy tytulow, kategorii Google i unit pricing pozostaja w osobnych zadaniach."
|
||||
)
|
||||
warnings.append(
|
||||
f"Kontrole atrybutow z adsPRO sa pobierane do limitu {limit}; wartosc z plusem oznacza, ze problemow moze byc wiecej."
|
||||
)
|
||||
|
||||
knowledge_rules = [
|
||||
{
|
||||
"id": rule.id,
|
||||
"topic": rule.topic,
|
||||
"rule_type": rule.rule_type,
|
||||
"condition": rule.condition,
|
||||
"recommendation": rule.recommendation,
|
||||
"risk": rule.risk,
|
||||
"source": rule.source,
|
||||
}
|
||||
for rule in rules_for_task(TASK_ID)
|
||||
]
|
||||
if not knowledge_rules:
|
||||
warnings.append(
|
||||
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
|
||||
"Reguly dotyczace feedu i Merchant Center bedziemy dopisywac osobno po akceptacji uzytkownika."
|
||||
)
|
||||
|
||||
return FeedMerchantQualityPlan(
|
||||
attribute_checks=attribute_checks,
|
||||
merchant_center_checks=MERCHANT_CENTER_CHECKS,
|
||||
scope=SCOPE,
|
||||
out_of_scope=OUT_OF_SCOPE,
|
||||
knowledge_rules=knowledge_rules,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def md_cell(value: Any) -> str:
|
||||
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
|
||||
|
||||
|
||||
def count_label(check: dict) -> str:
|
||||
value = str(check.get("count", 0))
|
||||
if check.get("is_limited"):
|
||||
return f"{value}+"
|
||||
return value
|
||||
|
||||
|
||||
def save_feed_merchant_quality_plan(domain: str, plan: FeedMerchantQualityPlan) -> tuple[Path, Path]:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "plans"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
|
||||
json_path = base / f"{stem}.json"
|
||||
md_path = base / f"{stem}.md"
|
||||
payload = {
|
||||
"created_at": ts.isoformat(timespec="seconds"),
|
||||
"client": domain,
|
||||
**plan.to_dict(),
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Plan: Sprawdzenie feedu i Merchant Center",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Kontrole atrybutow z adsPRO: {len(plan.attribute_checks)}",
|
||||
f"- Kontrole Merchant Center do pozniejszej integracji: {len(plan.merchant_center_checks)}",
|
||||
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
|
||||
"- Zmiany do wdrozenia: 0",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {item}" for item in plan.warnings)
|
||||
lines.append("")
|
||||
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
|
||||
for row in plan.scope:
|
||||
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
|
||||
lines.append("")
|
||||
lines.extend(["## Poza zakresem tego zadania", ""])
|
||||
lines.extend(f"- {item}" for item in plan.out_of_scope)
|
||||
lines.append("")
|
||||
if plan.attribute_checks:
|
||||
lines.extend(
|
||||
[
|
||||
"## Kontrole atrybutow z adsPRO",
|
||||
"",
|
||||
"| Obszar | Problem | Liczba | Zadanie naprawcze |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for check in plan.attribute_checks:
|
||||
lines.append(
|
||||
f"| {md_cell(check['name'])} | {md_cell(check['issue'])} | "
|
||||
f"{count_label(check)} | {md_cell(check['target_task'])} |"
|
||||
)
|
||||
lines.append("")
|
||||
lines.extend(["## Kontrole Merchant Center do integracji", ""])
|
||||
lines.extend(f"- {item}" for item in plan.merchant_center_checks)
|
||||
lines.append("")
|
||||
if plan.knowledge_rules:
|
||||
lines.extend(
|
||||
[
|
||||
"## Reguly z bazy wiedzy",
|
||||
"",
|
||||
"| ID | Temat | Rekomendacja | Ryzyko |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for rule in plan.knowledge_rules:
|
||||
lines.append(
|
||||
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
|
||||
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
|
||||
)
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def print_feed_merchant_quality_plan(plan: FeedMerchantQualityPlan) -> None:
|
||||
print("\nPlan sprawdzenia feedu i Merchant Center")
|
||||
print_table(
|
||||
["Metryka", "Liczba"],
|
||||
[
|
||||
["Kontrole atrybutow", str(len(plan.attribute_checks))],
|
||||
["Kontrole Merchant Center", str(len(plan.merchant_center_checks))],
|
||||
["Reguly wiedzy", str(len(plan.knowledge_rules))],
|
||||
["Zmiany do wdrozenia", "0"],
|
||||
],
|
||||
)
|
||||
if plan.warnings:
|
||||
print("\nUwagi")
|
||||
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
|
||||
print("\nZakres zadania")
|
||||
print_table(
|
||||
["Nr", "Obszar", "Co sprawdzic"],
|
||||
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
|
||||
)
|
||||
print("\nPoza zakresem")
|
||||
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
|
||||
if plan.attribute_checks:
|
||||
print("\nKontrole atrybutow z adsPRO")
|
||||
print_table(
|
||||
["Nr", "Obszar", "Problem", "Liczba", "Zadanie naprawcze"],
|
||||
[
|
||||
[str(index), check["name"], check["issue"], count_label(check), check["target_task"]]
|
||||
for index, check in enumerate(plan.attribute_checks, 1)
|
||||
],
|
||||
)
|
||||
print("\nKontrole Merchant Center do pozniejszej integracji")
|
||||
print_table(
|
||||
["Nr", "Kontrola"],
|
||||
[[str(index), item] for index, item in enumerate(plan.merchant_center_checks, 1)],
|
||||
)
|
||||
if plan.knowledge_rules:
|
||||
print("\nReguly z bazy wiedzy")
|
||||
print_table(
|
||||
["Nr", "ID", "Temat", "Rekomendacja"],
|
||||
[
|
||||
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
|
||||
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.knowledge_rules) > 10:
|
||||
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
|
||||
|
||||
|
||||
def print_next_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def apply_feed_merchant_quality_plan(
|
||||
client_config: ClientConfig,
|
||||
plan: FeedMerchantQualityPlan,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
print("\nTo zadanie jest audytem feedu i nie wdraza zmian w adsPRO ani Merchant Center.")
|
||||
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "audyt oznaczony jako wykonany",
|
||||
"campaign": "",
|
||||
"summary": {
|
||||
"attribute_checks": len(plan.attribute_checks),
|
||||
"merchant_center_checks": len(plan.merchant_center_checks),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def run_check_feed_merchant_quality(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
if apply_plan_path:
|
||||
if confirm_apply != "TAK":
|
||||
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
|
||||
if plan_data.get("client") != client_config.domain:
|
||||
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan = FeedMerchantQualityPlan.from_dict(plan_data)
|
||||
print_feed_merchant_quality_plan(plan)
|
||||
apply_feed_merchant_quality_plan(client_config, plan, show_navigation=show_navigation)
|
||||
return
|
||||
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print("Przygotowuje plan sprawdzenia feedu i Merchant Center...")
|
||||
plan = build_feed_merchant_quality_plan(client_config, global_rules)
|
||||
print_feed_merchant_quality_plan(plan)
|
||||
json_path, md_path = save_feed_merchant_quality_plan(client_config.domain, plan)
|
||||
print(f"\nPlan JSON: {json_path}")
|
||||
print(f"Plan Markdown: {md_path}")
|
||||
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "plan przygotowany",
|
||||
"campaign": "",
|
||||
"summary": {
|
||||
"attribute_checks": len(plan.attribute_checks),
|
||||
"merchant_center_checks": len(plan.merchant_center_checks),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if plan_only:
|
||||
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu feedu.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
567
src/gads_v2/tasks/impression_share_check.py
Normal file
567
src/gads_v2/tasks/impression_share_check.py
Normal file
@@ -0,0 +1,567 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..config import ClientConfig, client_dir
|
||||
from ..google_ads import get_google_ads_client, run_query
|
||||
from ..history import append_change_markdown, append_history, now_local
|
||||
from ..knowledge.store import rules_for_task
|
||||
from ..table import print_table
|
||||
|
||||
|
||||
TASK_ID = "check_impression_share"
|
||||
TASK_NAME = "Sprawdzenie udzialu w wyswietleniach"
|
||||
|
||||
|
||||
SCOPE = [
|
||||
{
|
||||
"area": "Udzial w wyswietleniach",
|
||||
"check": "Sprawdz search impression share dla kampanii z ostatnich 30 dni.",
|
||||
},
|
||||
{
|
||||
"area": "Utrata przez budzet",
|
||||
"check": "Oznacz kampanie z istotna utrata udzialu w wyswietleniach przez budzet.",
|
||||
},
|
||||
{
|
||||
"area": "Utrata przez ranking",
|
||||
"check": "Oznacz kampanie z istotna utrata udzialu w wyswietleniach przez ranking.",
|
||||
},
|
||||
{
|
||||
"area": "Widocznosc top",
|
||||
"check": "Pokaz top impression share i absolute top impression share jako sygnal konkurencyjnosci.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
OUT_OF_SCOPE = [
|
||||
"zmiany budzetow",
|
||||
"zmiany stawek i strategii ustalania stawek",
|
||||
"decyzje o zmianie Docelowego ROAS albo Docelowego CPA",
|
||||
"analiza zapytan uzytkownikow",
|
||||
"wdrazanie zmian na koncie Google Ads",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImpressionSharePlan:
|
||||
campaigns: list[dict]
|
||||
channel_summary: list[dict]
|
||||
problem_items: list[dict]
|
||||
scope: list[dict]
|
||||
out_of_scope: list[str]
|
||||
knowledge_rules: list[dict]
|
||||
warnings: list[str]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"task": TASK_ID,
|
||||
"task_name": TASK_NAME,
|
||||
"campaigns": self.campaigns,
|
||||
"channel_summary": self.channel_summary,
|
||||
"problem_items": self.problem_items,
|
||||
"scope": self.scope,
|
||||
"out_of_scope": self.out_of_scope,
|
||||
"knowledge_rules": self.knowledge_rules,
|
||||
"warnings": self.warnings,
|
||||
"changes": [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ImpressionSharePlan":
|
||||
return cls(
|
||||
campaigns=data.get("campaigns", []),
|
||||
channel_summary=data.get("channel_summary", []),
|
||||
problem_items=data.get("problem_items", []),
|
||||
scope=data.get("scope", []),
|
||||
out_of_scope=data.get("out_of_scope", []),
|
||||
knowledge_rules=data.get("knowledge_rules", []),
|
||||
warnings=data.get("warnings", []),
|
||||
)
|
||||
|
||||
|
||||
def enum_name(value: Any) -> str:
|
||||
name = getattr(value, "name", None)
|
||||
if name:
|
||||
return name
|
||||
return str(value)
|
||||
|
||||
|
||||
def md_cell(value: Any) -> str:
|
||||
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
|
||||
|
||||
|
||||
def micros_to_amount(value: int | float) -> float:
|
||||
return round(float(value or 0) / 1_000_000, 2)
|
||||
|
||||
|
||||
def format_money(value: int | float, currency_code: str) -> str:
|
||||
suffix = f" {currency_code}" if currency_code else ""
|
||||
return f"{micros_to_amount(value):.2f}{suffix}"
|
||||
|
||||
|
||||
def percent(value: float | int | None) -> float:
|
||||
if value is None:
|
||||
return 0.0
|
||||
return round(float(value or 0) * 100, 1)
|
||||
|
||||
|
||||
def percent_label(value: float | int | None) -> str:
|
||||
return f"{percent(value):.1f}%"
|
||||
|
||||
|
||||
def fetch_currency_code(google_client, customer_id: str) -> str:
|
||||
rows = run_query(
|
||||
google_client,
|
||||
customer_id,
|
||||
"""
|
||||
SELECT
|
||||
customer.currency_code
|
||||
FROM customer
|
||||
""",
|
||||
)
|
||||
if not rows:
|
||||
return ""
|
||||
return str(rows[0].customer.currency_code or "")
|
||||
|
||||
|
||||
def issue_severity(row: dict) -> str:
|
||||
if row["search_budget_lost_impression_share"] >= 0.3:
|
||||
return "wysokie"
|
||||
if row["search_rank_lost_impression_share"] >= 0.5:
|
||||
return "wysokie"
|
||||
if row["search_budget_lost_impression_share"] >= 0.15:
|
||||
return "srednie"
|
||||
if row["search_rank_lost_impression_share"] >= 0.3:
|
||||
return "srednie"
|
||||
if row["search_impression_share"] and row["search_impression_share"] < 0.2:
|
||||
return "niskie"
|
||||
return "ok"
|
||||
|
||||
|
||||
def campaign_flags(row: dict) -> list[str]:
|
||||
flags = []
|
||||
if row["search_budget_lost_impression_share"] >= 0.3:
|
||||
flags.append("duza utrata przez budzet")
|
||||
elif row["search_budget_lost_impression_share"] >= 0.15:
|
||||
flags.append("utrata przez budzet do oceny")
|
||||
if row["search_rank_lost_impression_share"] >= 0.5:
|
||||
flags.append("duza utrata przez ranking")
|
||||
elif row["search_rank_lost_impression_share"] >= 0.3:
|
||||
flags.append("utrata przez ranking do oceny")
|
||||
if row["search_impression_share"] and row["search_impression_share"] < 0.2:
|
||||
flags.append("niski udzial w wyswietleniach")
|
||||
if row["impressions"] == 0:
|
||||
flags.append("brak wyswietlen 30 dni")
|
||||
return flags or ["ok"]
|
||||
|
||||
|
||||
def fetch_impression_share_campaigns(client_config: ClientConfig) -> tuple[str, list[dict]]:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
customer_id = client_config.safe_customer_id
|
||||
currency_code = fetch_currency_code(google_client, customer_id)
|
||||
rows = run_query(
|
||||
google_client,
|
||||
customer_id,
|
||||
"""
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign.name,
|
||||
campaign.status,
|
||||
campaign.advertising_channel_type,
|
||||
metrics.impressions,
|
||||
metrics.clicks,
|
||||
metrics.cost_micros,
|
||||
metrics.conversions,
|
||||
metrics.conversions_value,
|
||||
metrics.search_impression_share,
|
||||
metrics.search_budget_lost_impression_share,
|
||||
metrics.search_rank_lost_impression_share,
|
||||
metrics.search_top_impression_share,
|
||||
metrics.search_absolute_top_impression_share
|
||||
FROM campaign
|
||||
WHERE campaign.status != 'REMOVED'
|
||||
AND segments.date DURING LAST_30_DAYS
|
||||
""",
|
||||
)
|
||||
|
||||
campaigns = []
|
||||
for row in rows:
|
||||
metrics = row.metrics
|
||||
record = {
|
||||
"campaign_id": str(row.campaign.id),
|
||||
"campaign_name": row.campaign.name,
|
||||
"status": enum_name(row.campaign.status),
|
||||
"channel_type": enum_name(row.campaign.advertising_channel_type),
|
||||
"impressions": int(metrics.impressions or 0),
|
||||
"clicks": int(metrics.clicks or 0),
|
||||
"cost_micros": int(metrics.cost_micros or 0),
|
||||
"cost": format_money(metrics.cost_micros, currency_code),
|
||||
"conversions": round(float(metrics.conversions or 0), 2),
|
||||
"conversion_value": round(float(metrics.conversions_value or 0), 2),
|
||||
"search_impression_share": float(metrics.search_impression_share or 0),
|
||||
"search_budget_lost_impression_share": float(metrics.search_budget_lost_impression_share or 0),
|
||||
"search_rank_lost_impression_share": float(metrics.search_rank_lost_impression_share or 0),
|
||||
"search_top_impression_share": float(metrics.search_top_impression_share or 0),
|
||||
"search_absolute_top_impression_share": float(metrics.search_absolute_top_impression_share or 0),
|
||||
}
|
||||
record["severity"] = issue_severity(record)
|
||||
record["flags"] = campaign_flags(record)
|
||||
campaigns.append(record)
|
||||
|
||||
severity_order = {"wysokie": 0, "srednie": 1, "niskie": 2, "ok": 9}
|
||||
campaigns.sort(
|
||||
key=lambda row: (
|
||||
severity_order.get(row["severity"], 9),
|
||||
-row["search_budget_lost_impression_share"],
|
||||
-row["search_rank_lost_impression_share"],
|
||||
row["campaign_name"],
|
||||
)
|
||||
)
|
||||
return currency_code, campaigns
|
||||
|
||||
|
||||
def build_channel_summary(campaigns: list[dict]) -> list[dict]:
|
||||
counter = Counter(row["channel_type"] for row in campaigns)
|
||||
return [{"channel_type": key, "count": value} for key, value in counter.most_common()]
|
||||
|
||||
|
||||
def build_problem_items(campaigns: list[dict]) -> list[dict]:
|
||||
items = []
|
||||
for campaign in campaigns:
|
||||
if campaign["flags"] == ["ok"]:
|
||||
continue
|
||||
recommendation = "sprawdz osobno budzet, ranking, strategie stawek i jakosc ruchu przed decyzja o zmianach"
|
||||
items.append(
|
||||
{
|
||||
"severity": campaign["severity"],
|
||||
"campaign_name": campaign["campaign_name"],
|
||||
"channel_type": campaign["channel_type"],
|
||||
"impression_share": percent_label(campaign["search_impression_share"]),
|
||||
"lost_budget": percent_label(campaign["search_budget_lost_impression_share"]),
|
||||
"lost_rank": percent_label(campaign["search_rank_lost_impression_share"]),
|
||||
"top_share": percent_label(campaign["search_top_impression_share"]),
|
||||
"flags": campaign["flags"],
|
||||
"recommendation": recommendation,
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def build_impression_share_plan(client_config: ClientConfig) -> ImpressionSharePlan:
|
||||
warnings = []
|
||||
try:
|
||||
_currency_code, campaigns = fetch_impression_share_campaigns(client_config)
|
||||
except Exception as exc:
|
||||
campaigns = []
|
||||
warnings.append(f"Nie udalo sie pobrac udzialu w wyswietleniach z Google Ads API: {exc}")
|
||||
|
||||
if not campaigns:
|
||||
warnings.append("Nie znaleziono kampanii z danymi udzialu w wyswietleniach albo nie udalo sie ich pobrac.")
|
||||
|
||||
warnings.append(
|
||||
"To zadanie tylko wskazuje utrate udzialu w wyswietleniach. Decyzje o budzetach i stawkach pozostaja w osobnych zadaniach."
|
||||
)
|
||||
|
||||
knowledge_rules = [
|
||||
{
|
||||
"id": rule.id,
|
||||
"topic": rule.topic,
|
||||
"rule_type": rule.rule_type,
|
||||
"condition": rule.condition,
|
||||
"recommendation": rule.recommendation,
|
||||
"risk": rule.risk,
|
||||
"source": rule.source,
|
||||
}
|
||||
for rule in rules_for_task(TASK_ID)
|
||||
]
|
||||
if not knowledge_rules:
|
||||
warnings.append(
|
||||
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
|
||||
"Reguly dotyczace aukcji i udzialu w wyswietleniach bedziemy dopisywac osobno po akceptacji uzytkownika."
|
||||
)
|
||||
|
||||
return ImpressionSharePlan(
|
||||
campaigns=campaigns,
|
||||
channel_summary=build_channel_summary(campaigns),
|
||||
problem_items=build_problem_items(campaigns),
|
||||
scope=SCOPE,
|
||||
out_of_scope=OUT_OF_SCOPE,
|
||||
knowledge_rules=knowledge_rules,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def save_impression_share_plan(domain: str, plan: ImpressionSharePlan) -> tuple[Path, Path]:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "plans"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
|
||||
json_path = base / f"{stem}.json"
|
||||
md_path = base / f"{stem}.md"
|
||||
payload = {
|
||||
"created_at": ts.isoformat(timespec="seconds"),
|
||||
"client": domain,
|
||||
**plan.to_dict(),
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Plan: Sprawdzenie udzialu w wyswietleniach",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Kampanie: {len(plan.campaigns)}",
|
||||
f"- Elementy do oceny: {len(plan.problem_items)}",
|
||||
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
|
||||
"- Zmiany do wdrozenia: 0",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {item}" for item in plan.warnings)
|
||||
lines.append("")
|
||||
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
|
||||
for row in plan.scope:
|
||||
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
|
||||
lines.append("")
|
||||
lines.extend(["## Poza zakresem tego zadania", ""])
|
||||
lines.extend(f"- {item}" for item in plan.out_of_scope)
|
||||
lines.append("")
|
||||
if plan.channel_summary:
|
||||
lines.extend(["## Podsumowanie po typach kampanii", "", "| Typ | Liczba |", "| --- | --- |"])
|
||||
for row in plan.channel_summary:
|
||||
lines.append(f"| {row['channel_type']} | {row['count']} |")
|
||||
lines.append("")
|
||||
if plan.problem_items:
|
||||
lines.extend(
|
||||
[
|
||||
"## Elementy do oceny",
|
||||
"",
|
||||
"| Waznosc | Kampania | Typ | Udzial | Utrata budzet | Utrata ranking | Top | Flagi | Rekomendacja |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for item in plan.problem_items:
|
||||
lines.append(
|
||||
f"| {item['severity']} | {md_cell(item['campaign_name'])} | {item['channel_type']} | "
|
||||
f"{item['impression_share']} | {item['lost_budget']} | {item['lost_rank']} | {item['top_share']} | "
|
||||
f"{md_cell(', '.join(item['flags']))} | {md_cell(item['recommendation'])} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.campaigns:
|
||||
lines.extend(
|
||||
[
|
||||
"## Kampanie",
|
||||
"",
|
||||
"| Kampania | Typ | Wyswietlenia | Koszt | Udzial | Utrata budzet | Utrata ranking | Top | Abs. top | Flagi |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for campaign in plan.campaigns:
|
||||
lines.append(
|
||||
f"| {md_cell(campaign['campaign_name'])} | {campaign['channel_type']} | {campaign['impressions']} | "
|
||||
f"{campaign['cost']} | {percent_label(campaign['search_impression_share'])} | "
|
||||
f"{percent_label(campaign['search_budget_lost_impression_share'])} | "
|
||||
f"{percent_label(campaign['search_rank_lost_impression_share'])} | "
|
||||
f"{percent_label(campaign['search_top_impression_share'])} | "
|
||||
f"{percent_label(campaign['search_absolute_top_impression_share'])} | "
|
||||
f"{md_cell(', '.join(campaign['flags']))} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.knowledge_rules:
|
||||
lines.extend(
|
||||
[
|
||||
"## Reguly z bazy wiedzy",
|
||||
"",
|
||||
"| ID | Temat | Rekomendacja | Ryzyko |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for rule in plan.knowledge_rules:
|
||||
lines.append(
|
||||
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
|
||||
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
|
||||
)
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def print_impression_share_plan(plan: ImpressionSharePlan) -> None:
|
||||
print("\nPlan sprawdzenia udzialu w wyswietleniach")
|
||||
print_table(
|
||||
["Metryka", "Liczba"],
|
||||
[
|
||||
["Kampanie", str(len(plan.campaigns))],
|
||||
["Elementy do oceny", str(len(plan.problem_items))],
|
||||
["Reguly wiedzy", str(len(plan.knowledge_rules))],
|
||||
["Zmiany do wdrozenia", "0"],
|
||||
],
|
||||
)
|
||||
if plan.warnings:
|
||||
print("\nUwagi")
|
||||
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
|
||||
print("\nZakres zadania")
|
||||
print_table(
|
||||
["Nr", "Obszar", "Co sprawdzic"],
|
||||
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
|
||||
)
|
||||
print("\nPoza zakresem")
|
||||
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
|
||||
if plan.channel_summary:
|
||||
print("\nPodsumowanie po typach kampanii")
|
||||
print_table(["Typ", "Liczba"], [[row["channel_type"], str(row["count"])] for row in plan.channel_summary])
|
||||
if plan.problem_items:
|
||||
print("\nElementy do oceny")
|
||||
print_table(
|
||||
["Nr", "Waznosc", "Kampania", "Typ", "Udzial", "Utrata budzet", "Utrata ranking", "Flagi"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
item["severity"],
|
||||
item["campaign_name"],
|
||||
item["channel_type"],
|
||||
item["impression_share"],
|
||||
item["lost_budget"],
|
||||
item["lost_rank"],
|
||||
", ".join(item["flags"]),
|
||||
]
|
||||
for index, item in enumerate(plan.problem_items[:30], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.problem_items) > 30:
|
||||
print(f"... oraz {len(plan.problem_items) - 30} kolejnych elementow w pliku planu")
|
||||
if plan.campaigns:
|
||||
print("\nKampanie")
|
||||
print_table(
|
||||
["Nr", "Kampania", "Typ", "Wysw.", "Udzial", "Budzet lost", "Rank lost", "Top", "Flagi"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
campaign["campaign_name"],
|
||||
campaign["channel_type"],
|
||||
str(campaign["impressions"]),
|
||||
percent_label(campaign["search_impression_share"]),
|
||||
percent_label(campaign["search_budget_lost_impression_share"]),
|
||||
percent_label(campaign["search_rank_lost_impression_share"]),
|
||||
percent_label(campaign["search_top_impression_share"]),
|
||||
", ".join(campaign["flags"]),
|
||||
]
|
||||
for index, campaign in enumerate(plan.campaigns[:30], 1)
|
||||
],
|
||||
)
|
||||
if plan.knowledge_rules:
|
||||
print("\nReguly z bazy wiedzy")
|
||||
print_table(
|
||||
["Nr", "ID", "Temat", "Rekomendacja"],
|
||||
[
|
||||
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
|
||||
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.knowledge_rules) > 10:
|
||||
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
|
||||
|
||||
|
||||
def print_next_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def apply_impression_share_plan(
|
||||
client_config: ClientConfig,
|
||||
plan: ImpressionSharePlan,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
print("\nTo zadanie jest audytem aukcji i nie wdraza zmian na koncie Google Ads.")
|
||||
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "audyt oznaczony jako wykonany",
|
||||
"campaign": ", ".join(item["campaign_name"] for item in plan.problem_items[:10]),
|
||||
"summary": {
|
||||
"campaigns": len(plan.campaigns),
|
||||
"problem_items": len(plan.problem_items),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def run_check_impression_share(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
_ = global_rules
|
||||
if apply_plan_path:
|
||||
if confirm_apply != "TAK":
|
||||
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
|
||||
if plan_data.get("client") != client_config.domain:
|
||||
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan = ImpressionSharePlan.from_dict(plan_data)
|
||||
print_impression_share_plan(plan)
|
||||
apply_impression_share_plan(client_config, plan, show_navigation=show_navigation)
|
||||
return
|
||||
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print("Przygotowuje plan sprawdzenia udzialu w wyswietleniach...")
|
||||
plan = build_impression_share_plan(client_config)
|
||||
print_impression_share_plan(plan)
|
||||
json_path, md_path = save_impression_share_plan(client_config.domain, plan)
|
||||
print(f"\nPlan JSON: {json_path}")
|
||||
print(f"Plan Markdown: {md_path}")
|
||||
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "plan przygotowany",
|
||||
"campaign": ", ".join(item["campaign_name"] for item in plan.problem_items[:10]),
|
||||
"summary": {
|
||||
"campaigns": len(plan.campaigns),
|
||||
"problem_items": len(plan.problem_items),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if plan_only:
|
||||
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu udzialu w wyswietleniach.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
576
src/gads_v2/tasks/keyword_status_check.py
Normal file
576
src/gads_v2/tasks/keyword_status_check.py
Normal file
@@ -0,0 +1,576 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..config import ClientConfig, client_dir
|
||||
from ..google_ads import get_google_ads_client, run_query
|
||||
from ..history import append_change_markdown, append_history, now_local
|
||||
from ..knowledge.store import rules_for_task
|
||||
from ..table import print_table
|
||||
|
||||
|
||||
TASK_ID = "check_keyword_statuses"
|
||||
TASK_NAME = "Sprawdzenie statusow slow kluczowych"
|
||||
|
||||
|
||||
SCOPE = [
|
||||
{
|
||||
"area": "Status slow kluczowych",
|
||||
"check": "Pokaz status kampanii, grupy reklam i slowa kluczowego dla aktywnych kampanii Search.",
|
||||
},
|
||||
{
|
||||
"area": "Polityki",
|
||||
"check": "Oznacz slowa z problemami polityk, jezeli Google Ads API udostepnia status zatwierdzenia.",
|
||||
},
|
||||
{
|
||||
"area": "Jakosc techniczna",
|
||||
"check": "Pokaz quality score i oznacz bardzo niskie wyniki jako techniczny sygnal do sprawdzenia.",
|
||||
},
|
||||
{
|
||||
"area": "Oddzielenie od zapytan",
|
||||
"check": "Nie analizuj tutaj search terms, wykluczen ani decyzji o dodawaniu nowych fraz.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
OUT_OF_SCOPE = [
|
||||
"analiza zapytan uzytkownikow",
|
||||
"dodawanie, usuwanie albo wykluczanie slow kluczowych",
|
||||
"budzety i wykorzystanie budzetu",
|
||||
"strategie stawek oraz cele Docelowy ROAS/Docelowy CPA",
|
||||
"wdrazanie zmian slow kluczowych na koncie Google Ads",
|
||||
]
|
||||
|
||||
|
||||
PROBLEM_APPROVAL_STATUSES = {
|
||||
"DISAPPROVED",
|
||||
"APPROVED_LIMITED",
|
||||
"AREA_OF_INTEREST_ONLY",
|
||||
"UNDER_REVIEW",
|
||||
"UNKNOWN",
|
||||
"UNSPECIFIED",
|
||||
}
|
||||
|
||||
|
||||
PROBLEM_STATUS_FIELDS = {
|
||||
"PAUSED",
|
||||
"REMOVED",
|
||||
"UNKNOWN",
|
||||
"UNSPECIFIED",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeywordStatusPlan:
|
||||
keywords: list[dict]
|
||||
status_summary: list[dict]
|
||||
approval_summary: list[dict]
|
||||
quality_summary: list[dict]
|
||||
problem_items: list[dict]
|
||||
scope: list[dict]
|
||||
out_of_scope: list[str]
|
||||
knowledge_rules: list[dict]
|
||||
warnings: list[str]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"task": TASK_ID,
|
||||
"task_name": TASK_NAME,
|
||||
"keywords": self.keywords,
|
||||
"status_summary": self.status_summary,
|
||||
"approval_summary": self.approval_summary,
|
||||
"quality_summary": self.quality_summary,
|
||||
"problem_items": self.problem_items,
|
||||
"scope": self.scope,
|
||||
"out_of_scope": self.out_of_scope,
|
||||
"knowledge_rules": self.knowledge_rules,
|
||||
"warnings": self.warnings,
|
||||
"changes": [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "KeywordStatusPlan":
|
||||
return cls(
|
||||
keywords=data.get("keywords", []),
|
||||
status_summary=data.get("status_summary", []),
|
||||
approval_summary=data.get("approval_summary", []),
|
||||
quality_summary=data.get("quality_summary", []),
|
||||
problem_items=data.get("problem_items", []),
|
||||
scope=data.get("scope", []),
|
||||
out_of_scope=data.get("out_of_scope", []),
|
||||
knowledge_rules=data.get("knowledge_rules", []),
|
||||
warnings=data.get("warnings", []),
|
||||
)
|
||||
|
||||
|
||||
def enum_name(value: Any) -> str:
|
||||
name = getattr(value, "name", None)
|
||||
if name:
|
||||
return name
|
||||
return str(value)
|
||||
|
||||
|
||||
def md_cell(value: Any) -> str:
|
||||
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
|
||||
|
||||
|
||||
def safe_quality_score(value: Any) -> int:
|
||||
try:
|
||||
return int(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def quality_bucket(score: int) -> str:
|
||||
if score <= 0:
|
||||
return "brak danych"
|
||||
if score <= 3:
|
||||
return "niski"
|
||||
if score <= 6:
|
||||
return "sredni"
|
||||
return "dobry"
|
||||
|
||||
|
||||
def keyword_severity(row: dict) -> str:
|
||||
if row["approval_status"] == "DISAPPROVED":
|
||||
return "wysokie"
|
||||
if row["keyword_status"] in PROBLEM_STATUS_FIELDS or row["ad_group_status"] in PROBLEM_STATUS_FIELDS:
|
||||
return "srednie"
|
||||
if row["approval_status"] in {"APPROVED_LIMITED", "AREA_OF_INTEREST_ONLY"}:
|
||||
return "srednie"
|
||||
if row["quality_score"] and row["quality_score"] <= 3:
|
||||
return "srednie"
|
||||
if row["approval_status"] in {"UNDER_REVIEW", "UNKNOWN", "UNSPECIFIED"}:
|
||||
return "niskie"
|
||||
return "ok"
|
||||
|
||||
|
||||
def keyword_flags(row: dict) -> list[str]:
|
||||
flags = []
|
||||
if row["campaign_status"] in PROBLEM_STATUS_FIELDS:
|
||||
flags.append(f"kampania: {row['campaign_status']}")
|
||||
if row["ad_group_status"] in PROBLEM_STATUS_FIELDS:
|
||||
flags.append(f"grupa reklam: {row['ad_group_status']}")
|
||||
if row["keyword_status"] in PROBLEM_STATUS_FIELDS:
|
||||
flags.append(f"slowo: {row['keyword_status']}")
|
||||
if row["approval_status"] in PROBLEM_APPROVAL_STATUSES:
|
||||
flags.append(f"polityka: {row['approval_status']}")
|
||||
if row["quality_score"] and row["quality_score"] <= 3:
|
||||
flags.append(f"niski quality score: {row['quality_score']}")
|
||||
return flags or ["ok"]
|
||||
|
||||
|
||||
def fetch_keywords(client_config: ClientConfig) -> list[dict]:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
rows = run_query(
|
||||
google_client,
|
||||
client_config.safe_customer_id,
|
||||
"""
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign.name,
|
||||
campaign.status,
|
||||
campaign.advertising_channel_type,
|
||||
ad_group.id,
|
||||
ad_group.name,
|
||||
ad_group.status,
|
||||
ad_group_criterion.criterion_id,
|
||||
ad_group_criterion.status,
|
||||
ad_group_criterion.keyword.text,
|
||||
ad_group_criterion.keyword.match_type,
|
||||
ad_group_criterion.quality_info.quality_score
|
||||
FROM keyword_view
|
||||
WHERE campaign.status != 'REMOVED'
|
||||
AND ad_group.status != 'REMOVED'
|
||||
AND ad_group_criterion.status != 'REMOVED'
|
||||
AND ad_group_criterion.type = 'KEYWORD'
|
||||
""",
|
||||
)
|
||||
|
||||
keywords = []
|
||||
for row in rows:
|
||||
criterion = row.ad_group_criterion
|
||||
keyword = criterion.keyword
|
||||
quality_score = safe_quality_score(criterion.quality_info.quality_score)
|
||||
record = {
|
||||
"campaign_id": str(row.campaign.id),
|
||||
"campaign_name": row.campaign.name,
|
||||
"campaign_status": enum_name(row.campaign.status),
|
||||
"channel_type": enum_name(row.campaign.advertising_channel_type),
|
||||
"ad_group_id": str(row.ad_group.id),
|
||||
"ad_group_name": row.ad_group.name,
|
||||
"ad_group_status": enum_name(row.ad_group.status),
|
||||
"criterion_id": str(criterion.criterion_id),
|
||||
"keyword_text": keyword.text,
|
||||
"match_type": enum_name(keyword.match_type),
|
||||
"keyword_status": enum_name(criterion.status),
|
||||
"approval_status": "niedostepne w API",
|
||||
"quality_score": quality_score,
|
||||
"quality_bucket": quality_bucket(quality_score),
|
||||
}
|
||||
record["severity"] = keyword_severity(record)
|
||||
record["flags"] = keyword_flags(record)
|
||||
keywords.append(record)
|
||||
|
||||
severity_order = {"wysokie": 0, "srednie": 1, "niskie": 2, "ok": 9}
|
||||
keywords.sort(
|
||||
key=lambda row: (
|
||||
severity_order.get(row["severity"], 9),
|
||||
row["campaign_name"],
|
||||
row["ad_group_name"],
|
||||
row["keyword_text"],
|
||||
)
|
||||
)
|
||||
return keywords
|
||||
|
||||
|
||||
def build_counter_summary(rows: list[dict], field: str, label: str) -> list[dict]:
|
||||
counter = Counter(row.get(field, "") or "(brak)" for row in rows)
|
||||
return [{label: key, "count": value} for key, value in counter.most_common()]
|
||||
|
||||
|
||||
def build_problem_items(keywords: list[dict]) -> list[dict]:
|
||||
problem_items = []
|
||||
for keyword in keywords:
|
||||
if keyword["flags"] == ["ok"]:
|
||||
continue
|
||||
problem_items.append(
|
||||
{
|
||||
"severity": keyword["severity"],
|
||||
"campaign_name": keyword["campaign_name"],
|
||||
"ad_group_name": keyword["ad_group_name"],
|
||||
"keyword_text": keyword["keyword_text"],
|
||||
"match_type": keyword["match_type"],
|
||||
"keyword_status": keyword["keyword_status"],
|
||||
"approval_status": keyword["approval_status"],
|
||||
"quality_score": keyword["quality_score"],
|
||||
"flags": keyword["flags"],
|
||||
"recommendation": "sprawdz status i przyczyne ograniczenia slowa kluczowego w Google Ads",
|
||||
}
|
||||
)
|
||||
severity_order = {"wysokie": 0, "srednie": 1, "niskie": 2, "ok": 9}
|
||||
problem_items.sort(
|
||||
key=lambda row: (
|
||||
severity_order.get(row["severity"], 9),
|
||||
row["campaign_name"],
|
||||
row["ad_group_name"],
|
||||
row["keyword_text"],
|
||||
)
|
||||
)
|
||||
return problem_items
|
||||
|
||||
|
||||
def build_keyword_status_plan(client_config: ClientConfig) -> KeywordStatusPlan:
|
||||
warnings = []
|
||||
try:
|
||||
keywords = fetch_keywords(client_config)
|
||||
except Exception as exc:
|
||||
keywords = []
|
||||
warnings.append(f"Nie udalo sie pobrac slow kluczowych z Google Ads API: {exc}")
|
||||
|
||||
if not keywords:
|
||||
warnings.append("Nie znaleziono slow kluczowych albo nie udalo sie ich pobrac.")
|
||||
|
||||
knowledge_rules = [
|
||||
{
|
||||
"id": rule.id,
|
||||
"topic": rule.topic,
|
||||
"rule_type": rule.rule_type,
|
||||
"condition": rule.condition,
|
||||
"recommendation": rule.recommendation,
|
||||
"risk": rule.risk,
|
||||
"source": rule.source,
|
||||
}
|
||||
for rule in rules_for_task(TASK_ID)
|
||||
]
|
||||
if not knowledge_rules:
|
||||
warnings.append(
|
||||
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
|
||||
"Reguly dotyczace statusow slow kluczowych bedziemy dopisywac osobno po akceptacji uzytkownika."
|
||||
)
|
||||
|
||||
return KeywordStatusPlan(
|
||||
keywords=keywords,
|
||||
status_summary=build_counter_summary(keywords, "keyword_status", "status"),
|
||||
approval_summary=build_counter_summary(keywords, "approval_status", "approval_status"),
|
||||
quality_summary=build_counter_summary(keywords, "quality_bucket", "quality_bucket"),
|
||||
problem_items=build_problem_items(keywords),
|
||||
scope=SCOPE,
|
||||
out_of_scope=OUT_OF_SCOPE,
|
||||
knowledge_rules=knowledge_rules,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def save_keyword_status_plan(domain: str, plan: KeywordStatusPlan) -> tuple[Path, Path]:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "plans"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
|
||||
json_path = base / f"{stem}.json"
|
||||
md_path = base / f"{stem}.md"
|
||||
payload = {
|
||||
"created_at": ts.isoformat(timespec="seconds"),
|
||||
"client": domain,
|
||||
**plan.to_dict(),
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Plan: Sprawdzenie statusow slow kluczowych",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Slowa kluczowe: {len(plan.keywords)}",
|
||||
f"- Elementy do oceny: {len(plan.problem_items)}",
|
||||
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
|
||||
"- Zmiany do wdrozenia: 0",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {item}" for item in plan.warnings)
|
||||
lines.append("")
|
||||
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
|
||||
for row in plan.scope:
|
||||
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
|
||||
lines.append("")
|
||||
lines.extend(["## Poza zakresem tego zadania", ""])
|
||||
lines.extend(f"- {item}" for item in plan.out_of_scope)
|
||||
lines.append("")
|
||||
if plan.problem_items:
|
||||
lines.extend(
|
||||
[
|
||||
"## Elementy do oceny",
|
||||
"",
|
||||
"| Waznosc | Kampania | Grupa reklam | Slowo | Dopasowanie | Status | Polityka | QS | Flagi | Rekomendacja |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for item in plan.problem_items:
|
||||
lines.append(
|
||||
f"| {item['severity']} | {md_cell(item['campaign_name'])} | {md_cell(item['ad_group_name'])} | "
|
||||
f"{md_cell(item['keyword_text'])} | {item['match_type']} | {item['keyword_status']} | "
|
||||
f"{item['approval_status']} | {item['quality_score']} | {md_cell(', '.join(item['flags']))} | "
|
||||
f"{md_cell(item['recommendation'])} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.status_summary:
|
||||
lines.extend(["## Statusy slow kluczowych", "", "| Status | Liczba |", "| --- | --- |"])
|
||||
for row in plan.status_summary:
|
||||
lines.append(f"| {row['status']} | {row['count']} |")
|
||||
lines.append("")
|
||||
if plan.approval_summary:
|
||||
lines.extend(["## Statusy zatwierdzenia", "", "| Status polityki | Liczba |", "| --- | --- |"])
|
||||
for row in plan.approval_summary:
|
||||
lines.append(f"| {row['approval_status']} | {row['count']} |")
|
||||
lines.append("")
|
||||
if plan.quality_summary:
|
||||
lines.extend(["## Quality score", "", "| Koszyk | Liczba |", "| --- | --- |"])
|
||||
for row in plan.quality_summary:
|
||||
lines.append(f"| {row['quality_bucket']} | {row['count']} |")
|
||||
lines.append("")
|
||||
if plan.keywords:
|
||||
lines.extend(
|
||||
[
|
||||
"## Slowa kluczowe",
|
||||
"",
|
||||
"| Kampania | Grupa reklam | Slowo | Dopasowanie | Status | Polityka | QS | Flagi |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for keyword in plan.keywords:
|
||||
lines.append(
|
||||
f"| {md_cell(keyword['campaign_name'])} | {md_cell(keyword['ad_group_name'])} | "
|
||||
f"{md_cell(keyword['keyword_text'])} | {keyword['match_type']} | {keyword['keyword_status']} | "
|
||||
f"{keyword['approval_status']} | {keyword['quality_score']} | {md_cell(', '.join(keyword['flags']))} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.knowledge_rules:
|
||||
lines.extend(
|
||||
[
|
||||
"## Reguly z bazy wiedzy",
|
||||
"",
|
||||
"| ID | Temat | Rekomendacja | Ryzyko |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for rule in plan.knowledge_rules:
|
||||
lines.append(
|
||||
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
|
||||
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
|
||||
)
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def print_keyword_status_plan(plan: KeywordStatusPlan) -> None:
|
||||
print("\nPlan sprawdzenia statusow slow kluczowych")
|
||||
print_table(
|
||||
["Metryka", "Liczba"],
|
||||
[
|
||||
["Slowa kluczowe", str(len(plan.keywords))],
|
||||
["Elementy do oceny", str(len(plan.problem_items))],
|
||||
["Reguly wiedzy", str(len(plan.knowledge_rules))],
|
||||
["Zmiany do wdrozenia", "0"],
|
||||
],
|
||||
)
|
||||
if plan.warnings:
|
||||
print("\nUwagi")
|
||||
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
|
||||
print("\nZakres zadania")
|
||||
print_table(
|
||||
["Nr", "Obszar", "Co sprawdzic"],
|
||||
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
|
||||
)
|
||||
print("\nPoza zakresem")
|
||||
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
|
||||
if plan.problem_items:
|
||||
print("\nElementy do oceny")
|
||||
print_table(
|
||||
["Nr", "Waznosc", "Kampania", "Grupa reklam", "Slowo", "Status", "Polityka", "QS", "Flagi"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
item["severity"],
|
||||
item["campaign_name"],
|
||||
item["ad_group_name"],
|
||||
item["keyword_text"],
|
||||
item["keyword_status"],
|
||||
item["approval_status"],
|
||||
str(item["quality_score"]),
|
||||
", ".join(item["flags"]),
|
||||
]
|
||||
for index, item in enumerate(plan.problem_items[:30], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.problem_items) > 30:
|
||||
print(f"... oraz {len(plan.problem_items) - 30} kolejnych elementow w pliku planu")
|
||||
if plan.status_summary:
|
||||
print("\nStatusy slow kluczowych")
|
||||
print_table(["Status", "Liczba"], [[row["status"], str(row["count"])] for row in plan.status_summary])
|
||||
if plan.approval_summary:
|
||||
print("\nStatusy zatwierdzenia")
|
||||
print_table(
|
||||
["Status polityki", "Liczba"],
|
||||
[[row["approval_status"], str(row["count"])] for row in plan.approval_summary],
|
||||
)
|
||||
if plan.quality_summary:
|
||||
print("\nQuality score")
|
||||
print_table(["Koszyk", "Liczba"], [[row["quality_bucket"], str(row["count"])] for row in plan.quality_summary])
|
||||
if plan.knowledge_rules:
|
||||
print("\nReguly z bazy wiedzy")
|
||||
print_table(
|
||||
["Nr", "ID", "Temat", "Rekomendacja"],
|
||||
[
|
||||
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
|
||||
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.knowledge_rules) > 10:
|
||||
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
|
||||
|
||||
|
||||
def print_next_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def apply_keyword_status_plan(
|
||||
client_config: ClientConfig,
|
||||
plan: KeywordStatusPlan,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
print("\nTo zadanie jest audytem statusow slow kluczowych i nie wdraza zmian na koncie Google Ads.")
|
||||
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "audyt oznaczony jako wykonany",
|
||||
"campaign": ", ".join(item["campaign_name"] for item in plan.problem_items[:10]),
|
||||
"summary": {
|
||||
"keywords": len(plan.keywords),
|
||||
"problem_items": len(plan.problem_items),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def run_check_keyword_statuses(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
_ = global_rules
|
||||
if apply_plan_path:
|
||||
if confirm_apply != "TAK":
|
||||
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
|
||||
if plan_data.get("client") != client_config.domain:
|
||||
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan = KeywordStatusPlan.from_dict(plan_data)
|
||||
print_keyword_status_plan(plan)
|
||||
apply_keyword_status_plan(client_config, plan, show_navigation=show_navigation)
|
||||
return
|
||||
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print("Przygotowuje plan sprawdzenia statusow slow kluczowych...")
|
||||
plan = build_keyword_status_plan(client_config)
|
||||
print_keyword_status_plan(plan)
|
||||
json_path, md_path = save_keyword_status_plan(client_config.domain, plan)
|
||||
print(f"\nPlan JSON: {json_path}")
|
||||
print(f"Plan Markdown: {md_path}")
|
||||
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "plan przygotowany",
|
||||
"campaign": ", ".join(item["campaign_name"] for item in plan.problem_items[:10]),
|
||||
"summary": {
|
||||
"keywords": len(plan.keywords),
|
||||
"problem_items": len(plan.problem_items),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if plan_only:
|
||||
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu statusow slow kluczowych.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
1044
src/gads_v2/tasks/pla_cl1_sync.py
Normal file
1044
src/gads_v2/tasks/pla_cl1_sync.py
Normal file
File diff suppressed because it is too large
Load Diff
367
src/gads_v2/tasks/pla_settings_check.py
Normal file
367
src/gads_v2/tasks/pla_settings_check.py
Normal file
@@ -0,0 +1,367 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from google.protobuf import field_mask_pb2
|
||||
|
||||
from ..config import ClientConfig, client_dir
|
||||
from ..google_ads import get_google_ads_client, run_query
|
||||
from ..history import append_change_markdown, append_history, now_local
|
||||
|
||||
|
||||
@dataclass
|
||||
class SettingsPlan:
|
||||
campaigns: list[dict]
|
||||
changes: list[dict]
|
||||
skipped_rules: list[str]
|
||||
warnings: list[str]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"task": "check_pla_settings",
|
||||
"campaigns": self.campaigns,
|
||||
"changes": self.changes,
|
||||
"skipped_rules": self.skipped_rules,
|
||||
"warnings": self.warnings,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "SettingsPlan":
|
||||
return cls(
|
||||
campaigns=data.get("campaigns", []),
|
||||
changes=data.get("changes", []),
|
||||
skipped_rules=data.get("skipped_rules", []),
|
||||
warnings=data.get("warnings", []),
|
||||
)
|
||||
|
||||
|
||||
def enum_name(value) -> str:
|
||||
name = getattr(value, "name", None)
|
||||
if name:
|
||||
return name
|
||||
return str(value)
|
||||
|
||||
|
||||
def priority_name(value) -> str:
|
||||
raw = enum_name(value)
|
||||
return {"0": "LOW", "1": "MEDIUM", "2": "HIGH"}.get(raw, raw)
|
||||
|
||||
|
||||
def human_geo(value: str) -> str:
|
||||
return {
|
||||
"PRESENCE": "Obecność",
|
||||
"PRESENCE_OR_INTEREST": "Obecność lub zainteresowanie",
|
||||
"SEARCH_INTEREST": "Zainteresowanie wyszukiwaniem",
|
||||
}.get(value, value)
|
||||
|
||||
|
||||
def human_priority(value: str) -> str:
|
||||
return {
|
||||
"LOW": "Niski",
|
||||
"MEDIUM": "Średni",
|
||||
"HIGH": "Wysoki",
|
||||
}.get(value, value)
|
||||
|
||||
|
||||
def build_settings_plan(client_config: ClientConfig, global_rules: dict) -> SettingsPlan:
|
||||
rules = client_config.effective_rules(global_rules, "pla_settings")
|
||||
require_presence_only = bool(rules.get("require_presence_only", True))
|
||||
require_high_priority = bool(rules.get("require_high_priority", True))
|
||||
|
||||
skipped_rules = []
|
||||
if not require_presence_only:
|
||||
skipped_rules.append("Regula lokalizacji Obecnosc jest wylaczona dla tego klienta.")
|
||||
if not require_high_priority:
|
||||
skipped_rules.append("Regula priorytetu wysokiego jest wylaczona dla tego klienta.")
|
||||
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
customer_id = client_config.safe_customer_id
|
||||
rows = run_query(
|
||||
google_client,
|
||||
customer_id,
|
||||
"""
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign.name,
|
||||
campaign.status,
|
||||
campaign.advertising_channel_type,
|
||||
campaign.geo_target_type_setting.positive_geo_target_type,
|
||||
campaign.shopping_setting.campaign_priority
|
||||
FROM campaign
|
||||
WHERE campaign.advertising_channel_type = 'SHOPPING'
|
||||
AND campaign.status = 'ENABLED'
|
||||
""",
|
||||
)
|
||||
|
||||
campaigns = []
|
||||
changes = []
|
||||
for row in rows:
|
||||
campaign = row.campaign
|
||||
positive_geo = enum_name(campaign.geo_target_type_setting.positive_geo_target_type)
|
||||
priority = priority_name(campaign.shopping_setting.campaign_priority)
|
||||
record = {
|
||||
"campaign_id": str(campaign.id),
|
||||
"campaign_name": campaign.name,
|
||||
"status": enum_name(campaign.status),
|
||||
"positive_geo_target_type": positive_geo,
|
||||
"positive_geo_target_type_label": human_geo(positive_geo),
|
||||
"campaign_priority": priority,
|
||||
"campaign_priority_label": human_priority(priority),
|
||||
}
|
||||
campaigns.append(record)
|
||||
|
||||
if require_presence_only and positive_geo != "PRESENCE":
|
||||
changes.append(
|
||||
{
|
||||
"campaign_id": str(campaign.id),
|
||||
"campaign_name": campaign.name,
|
||||
"setting": "lokalizacje",
|
||||
"current_value": positive_geo,
|
||||
"target_value": "PRESENCE",
|
||||
"current_label": human_geo(positive_geo),
|
||||
"target_label": human_geo("PRESENCE"),
|
||||
"description": "Ustaw lokalizacje na Obecnosc.",
|
||||
}
|
||||
)
|
||||
if require_high_priority and priority != "HIGH":
|
||||
changes.append(
|
||||
{
|
||||
"campaign_id": str(campaign.id),
|
||||
"campaign_name": campaign.name,
|
||||
"setting": "priorytet kampanii",
|
||||
"current_value": priority,
|
||||
"target_value": "HIGH",
|
||||
"current_label": human_priority(priority),
|
||||
"target_label": human_priority("HIGH"),
|
||||
"description": "Ustaw priorytet kampanii na wysoki.",
|
||||
}
|
||||
)
|
||||
|
||||
warnings = []
|
||||
if not campaigns:
|
||||
warnings.append("Nie znaleziono kampanii PLA.")
|
||||
return SettingsPlan(campaigns=campaigns, changes=changes, skipped_rules=skipped_rules, warnings=warnings)
|
||||
|
||||
|
||||
def save_settings_plan(domain: str, plan: SettingsPlan) -> tuple[Path, Path]:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "plans"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_check_pla_settings"
|
||||
json_path = base / f"{stem}.json"
|
||||
md_path = base / f"{stem}.md"
|
||||
payload = {
|
||||
"created_at": ts.isoformat(timespec="seconds"),
|
||||
"client": domain,
|
||||
**plan.to_dict(),
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Plan: Sprawdzenie ustawien kampanii PLA",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Kampanie PLA: {len(plan.campaigns)}",
|
||||
f"- Korekty do wdrozenia: {len(plan.changes)}",
|
||||
"",
|
||||
]
|
||||
if plan.skipped_rules:
|
||||
lines.extend(["## Wylaczone reguly", ""])
|
||||
lines.extend(f"- {item}" for item in plan.skipped_rules)
|
||||
lines.append("")
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {item}" for item in plan.warnings)
|
||||
lines.append("")
|
||||
lines.extend(["## Kampanie PLA", "", "| Kampania | Status | Lokalizacje | Priorytet |", "| --- | --- | --- | --- |"])
|
||||
for row in plan.campaigns:
|
||||
lines.append(
|
||||
f"| {row['campaign_name']} | {row['status']} | "
|
||||
f"{row.get('positive_geo_target_type_label', row['positive_geo_target_type'])} | "
|
||||
f"{row.get('campaign_priority_label', row['campaign_priority'])} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.changes:
|
||||
lines.extend(["## Planowane korekty", "", "| Kampania | Ustawienie | Obecnie | Docelowo |", "| --- | --- | --- | --- |"])
|
||||
for row in plan.changes:
|
||||
lines.append(
|
||||
f"| {row['campaign_name']} | {row['setting']} | "
|
||||
f"{row.get('current_label', row['current_value'])} | "
|
||||
f"{row.get('target_label', row['target_value'])} |"
|
||||
)
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def print_settings_plan(plan: SettingsPlan) -> None:
|
||||
print("\nPlan sprawdzenia ustawien kampanii PLA")
|
||||
print(f"Kampanie PLA: {len(plan.campaigns)}")
|
||||
print(f"Korekty do wdrozenia: {len(plan.changes)}")
|
||||
for item in plan.skipped_rules:
|
||||
print(f"Pominieto: {item}")
|
||||
for item in plan.warnings:
|
||||
print(f"Uwaga: {item}")
|
||||
for change in plan.changes[:30]:
|
||||
print(
|
||||
f" {change['campaign_name']} | {change['setting']} | "
|
||||
f"{change.get('current_label', change['current_value'])} -> "
|
||||
f"{change.get('target_label', change['target_value'])}"
|
||||
)
|
||||
if len(plan.changes) > 30:
|
||||
print(f" ... oraz {len(plan.changes) - 30} kolejnych korekt")
|
||||
|
||||
|
||||
def print_next_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def apply_settings_plan(client_config: ClientConfig, plan: SettingsPlan, show_navigation: bool = True) -> None:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
customer_id = client_config.safe_customer_id
|
||||
service = google_client.get_service("CampaignService")
|
||||
|
||||
changes_by_campaign: dict[str, dict] = {}
|
||||
for change in plan.changes:
|
||||
changes_by_campaign.setdefault(
|
||||
change["campaign_id"],
|
||||
{"campaign_name": change["campaign_name"], "settings": set()},
|
||||
)
|
||||
changes_by_campaign[change["campaign_id"]]["settings"].add(change["setting"])
|
||||
|
||||
operations = []
|
||||
for campaign_id, row in changes_by_campaign.items():
|
||||
op = google_client.get_type("CampaignOperation")
|
||||
campaign = op.update
|
||||
campaign.resource_name = service.campaign_path(customer_id, campaign_id)
|
||||
paths = []
|
||||
if "lokalizacje" in row["settings"]:
|
||||
campaign.geo_target_type_setting.positive_geo_target_type = (
|
||||
google_client.enums.PositiveGeoTargetTypeEnum.PRESENCE
|
||||
)
|
||||
paths.append("geo_target_type_setting.positive_geo_target_type")
|
||||
if "priorytet kampanii" in row["settings"]:
|
||||
campaign.shopping_setting.campaign_priority = 2
|
||||
paths.append("shopping_setting.campaign_priority")
|
||||
op.update_mask = field_mask_pb2.FieldMask(paths=paths)
|
||||
operations.append(op)
|
||||
|
||||
changed = 0
|
||||
if operations:
|
||||
response = service.mutate_campaigns(customer_id=customer_id, operations=operations)
|
||||
changed = len(response.results)
|
||||
|
||||
print("\nWynik wdrozenia zmian")
|
||||
print(f"Zmieniono kampanii: {changed}")
|
||||
print(f"Korekty ustawien: {len(plan.changes)}")
|
||||
|
||||
rows = [
|
||||
{
|
||||
"klient": client_config.domain,
|
||||
"kampania": change["campaign_name"],
|
||||
"czynnosc": change["description"],
|
||||
"grupa reklam": "",
|
||||
"produkt": f"{change.get('current_label', change['current_value'])} -> {change.get('target_label', change['target_value'])}",
|
||||
}
|
||||
for change in plan.changes
|
||||
]
|
||||
changes_path = append_change_markdown(client_config.domain, "Sprawdzenie ustawien kampanii PLA", rows)
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": "Sprawdzenie ustawien",
|
||||
"status": "wdrozono zmiany",
|
||||
"campaign": ", ".join(sorted({change["campaign_name"] for change in plan.changes})[:10]),
|
||||
"summary": {"campaigns_changed": changed, "settings_changes": len(plan.changes)},
|
||||
},
|
||||
)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def run_check_pla_settings(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
if apply_plan_path:
|
||||
if confirm_apply != "TAK":
|
||||
print("Do wdrozenia planu wymagane jest --confirm-apply TAK.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
|
||||
if plan_data.get("client") != client_config.domain:
|
||||
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan = SettingsPlan.from_dict(plan_data)
|
||||
print_settings_plan(plan)
|
||||
apply_settings_plan(client_config, plan, show_navigation=show_navigation)
|
||||
return
|
||||
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print("Sprawdzam ustawienia kampanii PLA...")
|
||||
plan = build_settings_plan(client_config, global_rules)
|
||||
print_settings_plan(plan)
|
||||
json_path, md_path = save_settings_plan(client_config.domain, plan)
|
||||
print(f"\nPlan JSON: {json_path}")
|
||||
print(f"Plan Markdown: {md_path}")
|
||||
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": "Sprawdzenie ustawien",
|
||||
"status": "plan przygotowany",
|
||||
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
|
||||
"summary": {"campaigns": len(plan.campaigns), "changes": len(plan.changes)},
|
||||
},
|
||||
)
|
||||
|
||||
if plan_only:
|
||||
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
if not plan.changes:
|
||||
print("\nBrak zmian do wdrozenia.")
|
||||
append_change_markdown(client_config.domain, "Sprawdzenie ustawien kampanii PLA", [])
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
answer = input("\nWpisz TAK, aby wdrozyc powyzsze zmiany: ").strip()
|
||||
if answer != "TAK":
|
||||
print("Przerwano. Zmiany nie zostaly wdrozone.")
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": "Sprawdzenie ustawien",
|
||||
"status": "odrzucono wdrozenie",
|
||||
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
|
||||
},
|
||||
)
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
apply_settings_plan(client_config, plan, show_navigation=show_navigation)
|
||||
543
src/gads_v2/tasks/pmax_structure_check.py
Normal file
543
src/gads_v2/tasks/pmax_structure_check.py
Normal file
@@ -0,0 +1,543 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..config import ClientConfig, client_dir
|
||||
from ..google_ads import get_google_ads_client, run_query
|
||||
from ..history import append_change_markdown, append_history, now_local
|
||||
from ..knowledge.store import rules_for_task
|
||||
from ..table import print_table
|
||||
|
||||
|
||||
TASK_ID = "check_pmax_structure"
|
||||
TASK_NAME = "Sprawdzenie struktury PMax"
|
||||
|
||||
|
||||
SCOPE = [
|
||||
{
|
||||
"area": "Kampanie PMax",
|
||||
"check": "Pokaz aktywne i wstrzymane kampanie Performance Max oraz ich podstawowe wyniki z ostatnich 30 dni.",
|
||||
},
|
||||
{
|
||||
"area": "Asset groups",
|
||||
"check": "Policz asset groups w kazdej kampanii i wskaz kampanie wymagajace recznej oceny struktury.",
|
||||
},
|
||||
{
|
||||
"area": "Feed produktowy",
|
||||
"check": "Oznacz, ze ocena feedu produktowego jest polaczona z osobnym zadaniem Feed i Merchant Center.",
|
||||
},
|
||||
{
|
||||
"area": "Brand / non-brand",
|
||||
"check": "Wskaz kampanie, ktore po nazwie moga wymagac recznej oceny podzialu brand/non-brand.",
|
||||
},
|
||||
{
|
||||
"area": "Kanibalizacja",
|
||||
"check": "Wypisz ryzyko kanibalizacji Search, Shopping i remarketingu jako punkt do recznej oceny.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
OUT_OF_SCOPE = [
|
||||
"budzety i pacing budzetu",
|
||||
"strategie stawek oraz cele Docelowy ROAS/Docelowy CPA",
|
||||
"zapytania uzytkownikow oraz wykluczenia",
|
||||
"reklamy RSA i zasoby Search",
|
||||
"wdrazanie zmian w kampaniach Performance Max",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PmaxStructurePlan:
|
||||
currency_code: str
|
||||
campaigns: list[dict]
|
||||
asset_groups: list[dict]
|
||||
scope: list[dict]
|
||||
out_of_scope: list[str]
|
||||
knowledge_rules: list[dict]
|
||||
warnings: list[str]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"task": TASK_ID,
|
||||
"task_name": TASK_NAME,
|
||||
"currency_code": self.currency_code,
|
||||
"campaigns": self.campaigns,
|
||||
"asset_groups": self.asset_groups,
|
||||
"scope": self.scope,
|
||||
"out_of_scope": self.out_of_scope,
|
||||
"knowledge_rules": self.knowledge_rules,
|
||||
"warnings": self.warnings,
|
||||
"changes": [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "PmaxStructurePlan":
|
||||
return cls(
|
||||
currency_code=data.get("currency_code", ""),
|
||||
campaigns=data.get("campaigns", []),
|
||||
asset_groups=data.get("asset_groups", []),
|
||||
scope=data.get("scope", []),
|
||||
out_of_scope=data.get("out_of_scope", []),
|
||||
knowledge_rules=data.get("knowledge_rules", []),
|
||||
warnings=data.get("warnings", []),
|
||||
)
|
||||
|
||||
|
||||
def enum_name(value: Any) -> str:
|
||||
name = getattr(value, "name", None)
|
||||
if name:
|
||||
return name
|
||||
return str(value)
|
||||
|
||||
|
||||
def safe_int(value: Any) -> int:
|
||||
try:
|
||||
return int(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def safe_float(value: Any) -> float:
|
||||
try:
|
||||
return float(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def micros_to_amount(value: int | float) -> float:
|
||||
return round(float(value or 0) / 1_000_000, 2)
|
||||
|
||||
|
||||
def format_money(value: int | float, currency_code: str) -> str:
|
||||
suffix = f" {currency_code}" if currency_code else ""
|
||||
return f"{micros_to_amount(value):.2f}{suffix}"
|
||||
|
||||
|
||||
def format_decimal(value: int | float) -> str:
|
||||
return f"{float(value or 0):.2f}"
|
||||
|
||||
|
||||
def md_cell(value: Any) -> str:
|
||||
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
|
||||
|
||||
|
||||
def pmax_risks(campaign_name: str, asset_group_count: int, conversions_30d: float) -> list[str]:
|
||||
name = campaign_name.casefold()
|
||||
risks = []
|
||||
if asset_group_count == 0:
|
||||
risks.append("brak asset groups")
|
||||
if asset_group_count == 1:
|
||||
risks.append("jedna asset group")
|
||||
if any(token in name for token in ["brand", "branded", "marka"]):
|
||||
risks.append("sprawdz brand/non-brand")
|
||||
if conversions_30d <= 0:
|
||||
risks.append("brak konwersji w 30 dni")
|
||||
risks.append("sprawdz kanibalizacje Search/Shopping")
|
||||
return risks
|
||||
|
||||
|
||||
def fetch_currency_code(google_client, customer_id: str) -> str:
|
||||
rows = run_query(
|
||||
google_client,
|
||||
customer_id,
|
||||
"""
|
||||
SELECT
|
||||
customer.currency_code
|
||||
FROM customer
|
||||
""",
|
||||
)
|
||||
if not rows:
|
||||
return ""
|
||||
return str(rows[0].customer.currency_code or "")
|
||||
|
||||
|
||||
def fetch_pmax_asset_groups(google_client, customer_id: str, warnings: list[str]) -> list[dict]:
|
||||
try:
|
||||
rows = run_query(
|
||||
google_client,
|
||||
customer_id,
|
||||
"""
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign.name,
|
||||
asset_group.id,
|
||||
asset_group.name,
|
||||
asset_group.status
|
||||
FROM asset_group
|
||||
WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX'
|
||||
AND campaign.status != 'REMOVED'
|
||||
AND asset_group.status != 'REMOVED'
|
||||
""",
|
||||
)
|
||||
except Exception as exc:
|
||||
warnings.append(f"Nie udalo sie pobrac asset groups PMax: {exc}")
|
||||
return []
|
||||
|
||||
asset_groups = []
|
||||
for row in rows:
|
||||
asset_groups.append(
|
||||
{
|
||||
"campaign_id": str(row.campaign.id),
|
||||
"campaign_name": row.campaign.name,
|
||||
"asset_group_id": str(row.asset_group.id),
|
||||
"asset_group_name": row.asset_group.name,
|
||||
"status": enum_name(row.asset_group.status),
|
||||
}
|
||||
)
|
||||
return asset_groups
|
||||
|
||||
|
||||
def fetch_pmax_campaigns(client_config: ClientConfig) -> tuple[str, list[dict], list[dict], list[str]]:
|
||||
warnings: list[str] = []
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
customer_id = client_config.safe_customer_id
|
||||
currency_code = fetch_currency_code(google_client, customer_id)
|
||||
asset_groups = fetch_pmax_asset_groups(google_client, customer_id, warnings)
|
||||
asset_groups_by_campaign: dict[str, list[dict]] = defaultdict(list)
|
||||
for asset_group in asset_groups:
|
||||
asset_groups_by_campaign[asset_group["campaign_id"]].append(asset_group)
|
||||
|
||||
rows = run_query(
|
||||
google_client,
|
||||
customer_id,
|
||||
"""
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign.name,
|
||||
campaign.status,
|
||||
campaign.advertising_channel_type,
|
||||
campaign.bidding_strategy_type,
|
||||
metrics.cost_micros,
|
||||
metrics.conversions,
|
||||
metrics.conversions_value
|
||||
FROM campaign
|
||||
WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX'
|
||||
AND campaign.status != 'REMOVED'
|
||||
AND segments.date DURING LAST_30_DAYS
|
||||
""",
|
||||
)
|
||||
|
||||
campaigns = []
|
||||
for row in rows:
|
||||
campaign_id = str(row.campaign.id)
|
||||
asset_group_count = len(asset_groups_by_campaign.get(campaign_id, []))
|
||||
conversions_30d = safe_float(row.metrics.conversions)
|
||||
campaigns.append(
|
||||
{
|
||||
"campaign_id": campaign_id,
|
||||
"campaign_name": row.campaign.name,
|
||||
"status": enum_name(row.campaign.status),
|
||||
"channel_type": enum_name(row.campaign.advertising_channel_type),
|
||||
"bidding_strategy_type": enum_name(row.campaign.bidding_strategy_type),
|
||||
"cost_30d_micros": safe_int(row.metrics.cost_micros),
|
||||
"conversions_30d": conversions_30d,
|
||||
"conversion_value_30d": safe_float(row.metrics.conversions_value),
|
||||
"asset_group_count": asset_group_count,
|
||||
"risk_labels": pmax_risks(row.campaign.name, asset_group_count, conversions_30d),
|
||||
}
|
||||
)
|
||||
return currency_code, campaigns, asset_groups, warnings
|
||||
|
||||
|
||||
def build_pmax_structure_plan(client_config: ClientConfig) -> PmaxStructurePlan:
|
||||
warnings = []
|
||||
try:
|
||||
currency_code, campaigns, asset_groups, fetch_warnings = fetch_pmax_campaigns(client_config)
|
||||
warnings.extend(fetch_warnings)
|
||||
except Exception as exc:
|
||||
currency_code = ""
|
||||
campaigns = []
|
||||
asset_groups = []
|
||||
warnings.append(f"Nie udalo sie pobrac kampanii Performance Max z Google Ads API: {exc}")
|
||||
|
||||
if not campaigns:
|
||||
warnings.append("Nie znaleziono kampanii Performance Max z danymi z ostatnich 30 dni albo nie udalo sie ich pobrac.")
|
||||
|
||||
warnings.append(
|
||||
"Informacje o feedzie produktowym i problemach produktow sprawdzaj w osobnym zadaniu Feed i Merchant Center."
|
||||
)
|
||||
warnings.append(
|
||||
"Kanibalizacja Search/Shopping/remarketing wymaga recznej oceny z innymi zadaniami, a nie automatycznej decyzji."
|
||||
)
|
||||
|
||||
knowledge_rules = [
|
||||
{
|
||||
"id": rule.id,
|
||||
"topic": rule.topic,
|
||||
"rule_type": rule.rule_type,
|
||||
"condition": rule.condition,
|
||||
"recommendation": rule.recommendation,
|
||||
"risk": rule.risk,
|
||||
"source": rule.source,
|
||||
}
|
||||
for rule in rules_for_task(TASK_ID)
|
||||
]
|
||||
if not knowledge_rules:
|
||||
warnings.append(
|
||||
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
|
||||
"Reguly dotyczace Performance Max bedziemy dopisywac osobno po akceptacji uzytkownika."
|
||||
)
|
||||
|
||||
campaigns.sort(key=lambda row: (row["status"], row["campaign_name"]))
|
||||
return PmaxStructurePlan(
|
||||
currency_code=currency_code,
|
||||
campaigns=campaigns,
|
||||
asset_groups=asset_groups,
|
||||
scope=SCOPE,
|
||||
out_of_scope=OUT_OF_SCOPE,
|
||||
knowledge_rules=knowledge_rules,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def save_pmax_structure_plan(domain: str, plan: PmaxStructurePlan) -> tuple[Path, Path]:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "plans"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
|
||||
json_path = base / f"{stem}.json"
|
||||
md_path = base / f"{stem}.md"
|
||||
payload = {
|
||||
"created_at": ts.isoformat(timespec="seconds"),
|
||||
"client": domain,
|
||||
**plan.to_dict(),
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Plan: Sprawdzenie struktury PMax",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Kampanie PMax: {len(plan.campaigns)}",
|
||||
f"- Asset groups: {len(plan.asset_groups)}",
|
||||
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
|
||||
"- Zmiany do wdrozenia: 0",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {item}" for item in plan.warnings)
|
||||
lines.append("")
|
||||
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
|
||||
for row in plan.scope:
|
||||
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
|
||||
lines.append("")
|
||||
lines.extend(["## Poza zakresem tego zadania", ""])
|
||||
lines.extend(f"- {item}" for item in plan.out_of_scope)
|
||||
lines.append("")
|
||||
if plan.campaigns:
|
||||
lines.extend(
|
||||
[
|
||||
"## Kampanie Performance Max",
|
||||
"",
|
||||
"| Kampania | Status | Asset groups | Koszt 30 dni | Konwersje | Wartosc konwersji | Ryzyka |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for campaign in plan.campaigns:
|
||||
lines.append(
|
||||
f"| {md_cell(campaign['campaign_name'])} | {campaign['status']} | "
|
||||
f"{campaign['asset_group_count']} | {format_money(campaign['cost_30d_micros'], plan.currency_code)} | "
|
||||
f"{format_decimal(campaign['conversions_30d'])} | {format_decimal(campaign['conversion_value_30d'])} | "
|
||||
f"{md_cell(', '.join(campaign['risk_labels']))} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.asset_groups:
|
||||
lines.extend(
|
||||
[
|
||||
"## Asset groups",
|
||||
"",
|
||||
"| Kampania | Asset group | Status |",
|
||||
"| --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for asset_group in plan.asset_groups:
|
||||
lines.append(
|
||||
f"| {md_cell(asset_group['campaign_name'])} | "
|
||||
f"{md_cell(asset_group['asset_group_name'])} | {asset_group['status']} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.knowledge_rules:
|
||||
lines.extend(
|
||||
[
|
||||
"## Reguly z bazy wiedzy",
|
||||
"",
|
||||
"| ID | Temat | Rekomendacja | Ryzyko |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for rule in plan.knowledge_rules:
|
||||
lines.append(
|
||||
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
|
||||
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
|
||||
)
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def print_pmax_structure_plan(plan: PmaxStructurePlan) -> None:
|
||||
print("\nPlan sprawdzenia struktury PMax")
|
||||
print_table(
|
||||
["Metryka", "Liczba"],
|
||||
[
|
||||
["Kampanie PMax", str(len(plan.campaigns))],
|
||||
["Asset groups", str(len(plan.asset_groups))],
|
||||
["Reguly wiedzy", str(len(plan.knowledge_rules))],
|
||||
["Zmiany do wdrozenia", "0"],
|
||||
],
|
||||
)
|
||||
if plan.warnings:
|
||||
print("\nUwagi")
|
||||
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
|
||||
print("\nZakres zadania")
|
||||
print_table(
|
||||
["Nr", "Obszar", "Co sprawdzic"],
|
||||
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
|
||||
)
|
||||
print("\nPoza zakresem")
|
||||
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
|
||||
if plan.campaigns:
|
||||
print("\nKampanie Performance Max")
|
||||
print_table(
|
||||
["Nr", "Kampania", "Status", "Asset groups", "Koszt 30 dni", "Konw.", "Ryzyka"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
campaign["campaign_name"],
|
||||
campaign["status"],
|
||||
str(campaign["asset_group_count"]),
|
||||
format_money(campaign["cost_30d_micros"], plan.currency_code),
|
||||
format_decimal(campaign["conversions_30d"]),
|
||||
", ".join(campaign["risk_labels"]),
|
||||
]
|
||||
for index, campaign in enumerate(plan.campaigns, 1)
|
||||
],
|
||||
)
|
||||
if plan.asset_groups:
|
||||
print("\nAsset groups")
|
||||
print_table(
|
||||
["Nr", "Kampania", "Asset group", "Status"],
|
||||
[
|
||||
[str(index), row["campaign_name"], row["asset_group_name"], row["status"]]
|
||||
for index, row in enumerate(plan.asset_groups[:30], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.asset_groups) > 30:
|
||||
print(f"... oraz {len(plan.asset_groups) - 30} kolejnych asset groups w pliku planu")
|
||||
if plan.knowledge_rules:
|
||||
print("\nReguly z bazy wiedzy")
|
||||
print_table(
|
||||
["Nr", "ID", "Temat", "Rekomendacja"],
|
||||
[
|
||||
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
|
||||
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.knowledge_rules) > 10:
|
||||
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
|
||||
|
||||
|
||||
def print_next_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def apply_pmax_structure_plan(
|
||||
client_config: ClientConfig,
|
||||
plan: PmaxStructurePlan,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
print("\nTo zadanie jest audytem struktury PMax i nie wdraza zmian na koncie Google Ads.")
|
||||
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "audyt oznaczony jako wykonany",
|
||||
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
|
||||
"summary": {
|
||||
"campaigns": len(plan.campaigns),
|
||||
"asset_groups": len(plan.asset_groups),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def run_check_pmax_structure(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
_ = global_rules
|
||||
if apply_plan_path:
|
||||
if confirm_apply != "TAK":
|
||||
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
|
||||
if plan_data.get("client") != client_config.domain:
|
||||
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan = PmaxStructurePlan.from_dict(plan_data)
|
||||
print_pmax_structure_plan(plan)
|
||||
apply_pmax_structure_plan(client_config, plan, show_navigation=show_navigation)
|
||||
return
|
||||
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print("Przygotowuje plan sprawdzenia struktury PMax...")
|
||||
plan = build_pmax_structure_plan(client_config)
|
||||
print_pmax_structure_plan(plan)
|
||||
json_path, md_path = save_pmax_structure_plan(client_config.domain, plan)
|
||||
print(f"\nPlan JSON: {json_path}")
|
||||
print(f"Plan Markdown: {md_path}")
|
||||
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "plan przygotowany",
|
||||
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
|
||||
"summary": {
|
||||
"campaigns": len(plan.campaigns),
|
||||
"asset_groups": len(plan.asset_groups),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if plan_only:
|
||||
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu struktury PMax.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
731
src/gads_v2/tasks/product_feed_optimization.py
Normal file
731
src/gads_v2/tasks/product_feed_optimization.py
Normal file
@@ -0,0 +1,731 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from ..config import ClientConfig, client_dir
|
||||
from ..history import append_change_markdown, append_history, now_local
|
||||
from ..table import print_table
|
||||
|
||||
|
||||
TASK_ID = "optimize_product_feed"
|
||||
TASK_NAME = "Optymalizacja feed produktow"
|
||||
TASK_TITLES_ID = "optimize_product_titles"
|
||||
TASK_TITLES_NAME = "Optymalizacja tytulow produktow"
|
||||
TASK_CATEGORIES_ID = "optimize_product_categories"
|
||||
TASK_CATEGORIES_NAME = "Optymalizacja kategorii Google"
|
||||
TASK_UNIT_PRICING_ID = "fill_product_unit_pricing"
|
||||
TASK_UNIT_PRICING_NAME = "Uzupelnienie unit pricing"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProductFeedPlan:
|
||||
products: list[dict]
|
||||
title_changes: list[dict]
|
||||
category_changes: list[dict]
|
||||
unit_pricing_changes: list[dict]
|
||||
skipped: list[dict]
|
||||
warnings: list[str]
|
||||
task_id: str = TASK_ID
|
||||
task_name: str = TASK_NAME
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"task": self.task_id,
|
||||
"task_name": self.task_name,
|
||||
"products": self.products,
|
||||
"title_changes": self.title_changes,
|
||||
"category_changes": self.category_changes,
|
||||
"unit_pricing_changes": self.unit_pricing_changes,
|
||||
"skipped": self.skipped,
|
||||
"warnings": self.warnings,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ProductFeedPlan":
|
||||
return cls(
|
||||
products=data.get("products", []),
|
||||
title_changes=data.get("title_changes", []),
|
||||
category_changes=data.get("category_changes", []),
|
||||
unit_pricing_changes=data.get("unit_pricing_changes", data.get("unit_pricing_previews", [])),
|
||||
skipped=data.get("skipped", []),
|
||||
warnings=data.get("warnings", []),
|
||||
task_id=data.get("task", TASK_ID),
|
||||
task_name=data.get("task_name", TASK_NAME),
|
||||
)
|
||||
|
||||
|
||||
def adspro_credentials(client_config: ClientConfig) -> tuple[str, str, str]:
|
||||
api_url = os.environ.get("ADSPRO_API_URL")
|
||||
api_key = os.environ.get("ADSPRO_API_KEY")
|
||||
if not api_url or not api_key:
|
||||
raise RuntimeError("Brak ADSPRO_API_URL lub ADSPRO_API_KEY w .env.")
|
||||
if not client_config.adspro_client_id:
|
||||
raise RuntimeError(f"Brak adspro_client_id dla {client_config.domain} w config/clients.toml.")
|
||||
return api_url, api_key, client_config.adspro_client_id
|
||||
|
||||
|
||||
def adspro_request(api_url: str, payload: dict, timeout: int = 30) -> dict:
|
||||
response = requests.post(api_url, data=payload, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
response.encoding = "utf-8"
|
||||
data = response.json()
|
||||
if data.get("result") == "error":
|
||||
raise RuntimeError(data.get("message") or "adsPRO zwrocil blad.")
|
||||
return data
|
||||
|
||||
|
||||
def fetch_products_by_action(client_config: ClientConfig, action: str, limit: int) -> list[dict]:
|
||||
api_url, api_key, adspro_client_id = adspro_credentials(client_config)
|
||||
data = adspro_request(
|
||||
api_url,
|
||||
{
|
||||
"action": action,
|
||||
"api_key": api_key,
|
||||
"client_id": adspro_client_id,
|
||||
"limit": str(limit),
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
return data.get("products", [])
|
||||
|
||||
|
||||
def fetch_missing_title_products(client_config: ClientConfig, limit: int) -> list[dict]:
|
||||
return fetch_products_by_action(client_config, "products_get_missing_title", limit)
|
||||
|
||||
|
||||
def fetch_missing_category_products(client_config: ClientConfig, limit: int) -> list[dict]:
|
||||
return fetch_products_by_action(client_config, "products_get_missing_google_category", limit)
|
||||
|
||||
|
||||
def fetch_missing_unit_pricing_products(client_config: ClientConfig, limit: int) -> list[dict]:
|
||||
api_url, api_key, adspro_client_id = adspro_credentials(client_config)
|
||||
data = adspro_request(
|
||||
api_url,
|
||||
{
|
||||
"action": "products_get_missing_unit_pricing",
|
||||
"api_key": api_key,
|
||||
"client_id": adspro_client_id,
|
||||
"top": str(limit),
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
return data.get("products", [])
|
||||
|
||||
|
||||
def merge_products(*groups: list[dict]) -> list[dict]:
|
||||
merged: dict[str, dict] = {}
|
||||
for products in groups:
|
||||
for product in products:
|
||||
offer_id = str(product.get("offer_id") or product.get("id") or "").strip()
|
||||
if not offer_id:
|
||||
continue
|
||||
merged.setdefault(offer_id, {}).update(product)
|
||||
return list(merged.values())
|
||||
|
||||
|
||||
def changelog_path(domain: str) -> Path:
|
||||
return client_dir(domain) / "produkty_changelog.jsonl"
|
||||
|
||||
|
||||
def read_product_changelog(domain: str) -> list[dict]:
|
||||
path = changelog_path(domain)
|
||||
if not path.exists():
|
||||
return []
|
||||
entries = []
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return entries
|
||||
|
||||
|
||||
def latest_title_change_dates(domain: str) -> dict[str, date]:
|
||||
latest: dict[str, date] = {}
|
||||
for entry in read_product_changelog(domain):
|
||||
if entry.get("field") != "title":
|
||||
continue
|
||||
product_id = str(entry.get("product_id") or entry.get("offer_id") or "")
|
||||
if not product_id:
|
||||
continue
|
||||
try:
|
||||
changed_at = date.fromisoformat(str(entry.get("date")))
|
||||
except ValueError:
|
||||
continue
|
||||
if product_id not in latest or changed_at > latest[product_id]:
|
||||
latest[product_id] = changed_at
|
||||
return latest
|
||||
|
||||
|
||||
def append_product_changelog(domain: str, rows: list[dict]) -> Path:
|
||||
path = changelog_path(domain)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
today = now_local().date().isoformat()
|
||||
with path.open("a", encoding="utf-8") as f:
|
||||
for row in rows:
|
||||
f.write(
|
||||
json.dumps(
|
||||
{
|
||||
"product_id": row["offer_id"],
|
||||
"date": today,
|
||||
"field": row["field"],
|
||||
"old": row.get("current_value", ""),
|
||||
"new": row.get("target_value", ""),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def clean_title(value: str) -> str:
|
||||
value = re.sub(r"\s+", " ", value or "").strip()
|
||||
value = value.replace(" - - ", " - ")
|
||||
if value.isupper() and len(value) > 12:
|
||||
value = value.title()
|
||||
return value[:150].strip()
|
||||
|
||||
|
||||
def suggest_title(product: dict) -> str:
|
||||
source = product.get("default_name") or product.get("title") or product.get("custom_title") or ""
|
||||
title = clean_title(source)
|
||||
brand = clean_title(product.get("brand") or "")
|
||||
if brand and title and not title.lower().startswith(brand.lower()):
|
||||
title = clean_title(f"{brand} {title}")
|
||||
return title
|
||||
|
||||
|
||||
def unit_pricing_preview(title: str) -> dict | None:
|
||||
match = re.search(r"(\d+(?:[,.]\d+)?)\s*(ml|l|g|kg|szt|sztuk|caps|kaps)\b", title or "", re.IGNORECASE)
|
||||
if not match:
|
||||
return None
|
||||
amount = match.group(1).replace(",", ".")
|
||||
unit = match.group(2).lower()
|
||||
unit = {"sztuk": "szt", "caps": "szt", "kaps": "szt"}.get(unit, unit)
|
||||
if unit in {"ml", "l"}:
|
||||
base = "100 ml" if unit == "ml" else "1 l"
|
||||
elif unit in {"g", "kg"}:
|
||||
base = "100 g" if unit == "g" else "1 kg"
|
||||
else:
|
||||
base = "1 szt"
|
||||
return {
|
||||
"unit_pricing_measure": f"{amount} {unit}",
|
||||
"unit_pricing_base_measure": base,
|
||||
}
|
||||
|
||||
|
||||
def build_product_feed_plan(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
scope: str = "all",
|
||||
task_id: str = TASK_ID,
|
||||
task_name: str = TASK_NAME,
|
||||
) -> ProductFeedPlan:
|
||||
rules = client_config.effective_rules(global_rules, "product_feed_optimization")
|
||||
limit = int(rules.get("limit", 10))
|
||||
min_days_between_title_changes = int(rules.get("min_days_between_title_changes", 30))
|
||||
|
||||
title_products = fetch_missing_title_products(client_config, limit) if scope in {"all", "titles"} else []
|
||||
category_products = fetch_missing_category_products(client_config, limit) if scope in {"all", "categories"} else []
|
||||
unit_products = fetch_missing_unit_pricing_products(client_config, limit) if scope in {"all", "unit_pricing"} else []
|
||||
products = merge_products(title_products, category_products, unit_products)
|
||||
latest_changes = latest_title_change_dates(client_config.domain)
|
||||
today = now_local().date()
|
||||
|
||||
title_changes = []
|
||||
category_changes = []
|
||||
unit_changes = []
|
||||
skipped = []
|
||||
warnings = []
|
||||
|
||||
for product in title_products:
|
||||
offer_id = str(product.get("offer_id") or product.get("id") or "").strip()
|
||||
default_title = product.get("default_name") or product.get("title") or ""
|
||||
if not offer_id:
|
||||
skipped.append({"offer_id": "", "reason": "produkt bez offer_id"})
|
||||
continue
|
||||
|
||||
changed_at = latest_changes.get(offer_id)
|
||||
if changed_at and (today - changed_at).days < min_days_between_title_changes:
|
||||
skipped.append(
|
||||
{
|
||||
"offer_id": offer_id,
|
||||
"reason": f"tytul zmieniony {(today - changed_at).days} dni temu, minimum {min_days_between_title_changes}",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
needs_title = bool(product.get("needs_title")) or not product.get("title_changed")
|
||||
proposed_title = suggest_title(product)
|
||||
current_title = product.get("custom_title") or default_title
|
||||
if needs_title and proposed_title and proposed_title != current_title:
|
||||
title_changes.append(
|
||||
{
|
||||
"offer_id": offer_id,
|
||||
"field": "title",
|
||||
"current_value": current_title,
|
||||
"target_value": proposed_title,
|
||||
"reason": "brak zoptymalizowanego tytulu lub tytul wymaga normalizacji",
|
||||
}
|
||||
)
|
||||
elif needs_title:
|
||||
title_changes.append(
|
||||
{
|
||||
"offer_id": offer_id,
|
||||
"field": "title",
|
||||
"current_value": current_title,
|
||||
"target_value": "",
|
||||
"reason": "brak zoptymalizowanego tytulu; tytul wybiera agent AI po analizie produktu",
|
||||
"requires_agent_decision": True,
|
||||
}
|
||||
)
|
||||
|
||||
for product in category_products:
|
||||
offer_id = str(product.get("offer_id") or product.get("id") or "").strip()
|
||||
if not offer_id:
|
||||
skipped.append({"offer_id": "", "reason": "produkt bez offer_id"})
|
||||
continue
|
||||
current_category = product.get("google_product_category") or product.get("google_category") or ""
|
||||
category_changes.append(
|
||||
{
|
||||
"offer_id": offer_id,
|
||||
"field": "google_product_category",
|
||||
"current_value": current_category,
|
||||
"target_value": "",
|
||||
"reason": "brak kategorii Google; kategorie wybiera agent AI po analizie produktu",
|
||||
"requires_agent_decision": True,
|
||||
}
|
||||
)
|
||||
|
||||
seen_unit_offer_ids = set()
|
||||
for product in unit_products:
|
||||
offer_id = str(product.get("offer_id") or product.get("id") or "").strip()
|
||||
default_title = product.get("default_name") or product.get("title") or product.get("custom_title") or ""
|
||||
if not offer_id or offer_id in seen_unit_offer_ids:
|
||||
continue
|
||||
seen_unit_offer_ids.add(offer_id)
|
||||
preview = unit_pricing_preview(default_title)
|
||||
if not preview:
|
||||
skipped.append({"offer_id": offer_id, "reason": "brak jednoznacznego unit pricing w nazwie produktu"})
|
||||
continue
|
||||
unit_changes.append(
|
||||
{
|
||||
"offer_id": offer_id,
|
||||
"field": "unit_pricing",
|
||||
"title": default_title,
|
||||
"current_unit_pricing_measure": product.get("unit_pricing_measure") or "",
|
||||
"current_unit_pricing_base_measure": product.get("unit_pricing_base_measure") or "",
|
||||
"unit_pricing_measure": preview["unit_pricing_measure"],
|
||||
"unit_pricing_base_measure": preview["unit_pricing_base_measure"],
|
||||
"reason": "brak unit pricing; wartosc wyliczona z nazwy produktu",
|
||||
}
|
||||
)
|
||||
|
||||
if scope == "titles" and not title_products:
|
||||
warnings.append("adsPRO nie zwrocil produktow bez zoptymalizowanego tytulu.")
|
||||
if scope == "categories" and not category_products:
|
||||
warnings.append("adsPRO nie zwrocil produktow bez kategorii Google.")
|
||||
if scope == "all" and not products:
|
||||
warnings.append("adsPRO nie zwrocil produktow do optymalizacji.")
|
||||
if scope == "unit_pricing" and not unit_products:
|
||||
warnings.append("adsPRO nie zwrocil produktow bez unit pricing.")
|
||||
if category_changes:
|
||||
warnings.append("Kategorie Google wybiera agent AI; skrypt nie zgaduje ich automatycznie.")
|
||||
if title_changes and any(row.get("requires_agent_decision") for row in title_changes):
|
||||
warnings.append("Czesc tytulow wymaga decyzji agenta AI; skrypt nie przepisuje tytulu bazowego jako optymalizacji.")
|
||||
if unit_changes:
|
||||
warnings.append("Unit pricing zostanie zapisany w adsPRO dopiero po akceptacji planu.")
|
||||
|
||||
return ProductFeedPlan(
|
||||
products=products,
|
||||
title_changes=title_changes,
|
||||
category_changes=category_changes,
|
||||
unit_pricing_changes=unit_changes,
|
||||
skipped=skipped,
|
||||
warnings=warnings,
|
||||
task_id=task_id,
|
||||
task_name=task_name,
|
||||
)
|
||||
|
||||
|
||||
def save_product_feed_plan(domain: str, plan: ProductFeedPlan) -> tuple[Path, Path]:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "plans"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{plan.task_id}"
|
||||
json_path = base / f"{stem}.json"
|
||||
md_path = base / f"{stem}.md"
|
||||
payload = {
|
||||
"created_at": ts.isoformat(timespec="seconds"),
|
||||
"client": domain,
|
||||
**plan.to_dict(),
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
f"# Plan: {plan.task_name}",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Produkty z adsPRO: {len(plan.products)}",
|
||||
f"- Tytuly do zmiany: {len(plan.title_changes)}",
|
||||
f"- Kategorie do uzupelnienia: {len(plan.category_changes)}",
|
||||
f"- Unit pricing do zmiany: {len(plan.unit_pricing_changes)}",
|
||||
f"- Pominiete: {len(plan.skipped)}",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {item}" for item in plan.warnings)
|
||||
lines.append("")
|
||||
if plan.title_changes:
|
||||
lines.extend(["## Tytuly do decyzji lub zmiany", "", "| Produkt | Obecnie | Docelowo | Powod |", "| --- | --- | --- | --- |"])
|
||||
for row in plan.title_changes:
|
||||
lines.append(f"| {row['offer_id']} | {row['current_value']} | {row['target_value']} | {row['reason']} |")
|
||||
lines.append("")
|
||||
if plan.category_changes:
|
||||
lines.extend(["## Kategorie Google do decyzji agenta AI", "", "| Produkt | Obecnie | Decyzja agenta AI | Powod |", "| --- | --- | --- | --- |"])
|
||||
for row in plan.category_changes:
|
||||
lines.append(f"| {row['offer_id']} | {row['current_value']} | {row['target_value']} | {row['reason']} |")
|
||||
lines.append("")
|
||||
if plan.unit_pricing_changes:
|
||||
lines.extend(["## Unit pricing do zmiany", "", "| Produkt | Measure | Base measure | Powod |", "| --- | --- | --- | --- |"])
|
||||
for row in plan.unit_pricing_changes:
|
||||
lines.append(
|
||||
f"| {row['offer_id']} | {row['unit_pricing_measure']} | {row['unit_pricing_base_measure']} | {row['reason']} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.skipped:
|
||||
lines.extend(["## Pominiete", "", "| Produkt | Powod |", "| --- | --- |"])
|
||||
for row in plan.skipped:
|
||||
lines.append(f"| {row.get('offer_id', '')} | {row.get('reason', '')} |")
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def print_product_feed_plan(plan: ProductFeedPlan) -> None:
|
||||
print(f"\nPlan: {plan.task_name}")
|
||||
print_table(
|
||||
["Zakres", "Liczba"],
|
||||
[
|
||||
["Produkty z adsPRO", str(len(plan.products))],
|
||||
["Tytuly do zmiany", str(len(plan.title_changes))],
|
||||
["Kategorie do uzupelnienia", str(len(plan.category_changes))],
|
||||
["Unit pricing do zmiany", str(len(plan.unit_pricing_changes))],
|
||||
["Pominiete", str(len(plan.skipped))],
|
||||
],
|
||||
)
|
||||
if plan.title_changes:
|
||||
print("\nNajwazniejsze dzialania")
|
||||
rows = [
|
||||
[str(i), row["offer_id"], "Zmien tytul", row["target_value"]]
|
||||
for i, row in enumerate(plan.title_changes[:10], 1)
|
||||
]
|
||||
print_table(["Nr", "Produkt", "Dzialanie", "Docelowo"], rows)
|
||||
if len(plan.title_changes) > 10:
|
||||
print(f"... oraz {len(plan.title_changes) - 10} kolejnych zmian tytulow")
|
||||
for warning in plan.warnings:
|
||||
print(f"Uwaga: {warning}")
|
||||
|
||||
|
||||
def print_next_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def set_product_title(api_url: str, api_key: str, client_id: str, row: dict) -> None:
|
||||
adspro_request(
|
||||
api_url,
|
||||
{
|
||||
"action": "product_title_set",
|
||||
"api_key": api_key,
|
||||
"client_id": client_id,
|
||||
"offer_id": row["offer_id"],
|
||||
"title": row["target_value"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def set_product_category(api_url: str, api_key: str, client_id: str, row: dict) -> None:
|
||||
adspro_request(
|
||||
api_url,
|
||||
{
|
||||
"action": "product_google_category_set",
|
||||
"api_key": api_key,
|
||||
"client_id": client_id,
|
||||
"offer_id": row["offer_id"],
|
||||
"google_product_category": row["target_value"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def set_product_unit_pricing(api_url: str, api_key: str, client_id: str, row: dict) -> None:
|
||||
adspro_request(
|
||||
api_url,
|
||||
{
|
||||
"action": "product_unit_pricing_set",
|
||||
"api_key": api_key,
|
||||
"client_id": client_id,
|
||||
"offer_id": row["offer_id"],
|
||||
"unit_pricing_measure": row["unit_pricing_measure"],
|
||||
"unit_pricing_base_measure": row["unit_pricing_base_measure"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def apply_product_feed_plan(client_config: ClientConfig, plan: ProductFeedPlan, show_navigation: bool = True) -> None:
|
||||
api_url, api_key, adspro_client_id = adspro_credentials(client_config)
|
||||
applied = []
|
||||
skipped = []
|
||||
|
||||
for row in plan.title_changes:
|
||||
if not row.get("target_value"):
|
||||
skipped.append({**row, "skip_reason": "brak target_value"})
|
||||
continue
|
||||
set_product_title(api_url, api_key, adspro_client_id, row)
|
||||
applied.append(row)
|
||||
|
||||
for row in plan.category_changes:
|
||||
if not row.get("target_value"):
|
||||
skipped.append({**row, "skip_reason": "brak target_value"})
|
||||
continue
|
||||
set_product_category(api_url, api_key, adspro_client_id, row)
|
||||
applied.append(row)
|
||||
|
||||
for row in plan.unit_pricing_changes:
|
||||
if not row.get("unit_pricing_measure") or not row.get("unit_pricing_base_measure"):
|
||||
skipped.append({**row, "skip_reason": "brak unit_pricing_measure lub unit_pricing_base_measure"})
|
||||
continue
|
||||
set_product_unit_pricing(api_url, api_key, adspro_client_id, row)
|
||||
applied.append(
|
||||
{
|
||||
"offer_id": row["offer_id"],
|
||||
"field": "unit_pricing",
|
||||
"current_value": (
|
||||
f"{row.get('current_unit_pricing_measure', '')} / "
|
||||
f"{row.get('current_unit_pricing_base_measure', '')}"
|
||||
).strip(" /"),
|
||||
"target_value": f"{row['unit_pricing_measure']} / {row['unit_pricing_base_measure']}",
|
||||
}
|
||||
)
|
||||
|
||||
print("\nWynik wdrozenia zmian")
|
||||
print(f"Wdrozono zmian: {len(applied)}")
|
||||
print(f"Pominieto: {len(skipped)}")
|
||||
|
||||
change_rows = [
|
||||
{
|
||||
"klient": client_config.domain,
|
||||
"produkt": row["offer_id"],
|
||||
"pole": row["field"],
|
||||
"obecnie": row.get("current_value", ""),
|
||||
"docelowo": row.get("target_value", ""),
|
||||
}
|
||||
for row in applied
|
||||
]
|
||||
changes_path = append_change_markdown(client_config.domain, plan.task_name, change_rows)
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": plan.task_name,
|
||||
"status": "wdrozono zmiany",
|
||||
"product": ", ".join(row["offer_id"] for row in applied[:10]),
|
||||
"summary": {"applied": len(applied), "skipped": len(skipped)},
|
||||
},
|
||||
)
|
||||
changelog = append_product_changelog(client_config.domain, applied) if applied else changelog_path(client_config.domain)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
print(f"Changelog produktow: {changelog}")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def run_product_feed_task(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
scope: str,
|
||||
task_id: str,
|
||||
task_name: str,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
if apply_plan_path:
|
||||
if confirm_apply != "TAK":
|
||||
print("Do wdrozenia planu wymagane jest --confirm-apply TAK.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
|
||||
if plan_data.get("client") != client_config.domain:
|
||||
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan = ProductFeedPlan.from_dict(plan_data)
|
||||
print_product_feed_plan(plan)
|
||||
apply_product_feed_plan(client_config, plan, show_navigation=show_navigation)
|
||||
return
|
||||
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print(f"Pobieram produkty z adsPRO i przygotowuje plan: {task_name}...")
|
||||
plan = build_product_feed_plan(client_config, global_rules, scope=scope, task_id=task_id, task_name=task_name)
|
||||
print_product_feed_plan(plan)
|
||||
json_path, md_path = save_product_feed_plan(client_config.domain, plan)
|
||||
print(f"\nPlan JSON: {json_path}")
|
||||
print(f"Plan Markdown: {md_path}")
|
||||
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": task_name,
|
||||
"status": "plan przygotowany",
|
||||
"product": ", ".join(str(product.get("offer_id") or product.get("id") or "") for product in plan.products[:10]),
|
||||
"summary": {
|
||||
"products": len(plan.products),
|
||||
"title_changes": len(plan.title_changes),
|
||||
"category_changes": len(plan.category_changes),
|
||||
"unit_pricing_changes": len(plan.unit_pricing_changes),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if plan_only:
|
||||
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
if (
|
||||
not plan.title_changes
|
||||
and not any(row.get("target_value") for row in plan.category_changes)
|
||||
and not plan.unit_pricing_changes
|
||||
):
|
||||
print("\nBrak gotowych zmian do wdrozenia.")
|
||||
append_change_markdown(client_config.domain, task_name, [])
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
answer = input("\nWpisz TAK, aby wdrozyc powyzsze zmiany: ").strip()
|
||||
if answer != "TAK":
|
||||
print("Przerwano. Zmiany nie zostaly wdrozone.")
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": task_name,
|
||||
"status": "odrzucono wdrozenie",
|
||||
"product": ", ".join(row["offer_id"] for row in plan.title_changes[:10]),
|
||||
},
|
||||
)
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
apply_product_feed_plan(client_config, plan, show_navigation=show_navigation)
|
||||
|
||||
|
||||
def run_optimize_product_feed(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
run_product_feed_task(
|
||||
client_config,
|
||||
global_rules,
|
||||
"all",
|
||||
TASK_ID,
|
||||
TASK_NAME,
|
||||
plan_only=plan_only,
|
||||
apply_plan_path=apply_plan_path,
|
||||
confirm_apply=confirm_apply,
|
||||
show_navigation=show_navigation,
|
||||
)
|
||||
|
||||
|
||||
def run_optimize_product_titles(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
run_product_feed_task(
|
||||
client_config,
|
||||
global_rules,
|
||||
"titles",
|
||||
TASK_TITLES_ID,
|
||||
TASK_TITLES_NAME,
|
||||
plan_only=plan_only,
|
||||
apply_plan_path=apply_plan_path,
|
||||
confirm_apply=confirm_apply,
|
||||
show_navigation=show_navigation,
|
||||
)
|
||||
|
||||
|
||||
def run_optimize_product_categories(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
run_product_feed_task(
|
||||
client_config,
|
||||
global_rules,
|
||||
"categories",
|
||||
TASK_CATEGORIES_ID,
|
||||
TASK_CATEGORIES_NAME,
|
||||
plan_only=plan_only,
|
||||
apply_plan_path=apply_plan_path,
|
||||
confirm_apply=confirm_apply,
|
||||
show_navigation=show_navigation,
|
||||
)
|
||||
|
||||
|
||||
def run_fill_product_unit_pricing(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
run_product_feed_task(
|
||||
client_config,
|
||||
global_rules,
|
||||
"unit_pricing",
|
||||
TASK_UNIT_PRICING_ID,
|
||||
TASK_UNIT_PRICING_NAME,
|
||||
plan_only=plan_only,
|
||||
apply_plan_path=apply_plan_path,
|
||||
confirm_apply=confirm_apply,
|
||||
show_navigation=show_navigation,
|
||||
)
|
||||
461
src/gads_v2/tasks/remarketing_setup_check.py
Normal file
461
src/gads_v2/tasks/remarketing_setup_check.py
Normal file
@@ -0,0 +1,461 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..config import ClientConfig, client_dir
|
||||
from ..google_ads import get_google_ads_client, run_query
|
||||
from ..history import append_change_markdown, append_history, now_local
|
||||
from ..knowledge.store import rules_for_task
|
||||
from ..table import print_table
|
||||
|
||||
|
||||
TASK_ID = "check_remarketing_setup"
|
||||
TASK_NAME = "Sprawdzenie remarketingu"
|
||||
|
||||
|
||||
SCOPE = [
|
||||
{
|
||||
"area": "Listy odbiorcow",
|
||||
"check": "Pokaz listy odbiorcow z Google Ads, ich typ, status czlonkostwa i rozmiary dla Search oraz Display.",
|
||||
},
|
||||
{
|
||||
"area": "Dynamiczny remarketing",
|
||||
"check": "Wypisz kontrole tagowania produktowego i identyfikatorow produktow do recznej oceny.",
|
||||
},
|
||||
{
|
||||
"area": "Powiazanie z PMax",
|
||||
"check": "Oznacz ryzyko nakladania remarketingu z PMax jako punkt do recznej oceny.",
|
||||
},
|
||||
{
|
||||
"area": "Gotowosc list",
|
||||
"check": "Oznacz listy zbyt male albo zamkniete jako potencjalny problem wykorzystania w kampaniach.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
DYNAMIC_REMARKETING_CHECKS = [
|
||||
"czy tag Google Ads / GTM przekazuje identyfikatory produktow zgodne z feedem",
|
||||
"czy zdarzenia e-commerce rozrozniaja view_item, add_to_cart i purchase",
|
||||
"czy remarketing dynamiczny ma dostep do feedu produktowego",
|
||||
"czy listy odbiorcow sa wystarczajaco duze dla Search i Display",
|
||||
"czy PMax nie przechwytuje calego remarketingu bez osobnej kontroli",
|
||||
]
|
||||
|
||||
|
||||
OUT_OF_SCOPE = [
|
||||
"budzety i pacing budzetu",
|
||||
"strategie stawek oraz cele Docelowy ROAS/Docelowy CPA",
|
||||
"tworzenie reklam i kreacji remarketingowych",
|
||||
"problemy feedu i Merchant Center poza kontrola identyfikatorow produktow",
|
||||
"wdrazanie zmian w tagowaniu albo listach odbiorcow",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemarketingSetupPlan:
|
||||
user_lists: list[dict]
|
||||
list_type_summary: list[dict]
|
||||
dynamic_remarketing_checks: list[str]
|
||||
scope: list[dict]
|
||||
out_of_scope: list[str]
|
||||
knowledge_rules: list[dict]
|
||||
warnings: list[str]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"task": TASK_ID,
|
||||
"task_name": TASK_NAME,
|
||||
"user_lists": self.user_lists,
|
||||
"list_type_summary": self.list_type_summary,
|
||||
"dynamic_remarketing_checks": self.dynamic_remarketing_checks,
|
||||
"scope": self.scope,
|
||||
"out_of_scope": self.out_of_scope,
|
||||
"knowledge_rules": self.knowledge_rules,
|
||||
"warnings": self.warnings,
|
||||
"changes": [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "RemarketingSetupPlan":
|
||||
return cls(
|
||||
user_lists=data.get("user_lists", []),
|
||||
list_type_summary=data.get("list_type_summary", []),
|
||||
dynamic_remarketing_checks=data.get("dynamic_remarketing_checks", []),
|
||||
scope=data.get("scope", []),
|
||||
out_of_scope=data.get("out_of_scope", []),
|
||||
knowledge_rules=data.get("knowledge_rules", []),
|
||||
warnings=data.get("warnings", []),
|
||||
)
|
||||
|
||||
|
||||
def enum_name(value: Any) -> str:
|
||||
name = getattr(value, "name", None)
|
||||
if name:
|
||||
return name
|
||||
return str(value)
|
||||
|
||||
|
||||
def safe_int(value: Any) -> int:
|
||||
try:
|
||||
return int(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def md_cell(value: Any) -> str:
|
||||
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
|
||||
|
||||
|
||||
def size_label(search_size: int, display_size: int) -> str:
|
||||
max_size = max(search_size, display_size)
|
||||
if max_size <= 0:
|
||||
return "brak rozmiaru"
|
||||
if max_size < 100:
|
||||
return "bardzo mala lista"
|
||||
if max_size < 1000:
|
||||
return "mala lista"
|
||||
return "rozmiar ok"
|
||||
|
||||
|
||||
def user_list_flags(row: dict) -> list[str]:
|
||||
flags = []
|
||||
if row["membership_status"] and row["membership_status"] != "OPEN":
|
||||
flags.append("lista zamknieta")
|
||||
if row["size_status"] != "rozmiar ok":
|
||||
flags.append(row["size_status"])
|
||||
if row["type"] in {"UNKNOWN", "UNSPECIFIED"}:
|
||||
flags.append("nieznany typ")
|
||||
return flags or ["ok"]
|
||||
|
||||
|
||||
def fetch_user_lists(client_config: ClientConfig) -> list[dict]:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
rows = run_query(
|
||||
google_client,
|
||||
client_config.safe_customer_id,
|
||||
"""
|
||||
SELECT
|
||||
user_list.id,
|
||||
user_list.name,
|
||||
user_list.type,
|
||||
user_list.membership_status,
|
||||
user_list.size_for_search,
|
||||
user_list.size_for_display
|
||||
FROM user_list
|
||||
""",
|
||||
)
|
||||
|
||||
user_lists = []
|
||||
for row in rows:
|
||||
user_list = row.user_list
|
||||
search_size = safe_int(user_list.size_for_search)
|
||||
display_size = safe_int(user_list.size_for_display)
|
||||
record = {
|
||||
"user_list_id": str(user_list.id),
|
||||
"name": user_list.name,
|
||||
"type": enum_name(user_list.type),
|
||||
"membership_status": enum_name(user_list.membership_status),
|
||||
"size_for_search": search_size,
|
||||
"size_for_display": display_size,
|
||||
"size_status": size_label(search_size, display_size),
|
||||
}
|
||||
record["flags"] = user_list_flags(record)
|
||||
user_lists.append(record)
|
||||
user_lists.sort(key=lambda row: (row["size_status"], row["name"]))
|
||||
return user_lists
|
||||
|
||||
|
||||
def build_list_type_summary(user_lists: list[dict]) -> list[dict]:
|
||||
counter = Counter(row["type"] for row in user_lists)
|
||||
return [{"type": key, "count": value} for key, value in counter.most_common()]
|
||||
|
||||
|
||||
def build_remarketing_setup_plan(client_config: ClientConfig) -> RemarketingSetupPlan:
|
||||
warnings = []
|
||||
try:
|
||||
user_lists = fetch_user_lists(client_config)
|
||||
except Exception as exc:
|
||||
user_lists = []
|
||||
warnings.append(f"Nie udalo sie pobrac list odbiorcow z Google Ads API: {exc}")
|
||||
|
||||
if not user_lists:
|
||||
warnings.append("Nie znaleziono list odbiorcow albo nie udalo sie ich pobrac.")
|
||||
|
||||
warnings.append(
|
||||
"Dynamiczny remarketing wymaga potwierdzenia tagowania produktowego w zadaniu Pomiar i konwersje oraz zgodnosci feedu w zadaniu Feed i Merchant Center."
|
||||
)
|
||||
warnings.append(
|
||||
"Konflikt remarketingu z PMax wymaga recznej oceny razem z zadaniem Sprawdzenie struktury PMax."
|
||||
)
|
||||
|
||||
knowledge_rules = [
|
||||
{
|
||||
"id": rule.id,
|
||||
"topic": rule.topic,
|
||||
"rule_type": rule.rule_type,
|
||||
"condition": rule.condition,
|
||||
"recommendation": rule.recommendation,
|
||||
"risk": rule.risk,
|
||||
"source": rule.source,
|
||||
}
|
||||
for rule in rules_for_task(TASK_ID)
|
||||
]
|
||||
if not knowledge_rules:
|
||||
warnings.append(
|
||||
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
|
||||
"Reguly dotyczace remarketingu bedziemy dopisywac osobno po akceptacji uzytkownika."
|
||||
)
|
||||
|
||||
return RemarketingSetupPlan(
|
||||
user_lists=user_lists,
|
||||
list_type_summary=build_list_type_summary(user_lists),
|
||||
dynamic_remarketing_checks=DYNAMIC_REMARKETING_CHECKS,
|
||||
scope=SCOPE,
|
||||
out_of_scope=OUT_OF_SCOPE,
|
||||
knowledge_rules=knowledge_rules,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def save_remarketing_setup_plan(domain: str, plan: RemarketingSetupPlan) -> tuple[Path, Path]:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "plans"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
|
||||
json_path = base / f"{stem}.json"
|
||||
md_path = base / f"{stem}.md"
|
||||
payload = {
|
||||
"created_at": ts.isoformat(timespec="seconds"),
|
||||
"client": domain,
|
||||
**plan.to_dict(),
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Plan: Sprawdzenie remarketingu",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Listy odbiorcow: {len(plan.user_lists)}",
|
||||
f"- Typy list: {len(plan.list_type_summary)}",
|
||||
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
|
||||
"- Zmiany do wdrozenia: 0",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {item}" for item in plan.warnings)
|
||||
lines.append("")
|
||||
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
|
||||
for row in plan.scope:
|
||||
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
|
||||
lines.append("")
|
||||
lines.extend(["## Poza zakresem tego zadania", ""])
|
||||
lines.extend(f"- {item}" for item in plan.out_of_scope)
|
||||
lines.append("")
|
||||
if plan.list_type_summary:
|
||||
lines.extend(["## Podsumowanie typow list", "", "| Typ | Liczba |", "| --- | --- |"])
|
||||
for row in plan.list_type_summary:
|
||||
lines.append(f"| {row['type']} | {row['count']} |")
|
||||
lines.append("")
|
||||
if plan.user_lists:
|
||||
lines.extend(
|
||||
[
|
||||
"## Listy odbiorcow",
|
||||
"",
|
||||
"| Lista | Typ | Status | Search size | Display size | Flagi |",
|
||||
"| --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for row in plan.user_lists:
|
||||
lines.append(
|
||||
f"| {md_cell(row['name'])} | {row['type']} | {row['membership_status']} | "
|
||||
f"{row['size_for_search']} | {row['size_for_display']} | {md_cell(', '.join(row['flags']))} |"
|
||||
)
|
||||
lines.append("")
|
||||
lines.extend(["## Kontrole dynamicznego remarketingu", ""])
|
||||
lines.extend(f"- {item}" for item in plan.dynamic_remarketing_checks)
|
||||
lines.append("")
|
||||
if plan.knowledge_rules:
|
||||
lines.extend(
|
||||
[
|
||||
"## Reguly z bazy wiedzy",
|
||||
"",
|
||||
"| ID | Temat | Rekomendacja | Ryzyko |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for rule in plan.knowledge_rules:
|
||||
lines.append(
|
||||
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
|
||||
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
|
||||
)
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def print_remarketing_setup_plan(plan: RemarketingSetupPlan) -> None:
|
||||
print("\nPlan sprawdzenia remarketingu")
|
||||
print_table(
|
||||
["Metryka", "Liczba"],
|
||||
[
|
||||
["Listy odbiorcow", str(len(plan.user_lists))],
|
||||
["Typy list", str(len(plan.list_type_summary))],
|
||||
["Reguly wiedzy", str(len(plan.knowledge_rules))],
|
||||
["Zmiany do wdrozenia", "0"],
|
||||
],
|
||||
)
|
||||
if plan.warnings:
|
||||
print("\nUwagi")
|
||||
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
|
||||
print("\nZakres zadania")
|
||||
print_table(
|
||||
["Nr", "Obszar", "Co sprawdzic"],
|
||||
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
|
||||
)
|
||||
print("\nPoza zakresem")
|
||||
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
|
||||
if plan.list_type_summary:
|
||||
print("\nPodsumowanie typow list")
|
||||
print_table(
|
||||
["Typ", "Liczba"],
|
||||
[[row["type"], str(row["count"])] for row in plan.list_type_summary],
|
||||
)
|
||||
if plan.user_lists:
|
||||
print("\nListy odbiorcow")
|
||||
print_table(
|
||||
["Nr", "Lista", "Typ", "Status", "Search", "Display", "Flagi"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
row["name"],
|
||||
row["type"],
|
||||
row["membership_status"],
|
||||
str(row["size_for_search"]),
|
||||
str(row["size_for_display"]),
|
||||
", ".join(row["flags"]),
|
||||
]
|
||||
for index, row in enumerate(plan.user_lists[:30], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.user_lists) > 30:
|
||||
print(f"... oraz {len(plan.user_lists) - 30} kolejnych list w pliku planu")
|
||||
print("\nKontrole dynamicznego remarketingu")
|
||||
print_table(
|
||||
["Nr", "Kontrola"],
|
||||
[[str(index), item] for index, item in enumerate(plan.dynamic_remarketing_checks, 1)],
|
||||
)
|
||||
if plan.knowledge_rules:
|
||||
print("\nReguly z bazy wiedzy")
|
||||
print_table(
|
||||
["Nr", "ID", "Temat", "Rekomendacja"],
|
||||
[
|
||||
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
|
||||
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.knowledge_rules) > 10:
|
||||
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
|
||||
|
||||
|
||||
def print_next_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def apply_remarketing_setup_plan(
|
||||
client_config: ClientConfig,
|
||||
plan: RemarketingSetupPlan,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
print("\nTo zadanie jest audytem remarketingu i nie wdraza zmian na koncie Google Ads.")
|
||||
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "audyt oznaczony jako wykonany",
|
||||
"campaign": "",
|
||||
"summary": {
|
||||
"user_lists": len(plan.user_lists),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def run_check_remarketing_setup(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
_ = global_rules
|
||||
if apply_plan_path:
|
||||
if confirm_apply != "TAK":
|
||||
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
|
||||
if plan_data.get("client") != client_config.domain:
|
||||
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan = RemarketingSetupPlan.from_dict(plan_data)
|
||||
print_remarketing_setup_plan(plan)
|
||||
apply_remarketing_setup_plan(client_config, plan, show_navigation=show_navigation)
|
||||
return
|
||||
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print("Przygotowuje plan sprawdzenia remarketingu...")
|
||||
plan = build_remarketing_setup_plan(client_config)
|
||||
print_remarketing_setup_plan(plan)
|
||||
json_path, md_path = save_remarketing_setup_plan(client_config.domain, plan)
|
||||
print(f"\nPlan JSON: {json_path}")
|
||||
print(f"Plan Markdown: {md_path}")
|
||||
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "plan przygotowany",
|
||||
"campaign": "",
|
||||
"summary": {
|
||||
"user_lists": len(plan.user_lists),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if plan_only:
|
||||
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu remarketingu.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
438
src/gads_v2/tasks/rsa_assets_check.py
Normal file
438
src/gads_v2/tasks/rsa_assets_check.py
Normal file
@@ -0,0 +1,438 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..config import ClientConfig, client_dir
|
||||
from ..google_ads import get_google_ads_client, run_query
|
||||
from ..history import append_change_markdown, append_history, now_local
|
||||
from ..knowledge.store import rules_for_task
|
||||
from ..table import print_table
|
||||
|
||||
|
||||
TASK_ID = "check_rsa_assets"
|
||||
TASK_NAME = "Sprawdzenie reklam RSA i zasobow"
|
||||
MAX_RSA_ADS = 100
|
||||
|
||||
|
||||
SCOPE = [
|
||||
{
|
||||
"area": "Reklamy RSA",
|
||||
"check": "Pokaz aktywne reklamy RSA w aktywnych kampaniach Search i grupach reklam.",
|
||||
},
|
||||
{
|
||||
"area": "Naglowki",
|
||||
"check": "Policz naglowki i oznacz reklamy z mala liczba wariantow albo duplikatami.",
|
||||
},
|
||||
{
|
||||
"area": "Teksty reklam",
|
||||
"check": "Policz opisy i oznacz reklamy z mala liczba wariantow.",
|
||||
},
|
||||
{
|
||||
"area": "DKI",
|
||||
"check": "Oznacz uzycie Dynamic Keyword Insertion jako element wymagajacy recznej oceny.",
|
||||
},
|
||||
{
|
||||
"area": "Final URL",
|
||||
"check": "Sprawdz, czy reklama ma finalny adres URL.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
OUT_OF_SCOPE = [
|
||||
"zapytania uzytkownikow i wykluczenia",
|
||||
"budzety i pacing budzetu",
|
||||
"strategie stawek oraz cele Docelowy ROAS/Docelowy CPA",
|
||||
"podstawowe ustawienia kampanii, np. lokalizacje i sieci",
|
||||
"automatyczne tworzenie albo edycja reklam",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class RsaAssetsPlan:
|
||||
ads: list[dict]
|
||||
scope: list[dict]
|
||||
out_of_scope: list[str]
|
||||
knowledge_rules: list[dict]
|
||||
warnings: list[str]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"task": TASK_ID,
|
||||
"task_name": TASK_NAME,
|
||||
"ads": self.ads,
|
||||
"scope": self.scope,
|
||||
"out_of_scope": self.out_of_scope,
|
||||
"knowledge_rules": self.knowledge_rules,
|
||||
"warnings": self.warnings,
|
||||
"changes": [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "RsaAssetsPlan":
|
||||
return cls(
|
||||
ads=data.get("ads", []),
|
||||
scope=data.get("scope", []),
|
||||
out_of_scope=data.get("out_of_scope", []),
|
||||
knowledge_rules=data.get("knowledge_rules", []),
|
||||
warnings=data.get("warnings", []),
|
||||
)
|
||||
|
||||
|
||||
def enum_name(value: Any) -> str:
|
||||
name = getattr(value, "name", None)
|
||||
if name:
|
||||
return name
|
||||
return str(value)
|
||||
|
||||
|
||||
def text_asset_values(assets: Any) -> list[str]:
|
||||
values = []
|
||||
for asset in assets or []:
|
||||
text = str(getattr(asset, "text", "") or "").strip()
|
||||
if text:
|
||||
values.append(text)
|
||||
return values
|
||||
|
||||
|
||||
def has_dki(values: list[str]) -> bool:
|
||||
return any("{keyword:" in value.casefold() for value in values)
|
||||
|
||||
|
||||
def duplicate_count(values: list[str]) -> int:
|
||||
normalized = [value.casefold().strip() for value in values if value.strip()]
|
||||
return len(normalized) - len(set(normalized))
|
||||
|
||||
|
||||
def risk_labels(headlines: list[str], descriptions: list[str], final_urls: list[str]) -> list[str]:
|
||||
risks = []
|
||||
if len(headlines) < 8:
|
||||
risks.append("malo naglowkow")
|
||||
if len(descriptions) < 3:
|
||||
risks.append("malo opisow")
|
||||
if duplicate_count(headlines) > 0:
|
||||
risks.append("duplikaty naglowkow")
|
||||
if has_dki(headlines + descriptions):
|
||||
risks.append("sprawdz DKI")
|
||||
if not final_urls:
|
||||
risks.append("brak final URL")
|
||||
return risks or ["do oceny"]
|
||||
|
||||
|
||||
def md_cell(value: Any) -> str:
|
||||
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
|
||||
|
||||
|
||||
def fetch_rsa_ads(client_config: ClientConfig) -> list[dict]:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
rows = run_query(
|
||||
google_client,
|
||||
client_config.safe_customer_id,
|
||||
f"""
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign.name,
|
||||
ad_group.id,
|
||||
ad_group.name,
|
||||
ad_group_ad.ad.id,
|
||||
ad_group_ad.status,
|
||||
ad_group_ad.ad.final_urls,
|
||||
ad_group_ad.ad.responsive_search_ad.headlines,
|
||||
ad_group_ad.ad.responsive_search_ad.descriptions
|
||||
FROM ad_group_ad
|
||||
WHERE campaign.status = 'ENABLED'
|
||||
AND ad_group.status = 'ENABLED'
|
||||
AND ad_group_ad.status != 'REMOVED'
|
||||
AND ad_group_ad.ad.type = 'RESPONSIVE_SEARCH_AD'
|
||||
LIMIT {MAX_RSA_ADS}
|
||||
""",
|
||||
)
|
||||
|
||||
ads = []
|
||||
for row in rows:
|
||||
ad = row.ad_group_ad.ad
|
||||
rsa = ad.responsive_search_ad
|
||||
headlines = text_asset_values(rsa.headlines)
|
||||
descriptions = text_asset_values(rsa.descriptions)
|
||||
final_urls = [str(url) for url in ad.final_urls]
|
||||
risks = risk_labels(headlines, descriptions, final_urls)
|
||||
ads.append(
|
||||
{
|
||||
"campaign_id": str(row.campaign.id),
|
||||
"campaign_name": row.campaign.name,
|
||||
"ad_group_id": str(row.ad_group.id),
|
||||
"ad_group_name": row.ad_group.name,
|
||||
"ad_id": str(ad.id),
|
||||
"status": enum_name(row.ad_group_ad.status),
|
||||
"headline_count": len(headlines),
|
||||
"description_count": len(descriptions),
|
||||
"headlines": headlines,
|
||||
"descriptions": descriptions,
|
||||
"final_urls": final_urls,
|
||||
"has_dki": has_dki(headlines + descriptions),
|
||||
"duplicate_headlines": duplicate_count(headlines),
|
||||
"risk_labels": risks,
|
||||
}
|
||||
)
|
||||
return ads
|
||||
|
||||
|
||||
def build_rsa_assets_plan(client_config: ClientConfig) -> RsaAssetsPlan:
|
||||
warnings = []
|
||||
try:
|
||||
ads = fetch_rsa_ads(client_config)
|
||||
except Exception as exc:
|
||||
ads = []
|
||||
warnings.append(f"Nie udalo sie pobrac reklam RSA z Google Ads API: {exc}")
|
||||
|
||||
if not ads:
|
||||
warnings.append("Nie znaleziono aktywnych reklam RSA albo nie udalo sie ich pobrac.")
|
||||
|
||||
rules = rules_for_task(TASK_ID)
|
||||
knowledge_rules = [
|
||||
{
|
||||
"id": rule.id,
|
||||
"topic": rule.topic,
|
||||
"rule_type": rule.rule_type,
|
||||
"condition": rule.condition,
|
||||
"recommendation": rule.recommendation,
|
||||
"risk": rule.risk,
|
||||
"source": rule.source,
|
||||
}
|
||||
for rule in rules
|
||||
]
|
||||
if not knowledge_rules:
|
||||
warnings.append(
|
||||
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
|
||||
"Reguly dotyczace RSA i zasobow bedziemy dopisywac osobno po akceptacji uzytkownika."
|
||||
)
|
||||
|
||||
ads.sort(key=lambda row: (len(row["risk_labels"]), row["campaign_name"], row["ad_group_name"]), reverse=True)
|
||||
return RsaAssetsPlan(
|
||||
ads=ads,
|
||||
scope=SCOPE,
|
||||
out_of_scope=OUT_OF_SCOPE,
|
||||
knowledge_rules=knowledge_rules,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def save_rsa_assets_plan(domain: str, plan: RsaAssetsPlan) -> tuple[Path, Path]:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "plans"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
|
||||
json_path = base / f"{stem}.json"
|
||||
md_path = base / f"{stem}.md"
|
||||
payload = {
|
||||
"created_at": ts.isoformat(timespec="seconds"),
|
||||
"client": domain,
|
||||
**plan.to_dict(),
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Plan: Sprawdzenie reklam RSA i zasobow",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Reklamy RSA: {len(plan.ads)}",
|
||||
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
|
||||
"- Zmiany do wdrozenia: 0",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {item}" for item in plan.warnings)
|
||||
lines.append("")
|
||||
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
|
||||
for row in plan.scope:
|
||||
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
|
||||
lines.append("")
|
||||
lines.extend(["## Poza zakresem tego zadania", ""])
|
||||
lines.extend(f"- {item}" for item in plan.out_of_scope)
|
||||
lines.append("")
|
||||
if plan.ads:
|
||||
lines.extend(
|
||||
[
|
||||
"## Reklamy RSA",
|
||||
"",
|
||||
"| Kampania | Grupa reklam | Reklama | Naglowki | Opisy | Ryzyka |",
|
||||
"| --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for ad in plan.ads:
|
||||
lines.append(
|
||||
f"| {md_cell(ad['campaign_name'])} | {md_cell(ad['ad_group_name'])} | "
|
||||
f"{md_cell(ad['ad_id'])} | {ad['headline_count']} | {ad['description_count']} | "
|
||||
f"{md_cell(', '.join(ad['risk_labels']))} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.knowledge_rules:
|
||||
lines.extend(
|
||||
[
|
||||
"## Reguly z bazy wiedzy",
|
||||
"",
|
||||
"| ID | Temat | Rekomendacja | Ryzyko |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for rule in plan.knowledge_rules:
|
||||
lines.append(
|
||||
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
|
||||
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
|
||||
)
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def print_rsa_assets_plan(plan: RsaAssetsPlan) -> None:
|
||||
print("\nPlan sprawdzenia reklam RSA i zasobow")
|
||||
print_table(
|
||||
["Metryka", "Liczba"],
|
||||
[
|
||||
["Reklamy RSA", str(len(plan.ads))],
|
||||
["Reguly wiedzy", str(len(plan.knowledge_rules))],
|
||||
["Zmiany do wdrozenia", "0"],
|
||||
],
|
||||
)
|
||||
if plan.warnings:
|
||||
print("\nUwagi")
|
||||
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
|
||||
print("\nZakres zadania")
|
||||
print_table(
|
||||
["Nr", "Obszar", "Co sprawdzic"],
|
||||
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
|
||||
)
|
||||
print("\nPoza zakresem")
|
||||
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
|
||||
if plan.ads:
|
||||
print("\nReklamy RSA")
|
||||
print_table(
|
||||
["Nr", "Kampania", "Grupa reklam", "Naglowki", "Opisy", "Ryzyka"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
ad["campaign_name"],
|
||||
ad["ad_group_name"],
|
||||
str(ad["headline_count"]),
|
||||
str(ad["description_count"]),
|
||||
", ".join(ad["risk_labels"]),
|
||||
]
|
||||
for index, ad in enumerate(plan.ads[:30], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.ads) > 30:
|
||||
print(f"... oraz {len(plan.ads) - 30} kolejnych reklam w pliku planu")
|
||||
if plan.knowledge_rules:
|
||||
print("\nReguly z bazy wiedzy")
|
||||
print_table(
|
||||
["Nr", "ID", "Temat", "Rekomendacja"],
|
||||
[
|
||||
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
|
||||
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.knowledge_rules) > 10:
|
||||
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
|
||||
|
||||
|
||||
def print_next_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def apply_rsa_assets_plan(
|
||||
client_config: ClientConfig,
|
||||
plan: RsaAssetsPlan,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
print("\nTo zadanie jest audytem reklam RSA i nie edytuje reklam na koncie Google Ads.")
|
||||
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "audyt oznaczony jako wykonany",
|
||||
"campaign": ", ".join(sorted({ad["campaign_name"] for ad in plan.ads})[:10]),
|
||||
"summary": {
|
||||
"ads": len(plan.ads),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def run_check_rsa_assets(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
_ = global_rules
|
||||
if apply_plan_path:
|
||||
if confirm_apply != "TAK":
|
||||
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
|
||||
if plan_data.get("client") != client_config.domain:
|
||||
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan = RsaAssetsPlan.from_dict(plan_data)
|
||||
print_rsa_assets_plan(plan)
|
||||
apply_rsa_assets_plan(client_config, plan, show_navigation=show_navigation)
|
||||
return
|
||||
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print("Przygotowuje plan sprawdzenia reklam RSA i zasobow...")
|
||||
plan = build_rsa_assets_plan(client_config)
|
||||
print_rsa_assets_plan(plan)
|
||||
json_path, md_path = save_rsa_assets_plan(client_config.domain, plan)
|
||||
print(f"\nPlan JSON: {json_path}")
|
||||
print(f"Plan Markdown: {md_path}")
|
||||
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "plan przygotowany",
|
||||
"campaign": ", ".join(sorted({ad["campaign_name"] for ad in plan.ads})[:10]),
|
||||
"summary": {
|
||||
"ads": len(plan.ads),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if plan_only:
|
||||
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu reklam RSA.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
482
src/gads_v2/tasks/search_basic_settings_check.py
Normal file
482
src/gads_v2/tasks/search_basic_settings_check.py
Normal file
@@ -0,0 +1,482 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from google.protobuf import field_mask_pb2
|
||||
|
||||
from ..config import ClientConfig, client_dir
|
||||
from ..google_ads import get_google_ads_client, run_query
|
||||
from ..history import append_change_markdown, append_history, now_local
|
||||
from ..knowledge.store import rules_for_task
|
||||
from ..table import print_table
|
||||
|
||||
|
||||
TASK_ID = "check_search_basic_settings"
|
||||
TASK_NAME = "Sprawdzenie podstawowych ustawien Search"
|
||||
|
||||
|
||||
SCOPE = [
|
||||
{
|
||||
"area": "Lokalizacje",
|
||||
"check": "Sprawdz typ kierowania lokalizacji, zwlaszcza Obecnosc vs Obecnosc lub zainteresowanie.",
|
||||
},
|
||||
{
|
||||
"area": "Sieci",
|
||||
"check": "Sprawdz, czy kampanie Search nie maja niechcaco wlaczonej sieci reklamowej albo partnerow wyszukiwania.",
|
||||
},
|
||||
{
|
||||
"area": "Jezyki",
|
||||
"check": "Sprawdz, czy ustawienia jezykowe sa zgodne z rynkiem klienta.",
|
||||
},
|
||||
{
|
||||
"area": "Harmonogram reklam",
|
||||
"check": "Sprawdz, czy harmonogram jest swiadomie ustawiony albo czy kampania dziala caly czas.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
OUT_OF_SCOPE = [
|
||||
"budzety i wykorzystanie budzetu",
|
||||
"strategie stawek i uczenie strategii",
|
||||
"zapytania uzytkownikow oraz wykluczenia",
|
||||
"reklamy RSA i zasoby reklam",
|
||||
"wyniki kampanii oraz rentownosc",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchBasicSettingsPlan:
|
||||
campaigns: list[dict]
|
||||
changes: list[dict]
|
||||
scope: list[dict]
|
||||
out_of_scope: list[str]
|
||||
knowledge_rules: list[dict]
|
||||
warnings: list[str]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"task": TASK_ID,
|
||||
"task_name": TASK_NAME,
|
||||
"campaigns": self.campaigns,
|
||||
"changes": self.changes,
|
||||
"scope": self.scope,
|
||||
"out_of_scope": self.out_of_scope,
|
||||
"knowledge_rules": self.knowledge_rules,
|
||||
"warnings": self.warnings,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "SearchBasicSettingsPlan":
|
||||
return cls(
|
||||
campaigns=data.get("campaigns", []),
|
||||
changes=data.get("changes", []),
|
||||
scope=data.get("scope", []),
|
||||
out_of_scope=data.get("out_of_scope", []),
|
||||
knowledge_rules=data.get("knowledge_rules", []),
|
||||
warnings=data.get("warnings", []),
|
||||
)
|
||||
|
||||
|
||||
def enum_name(value) -> str:
|
||||
name = getattr(value, "name", None)
|
||||
if name:
|
||||
return name
|
||||
return str(value)
|
||||
|
||||
|
||||
def human_geo(value: str) -> str:
|
||||
return {
|
||||
"PRESENCE": "Obecnosc",
|
||||
"PRESENCE_OR_INTEREST": "Obecnosc lub zainteresowanie",
|
||||
"SEARCH_INTEREST": "Zainteresowanie wyszukiwaniem",
|
||||
}.get(value, value)
|
||||
|
||||
|
||||
def yes_no(value: bool) -> str:
|
||||
return "TAK" if value else "NIE"
|
||||
|
||||
|
||||
def md_cell(value) -> str:
|
||||
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
|
||||
|
||||
|
||||
def build_required_changes(campaign: dict) -> list[dict]:
|
||||
changes = []
|
||||
if campaign["positive_geo_target_type"] != "PRESENCE":
|
||||
changes.append(
|
||||
{
|
||||
"campaign_id": campaign["campaign_id"],
|
||||
"campaign_name": campaign["campaign_name"],
|
||||
"setting": "lokalizacje",
|
||||
"current_value": campaign["positive_geo_target_type"],
|
||||
"target_value": "PRESENCE",
|
||||
"current_label": campaign.get("positive_geo_target_type_label", campaign["positive_geo_target_type"]),
|
||||
"target_label": human_geo("PRESENCE"),
|
||||
"description": "Ustaw lokalizacje na Obecnosc.",
|
||||
}
|
||||
)
|
||||
if campaign["target_content_network"]:
|
||||
changes.append(
|
||||
{
|
||||
"campaign_id": campaign["campaign_id"],
|
||||
"campaign_name": campaign["campaign_name"],
|
||||
"setting": "siec reklamowa",
|
||||
"current_value": "true",
|
||||
"target_value": "false",
|
||||
"current_label": "Wlaczona",
|
||||
"target_label": "Wylaczona",
|
||||
"description": "Wylacz siec reklamowa w kampanii Search.",
|
||||
}
|
||||
)
|
||||
if campaign["target_partner_search_network"]:
|
||||
changes.append(
|
||||
{
|
||||
"campaign_id": campaign["campaign_id"],
|
||||
"campaign_name": campaign["campaign_name"],
|
||||
"setting": "partnerzy wyszukiwania",
|
||||
"current_value": "true",
|
||||
"target_value": "false",
|
||||
"current_label": "Wlaczeni",
|
||||
"target_label": "Wylaczeni",
|
||||
"description": "Wylacz partnerow wyszukiwania w kampanii Search.",
|
||||
}
|
||||
)
|
||||
return changes
|
||||
|
||||
|
||||
def fetch_search_campaigns(client_config: ClientConfig) -> list[dict]:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
rows = run_query(
|
||||
google_client,
|
||||
client_config.safe_customer_id,
|
||||
"""
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign.name,
|
||||
campaign.status,
|
||||
campaign.advertising_channel_type,
|
||||
campaign.geo_target_type_setting.positive_geo_target_type,
|
||||
campaign.network_settings.target_google_search,
|
||||
campaign.network_settings.target_search_network,
|
||||
campaign.network_settings.target_partner_search_network,
|
||||
campaign.network_settings.target_content_network
|
||||
FROM campaign
|
||||
WHERE campaign.advertising_channel_type = 'SEARCH'
|
||||
AND campaign.status != 'REMOVED'
|
||||
""",
|
||||
)
|
||||
campaigns = []
|
||||
for row in rows:
|
||||
campaign = row.campaign
|
||||
positive_geo = enum_name(campaign.geo_target_type_setting.positive_geo_target_type)
|
||||
campaigns.append(
|
||||
{
|
||||
"campaign_id": str(campaign.id),
|
||||
"campaign_name": campaign.name,
|
||||
"status": enum_name(campaign.status),
|
||||
"positive_geo_target_type": positive_geo,
|
||||
"positive_geo_target_type_label": human_geo(positive_geo),
|
||||
"target_google_search": bool(campaign.network_settings.target_google_search),
|
||||
"target_search_network": bool(campaign.network_settings.target_search_network),
|
||||
"target_partner_search_network": bool(campaign.network_settings.target_partner_search_network),
|
||||
"target_content_network": bool(campaign.network_settings.target_content_network),
|
||||
}
|
||||
)
|
||||
return campaigns
|
||||
|
||||
|
||||
def build_search_basic_settings_plan(client_config: ClientConfig) -> SearchBasicSettingsPlan:
|
||||
warnings = []
|
||||
try:
|
||||
campaigns = fetch_search_campaigns(client_config)
|
||||
except Exception as exc:
|
||||
campaigns = []
|
||||
warnings.append(f"Nie udalo sie pobrac kampanii Search z Google Ads API: {exc}")
|
||||
|
||||
if not campaigns:
|
||||
warnings.append("Nie znaleziono kampanii Search albo nie udalo sie ich pobrac.")
|
||||
|
||||
changes = []
|
||||
for campaign in campaigns:
|
||||
changes.extend(build_required_changes(campaign))
|
||||
|
||||
rules = rules_for_task(TASK_ID)
|
||||
knowledge_rules = [
|
||||
{
|
||||
"id": rule.id,
|
||||
"topic": rule.topic,
|
||||
"rule_type": rule.rule_type,
|
||||
"condition": rule.condition,
|
||||
"recommendation": rule.recommendation,
|
||||
"risk": rule.risk,
|
||||
"source": rule.source,
|
||||
}
|
||||
for rule in rules
|
||||
]
|
||||
if not knowledge_rules:
|
||||
warnings.append(
|
||||
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
|
||||
"Uzyj `python gads.py wiedza przypisz --restart`, gdy bedziemy wybierac reguly dla Search."
|
||||
)
|
||||
|
||||
return SearchBasicSettingsPlan(
|
||||
campaigns=campaigns,
|
||||
changes=changes,
|
||||
scope=SCOPE,
|
||||
out_of_scope=OUT_OF_SCOPE,
|
||||
knowledge_rules=knowledge_rules,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def save_search_basic_settings_plan(domain: str, plan: SearchBasicSettingsPlan) -> tuple[Path, Path]:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "plans"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
|
||||
json_path = base / f"{stem}.json"
|
||||
md_path = base / f"{stem}.md"
|
||||
payload = {
|
||||
"created_at": ts.isoformat(timespec="seconds"),
|
||||
"client": domain,
|
||||
**plan.to_dict(),
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Plan: Sprawdzenie podstawowych ustawien Search",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Kampanie Search: {len(plan.campaigns)}",
|
||||
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
|
||||
f"- Zmiany do wdrozenia: {len(plan.changes)}",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {item}" for item in plan.warnings)
|
||||
lines.append("")
|
||||
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
|
||||
for row in plan.scope:
|
||||
lines.append(f"| {row.get('area', '')} | {row.get('check', '')} |")
|
||||
lines.append("")
|
||||
lines.extend(["## Poza zakresem tego zadania", ""])
|
||||
lines.extend(f"- {item}" for item in plan.out_of_scope)
|
||||
lines.append("")
|
||||
if plan.campaigns:
|
||||
lines.extend(
|
||||
[
|
||||
"## Kampanie Search",
|
||||
"",
|
||||
"| Kampania | Status | Lokalizacje | Google Search | Search Network | Partnerzy | Siec reklamowa |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for campaign in plan.campaigns:
|
||||
lines.append(
|
||||
f"| {md_cell(campaign['campaign_name'])} | {campaign['status']} | "
|
||||
f"{campaign.get('positive_geo_target_type_label', campaign['positive_geo_target_type'])} | "
|
||||
f"{yes_no(campaign['target_google_search'])} | "
|
||||
f"{yes_no(campaign['target_search_network'])} | "
|
||||
f"{yes_no(campaign['target_partner_search_network'])} | "
|
||||
f"{yes_no(campaign['target_content_network'])} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.changes:
|
||||
lines.extend(
|
||||
[
|
||||
"## Planowane korekty",
|
||||
"",
|
||||
"| Kampania | Ustawienie | Obecnie | Docelowo |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for change in plan.changes:
|
||||
lines.append(
|
||||
f"| {md_cell(change['campaign_name'])} | {md_cell(change['setting'])} | "
|
||||
f"{md_cell(change.get('current_label', change['current_value']))} | "
|
||||
f"{md_cell(change.get('target_label', change['target_value']))} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.knowledge_rules:
|
||||
lines.extend(
|
||||
[
|
||||
"## Reguly z bazy wiedzy",
|
||||
"",
|
||||
"| ID | Temat | Rekomendacja | Ryzyko |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for rule in plan.knowledge_rules:
|
||||
lines.append(
|
||||
f"| {rule.get('id', '')} | {rule.get('topic', '')} | "
|
||||
f"{rule.get('recommendation', '')} | {rule.get('risk', '')} |"
|
||||
)
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def print_search_basic_settings_plan(plan: SearchBasicSettingsPlan) -> None:
|
||||
print("\nPlan sprawdzenia podstawowych ustawien Search")
|
||||
print_table(
|
||||
["Metryka", "Liczba"],
|
||||
[
|
||||
["Kampanie Search", str(len(plan.campaigns))],
|
||||
["Reguly wiedzy", str(len(plan.knowledge_rules))],
|
||||
["Zmiany do wdrozenia", str(len(plan.changes))],
|
||||
],
|
||||
)
|
||||
if plan.warnings:
|
||||
print("\nUwagi")
|
||||
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
|
||||
print("\nZakres zadania")
|
||||
print_table(
|
||||
["Nr", "Obszar", "Co sprawdzic"],
|
||||
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
|
||||
)
|
||||
print("\nPoza zakresem")
|
||||
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
|
||||
if plan.campaigns:
|
||||
print("\nKampanie Search")
|
||||
print_table(
|
||||
["Nr", "Kampania", "Status", "Lokalizacje", "Partnerzy", "Siec reklamowa"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
campaign["campaign_name"],
|
||||
campaign["status"],
|
||||
campaign.get("positive_geo_target_type_label", campaign["positive_geo_target_type"]),
|
||||
yes_no(campaign["target_partner_search_network"]),
|
||||
yes_no(campaign["target_content_network"]),
|
||||
]
|
||||
for index, campaign in enumerate(plan.campaigns, 1)
|
||||
],
|
||||
)
|
||||
if plan.changes:
|
||||
print("\nPlanowane korekty")
|
||||
print_table(
|
||||
["Nr", "Kampania", "Ustawienie", "Obecnie", "Docelowo"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
change["campaign_name"],
|
||||
change["setting"],
|
||||
change.get("current_label", change["current_value"]),
|
||||
change.get("target_label", change["target_value"]),
|
||||
]
|
||||
for index, change in enumerate(plan.changes[:30], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.changes) > 30:
|
||||
print(f"... oraz {len(plan.changes) - 30} kolejnych korekt")
|
||||
if plan.knowledge_rules:
|
||||
print("\nReguly z bazy wiedzy")
|
||||
print_table(
|
||||
["Nr", "ID", "Temat", "Rekomendacja"],
|
||||
[
|
||||
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
|
||||
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.knowledge_rules) > 10:
|
||||
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
|
||||
|
||||
|
||||
def print_next_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def apply_search_basic_settings_plan(
|
||||
client_config: ClientConfig,
|
||||
plan: SearchBasicSettingsPlan,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
customer_id = client_config.safe_customer_id
|
||||
service = google_client.get_service("CampaignService")
|
||||
|
||||
changes_by_campaign: dict[str, dict] = {}
|
||||
for change in plan.changes:
|
||||
changes_by_campaign.setdefault(
|
||||
change["campaign_id"],
|
||||
{"campaign_name": change["campaign_name"], "settings": set()},
|
||||
)
|
||||
changes_by_campaign[change["campaign_id"]]["settings"].add(change["setting"])
|
||||
|
||||
operations = []
|
||||
for campaign_id, row in changes_by_campaign.items():
|
||||
op = google_client.get_type("CampaignOperation")
|
||||
campaign = op.update
|
||||
campaign.resource_name = service.campaign_path(customer_id, campaign_id)
|
||||
paths = []
|
||||
if "lokalizacje" in row["settings"]:
|
||||
campaign.geo_target_type_setting.positive_geo_target_type = (
|
||||
google_client.enums.PositiveGeoTargetTypeEnum.PRESENCE
|
||||
)
|
||||
paths.append("geo_target_type_setting.positive_geo_target_type")
|
||||
if "siec reklamowa" in row["settings"]:
|
||||
campaign.network_settings.target_content_network = False
|
||||
paths.append("network_settings.target_content_network")
|
||||
if "partnerzy wyszukiwania" in row["settings"]:
|
||||
campaign.network_settings.target_partner_search_network = False
|
||||
paths.append("network_settings.target_partner_search_network")
|
||||
op.update_mask = field_mask_pb2.FieldMask(paths=paths)
|
||||
operations.append(op)
|
||||
|
||||
changed = 0
|
||||
if operations:
|
||||
response = service.mutate_campaigns(customer_id=customer_id, operations=operations)
|
||||
changed = len(response.results)
|
||||
|
||||
print("\nWynik wdrozenia zmian")
|
||||
print(f"Zmieniono kampanii: {changed}")
|
||||
print(f"Korekty ustawien: {len(plan.changes)}")
|
||||
|
||||
rows = [
|
||||
{
|
||||
"klient": client_config.domain,
|
||||
"kampania": change["campaign_name"],
|
||||
"czynnosc": change["description"],
|
||||
"grupa reklam": "",
|
||||
"produkt": f"{change.get('current_label', change['current_value'])} -> {change.get('target_label', change['target_value'])}",
|
||||
}
|
||||
for change in plan.changes
|
||||
]
|
||||
changes_path = append_change_markdown(client_config.domain, TASK_NAME, rows)
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "wdrozono zmiany",
|
||||
"campaign": ", ".join(sorted({change["campaign_name"] for change in plan.changes})[:10]),
|
||||
"summary": {
|
||||
"campaigns_changed": changed,
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": len(plan.changes),
|
||||
},
|
||||
},
|
||||
)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def run_check_search_basic_settings(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str |
|
||||
458
src/gads_v2/tasks/search_terms_check.py
Normal file
458
src/gads_v2/tasks/search_terms_check.py
Normal file
@@ -0,0 +1,458 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..config import ClientConfig, client_dir
|
||||
from ..google_ads import get_google_ads_client, run_query
|
||||
from ..history import append_change_markdown, append_history, now_local
|
||||
from ..knowledge.store import rules_for_task
|
||||
from ..table import print_table
|
||||
|
||||
|
||||
TASK_ID = "check_search_terms"
|
||||
TASK_NAME = "Analiza zapytan i wykluczen"
|
||||
MAX_SEARCH_TERMS = 100
|
||||
|
||||
|
||||
SCOPE = [
|
||||
{
|
||||
"area": "Zapytania 7 dni",
|
||||
"check": "Pokaz zapytania uzytkownikow z ostatnich 7 dni posortowane po koszcie.",
|
||||
},
|
||||
{
|
||||
"area": "Kandydaci do wykluczen",
|
||||
"check": "Oznacz zapytania z kliknieciami i kosztem bez konwersji jako material do recznej oceny.",
|
||||
},
|
||||
{
|
||||
"area": "Broad match i jakosc dopasowan",
|
||||
"check": "Zbierz kontekst kampanii i grup reklam, aby latwiej ocenic, czy ruch pasuje do intencji.",
|
||||
},
|
||||
{
|
||||
"area": "Brand / non-brand",
|
||||
"check": "Nie rozstrzygaj automatycznie brandu; pokaz zapytania tak, aby agent mogl ocenic intencje.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
OUT_OF_SCOPE = [
|
||||
"budzety i pacing budzetu",
|
||||
"strategie stawek oraz cele Docelowy ROAS/Docelowy CPA",
|
||||
"podstawowe ustawienia kampanii, np. lokalizacje i sieci",
|
||||
"automatyczne dodawanie wykluczen do konta",
|
||||
"ocena reklam RSA i zasobow reklam",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchTermsPlan:
|
||||
currency_code: str
|
||||
search_terms: list[dict]
|
||||
scope: list[dict]
|
||||
out_of_scope: list[str]
|
||||
knowledge_rules: list[dict]
|
||||
warnings: list[str]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"task": TASK_ID,
|
||||
"task_name": TASK_NAME,
|
||||
"currency_code": self.currency_code,
|
||||
"search_terms": self.search_terms,
|
||||
"scope": self.scope,
|
||||
"out_of_scope": self.out_of_scope,
|
||||
"knowledge_rules": self.knowledge_rules,
|
||||
"warnings": self.warnings,
|
||||
"changes": [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "SearchTermsPlan":
|
||||
return cls(
|
||||
currency_code=data.get("currency_code", ""),
|
||||
search_terms=data.get("search_terms", []),
|
||||
scope=data.get("scope", []),
|
||||
out_of_scope=data.get("out_of_scope", []),
|
||||
knowledge_rules=data.get("knowledge_rules", []),
|
||||
warnings=data.get("warnings", []),
|
||||
)
|
||||
|
||||
|
||||
def enum_name(value: Any) -> str:
|
||||
name = getattr(value, "name", None)
|
||||
if name:
|
||||
return name
|
||||
return str(value)
|
||||
|
||||
|
||||
def micros_to_amount(value: int | float) -> float:
|
||||
return round(float(value or 0) / 1_000_000, 2)
|
||||
|
||||
|
||||
def format_money(value: int | float, currency_code: str) -> str:
|
||||
suffix = f" {currency_code}" if currency_code else ""
|
||||
return f"{micros_to_amount(value):.2f}{suffix}"
|
||||
|
||||
|
||||
def format_decimal(value: int | float) -> str:
|
||||
return f"{float(value or 0):.2f}"
|
||||
|
||||
|
||||
def safe_int(value: Any) -> int:
|
||||
try:
|
||||
return int(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def safe_float(value: Any) -> float:
|
||||
try:
|
||||
return float(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def action_label(clicks: int, cost_micros: int, conversions: float) -> str:
|
||||
if clicks <= 0:
|
||||
return "brak klikniec"
|
||||
if conversions > 0:
|
||||
return "zostaw do oceny pozytywnej"
|
||||
if clicks >= 10:
|
||||
return "pilny kandydat do oceny"
|
||||
if clicks >= 5:
|
||||
return "kandydat do oceny"
|
||||
if cost_micros > 0:
|
||||
return "obserwuj"
|
||||
return "brak kosztu"
|
||||
|
||||
|
||||
def fetch_currency_code(google_client, customer_id: str) -> str:
|
||||
rows = run_query(
|
||||
google_client,
|
||||
customer_id,
|
||||
"""
|
||||
SELECT
|
||||
customer.currency_code
|
||||
FROM customer
|
||||
""",
|
||||
)
|
||||
if not rows:
|
||||
return ""
|
||||
return str(rows[0].customer.currency_code or "")
|
||||
|
||||
|
||||
def fetch_search_terms(client_config: ClientConfig) -> tuple[str, list[dict]]:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
customer_id = client_config.safe_customer_id
|
||||
currency_code = fetch_currency_code(google_client, customer_id)
|
||||
rows = run_query(
|
||||
google_client,
|
||||
customer_id,
|
||||
f"""
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign.name,
|
||||
campaign.advertising_channel_type,
|
||||
ad_group.id,
|
||||
ad_group.name,
|
||||
search_term_view.search_term,
|
||||
metrics.impressions,
|
||||
metrics.clicks,
|
||||
metrics.cost_micros,
|
||||
metrics.conversions,
|
||||
metrics.conversions_value
|
||||
FROM search_term_view
|
||||
WHERE campaign.status = 'ENABLED'
|
||||
AND ad_group.status = 'ENABLED'
|
||||
AND campaign.advertising_channel_type = 'SEARCH'
|
||||
AND segments.date DURING LAST_7_DAYS
|
||||
ORDER BY metrics.cost_micros DESC
|
||||
LIMIT {MAX_SEARCH_TERMS}
|
||||
""",
|
||||
)
|
||||
|
||||
search_terms = []
|
||||
for row in rows:
|
||||
clicks = safe_int(row.metrics.clicks)
|
||||
cost_micros = safe_int(row.metrics.cost_micros)
|
||||
conversions = safe_float(row.metrics.conversions)
|
||||
search_terms.append(
|
||||
{
|
||||
"campaign_id": str(row.campaign.id),
|
||||
"campaign_name": row.campaign.name,
|
||||
"channel_type": enum_name(row.campaign.advertising_channel_type),
|
||||
"ad_group_id": str(row.ad_group.id),
|
||||
"ad_group_name": row.ad_group.name,
|
||||
"search_term": str(row.search_term_view.search_term or ""),
|
||||
"impressions": safe_int(row.metrics.impressions),
|
||||
"clicks": clicks,
|
||||
"cost_micros": cost_micros,
|
||||
"conversions": conversions,
|
||||
"conversion_value": safe_float(row.metrics.conversions_value),
|
||||
"action_label": action_label(clicks, cost_micros, conversions),
|
||||
}
|
||||
)
|
||||
return currency_code, search_terms
|
||||
|
||||
|
||||
def build_search_terms_plan(client_config: ClientConfig) -> SearchTermsPlan:
|
||||
warnings = []
|
||||
try:
|
||||
currency_code, search_terms = fetch_search_terms(client_config)
|
||||
except Exception as exc:
|
||||
currency_code = ""
|
||||
search_terms = []
|
||||
warnings.append(f"Nie udalo sie pobrac zapytan z Google Ads API: {exc}")
|
||||
|
||||
if not search_terms:
|
||||
warnings.append("Nie znaleziono zapytan Search z ostatnich 7 dni albo nie udalo sie ich pobrac.")
|
||||
|
||||
rules = rules_for_task(TASK_ID)
|
||||
knowledge_rules = [
|
||||
{
|
||||
"id": rule.id,
|
||||
"topic": rule.topic,
|
||||
"rule_type": rule.rule_type,
|
||||
"condition": rule.condition,
|
||||
"recommendation": rule.recommendation,
|
||||
"risk": rule.risk,
|
||||
"source": rule.source,
|
||||
}
|
||||
for rule in rules
|
||||
]
|
||||
if not knowledge_rules:
|
||||
warnings.append(
|
||||
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
|
||||
"Reguly dotyczace zapytan i wykluczen bedziemy dopisywac osobno po akceptacji uzytkownika."
|
||||
)
|
||||
|
||||
return SearchTermsPlan(
|
||||
currency_code=currency_code,
|
||||
search_terms=search_terms,
|
||||
scope=SCOPE,
|
||||
out_of_scope=OUT_OF_SCOPE,
|
||||
knowledge_rules=knowledge_rules,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def save_search_terms_plan(domain: str, plan: SearchTermsPlan) -> tuple[Path, Path]:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "plans"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
|
||||
json_path = base / f"{stem}.json"
|
||||
md_path = base / f"{stem}.md"
|
||||
payload = {
|
||||
"created_at": ts.isoformat(timespec="seconds"),
|
||||
"client": domain,
|
||||
**plan.to_dict(),
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Plan: Analiza zapytan i wykluczen",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Zapytania z ostatnich 7 dni: {len(plan.search_terms)}",
|
||||
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
|
||||
"- Zmiany do wdrozenia: 0",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {item}" for item in plan.warnings)
|
||||
lines.append("")
|
||||
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
|
||||
for row in plan.scope:
|
||||
lines.append(f"| {row.get('area', '')} | {row.get('check', '')} |")
|
||||
lines.append("")
|
||||
lines.extend(["## Poza zakresem tego zadania", ""])
|
||||
lines.extend(f"- {item}" for item in plan.out_of_scope)
|
||||
lines.append("")
|
||||
if plan.search_terms:
|
||||
lines.extend(
|
||||
[
|
||||
"## Zapytania z ostatnich 7 dni",
|
||||
"",
|
||||
"| Zapytanie | Kampania | Grupa reklam | Klikniecia | Koszt | Konwersje | Ocena |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for term in plan.search_terms:
|
||||
lines.append(
|
||||
f"| {term['search_term']} | {term['campaign_name']} | {term['ad_group_name']} | "
|
||||
f"{term['clicks']} | {format_money(term['cost_micros'], plan.currency_code)} | "
|
||||
f"{format_decimal(term['conversions'])} | {term['action_label']} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.knowledge_rules:
|
||||
lines.extend(
|
||||
[
|
||||
"## Reguly z bazy wiedzy",
|
||||
"",
|
||||
"| ID | Temat | Rekomendacja | Ryzyko |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for rule in plan.knowledge_rules:
|
||||
lines.append(
|
||||
f"| {rule.get('id', '')} | {rule.get('topic', '')} | "
|
||||
f"{rule.get('recommendation', '')} | {rule.get('risk', '')} |"
|
||||
)
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def print_search_terms_plan(plan: SearchTermsPlan) -> None:
|
||||
print("\nPlan analizy zapytan i wykluczen")
|
||||
print_table(
|
||||
["Metryka", "Liczba"],
|
||||
[
|
||||
["Zapytania z 7 dni", str(len(plan.search_terms))],
|
||||
["Reguly wiedzy", str(len(plan.knowledge_rules))],
|
||||
["Zmiany do wdrozenia", "0"],
|
||||
],
|
||||
)
|
||||
if plan.warnings:
|
||||
print("\nUwagi")
|
||||
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
|
||||
print("\nZakres zadania")
|
||||
print_table(
|
||||
["Nr", "Obszar", "Co sprawdzic"],
|
||||
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
|
||||
)
|
||||
print("\nPoza zakresem")
|
||||
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
|
||||
if plan.search_terms:
|
||||
print("\nZapytania z ostatnich 7 dni")
|
||||
print_table(
|
||||
["Nr", "Zapytanie", "Kampania", "Klik.", "Koszt", "Konw.", "Ocena"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
term["search_term"],
|
||||
term["campaign_name"],
|
||||
str(term["clicks"]),
|
||||
format_money(term["cost_micros"], plan.currency_code),
|
||||
format_decimal(term["conversions"]),
|
||||
term["action_label"],
|
||||
]
|
||||
for index, term in enumerate(plan.search_terms[:30], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.search_terms) > 30:
|
||||
print(f"... oraz {len(plan.search_terms) - 30} kolejnych zapytan w pliku planu")
|
||||
if plan.knowledge_rules:
|
||||
print("\nReguly z bazy wiedzy")
|
||||
print_table(
|
||||
["Nr", "ID", "Temat", "Rekomendacja"],
|
||||
[
|
||||
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
|
||||
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.knowledge_rules) > 10:
|
||||
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
|
||||
|
||||
|
||||
def print_next_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def apply_search_terms_plan(
|
||||
client_config: ClientConfig,
|
||||
plan: SearchTermsPlan,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
print("\nTo zadanie jest audytem zapytan i nie dodaje wykluczen na koncie Google Ads.")
|
||||
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "audyt oznaczony jako wykonany",
|
||||
"campaign": ", ".join(sorted({term["campaign_name"] for term in plan.search_terms})[:10]),
|
||||
"summary": {
|
||||
"search_terms": len(plan.search_terms),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def run_check_search_terms(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
_ = global_rules
|
||||
if apply_plan_path:
|
||||
if confirm_apply != "TAK":
|
||||
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
|
||||
if plan_data.get("client") != client_config.domain:
|
||||
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan = SearchTermsPlan.from_dict(plan_data)
|
||||
print_search_terms_plan(plan)
|
||||
apply_search_terms_plan(client_config, plan, show_navigation=show_navigation)
|
||||
return
|
||||
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print("Przygotowuje plan analizy zapytan i wykluczen...")
|
||||
plan = build_search_terms_plan(client_config)
|
||||
print_search_terms_plan(plan)
|
||||
json_path, md_path = save_search_terms_plan(client_config.domain, plan)
|
||||
print(f"\nPlan JSON: {json_path}")
|
||||
print(f"Plan Markdown: {md_path}")
|
||||
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "plan przygotowany",
|
||||
"campaign": ", ".join(sorted({term["campaign_name"] for term in plan.search_terms})[:10]),
|
||||
"summary": {
|
||||
"search_terms": len(plan.search_terms),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if plan_only:
|
||||
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu zapytan.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
637
src/gads_v2/tasks/shopping_product_status_check.py
Normal file
637
src/gads_v2/tasks/shopping_product_status_check.py
Normal file
@@ -0,0 +1,637 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..config import ClientConfig, client_dir
|
||||
from ..google_ads import get_google_ads_client, run_query
|
||||
from ..history import append_change_markdown, append_history, now_local
|
||||
from ..knowledge.store import rules_for_task
|
||||
from ..table import print_table
|
||||
|
||||
|
||||
TASK_ID = "check_shopping_product_statuses"
|
||||
TASK_NAME = "Sprawdzenie statusow produktow Shopping"
|
||||
DEFAULT_PRODUCT_LIMIT = 500
|
||||
|
||||
|
||||
SCOPE = [
|
||||
{
|
||||
"area": "Status produktu",
|
||||
"check": "Pobierz statusy produktow z zasobu shopping_product w Google Ads API.",
|
||||
},
|
||||
{
|
||||
"area": "Emisja 30 dni",
|
||||
"check": "Porownaj produkty ze statusem z danymi emisji z shopping_performance_view z ostatnich 30 dni.",
|
||||
},
|
||||
{
|
||||
"area": "Produkty do oceny",
|
||||
"check": "Oznacz produkty niekwalifikujace sie albo kwalifikujace sie, ale bez wyswietlen w ostatnich 30 dniach.",
|
||||
},
|
||||
{
|
||||
"area": "Granica zadania",
|
||||
"check": "Oddziel status i emisje produktu od optymalizacji tytulow, kategorii Google, unit pricing i napraw feedu.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
OUT_OF_SCOPE = [
|
||||
"optymalizacja tytulow produktow",
|
||||
"wybor kategorii Google",
|
||||
"uzupelnianie unit pricing",
|
||||
"naprawa feedu w adsPRO albo Merchant Center",
|
||||
"zmiany stawek, budzetow i struktury kampanii Shopping/PMax",
|
||||
]
|
||||
|
||||
|
||||
PROBLEM_PRODUCT_STATUSES = {
|
||||
"DISAPPROVED",
|
||||
"NOT_ELIGIBLE",
|
||||
"LIMITED",
|
||||
"UNKNOWN",
|
||||
"UNSPECIFIED",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShoppingProductStatusPlan:
|
||||
product_limit: int
|
||||
products: list[dict]
|
||||
status_summary: list[dict]
|
||||
issue_summary: list[dict]
|
||||
problem_items: list[dict]
|
||||
scope: list[dict]
|
||||
out_of_scope: list[str]
|
||||
knowledge_rules: list[dict]
|
||||
warnings: list[str]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"task": TASK_ID,
|
||||
"task_name": TASK_NAME,
|
||||
"product_limit": self.product_limit,
|
||||
"products": self.products,
|
||||
"status_summary": self.status_summary,
|
||||
"issue_summary": self.issue_summary,
|
||||
"problem_items": self.problem_items,
|
||||
"scope": self.scope,
|
||||
"out_of_scope": self.out_of_scope,
|
||||
"knowledge_rules": self.knowledge_rules,
|
||||
"warnings": self.warnings,
|
||||
"changes": [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ShoppingProductStatusPlan":
|
||||
return cls(
|
||||
product_limit=int(data.get("product_limit") or DEFAULT_PRODUCT_LIMIT),
|
||||
products=data.get("products", []),
|
||||
status_summary=data.get("status_summary", []),
|
||||
issue_summary=data.get("issue_summary", []),
|
||||
problem_items=data.get("problem_items", []),
|
||||
scope=data.get("scope", []),
|
||||
out_of_scope=data.get("out_of_scope", []),
|
||||
knowledge_rules=data.get("knowledge_rules", []),
|
||||
warnings=data.get("warnings", []),
|
||||
)
|
||||
|
||||
|
||||
def enum_name(value: Any) -> str:
|
||||
name = getattr(value, "name", None)
|
||||
if name:
|
||||
return name
|
||||
return str(value)
|
||||
|
||||
|
||||
def md_cell(value: Any) -> str:
|
||||
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
|
||||
|
||||
|
||||
def micros_to_amount(value: int | float) -> float:
|
||||
return round(float(value or 0) / 1_000_000, 2)
|
||||
|
||||
|
||||
def format_money(value: int | float, currency_code: str) -> str:
|
||||
suffix = f" {currency_code}" if currency_code else ""
|
||||
return f"{micros_to_amount(value):.2f}{suffix}"
|
||||
|
||||
|
||||
def fetch_currency_code(google_client, customer_id: str) -> str:
|
||||
rows = run_query(
|
||||
google_client,
|
||||
customer_id,
|
||||
"""
|
||||
SELECT
|
||||
customer.currency_code
|
||||
FROM customer
|
||||
""",
|
||||
)
|
||||
if not rows:
|
||||
return ""
|
||||
return str(rows[0].customer.currency_code or "")
|
||||
|
||||
|
||||
def issue_to_dict(issue: Any) -> dict:
|
||||
code = getattr(issue, "code", "")
|
||||
severity = getattr(issue, "severity", "")
|
||||
resolution = getattr(issue, "resolution", "")
|
||||
attribute = getattr(issue, "attribute", "")
|
||||
description = getattr(issue, "description", "")
|
||||
detail = getattr(issue, "detail", "")
|
||||
return {
|
||||
"code": enum_name(code),
|
||||
"severity": enum_name(severity),
|
||||
"resolution": enum_name(resolution),
|
||||
"attribute": str(attribute or ""),
|
||||
"description": str(description or ""),
|
||||
"detail": str(detail or ""),
|
||||
}
|
||||
|
||||
|
||||
def fetch_shopping_products(client_config: ClientConfig, limit: int) -> tuple[str, list[dict]]:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
customer_id = client_config.safe_customer_id
|
||||
currency_code = fetch_currency_code(google_client, customer_id)
|
||||
rows = run_query(
|
||||
google_client,
|
||||
customer_id,
|
||||
f"""
|
||||
SELECT
|
||||
shopping_product.merchant_center_id,
|
||||
shopping_product.channel,
|
||||
shopping_product.language_code,
|
||||
shopping_product.feed_label,
|
||||
shopping_product.item_id,
|
||||
shopping_product.title,
|
||||
shopping_product.status,
|
||||
shopping_product.issues
|
||||
FROM shopping_product
|
||||
LIMIT {int(limit)}
|
||||
""",
|
||||
)
|
||||
|
||||
products = []
|
||||
for row in rows:
|
||||
product = row.shopping_product
|
||||
issues = [issue_to_dict(issue) for issue in getattr(product, "issues", [])]
|
||||
products.append(
|
||||
{
|
||||
"merchant_center_id": str(product.merchant_center_id or ""),
|
||||
"channel": enum_name(product.channel),
|
||||
"language_code": str(product.language_code or ""),
|
||||
"feed_label": str(product.feed_label or ""),
|
||||
"item_id": str(product.item_id or ""),
|
||||
"title": str(product.title or ""),
|
||||
"status": enum_name(product.status),
|
||||
"issues": issues,
|
||||
"issue_count": len(issues),
|
||||
"impressions_30d": 0,
|
||||
"clicks_30d": 0,
|
||||
"cost_30d_micros": 0,
|
||||
"conversions_30d": 0.0,
|
||||
"conversion_value_30d": 0.0,
|
||||
}
|
||||
)
|
||||
return currency_code, products
|
||||
|
||||
|
||||
def fetch_product_performance_30d(client_config: ClientConfig) -> dict[str, dict]:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
rows = run_query(
|
||||
google_client,
|
||||
client_config.safe_customer_id,
|
||||
"""
|
||||
SELECT
|
||||
segments.product_item_id,
|
||||
segments.product_title,
|
||||
metrics.impressions,
|
||||
metrics.clicks,
|
||||
metrics.cost_micros,
|
||||
metrics.conversions,
|
||||
metrics.conversions_value
|
||||
FROM shopping_performance_view
|
||||
WHERE segments.date DURING LAST_30_DAYS
|
||||
LIMIT 10000
|
||||
""",
|
||||
)
|
||||
|
||||
performance: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
item_id = str(row.segments.product_item_id or "")
|
||||
if not item_id:
|
||||
continue
|
||||
record = performance.setdefault(
|
||||
item_id,
|
||||
{
|
||||
"item_id": item_id,
|
||||
"title": str(row.segments.product_title or ""),
|
||||
"impressions_30d": 0,
|
||||
"clicks_30d": 0,
|
||||
"cost_30d_micros": 0,
|
||||
"conversions_30d": 0.0,
|
||||
"conversion_value_30d": 0.0,
|
||||
},
|
||||
)
|
||||
record["impressions_30d"] += int(row.metrics.impressions or 0)
|
||||
record["clicks_30d"] += int(row.metrics.clicks or 0)
|
||||
record["cost_30d_micros"] += int(row.metrics.cost_micros or 0)
|
||||
record["conversions_30d"] += float(row.metrics.conversions or 0)
|
||||
record["conversion_value_30d"] += float(row.metrics.conversions_value or 0)
|
||||
return performance
|
||||
|
||||
|
||||
def product_severity(product: dict) -> str:
|
||||
if product["status"] in {"DISAPPROVED", "NOT_ELIGIBLE"}:
|
||||
return "wysokie"
|
||||
if product["status"] in {"LIMITED", "UNKNOWN", "UNSPECIFIED"}:
|
||||
return "srednie"
|
||||
if product["issue_count"] > 0:
|
||||
return "srednie"
|
||||
if product["status"] == "ELIGIBLE" and product["impressions_30d"] == 0:
|
||||
return "niskie"
|
||||
return "ok"
|
||||
|
||||
|
||||
def product_flags(product: dict) -> list[str]:
|
||||
flags = []
|
||||
if product["status"] in PROBLEM_PRODUCT_STATUSES:
|
||||
flags.append(f"status: {product['status']}")
|
||||
if product["issue_count"]:
|
||||
flags.append(f"problemy MC/API: {product['issue_count']}")
|
||||
if product["status"] == "ELIGIBLE" and product["impressions_30d"] == 0:
|
||||
flags.append("brak wyswietlen 30 dni")
|
||||
return flags or ["ok"]
|
||||
|
||||
|
||||
def attach_performance(products: list[dict], performance: dict[str, dict]) -> list[dict]:
|
||||
for product in products:
|
||||
perf = performance.get(product["item_id"], {})
|
||||
product["impressions_30d"] = int(perf.get("impressions_30d", 0))
|
||||
product["clicks_30d"] = int(perf.get("clicks_30d", 0))
|
||||
product["cost_30d_micros"] = int(perf.get("cost_30d_micros", 0))
|
||||
product["conversions_30d"] = float(perf.get("conversions_30d", 0.0))
|
||||
product["conversion_value_30d"] = float(perf.get("conversion_value_30d", 0.0))
|
||||
product["severity"] = product_severity(product)
|
||||
product["flags"] = product_flags(product)
|
||||
severity_order = {"wysokie": 0, "srednie": 1, "niskie": 2, "ok": 9}
|
||||
products.sort(
|
||||
key=lambda row: (
|
||||
severity_order.get(row["severity"], 9),
|
||||
-row["impressions_30d"],
|
||||
row["title"],
|
||||
)
|
||||
)
|
||||
return products
|
||||
|
||||
|
||||
def build_status_summary(products: list[dict]) -> list[dict]:
|
||||
counter = Counter(product["status"] for product in products)
|
||||
return [{"status": key, "count": value} for key, value in counter.most_common()]
|
||||
|
||||
|
||||
def build_issue_summary(products: list[dict]) -> list[dict]:
|
||||
counter: Counter[str] = Counter()
|
||||
for product in products:
|
||||
for issue in product.get("issues", []):
|
||||
code = issue.get("code") or "(brak kodu)"
|
||||
counter[code] += 1
|
||||
return [{"issue_code": key, "count": value} for key, value in counter.most_common()]
|
||||
|
||||
|
||||
def build_problem_items(products: list[dict], currency_code: str) -> list[dict]:
|
||||
items = []
|
||||
for product in products:
|
||||
if product["flags"] == ["ok"]:
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"severity": product["severity"],
|
||||
"item_id": product["item_id"],
|
||||
"title": product["title"],
|
||||
"status": product["status"],
|
||||
"impressions_30d": product["impressions_30d"],
|
||||
"clicks_30d": product["clicks_30d"],
|
||||
"cost_30d": format_money(product["cost_30d_micros"], currency_code),
|
||||
"flags": product["flags"],
|
||||
"recommendation": "sprawdz status produktu w Merchant Center albo w diagnostyce produktow Google Ads",
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def build_shopping_product_status_plan(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
) -> ShoppingProductStatusPlan:
|
||||
rules = client_config.effective_rules(global_rules, "shopping_product_statuses")
|
||||
limit = int(rules.get("limit", DEFAULT_PRODUCT_LIMIT))
|
||||
warnings = []
|
||||
currency_code = ""
|
||||
try:
|
||||
currency_code, products = fetch_shopping_products(client_config, limit)
|
||||
except Exception as exc:
|
||||
products = []
|
||||
warnings.append(f"Nie udalo sie pobrac statusow shopping_product z Google Ads API: {exc}")
|
||||
|
||||
try:
|
||||
performance = fetch_product_performance_30d(client_config)
|
||||
except Exception as exc:
|
||||
performance = {}
|
||||
warnings.append(f"Nie udalo sie pobrac emisji produktow z shopping_performance_view: {exc}")
|
||||
|
||||
products = attach_performance(products, performance)
|
||||
if not products:
|
||||
warnings.append("Nie znaleziono produktow Shopping albo nie udalo sie ich pobrac.")
|
||||
if len(products) >= limit:
|
||||
warnings.append(f"Pobrano pierwsze {limit} produktow z Google Ads API; zwieksz limit w regulach klienta, jezeli trzeba szerszego audytu.")
|
||||
warnings.append(
|
||||
"To zadanie sprawdza status i emisje produktu. Naprawy feedu, tytulow, kategorii Google i unit pricing pozostaja w osobnych zadaniach."
|
||||
)
|
||||
|
||||
knowledge_rules = [
|
||||
{
|
||||
"id": rule.id,
|
||||
"topic": rule.topic,
|
||||
"rule_type": rule.rule_type,
|
||||
"condition": rule.condition,
|
||||
"recommendation": rule.recommendation,
|
||||
"risk": rule.risk,
|
||||
"source": rule.source,
|
||||
}
|
||||
for rule in rules_for_task(TASK_ID)
|
||||
]
|
||||
if not knowledge_rules:
|
||||
warnings.append(
|
||||
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
|
||||
"Reguly dotyczace statusow produktow Shopping bedziemy dopisywac osobno po akceptacji uzytkownika."
|
||||
)
|
||||
|
||||
return ShoppingProductStatusPlan(
|
||||
product_limit=limit,
|
||||
products=products,
|
||||
status_summary=build_status_summary(products),
|
||||
issue_summary=build_issue_summary(products),
|
||||
problem_items=build_problem_items(products, currency_code),
|
||||
scope=SCOPE,
|
||||
out_of_scope=OUT_OF_SCOPE,
|
||||
knowledge_rules=knowledge_rules,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def save_shopping_product_status_plan(domain: str, plan: ShoppingProductStatusPlan) -> tuple[Path, Path]:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "plans"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
|
||||
json_path = base / f"{stem}.json"
|
||||
md_path = base / f"{stem}.md"
|
||||
payload = {
|
||||
"created_at": ts.isoformat(timespec="seconds"),
|
||||
"client": domain,
|
||||
**plan.to_dict(),
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Plan: Sprawdzenie statusow produktow Shopping",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Produkty sprawdzone: {len(plan.products)}",
|
||||
f"- Elementy do oceny: {len(plan.problem_items)}",
|
||||
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
|
||||
"- Zmiany do wdrozenia: 0",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {item}" for item in plan.warnings)
|
||||
lines.append("")
|
||||
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
|
||||
for row in plan.scope:
|
||||
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
|
||||
lines.append("")
|
||||
lines.extend(["## Poza zakresem tego zadania", ""])
|
||||
lines.extend(f"- {item}" for item in plan.out_of_scope)
|
||||
lines.append("")
|
||||
if plan.status_summary:
|
||||
lines.extend(["## Statusy produktow", "", "| Status | Liczba |", "| --- | --- |"])
|
||||
for row in plan.status_summary:
|
||||
lines.append(f"| {row['status']} | {row['count']} |")
|
||||
lines.append("")
|
||||
if plan.issue_summary:
|
||||
lines.extend(["## Problemy produktow", "", "| Kod problemu | Liczba |", "| --- | --- |"])
|
||||
for row in plan.issue_summary:
|
||||
lines.append(f"| {md_cell(row['issue_code'])} | {row['count']} |")
|
||||
lines.append("")
|
||||
if plan.problem_items:
|
||||
lines.extend(
|
||||
[
|
||||
"## Elementy do oceny",
|
||||
"",
|
||||
"| Waznosc | Produkt | Tytul | Status | Wyswietlenia 30d | Klikniecia 30d | Koszt 30d | Flagi | Rekomendacja |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for item in plan.problem_items:
|
||||
lines.append(
|
||||
f"| {item['severity']} | {md_cell(item['item_id'])} | {md_cell(item['title'])} | {item['status']} | "
|
||||
f"{item['impressions_30d']} | {item['clicks_30d']} | {item['cost_30d']} | "
|
||||
f"{md_cell(', '.join(item['flags']))} | {md_cell(item['recommendation'])} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.products:
|
||||
lines.extend(
|
||||
[
|
||||
"## Produkty",
|
||||
"",
|
||||
"| Produkt | Tytul | Status | Jezyk | Feed label | Wyswietlenia 30d | Klikniecia 30d | Flagi |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for product in plan.products:
|
||||
lines.append(
|
||||
f"| {md_cell(product['item_id'])} | {md_cell(product['title'])} | {product['status']} | "
|
||||
f"{product['language_code']} | {md_cell(product['feed_label'])} | {product['impressions_30d']} | "
|
||||
f"{product['clicks_30d']} | {md_cell(', '.join(product['flags']))} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.knowledge_rules:
|
||||
lines.extend(
|
||||
[
|
||||
"## Reguly z bazy wiedzy",
|
||||
"",
|
||||
"| ID | Temat | Rekomendacja | Ryzyko |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for rule in plan.knowledge_rules:
|
||||
lines.append(
|
||||
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
|
||||
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
|
||||
)
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def print_shopping_product_status_plan(plan: ShoppingProductStatusPlan) -> None:
|
||||
print("\nPlan sprawdzenia statusow produktow Shopping")
|
||||
print_table(
|
||||
["Metryka", "Liczba"],
|
||||
[
|
||||
["Produkty sprawdzone", str(len(plan.products))],
|
||||
["Elementy do oceny", str(len(plan.problem_items))],
|
||||
["Reguly wiedzy", str(len(plan.knowledge_rules))],
|
||||
["Zmiany do wdrozenia", "0"],
|
||||
],
|
||||
)
|
||||
if plan.warnings:
|
||||
print("\nUwagi")
|
||||
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
|
||||
print("\nZakres zadania")
|
||||
print_table(
|
||||
["Nr", "Obszar", "Co sprawdzic"],
|
||||
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
|
||||
)
|
||||
print("\nPoza zakresem")
|
||||
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
|
||||
if plan.status_summary:
|
||||
print("\nStatusy produktow")
|
||||
print_table(["Status", "Liczba"], [[row["status"], str(row["count"])] for row in plan.status_summary])
|
||||
if plan.issue_summary:
|
||||
print("\nProblemy produktow")
|
||||
print_table(["Kod problemu", "Liczba"], [[row["issue_code"], str(row["count"])] for row in plan.issue_summary])
|
||||
if plan.problem_items:
|
||||
print("\nElementy do oceny")
|
||||
print_table(
|
||||
["Nr", "Waznosc", "Produkt", "Status", "Wysw. 30d", "Klik. 30d", "Flagi"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
item["severity"],
|
||||
item["item_id"],
|
||||
item["status"],
|
||||
str(item["impressions_30d"]),
|
||||
str(item["clicks_30d"]),
|
||||
", ".join(item["flags"]),
|
||||
]
|
||||
for index, item in enumerate(plan.problem_items[:30], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.problem_items) > 30:
|
||||
print(f"... oraz {len(plan.problem_items) - 30} kolejnych elementow w pliku planu")
|
||||
if plan.knowledge_rules:
|
||||
print("\nReguly z bazy wiedzy")
|
||||
print_table(
|
||||
["Nr", "ID", "Temat", "Rekomendacja"],
|
||||
[
|
||||
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
|
||||
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.knowledge_rules) > 10:
|
||||
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
|
||||
|
||||
|
||||
def print_next_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def apply_shopping_product_status_plan(
|
||||
client_config: ClientConfig,
|
||||
plan: ShoppingProductStatusPlan,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
print("\nTo zadanie jest audytem statusow produktow i nie wdraza zmian w Google Ads, adsPRO ani Merchant Center.")
|
||||
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "audyt oznaczony jako wykonany",
|
||||
"product": ", ".join(item["item_id"] for item in plan.problem_items[:10]),
|
||||
"summary": {
|
||||
"products": len(plan.products),
|
||||
"problem_items": len(plan.problem_items),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def run_check_shopping_product_statuses(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
if apply_plan_path:
|
||||
if confirm_apply != "TAK":
|
||||
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
|
||||
if plan_data.get("client") != client_config.domain:
|
||||
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan = ShoppingProductStatusPlan.from_dict(plan_data)
|
||||
print_shopping_product_status_plan(plan)
|
||||
apply_shopping_product_status_plan(client_config, plan, show_navigation=show_navigation)
|
||||
return
|
||||
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print("Przygotowuje plan sprawdzenia statusow produktow Shopping...")
|
||||
plan = build_shopping_product_status_plan(client_config, global_rules)
|
||||
print_shopping_product_status_plan(plan)
|
||||
json_path, md_path = save_shopping_product_status_plan(client_config.domain, plan)
|
||||
print(f"\nPlan JSON: {json_path}")
|
||||
print(f"Plan Markdown: {md_path}")
|
||||
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "plan przygotowany",
|
||||
"product": ", ".join(item["item_id"] for item in plan.problem_items[:10]),
|
||||
"summary": {
|
||||
"products": len(plan.products),
|
||||
"problem_items": len(plan.problem_items),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if plan_only:
|
||||
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu statusow produktow Shopping.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
788
src/gads_v2/tasks/shopping_troas_ag_optimization.py
Normal file
788
src/gads_v2/tasks/shopping_troas_ag_optimization.py
Normal file
@@ -0,0 +1,788 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from google.protobuf import field_mask_pb2
|
||||
|
||||
from ..config import ClientConfig, client_dir
|
||||
from ..google_ads import get_google_ads_client, run_query
|
||||
from ..history import append_change_markdown, append_history, now_local
|
||||
from ..knowledge.store import rules_for_task
|
||||
from ..table import print_table
|
||||
|
||||
|
||||
TASK_ID = "optimize_shopping_troas_ag"
|
||||
TASK_NAME = "Automatyzacja tROAS per grupa reklam PLA"
|
||||
|
||||
MIN_CLICKS_ALL_TIME = 100
|
||||
CONVERSIONS_TRIGGER = 10
|
||||
MIN_ROAS_DELTA = 1.0
|
||||
MAX_TROAS_STEP = 0.5
|
||||
|
||||
|
||||
SCOPE = [
|
||||
{
|
||||
"area": "Zakres",
|
||||
"check": "Analizuje tylko aktywne grupy reklam w aktywnych kampaniach Standard Shopping.",
|
||||
},
|
||||
{
|
||||
"area": "Dane 30 dni",
|
||||
"check": "Liczy realny ROAS grupy reklam z kosztu i wartosci konwersji z ostatnich 30 dni.",
|
||||
},
|
||||
{
|
||||
"area": "100 klikow",
|
||||
"check": "Grupy reklam z mniej niz 100 klikami od poczatku trafiaja tylko na watchliste.",
|
||||
},
|
||||
{
|
||||
"area": "Trigger 10 konwersji",
|
||||
"check": "Podbicie tROAS wymaga co najmniej 10 nowych konwersji wzgledem lokalnego baseline.",
|
||||
},
|
||||
{
|
||||
"area": "Stopniowanie",
|
||||
"check": "Jedna analiza moze podniesc tROAS grupy reklam maksymalnie o 0.5.",
|
||||
},
|
||||
{
|
||||
"area": "Rollback",
|
||||
"check": "Jesli po zmianie realny ROAS z 30 dni spada ponizej ustawionego tROAS, plan proponuje przywrocenie poprzedniej wartosci.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
OUT_OF_SCOPE = [
|
||||
"Performance Max",
|
||||
"Search",
|
||||
"kampanie Shopping bez grup reklam",
|
||||
"automatyczne wdrozenie bez akceptacji uzytkownika",
|
||||
"pauzowanie grup reklam z niskim albo zerowym ROAS",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShoppingTroasAgPlan:
|
||||
currency_code: str
|
||||
ad_groups: list[dict]
|
||||
watchlist: list[dict]
|
||||
target_changes: list[dict]
|
||||
rollback_changes: list[dict]
|
||||
scope: list[dict]
|
||||
out_of_scope: list[str]
|
||||
knowledge_rules: list[dict]
|
||||
warnings: list[str]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
changes = self.rollback_changes + self.target_changes
|
||||
return {
|
||||
"task": TASK_ID,
|
||||
"task_name": TASK_NAME,
|
||||
"currency_code": self.currency_code,
|
||||
"ad_groups": self.ad_groups,
|
||||
"watchlist": self.watchlist,
|
||||
"target_changes": self.target_changes,
|
||||
"rollback_changes": self.rollback_changes,
|
||||
"changes": changes,
|
||||
"scope": self.scope,
|
||||
"out_of_scope": self.out_of_scope,
|
||||
"knowledge_rules": self.knowledge_rules,
|
||||
"warnings": self.warnings,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ShoppingTroasAgPlan":
|
||||
return cls(
|
||||
currency_code=data.get("currency_code", ""),
|
||||
ad_groups=data.get("ad_groups", []),
|
||||
watchlist=data.get("watchlist", []),
|
||||
target_changes=data.get("target_changes", []),
|
||||
rollback_changes=data.get("rollback_changes", []),
|
||||
scope=data.get("scope", []),
|
||||
out_of_scope=data.get("out_of_scope", []),
|
||||
knowledge_rules=data.get("knowledge_rules", []),
|
||||
warnings=data.get("warnings", []),
|
||||
)
|
||||
|
||||
|
||||
def enum_name(value: Any) -> str:
|
||||
name = getattr(value, "name", None)
|
||||
if name:
|
||||
return name
|
||||
return str(value)
|
||||
|
||||
|
||||
def safe_float(value: Any) -> float:
|
||||
try:
|
||||
return float(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def safe_int(value: Any) -> int:
|
||||
try:
|
||||
return int(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def micros_to_amount(value: int | float) -> float:
|
||||
return round(float(value or 0) / 1_000_000, 2)
|
||||
|
||||
|
||||
def format_money(value: int | float, currency_code: str) -> str:
|
||||
suffix = f" {currency_code}" if currency_code else ""
|
||||
return f"{micros_to_amount(value):.2f}{suffix}"
|
||||
|
||||
|
||||
def format_decimal(value: int | float) -> str:
|
||||
return f"{float(value or 0):.2f}"
|
||||
|
||||
|
||||
def troas_label(value: int | float) -> str:
|
||||
if not value:
|
||||
return "brak override"
|
||||
return f"{float(value):.2f}"
|
||||
|
||||
|
||||
def baseline_path(domain: str) -> Path:
|
||||
return client_dir(domain) / "troas_ag_baseline.json"
|
||||
|
||||
|
||||
def load_baseline(domain: str) -> dict:
|
||||
path = baseline_path(domain)
|
||||
if not path.exists():
|
||||
return {"ad_groups": {}}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return {"ad_groups": {}}
|
||||
if not isinstance(data, dict):
|
||||
return {"ad_groups": {}}
|
||||
data.setdefault("ad_groups", {})
|
||||
return data
|
||||
|
||||
|
||||
def save_baseline(domain: str, data: dict) -> Path:
|
||||
path = baseline_path(domain)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def real_roas(cost_micros: int, conversion_value: float) -> float:
|
||||
cost = micros_to_amount(cost_micros)
|
||||
if cost <= 0:
|
||||
return 0.0
|
||||
return round(float(conversion_value or 0) / cost, 2)
|
||||
|
||||
|
||||
def effective_troas(row: dict) -> float:
|
||||
ad_group_target = safe_float(row.get("ad_group_target_roas"))
|
||||
if ad_group_target > 0:
|
||||
return ad_group_target
|
||||
return safe_float(row.get("campaign_target_roas")) or safe_float(row.get("campaign_maximize_target_roas"))
|
||||
|
||||
|
||||
def current_target_source(row: dict) -> str:
|
||||
if safe_float(row.get("ad_group_target_roas")) > 0:
|
||||
return "grupa reklam"
|
||||
if safe_float(row.get("campaign_target_roas")) > 0 or safe_float(row.get("campaign_maximize_target_roas")) > 0:
|
||||
return "kampania"
|
||||
return "brak celu"
|
||||
|
||||
|
||||
def suggested_raise(row: dict) -> dict | None:
|
||||
if safe_int(row.get("clicks_all_time")) < MIN_CLICKS_ALL_TIME:
|
||||
return None
|
||||
conversions_delta = safe_float(row.get("new_conversions_since_baseline"))
|
||||
if conversions_delta < CONVERSIONS_TRIGGER:
|
||||
return None
|
||||
current = safe_float(row.get("troas_pre"))
|
||||
actual = safe_float(row.get("real_roas_30d"))
|
||||
if current <= 0:
|
||||
return None
|
||||
if actual - current <= MIN_ROAS_DELTA:
|
||||
return None
|
||||
target = min(round(actual - 0.5, 2), round(current + MAX_TROAS_STEP, 2))
|
||||
if target <= current:
|
||||
return None
|
||||
return {
|
||||
"change_type": "raise",
|
||||
"campaign_id": row["campaign_id"],
|
||||
"campaign_name": row["campaign_name"],
|
||||
"ad_group_id": row["ad_group_id"],
|
||||
"ad_group_name": row["ad_group_name"],
|
||||
"current_troas": current,
|
||||
"target_troas": target,
|
||||
"previous_troas": current,
|
||||
"real_roas_30d": actual,
|
||||
"delta": round(actual - current, 2),
|
||||
"new_conversions_since_baseline": conversions_delta,
|
||||
"clicks_all_time": row.get("clicks_all_time", 0),
|
||||
"reason": "realny ROAS z 30 dni jest wyzszy od tROAS o ponad 1.0 i minelo co najmniej 10 nowych konwersji od baseline",
|
||||
}
|
||||
|
||||
|
||||
def suggested_rollback(row: dict) -> dict | None:
|
||||
baseline = row.get("baseline") or {}
|
||||
last_target = safe_float(baseline.get("last_target_troas"))
|
||||
previous = safe_float(baseline.get("previous_troas"))
|
||||
actual = safe_float(row.get("real_roas_30d"))
|
||||
if last_target <= 0 or previous <= 0:
|
||||
return None
|
||||
if actual >= last_target:
|
||||
return None
|
||||
return {
|
||||
"change_type": "rollback",
|
||||
"campaign_id": row["campaign_id"],
|
||||
"campaign_name": row["campaign_name"],
|
||||
"ad_group_id": row["ad_group_id"],
|
||||
"ad_group_name": row["ad_group_name"],
|
||||
"current_troas": row["troas_pre"],
|
||||
"target_troas": previous,
|
||||
"previous_troas": previous,
|
||||
"real_roas_30d": actual,
|
||||
"delta": round(actual - last_target, 2),
|
||||
"new_conversions_since_baseline": row.get("new_conversions_since_baseline", 0),
|
||||
"clicks_all_time": row.get("clicks_all_time", 0),
|
||||
"reason": "realny ROAS po zmianie jest nizszy niz ustawiony tROAS; plan proponuje przywrocenie poprzedniej wartosci",
|
||||
}
|
||||
|
||||
|
||||
def watchlist_reason(row: dict) -> str:
|
||||
clicks = safe_int(row.get("clicks_all_time"))
|
||||
actual = safe_float(row.get("real_roas_30d"))
|
||||
conversions = safe_float(row.get("conversions_30d"))
|
||||
if clicks < MIN_CLICKS_ALL_TIME and (actual <= 0 or conversions <= 0):
|
||||
return "mniej niz 100 klikow od poczatku i niski albo zerowy ROAS - obserwuj, bez akcji"
|
||||
if clicks < MIN_CLICKS_ALL_TIME:
|
||||
return "mniej niz 100 klikow od poczatku - obserwuj, bez akcji"
|
||||
return ""
|
||||
|
||||
|
||||
def fetch_currency_code(google_client, customer_id: str) -> str:
|
||||
rows = run_query(
|
||||
google_client,
|
||||
customer_id,
|
||||
"""
|
||||
SELECT
|
||||
customer.currency_code
|
||||
FROM customer
|
||||
""",
|
||||
)
|
||||
if not rows:
|
||||
return ""
|
||||
return str(rows[0].customer.currency_code or "")
|
||||
|
||||
|
||||
def fetch_all_time_clicks(google_client, customer_id: str) -> dict[str, int]:
|
||||
rows = run_query(
|
||||
google_client,
|
||||
customer_id,
|
||||
"""
|
||||
SELECT
|
||||
ad_group.id,
|
||||
metrics.clicks
|
||||
FROM ad_group
|
||||
WHERE campaign.status = 'ENABLED'
|
||||
AND ad_group.status = 'ENABLED'
|
||||
AND campaign.advertising_channel_type = 'SHOPPING'
|
||||
""",
|
||||
)
|
||||
clicks: dict[str, int] = {}
|
||||
for row in rows:
|
||||
clicks[str(row.ad_group.id)] = safe_int(row.metrics.clicks)
|
||||
return clicks
|
||||
|
||||
|
||||
def fetch_shopping_ad_groups(client_config: ClientConfig) -> tuple[str, list[dict], list[str]]:
|
||||
warnings: list[str] = []
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
customer_id = client_config.safe_customer_id
|
||||
currency_code = fetch_currency_code(google_client, customer_id)
|
||||
all_time_clicks = fetch_all_time_clicks(google_client, customer_id)
|
||||
rows = run_query(
|
||||
google_client,
|
||||
customer_id,
|
||||
"""
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign.name,
|
||||
campaign.status,
|
||||
campaign.advertising_channel_type,
|
||||
campaign.target_roas.target_roas,
|
||||
campaign.maximize_conversion_value.target_roas,
|
||||
ad_group.id,
|
||||
ad_group.name,
|
||||
ad_group.status,
|
||||
ad_group.type,
|
||||
ad_group.target_roas,
|
||||
ad_group.effective_target_roas,
|
||||
ad_group.effective_target_roas_source,
|
||||
metrics.cost_micros,
|
||||
metrics.clicks,
|
||||
metrics.conversions,
|
||||
metrics.conversions_value
|
||||
FROM ad_group
|
||||
WHERE campaign.status = 'ENABLED'
|
||||
AND ad_group.status = 'ENABLED'
|
||||
AND campaign.advertising_channel_type = 'SHOPPING'
|
||||
AND segments.date DURING LAST_30_DAYS
|
||||
""",
|
||||
)
|
||||
|
||||
records = []
|
||||
for row in rows:
|
||||
ad_group = row.ad_group
|
||||
campaign = row.campaign
|
||||
ad_group_id = str(ad_group.id)
|
||||
cost_micros = safe_int(row.metrics.cost_micros)
|
||||
conversion_value = safe_float(row.metrics.conversions_value)
|
||||
record = {
|
||||
"campaign_id": str(campaign.id),
|
||||
"campaign_name": campaign.name,
|
||||
"campaign_status": enum_name(campaign.status),
|
||||
"channel_type": enum_name(campaign.advertising_channel_type),
|
||||
"campaign_target_roas": safe_float(campaign.target_roas.target_roas),
|
||||
"campaign_maximize_target_roas": safe_float(campaign.maximize_conversion_value.target_roas),
|
||||
"ad_group_id": ad_group_id,
|
||||
"ad_group_name": ad_group.name,
|
||||
"ad_group_status": enum_name(ad_group.status),
|
||||
"ad_group_type": enum_name(ad_group.type),
|
||||
"ad_group_target_roas": safe_float(ad_group.target_roas),
|
||||
"ad_group_effective_target_roas": safe_float(ad_group.effective_target_roas),
|
||||
"ad_group_effective_target_roas_source": enum_name(ad_group.effective_target_roas_source),
|
||||
"cost_30d_micros": cost_micros,
|
||||
"clicks_30d": safe_int(row.metrics.clicks),
|
||||
"clicks_all_time": all_time_clicks.get(ad_group_id, 0),
|
||||
"conversions_30d": round(safe_float(row.metrics.conversions), 2),
|
||||
"conversion_value_30d": round(conversion_value, 2),
|
||||
"real_roas_30d": real_roas(cost_micros, conversion_value),
|
||||
}
|
||||
record["troas_pre"] = effective_troas(record)
|
||||
record["troas_source"] = current_target_source(record)
|
||||
records.append(record)
|
||||
if not records:
|
||||
warnings.append("Nie znaleziono aktywnych grup reklam w aktywnych kampaniach Standard Shopping z danymi z ostatnich 30 dni.")
|
||||
return currency_code, records, warnings
|
||||
|
||||
|
||||
def build_shopping_troas_ag_plan(client_config: ClientConfig) -> ShoppingTroasAgPlan:
|
||||
warnings: list[str] = []
|
||||
try:
|
||||
currency_code, ad_groups, fetch_warnings = fetch_shopping_ad_groups(client_config)
|
||||
warnings.extend(fetch_warnings)
|
||||
except Exception as exc:
|
||||
currency_code = ""
|
||||
ad_groups = []
|
||||
warnings.append(f"Nie udalo sie pobrac grup reklam Shopping z Google Ads API: {exc}")
|
||||
|
||||
baseline = load_baseline(client_config.domain)
|
||||
baseline_rows = baseline.get("ad_groups", {})
|
||||
watchlist: list[dict] = []
|
||||
target_changes: list[dict] = []
|
||||
rollback_changes: list[dict] = []
|
||||
|
||||
for row in ad_groups:
|
||||
stored = baseline_rows.get(row["ad_group_id"], {})
|
||||
row["baseline"] = stored
|
||||
baseline_conversions = safe_float(stored.get("baseline_conversions_30d"))
|
||||
row["baseline_conversions_30d"] = baseline_conversions
|
||||
row["baseline_known"] = bool(stored)
|
||||
row["new_conversions_since_baseline"] = round(max(0.0, row["conversions_30d"] - baseline_conversions), 2)
|
||||
row["watchlist_reason"] = watchlist_reason(row)
|
||||
if row["watchlist_reason"]:
|
||||
watchlist.append(row)
|
||||
continue
|
||||
rollback = suggested_rollback(row)
|
||||
if rollback:
|
||||
rollback_changes.append(rollback)
|
||||
continue
|
||||
change = suggested_raise(row)
|
||||
if change:
|
||||
target_changes.append(change)
|
||||
|
||||
ad_groups.sort(key=lambda item: (item["campaign_name"], item["ad_group_name"]))
|
||||
watchlist.sort(
|
||||
key=lambda item: (
|
||||
-safe_int(item["clicks_all_time"]),
|
||||
-safe_float(item["conversions_30d"]),
|
||||
item["campaign_name"],
|
||||
item["ad_group_name"],
|
||||
)
|
||||
)
|
||||
target_changes.sort(key=lambda item: (-item["delta"], item["campaign_name"], item["ad_group_name"]))
|
||||
rollback_changes.sort(key=lambda item: (item["campaign_name"], item["ad_group_name"]))
|
||||
|
||||
rules = rules_for_task(TASK_ID)
|
||||
knowledge_rules = [
|
||||
{
|
||||
"id": rule.id,
|
||||
"topic": rule.topic,
|
||||
"rule_type": rule.rule_type,
|
||||
"condition": rule.condition,
|
||||
"recommendation": rule.recommendation,
|
||||
"risk": rule.risk,
|
||||
"source": rule.source,
|
||||
}
|
||||
for rule in rules
|
||||
]
|
||||
|
||||
if not knowledge_rules:
|
||||
warnings.append("Do tego zadania nie przypisano jeszcze regul z bazy wiedzy.")
|
||||
|
||||
return ShoppingTroasAgPlan(
|
||||
currency_code=currency_code,
|
||||
ad_groups=ad_groups,
|
||||
watchlist=watchlist,
|
||||
target_changes=target_changes,
|
||||
rollback_changes=rollback_changes,
|
||||
scope=SCOPE,
|
||||
out_of_scope=OUT_OF_SCOPE,
|
||||
knowledge_rules=knowledge_rules,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def save_shopping_troas_ag_plan(domain: str, plan: ShoppingTroasAgPlan) -> tuple[Path, Path]:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "plans"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
|
||||
json_path = base / f"{stem}.json"
|
||||
md_path = base / f"{stem}.md"
|
||||
payload = {
|
||||
"created_at": ts.isoformat(timespec="seconds"),
|
||||
"client": domain,
|
||||
**plan.to_dict(),
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Plan: Automatyzacja tROAS per grupa reklam PLA",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Grupy reklam Shopping z danymi 30 dni: {len(plan.ad_groups)}",
|
||||
f"- Kandydaci do podniesienia tROAS: {len(plan.target_changes)}",
|
||||
f"- Kandydaci do rollbacku: {len(plan.rollback_changes)}",
|
||||
f"- Watchlista bez akcji: {len(plan.watchlist)}",
|
||||
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {item}" for item in plan.warnings)
|
||||
lines.append("")
|
||||
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
|
||||
for row in plan.scope:
|
||||
lines.append(f"| {row.get('area', '')} | {row.get('check', '')} |")
|
||||
lines.append("")
|
||||
if plan.target_changes:
|
||||
lines.extend(
|
||||
[
|
||||
"## Kandydaci do podniesienia tROAS",
|
||||
"",
|
||||
"| Kampania | Grupa reklam | tROAS pre | Real ROAS | Delta | Nowy tROAS | Nowe konwersje | Klikniecia all-time |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for change in plan.target_changes:
|
||||
lines.append(
|
||||
f"| {change['campaign_name']} | {change['ad_group_name']} | "
|
||||
f"{format_decimal(change['current_troas'])} | {format_decimal(change['real_roas_30d'])} | "
|
||||
f"{format_decimal(change['delta'])} | {format_decimal(change['target_troas'])} | "
|
||||
f"{format_decimal(change['new_conversions_since_baseline'])} | {change['clicks_all_time']} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.rollback_changes:
|
||||
lines.extend(
|
||||
[
|
||||
"## Kandydaci do rollbacku",
|
||||
"",
|
||||
"| Kampania | Grupa reklam | Obecny tROAS | Real ROAS | Przywroc tROAS | Powod |",
|
||||
"| --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for change in plan.rollback_changes:
|
||||
lines.append(
|
||||
f"| {change['campaign_name']} | {change['ad_group_name']} | "
|
||||
f"{format_decimal(change['current_troas'])} | {format_decimal(change['real_roas_30d'])} | "
|
||||
f"{format_decimal(change['target_troas'])} | {change['reason']} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.watchlist:
|
||||
lines.extend(
|
||||
[
|
||||
"## Watchlista bez akcji",
|
||||
"",
|
||||
"| Kampania | Grupa reklam | Klikniecia all-time | Konwersje 30 dni | Real ROAS | Powod |",
|
||||
"| --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for row in plan.watchlist:
|
||||
lines.append(
|
||||
f"| {row['campaign_name']} | {row['ad_group_name']} | {row['clicks_all_time']} | "
|
||||
f"{format_decimal(row['conversions_30d'])} | {format_decimal(row['real_roas_30d'])} | "
|
||||
f"{row['watchlist_reason']} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.knowledge_rules:
|
||||
lines.extend(
|
||||
[
|
||||
"## Reguly z bazy wiedzy",
|
||||
"",
|
||||
"| ID | Temat | Rekomendacja | Ryzyko |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for rule in plan.knowledge_rules:
|
||||
lines.append(
|
||||
f"| {rule.get('id', '')} | {rule.get('topic', '')} | "
|
||||
f"{rule.get('recommendation', '')} | {rule.get('risk', '')} |"
|
||||
)
|
||||
lines.append("")
|
||||
lines.extend(["## Poza zakresem tego zadania", ""])
|
||||
lines.extend(f"- {item}" for item in plan.out_of_scope)
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def print_shopping_troas_ag_plan(plan: ShoppingTroasAgPlan) -> None:
|
||||
print("\nPlan automatyzacji tROAS per grupa reklam PLA")
|
||||
print_table(
|
||||
["Metryka", "Liczba"],
|
||||
[
|
||||
["Grupy reklam Shopping z danymi 30 dni", str(len(plan.ad_groups))],
|
||||
["Kandydaci do podniesienia tROAS", str(len(plan.target_changes))],
|
||||
["Kandydaci do rollbacku", str(len(plan.rollback_changes))],
|
||||
["Watchlista bez akcji", str(len(plan.watchlist))],
|
||||
["Reguly wiedzy", str(len(plan.knowledge_rules))],
|
||||
],
|
||||
)
|
||||
if plan.warnings:
|
||||
print("\nUwagi")
|
||||
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
|
||||
print("\nZakres zadania")
|
||||
print_table(
|
||||
["Nr", "Obszar", "Co sprawdzic"],
|
||||
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
|
||||
)
|
||||
if plan.target_changes:
|
||||
print("\nKandydaci do podniesienia tROAS")
|
||||
print_table(
|
||||
["Nr", "Kampania", "Grupa reklam", "tROAS pre", "Real ROAS", "Delta", "Nowy tROAS", "Nowe konw."],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
change["campaign_name"],
|
||||
change["ad_group_name"],
|
||||
format_decimal(change["current_troas"]),
|
||||
format_decimal(change["real_roas_30d"]),
|
||||
format_decimal(change["delta"]),
|
||||
format_decimal(change["target_troas"]),
|
||||
format_decimal(change["new_conversions_since_baseline"]),
|
||||
]
|
||||
for index, change in enumerate(plan.target_changes, 1)
|
||||
],
|
||||
)
|
||||
if plan.rollback_changes:
|
||||
print("\nKandydaci do rollbacku")
|
||||
print_table(
|
||||
["Nr", "Kampania", "Grupa reklam", "Obecny tROAS", "Real ROAS", "Przywroc", "Powod"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
change["campaign_name"],
|
||||
change["ad_group_name"],
|
||||
format_decimal(change["current_troas"]),
|
||||
format_decimal(change["real_roas_30d"]),
|
||||
format_decimal(change["target_troas"]),
|
||||
change["reason"],
|
||||
]
|
||||
for index, change in enumerate(plan.rollback_changes, 1)
|
||||
],
|
||||
)
|
||||
if plan.watchlist:
|
||||
print("\nWatchlista bez akcji")
|
||||
print_table(
|
||||
["Nr", "Kampania", "Grupa reklam", "Klikniecia", "Konw. 30d", "ROAS", "Powod"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
row["campaign_name"],
|
||||
row["ad_group_name"],
|
||||
str(row["clicks_all_time"]),
|
||||
format_decimal(row["conversions_30d"]),
|
||||
format_decimal(row["real_roas_30d"]),
|
||||
row["watchlist_reason"],
|
||||
]
|
||||
for index, row in enumerate(plan.watchlist, 1)
|
||||
],
|
||||
)
|
||||
if plan.knowledge_rules:
|
||||
print("\nReguly z bazy wiedzy")
|
||||
print_table(
|
||||
["Nr", "ID", "Temat", "Rekomendacja"],
|
||||
[
|
||||
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
|
||||
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def apply_troas_changes(client_config: ClientConfig, plan: ShoppingTroasAgPlan, show_navigation: bool = True) -> None:
|
||||
changes = plan.rollback_changes + plan.target_changes
|
||||
changed = 0
|
||||
errors: list[str] = []
|
||||
if changes:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
customer_id = client_config.safe_customer_id
|
||||
service = google_client.get_service("AdGroupService")
|
||||
operations = []
|
||||
for change in changes:
|
||||
op = google_client.get_type("AdGroupOperation")
|
||||
ad_group = op.update
|
||||
ad_group.resource_name = service.ad_group_path(customer_id, change["ad_group_id"])
|
||||
ad_group.target_roas = float(change["target_troas"])
|
||||
op.update_mask = field_mask_pb2.FieldMask(paths=["target_roas"])
|
||||
operations.append(op)
|
||||
try:
|
||||
response = service.mutate_ad_groups(customer_id=customer_id, operations=operations)
|
||||
changed = len(response.results)
|
||||
except Exception as exc:
|
||||
errors.append(str(exc))
|
||||
|
||||
print("\nWynik wdrozenia zmian tROAS grup reklam")
|
||||
print(f"Zmieniono grup reklam: {changed}")
|
||||
print(f"Bledy: {len(errors)}")
|
||||
for error in errors:
|
||||
print(f"Blad: {error}")
|
||||
|
||||
if changed and not errors:
|
||||
baseline = load_baseline(client_config.domain)
|
||||
baseline_rows = baseline.setdefault("ad_groups", {})
|
||||
ts = now_local().isoformat(timespec="seconds")
|
||||
ad_groups_by_id = {row["ad_group_id"]: row for row in plan.ad_groups}
|
||||
for change in changes:
|
||||
source = ad_groups_by_id.get(change["ad_group_id"], {})
|
||||
baseline_rows[change["ad_group_id"]] = {
|
||||
"updated_at": ts,
|
||||
"campaign_id": change["campaign_id"],
|
||||
"campaign_name": change["campaign_name"],
|
||||
"ad_group_id": change["ad_group_id"],
|
||||
"ad_group_name": change["ad_group_name"],
|
||||
"previous_troas": change["current_troas"],
|
||||
"last_target_troas": change["target_troas"],
|
||||
"baseline_conversions_30d": source.get("conversions_30d", 0),
|
||||
"baseline_real_roas_30d": source.get("real_roas_30d", 0),
|
||||
"change_type": change["change_type"],
|
||||
}
|
||||
baseline_file = save_baseline(client_config.domain, baseline)
|
||||
print(f"Baseline tROAS AG: {baseline_file}")
|
||||
|
||||
rows = [
|
||||
{
|
||||
"klient": client_config.domain,
|
||||
"kampania": change.get("campaign_name", ""),
|
||||
"grupa reklam": change.get("ad_group_name", ""),
|
||||
"czynnosc": "Rollback tROAS" if change.get("change_type") == "rollback" else "Podniesienie tROAS",
|
||||
"produkt": f"{format_decimal(change.get('current_troas', 0))} -> {format_decimal(change.get('target_troas', 0))}",
|
||||
}
|
||||
for change in changes
|
||||
]
|
||||
changes_path = append_change_markdown(client_config.domain, TASK_NAME, rows)
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "wdrozono zmiany tROAS AG" if changes and not errors else "audyt oznaczony jako wykonany",
|
||||
"campaign": ", ".join(change.get("campaign_name", "") for change in changes[:10]),
|
||||
"summary": {
|
||||
"ad_groups": len(plan.ad_groups),
|
||||
"target_changes": len(plan.target_changes),
|
||||
"rollback_changes": len(plan.rollback_changes),
|
||||
"changed": changed,
|
||||
"errors": len(errors),
|
||||
},
|
||||
},
|
||||
)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def print_next_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def run_optimize_shopping_troas_ag(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
_ = global_rules
|
||||
if apply_plan_path:
|
||||
if confirm_apply != "TAK":
|
||||
print("Do wdrozenia planu wymagane jest --confirm-apply TAK.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
|
||||
if plan_data.get("client") != client_config.domain:
|
||||
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan = ShoppingTroasAgPlan.from_dict(plan_data)
|
||||
print_shopping_troas_ag_plan(plan)
|
||||
apply_troas_changes(client_config, plan, show_navigation=show_navigation)
|
||||
return
|
||||
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print("Przygotowuje plan automatyzacji tROAS per grupa reklam PLA...")
|
||||
plan = build_shopping_troas_ag_plan(client_config)
|
||||
print_shopping_troas_ag_plan(plan)
|
||||
json_path, md_path = save_shopping_troas_ag_plan(client_config.domain, plan)
|
||||
print(f"\nPlan JSON: {json_path}")
|
||||
print(f"Plan Markdown: {md_path}")
|
||||
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "plan przygotowany",
|
||||
"campaign": ", ".join(change["campaign_name"] for change in (plan.rollback_changes + plan.target_changes)[:10]),
|
||||
"summary": {
|
||||
"ad_groups": len(plan.ad_groups),
|
||||
"target_changes": len(plan.target_changes),
|
||||
"rollback_changes": len(plan.rollback_changes),
|
||||
"watchlist": len(plan.watchlist),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if plan_only:
|
||||
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
print("\nBrak automatycznego wdrozenia. Uzyj zapisanego planu i potwierdzenia, aby wdrozyc zmiany tROAS.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
Reference in New Issue
Block a user