update
This commit is contained in:
@@ -39,6 +39,7 @@ from .table import print_table
|
||||
from .task_catalog import (
|
||||
load_groups,
|
||||
load_tasks,
|
||||
print_compact_task_list,
|
||||
print_task_list,
|
||||
task_by_number,
|
||||
task_by_selection,
|
||||
@@ -74,6 +75,7 @@ from .tasks.product_feed_optimization import (
|
||||
run_optimize_product_feed,
|
||||
run_optimize_product_titles,
|
||||
)
|
||||
from .tasks.product_availability_check import run_check_product_availability
|
||||
from .tasks.additional_audits import (
|
||||
run_check_ad_group_performance,
|
||||
run_check_age_performance,
|
||||
@@ -147,6 +149,7 @@ def main() -> None:
|
||||
"optimize_product_titles",
|
||||
"optimize_product_categories",
|
||||
"fill_product_unit_pricing",
|
||||
"check_product_availability",
|
||||
"check_conversion_tracking",
|
||||
"check_search_basic_settings",
|
||||
"check_budget_usage",
|
||||
@@ -407,7 +410,7 @@ def main() -> None:
|
||||
|
||||
if not args.select and not args.task and not args.task_number and not args.group_number and not args.all_groups:
|
||||
print("\nWybierz zadanie do uruchomienia dla wszystkich klientow:")
|
||||
print_task_list(tasks)
|
||||
print_compact_task_list(tasks)
|
||||
print("\nNastepny krok:")
|
||||
print("python gads.py analiza-zadania --select <nr>")
|
||||
print("\nTa komenda przygotuje plany po kolei dla wszystkich klientow z config/clients.toml.")
|
||||
@@ -450,6 +453,7 @@ def main() -> None:
|
||||
cfg,
|
||||
domains,
|
||||
plan_only=True,
|
||||
pause_after_client=True,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -1393,6 +1397,16 @@ def run_task(
|
||||
show_navigation=show_navigation,
|
||||
)
|
||||
return
|
||||
if task_id == "check_product_availability":
|
||||
run_check_product_availability(
|
||||
client,
|
||||
global_rules,
|
||||
plan_only=plan_only,
|
||||
apply_plan_path=apply_plan_path,
|
||||
confirm_apply=confirm_apply,
|
||||
show_navigation=show_navigation,
|
||||
)
|
||||
return
|
||||
if task_id == "check_conversion_tracking":
|
||||
run_check_conversion_tracking(
|
||||
client,
|
||||
@@ -1712,7 +1726,31 @@ def run_task_sequence(tasks, client, global_rules, plan_only: bool = False) -> N
|
||||
print_sequence_navigation(client.domain)
|
||||
|
||||
|
||||
def run_tasks_for_all_clients(tasks, cfg, domains: list[str], plan_only: bool = True) -> None:
|
||||
def prompt_after_client(domain: str, has_next: bool) -> bool:
|
||||
if not has_next:
|
||||
return True
|
||||
print()
|
||||
print("Co dalej po tym kliencie?")
|
||||
print("Enter -> nastepny klient")
|
||||
print("q -> zakoncz tryb analiza-zadania")
|
||||
try:
|
||||
answer = input("Wybor: ").strip().lower()
|
||||
except EOFError:
|
||||
print()
|
||||
return True
|
||||
if answer in {"q", "quit", "koniec", "3"}:
|
||||
print(f"Przerwano po kliencie {domain}.")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def run_tasks_for_all_clients(
|
||||
tasks,
|
||||
cfg,
|
||||
domains: list[str],
|
||||
plan_only: bool = True,
|
||||
pause_after_client: bool = True,
|
||||
) -> None:
|
||||
total_clients = len(domains)
|
||||
total_tasks = len(tasks)
|
||||
print("\nTryb analiza-zadania")
|
||||
@@ -1724,17 +1762,18 @@ def run_tasks_for_all_clients(tasks, cfg, domains: list[str], plan_only: bool =
|
||||
["Klienci", str(total_clients)],
|
||||
["Zadania", str(total_tasks)],
|
||||
["Tryb", "plan-only"],
|
||||
["Pauza po kliencie", "TAK" if pause_after_client else "NIE"],
|
||||
],
|
||||
)
|
||||
for task_index, task in enumerate(tasks, 1):
|
||||
for client_index, domain in enumerate(domains, 1):
|
||||
print()
|
||||
print("#" * 72)
|
||||
print(f"Zadanie {task_index}/{total_tasks}: {task.group_name} / {task.name}")
|
||||
print(f"Klient {client_index}/{total_clients}: {domain}")
|
||||
print("#" * 72)
|
||||
for client_index, domain in enumerate(domains, 1):
|
||||
for task_index, task in enumerate(tasks, 1):
|
||||
print()
|
||||
print("-" * 72)
|
||||
print(f"Klient {client_index}/{total_clients}: {domain}")
|
||||
print(f"Zadanie {task_index}/{total_tasks}: {task.group_name} / {task.name}")
|
||||
print("-" * 72)
|
||||
try:
|
||||
run_task(
|
||||
@@ -1745,7 +1784,18 @@ def run_tasks_for_all_clients(tasks, cfg, domains: list[str], plan_only: bool =
|
||||
show_navigation=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"Nie udalo sie przygotowac planu dla klienta {domain}: {exc}")
|
||||
print(f"Nie udalo sie przygotowac planu {task.name} dla klienta {domain}: {exc}")
|
||||
if pause_after_client and not prompt_after_client(domain, client_index < total_clients):
|
||||
print()
|
||||
print("Zakonczono tryb analiza-zadania przed przejsciem do kolejnych klientow.")
|
||||
print("\nCo dalej:")
|
||||
print("1. Lista zadan")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print("1 -> python gads.py analiza-zadania")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
return
|
||||
print()
|
||||
print("Zakonczono tryb analiza-zadania.")
|
||||
print("\nCo dalej:")
|
||||
|
||||
@@ -124,3 +124,22 @@ def print_task_list(tasks: list[Task]) -> None:
|
||||
["Nr", "Zakres"],
|
||||
group_rows + [["ALL", "Wszystkie zadania ze wszystkich grup"]],
|
||||
)
|
||||
|
||||
|
||||
def print_compact_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(f"GRUPA {group.number}: {group.name}")
|
||||
for item in group_tasks:
|
||||
print(f" {item.selection} {item.name}")
|
||||
|
||||
print()
|
||||
print("Opcje zbiorcze")
|
||||
for group in groups:
|
||||
if any(task.group_id == group.id for task in tasks):
|
||||
print(f" {group.number}.0 Wszystkie zadania z grupy: {group.name}")
|
||||
print(" ALL Wszystkie zadania ze wszystkich grup")
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
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
|
||||
@@ -16,30 +21,47 @@ 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": "Akcje konwersji",
|
||||
"check": "Pobierz akcje konwersji z Google Ads, ich status, typ, kategorie i ustawienie podstawowa/dodatkowa.",
|
||||
},
|
||||
{
|
||||
"area": "Duplikacja konwersji",
|
||||
"check": "Sprawdz, czy konto nie liczy tych samych zdarzen jednoczesnie z Google Ads, GA4 i importow.",
|
||||
"area": "Dane 30 dni",
|
||||
"check": "Sprawdz, czy akcje konwersji zbieraja konwersje, wartosc konwersji i wszystkie konwersje z ostatnich 30 dni.",
|
||||
},
|
||||
{
|
||||
"area": "E-commerce",
|
||||
"check": "Sprawdz, czy konwersje zakupowe przekazuja wartosc i walute.",
|
||||
"area": "Wartosc konwersji",
|
||||
"check": "Oznacz konwersje zakupowe i e-commerce bez wartosci albo bez waluty jako problem do poprawy pomiaru.",
|
||||
},
|
||||
{
|
||||
"area": "Remarketing dynamiczny",
|
||||
"check": "Sprawdz, czy tagowanie e-commerce przekazuje identyfikatory produktow.",
|
||||
"area": "Jakosc optymalizacji",
|
||||
"check": "Oznacz brak aktywnych konwersji podstawowych, brak danych 30 dni oraz podejrzana duplikacje podobnych akcji.",
|
||||
},
|
||||
{
|
||||
"area": "Enhanced Conversions",
|
||||
"check": "Sprawdz, czy rozszerzone konwersje sa skonfigurowane tam, gdzie ma to sens.",
|
||||
"area": "Rekomendacje",
|
||||
"check": "Przygotuj rekomendacje decyzyjne do konfiguracji pomiaru; skrypt nie wdraza zmian automatycznie.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
ECOMMERCE_CATEGORIES = {"PURCHASE", "STORE_SALE", "ADD_TO_CART", "BEGIN_CHECKOUT"}
|
||||
NO_DYNAMIC_VALUE_TYPES = {"AD_CALL", "WEBSITE_CALL"}
|
||||
LEAD_CATEGORIES = {
|
||||
"SUBMIT_LEAD_FORM",
|
||||
"CONTACT",
|
||||
"REQUEST_QUOTE",
|
||||
"BOOK_APPOINTMENT",
|
||||
"PHONE_CALL_LEAD",
|
||||
"IMPORTED_LEAD",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversionTrackingPlan:
|
||||
currency_code: str
|
||||
conversion_actions: list[dict]
|
||||
action_summary: list[dict]
|
||||
findings: list[dict]
|
||||
changes: list[dict]
|
||||
scope: list[dict]
|
||||
knowledge_rules: list[dict]
|
||||
warnings: list[str]
|
||||
@@ -48,23 +70,410 @@ class ConversionTrackingPlan:
|
||||
return {
|
||||
"task": TASK_ID,
|
||||
"task_name": TASK_NAME,
|
||||
"currency_code": self.currency_code,
|
||||
"conversion_actions": self.conversion_actions,
|
||||
"action_summary": self.action_summary,
|
||||
"findings": self.findings,
|
||||
"changes": self.changes,
|
||||
"scope": self.scope,
|
||||
"knowledge_rules": self.knowledge_rules,
|
||||
"warnings": self.warnings,
|
||||
"changes": [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ConversionTrackingPlan":
|
||||
return cls(
|
||||
currency_code=data.get("currency_code", ""),
|
||||
conversion_actions=data.get("conversion_actions", []),
|
||||
action_summary=data.get("action_summary", []),
|
||||
findings=data.get("findings", []),
|
||||
changes=data.get("changes", []),
|
||||
scope=data.get("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 or "")
|
||||
|
||||
|
||||
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 format_decimal(value: Any) -> str:
|
||||
return f"{safe_float(value):.2f}"
|
||||
|
||||
|
||||
def format_money(value: Any, currency_code: str) -> str:
|
||||
suffix = f" {currency_code}" if currency_code else ""
|
||||
return f"{safe_float(value):.2f}{suffix}"
|
||||
|
||||
|
||||
def md_cell(value: Any) -> str:
|
||||
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
|
||||
|
||||
|
||||
def bool_label(value: bool) -> str:
|
||||
return "TAK" if value else "NIE"
|
||||
|
||||
|
||||
def get_attr(obj: Any, path: str, default: Any = "") -> Any:
|
||||
current = obj
|
||||
for part in path.split("."):
|
||||
try:
|
||||
current = getattr(current, part)
|
||||
except Exception:
|
||||
return default
|
||||
if current is None:
|
||||
return default
|
||||
return current
|
||||
|
||||
|
||||
def fetch_customer_currency(client_config: ClientConfig, google_client) -> str:
|
||||
rows = run_query(
|
||||
google_client,
|
||||
client_config.safe_customer_id,
|
||||
"""
|
||||
SELECT
|
||||
customer.currency_code
|
||||
FROM customer
|
||||
LIMIT 1
|
||||
""",
|
||||
)
|
||||
for row in rows:
|
||||
return str(row.customer.currency_code or "")
|
||||
return ""
|
||||
|
||||
|
||||
def fetch_conversion_actions(client_config: ClientConfig, google_client) -> list[dict]:
|
||||
rows = run_query(
|
||||
google_client,
|
||||
client_config.safe_customer_id,
|
||||
"""
|
||||
SELECT
|
||||
conversion_action.id,
|
||||
conversion_action.resource_name,
|
||||
conversion_action.name,
|
||||
conversion_action.status,
|
||||
conversion_action.type,
|
||||
conversion_action.category,
|
||||
conversion_action.primary_for_goal,
|
||||
conversion_action.include_in_conversions_metric,
|
||||
conversion_action.counting_type,
|
||||
conversion_action.value_settings.default_value,
|
||||
conversion_action.value_settings.default_currency_code,
|
||||
conversion_action.value_settings.always_use_default_value,
|
||||
conversion_action.click_through_lookback_window_days,
|
||||
conversion_action.view_through_lookback_window_days,
|
||||
conversion_action.attribution_model_settings.attribution_model
|
||||
FROM conversion_action
|
||||
WHERE conversion_action.status != 'REMOVED'
|
||||
""",
|
||||
)
|
||||
actions = []
|
||||
for row in rows:
|
||||
action = row.conversion_action
|
||||
actions.append(
|
||||
{
|
||||
"conversion_action_id": str(action.id),
|
||||
"resource_name": str(action.resource_name or ""),
|
||||
"name": str(action.name or ""),
|
||||
"status": enum_name(action.status),
|
||||
"type": enum_name(action.type_),
|
||||
"category": enum_name(action.category),
|
||||
"primary_for_goal": bool(get_attr(action, "primary_for_goal", False)),
|
||||
"include_in_conversions_metric": bool(get_attr(action, "include_in_conversions_metric", False)),
|
||||
"counting_type": enum_name(get_attr(action, "counting_type", "")),
|
||||
"default_value": safe_float(get_attr(action, "value_settings.default_value", 0)),
|
||||
"default_currency_code": str(get_attr(action, "value_settings.default_currency_code", "") or ""),
|
||||
"always_use_default_value": bool(get_attr(action, "value_settings.always_use_default_value", False)),
|
||||
"click_window_days": safe_int(get_attr(action, "click_through_lookback_window_days", 0)),
|
||||
"view_window_days": safe_int(get_attr(action, "view_through_lookback_window_days", 0)),
|
||||
"attribution_model": enum_name(
|
||||
get_attr(action, "attribution_model_settings.attribution_model", "")
|
||||
),
|
||||
}
|
||||
)
|
||||
actions.sort(key=lambda item: (item["status"] != "ENABLED", item["category"], item["name"]))
|
||||
return actions
|
||||
|
||||
|
||||
def fetch_conversion_performance(client_config: ClientConfig, google_client) -> dict[str, dict]:
|
||||
rows = run_query(
|
||||
google_client,
|
||||
client_config.safe_customer_id,
|
||||
"""
|
||||
SELECT
|
||||
segments.conversion_action,
|
||||
segments.conversion_action_name,
|
||||
segments.conversion_action_category,
|
||||
metrics.conversions,
|
||||
metrics.conversions_value,
|
||||
metrics.all_conversions,
|
||||
metrics.all_conversions_value
|
||||
FROM customer
|
||||
WHERE segments.date DURING LAST_30_DAYS
|
||||
""",
|
||||
)
|
||||
performance: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
resource_name = str(row.segments.conversion_action or "")
|
||||
if not resource_name:
|
||||
continue
|
||||
bucket = performance.setdefault(
|
||||
resource_name,
|
||||
{
|
||||
"resource_name": resource_name,
|
||||
"name": str(row.segments.conversion_action_name or ""),
|
||||
"category": enum_name(row.segments.conversion_action_category),
|
||||
"conversions_30d": 0.0,
|
||||
"conversion_value_30d": 0.0,
|
||||
"all_conversions_30d": 0.0,
|
||||
"all_conversion_value_30d": 0.0,
|
||||
},
|
||||
)
|
||||
bucket["conversions_30d"] += safe_float(row.metrics.conversions)
|
||||
bucket["conversion_value_30d"] += safe_float(row.metrics.conversions_value)
|
||||
bucket["all_conversions_30d"] += safe_float(row.metrics.all_conversions)
|
||||
bucket["all_conversion_value_30d"] += safe_float(row.metrics.all_conversions_value)
|
||||
return performance
|
||||
|
||||
|
||||
def attach_performance(actions: list[dict], performance: dict[str, dict]) -> list[dict]:
|
||||
for action in actions:
|
||||
row = performance.get(action["resource_name"], {})
|
||||
action["conversions_30d"] = round(safe_float(row.get("conversions_30d")), 2)
|
||||
action["conversion_value_30d"] = round(safe_float(row.get("conversion_value_30d")), 2)
|
||||
action["all_conversions_30d"] = round(safe_float(row.get("all_conversions_30d")), 2)
|
||||
action["all_conversion_value_30d"] = round(safe_float(row.get("all_conversion_value_30d")), 2)
|
||||
action["is_collecting_data"] = action["all_conversions_30d"] > 0
|
||||
action["problem_flags"] = action_problem_flags(action)
|
||||
action["recommendation"] = action_recommendation(action)
|
||||
return actions
|
||||
|
||||
|
||||
def can_use_dynamic_conversion_value(action: dict) -> bool:
|
||||
return (
|
||||
action.get("status") == "ENABLED"
|
||||
and action.get("category") in ECOMMERCE_CATEGORIES
|
||||
and action.get("type") not in NO_DYNAMIC_VALUE_TYPES
|
||||
)
|
||||
|
||||
|
||||
def needs_dynamic_conversion_value(action: dict) -> bool:
|
||||
if not can_use_dynamic_conversion_value(action):
|
||||
return False
|
||||
if action.get("always_use_default_value"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def build_value_setting_changes(actions: list[dict]) -> list[dict]:
|
||||
changes = []
|
||||
for action in actions:
|
||||
if not needs_dynamic_conversion_value(action):
|
||||
continue
|
||||
reason_parts = []
|
||||
if action.get("always_use_default_value"):
|
||||
reason_parts.append("akcja uzywa stalej wartosci")
|
||||
changes.append(
|
||||
{
|
||||
"conversion_action_id": action["conversion_action_id"],
|
||||
"resource_name": action["resource_name"],
|
||||
"name": action["name"],
|
||||
"category": action["category"],
|
||||
"type": action["type"],
|
||||
"setting": "wartosc konwersji",
|
||||
"field": "value_settings.always_use_default_value",
|
||||
"current_value": "always_use_default_value=true",
|
||||
"target_value": "always_use_default_value=false",
|
||||
"current_label": "Stala wartosc albo brak wartosci",
|
||||
"target_label": "Zastosuj inna wartosc do kazdej konwersji",
|
||||
"description": "Ustaw akcje konwersji na uzywanie roznych wartosci dla kazdej konwersji.",
|
||||
"reason": ", ".join(reason_parts) or "akcja e-commerce uzywa stalej wartosci",
|
||||
"note": "Ta zmiana poprawia ustawienie w Google Ads. Po stronie tagu/GA4/GTM nadal musi byc wysylane value i currency.",
|
||||
}
|
||||
)
|
||||
return changes
|
||||
|
||||
|
||||
def action_problem_flags(action: dict) -> list[str]:
|
||||
flags = []
|
||||
category = action.get("category", "")
|
||||
is_collecting_data = action.get("is_collecting_data") or action.get("all_conversions_30d", 0) > 0
|
||||
if action.get("status") != "ENABLED":
|
||||
flags.append("akcja nieaktywna")
|
||||
if action.get("status") == "ENABLED" and not is_collecting_data:
|
||||
flags.append("brak danych 30 dni")
|
||||
if action.get("primary_for_goal") and action.get("conversions_30d", 0) <= 0:
|
||||
flags.append("podstawowa bez konwersji")
|
||||
if category in ECOMMERCE_CATEGORIES and action.get("all_conversions_30d", 0) > 0:
|
||||
if action.get("all_conversion_value_30d", 0) <= 0:
|
||||
if action.get("always_use_default_value"):
|
||||
flags.append("brak wartosci przez ustawienie Google Ads")
|
||||
else:
|
||||
flags.append("brak wartosci mimo ustawienia dynamicznego")
|
||||
if not action.get("default_currency_code"):
|
||||
flags.append("brak waluty domyslnej")
|
||||
if action.get("always_use_default_value") and category in ECOMMERCE_CATEGORIES:
|
||||
flags.append("e-commerce uzywa stalej wartosci")
|
||||
return flags or ["ok"]
|
||||
|
||||
|
||||
def action_recommendation(action: dict) -> str:
|
||||
flags = set(action.get("problem_flags", []))
|
||||
category = action.get("category", "")
|
||||
if "akcja nieaktywna" in flags:
|
||||
return "Zostaw nieaktywna, jesli jest historyczna; w przeciwnym razie uporzadkuj konfiguracje konwersji."
|
||||
if "podstawowa bez konwersji" in flags:
|
||||
return "Sprawdz tag i zasadnosc uzywania tej akcji jako podstawowej do optymalizacji."
|
||||
if "brak wartosci przez ustawienie Google Ads" in flags and category in ECOMMERCE_CATEGORIES:
|
||||
return "W Google Ads wlacz rozne wartosci dla kazdej konwersji, a potem sprawdz czy tag przekazuje value i currency."
|
||||
if "brak wartosci mimo ustawienia dynamicznego" in flags and category in ECOMMERCE_CATEGORIES:
|
||||
return "Ustawienie w Google Ads wyglada poprawnie; napraw wysylanie value i currency po stronie strony, GTM albo GA4."
|
||||
if "e-commerce uzywa stalej wartosci" in flags:
|
||||
return "Przelacz pomiar zakupow na rzeczywista wartosc transakcji zamiast stalej wartosci."
|
||||
if "brak danych 30 dni" in flags:
|
||||
return "Sprawdz, czy akcja nadal powinna byc aktywna i czy tag uruchamia sie na wlasciwym zdarzeniu."
|
||||
if action.get("primary_for_goal") and category in LEAD_CATEGORIES:
|
||||
return "Dane nadaja sie do optymalizacji leadowej, jesli zdarzenie odpowiada realnemu leadowi."
|
||||
if action.get("primary_for_goal") and category in ECOMMERCE_CATEGORIES:
|
||||
return "Dane zakupowe wygladaja na podstawowy sygnal optymalizacji; kontroluj wartosc i duplikacje."
|
||||
return "Bez pilnej zmiany; zostaw jako kontekst albo konwersje dodatkowa."
|
||||
|
||||
|
||||
def build_action_summary(actions: list[dict]) -> list[dict]:
|
||||
counter = Counter()
|
||||
counter["akcje konwersji"] = len(actions)
|
||||
counter["aktywne"] = sum(1 for action in actions if action["status"] == "ENABLED")
|
||||
counter["podstawowe"] = sum(1 for action in actions if action["primary_for_goal"])
|
||||
counter["zbieraja dane 30 dni"] = sum(
|
||||
1 for action in actions if action.get("is_collecting_data") or action.get("all_conversions_30d", 0) > 0
|
||||
)
|
||||
counter["z problemami"] = sum(1 for action in actions if action.get("problem_flags", ["ok"]) != ["ok"])
|
||||
counter["podstawowe z danymi"] = sum(
|
||||
1 for action in actions if action["primary_for_goal"] and action["conversions_30d"] > 0
|
||||
)
|
||||
return [{"metric": key, "count": value} for key, value in counter.items()]
|
||||
|
||||
|
||||
def duplicate_name_findings(actions: list[dict]) -> list[dict]:
|
||||
normalized: dict[tuple[str, str], list[dict]] = {}
|
||||
for action in actions:
|
||||
key = (" ".join(action["name"].lower().split()), action.get("category", ""))
|
||||
normalized.setdefault(key, []).append(action)
|
||||
findings = []
|
||||
for (_name, category), rows in normalized.items():
|
||||
active_rows = [row for row in rows if row["status"] == "ENABLED"]
|
||||
if len(active_rows) < 2:
|
||||
continue
|
||||
findings.append(
|
||||
{
|
||||
"severity": "srednie",
|
||||
"area": "Duplikacja konwersji",
|
||||
"item": ", ".join(row["name"] for row in active_rows[:4]),
|
||||
"problem": f"{len(active_rows)} aktywne akcje o tej samej nazwie i kategorii {category}",
|
||||
"recommendation": "Sprawdz, czy konto nie liczy tego samego zdarzenia kilka razy.",
|
||||
}
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def build_findings(actions: list[dict]) -> list[dict]:
|
||||
findings = []
|
||||
if not actions:
|
||||
findings.append(
|
||||
{
|
||||
"severity": "wysokie",
|
||||
"area": "Konwersje",
|
||||
"item": "konto",
|
||||
"problem": "brak akcji konwersji",
|
||||
"recommendation": "Skonfiguruj podstawowe konwersje przed optymalizacja kampanii.",
|
||||
}
|
||||
)
|
||||
return findings
|
||||
primary_actions = [action for action in actions if action["primary_for_goal"] and action["status"] == "ENABLED"]
|
||||
if not primary_actions:
|
||||
findings.append(
|
||||
{
|
||||
"severity": "wysokie",
|
||||
"area": "Optymalizacja",
|
||||
"item": "cele konta",
|
||||
"problem": "brak aktywnych konwersji podstawowych",
|
||||
"recommendation": "Ustaw jako podstawowe tylko te konwersje, ktore maja sterowac strategiami stawek.",
|
||||
}
|
||||
)
|
||||
if primary_actions and not any(action["conversions_30d"] > 0 for action in primary_actions):
|
||||
findings.append(
|
||||
{
|
||||
"severity": "wysokie",
|
||||
"area": "Dane 30 dni",
|
||||
"item": "konwersje podstawowe",
|
||||
"problem": "konwersje podstawowe nie zbieraja danych z ostatnich 30 dni",
|
||||
"recommendation": "Sprawdz tagi, importy i cele przed wlaczaniem automatyzacji opartej o konwersje.",
|
||||
}
|
||||
)
|
||||
for action in actions:
|
||||
for flag in action.get("problem_flags", []):
|
||||
if flag == "ok":
|
||||
continue
|
||||
severity = (
|
||||
"wysokie"
|
||||
if flag in {
|
||||
"podstawowa bez konwersji",
|
||||
"brak wartosci przez ustawienie Google Ads",
|
||||
"brak wartosci mimo ustawienia dynamicznego",
|
||||
}
|
||||
else "srednie"
|
||||
)
|
||||
findings.append(
|
||||
{
|
||||
"severity": severity,
|
||||
"area": action.get("category", ""),
|
||||
"item": action.get("name", ""),
|
||||
"problem": flag,
|
||||
"recommendation": action.get("recommendation", ""),
|
||||
}
|
||||
)
|
||||
findings.extend(duplicate_name_findings(actions))
|
||||
order = {"wysokie": 0, "srednie": 1, "niskie": 2}
|
||||
findings.sort(key=lambda item: (order.get(item["severity"], 9), item["area"], item["item"]))
|
||||
return findings
|
||||
|
||||
|
||||
def build_conversion_tracking_plan(client_config: ClientConfig) -> ConversionTrackingPlan:
|
||||
rules = rules_for_task(TASK_ID)
|
||||
warnings = []
|
||||
currency_code = ""
|
||||
actions: list[dict] = []
|
||||
try:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
currency_code = fetch_customer_currency(client_config, google_client)
|
||||
actions = fetch_conversion_actions(client_config, google_client)
|
||||
performance = fetch_conversion_performance(client_config, google_client)
|
||||
actions = attach_performance(actions, performance)
|
||||
except Exception as exc:
|
||||
warnings.append(f"Nie udalo sie pobrac danych pomiaru konwersji z Google Ads API: {exc}")
|
||||
|
||||
if not client_config.google_ads_customer_id:
|
||||
warnings.append("Klient nie ma google_ads_customer_id w config/clients.toml.")
|
||||
if not actions:
|
||||
warnings.append("Nie znaleziono akcji konwersji albo nie udalo sie ich pobrac.")
|
||||
|
||||
knowledge_rules = [
|
||||
{
|
||||
"id": rule.id,
|
||||
@@ -75,17 +484,25 @@ def build_conversion_tracking_plan(client_config: ClientConfig) -> ConversionTra
|
||||
"risk": rule.risk,
|
||||
"source": rule.source,
|
||||
}
|
||||
for rule in rules
|
||||
for rule in rules_for_task(TASK_ID)
|
||||
]
|
||||
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."
|
||||
"Reguly dotyczace pomiaru konwersji warto dopisac po pierwszych audytach."
|
||||
)
|
||||
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)
|
||||
|
||||
changes = build_value_setting_changes(actions)
|
||||
return ConversionTrackingPlan(
|
||||
currency_code=currency_code,
|
||||
conversion_actions=actions,
|
||||
action_summary=build_action_summary(actions),
|
||||
findings=build_findings(actions),
|
||||
changes=changes,
|
||||
scope=DEFAULT_SCOPE,
|
||||
knowledge_rules=knowledge_rules,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def save_conversion_tracking_plan(domain: str, plan: ConversionTrackingPlan) -> tuple[Path, Path]:
|
||||
@@ -110,9 +527,10 @@ def save_conversion_tracking_plan(domain: str, plan: ConversionTrackingPlan) ->
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Obszary audytu: {len(plan.scope)}",
|
||||
f"- Akcje konwersji: {len(plan.conversion_actions)}",
|
||||
f"- Problemy / rekomendacje: {len(plan.findings)}",
|
||||
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
|
||||
"- Zmiany do wdrozenia: 0",
|
||||
f"- Zmiany do wdrozenia: {len(plan.changes)}",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
@@ -121,8 +539,62 @@ def save_conversion_tracking_plan(domain: str, plan: ConversionTrackingPlan) ->
|
||||
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(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
|
||||
lines.append("")
|
||||
if plan.action_summary:
|
||||
lines.extend(["## Podsumowanie akcji", "", "| Metryka | Liczba |", "| --- | --- |"])
|
||||
for row in plan.action_summary:
|
||||
lines.append(f"| {md_cell(row['metric'])} | {row['count']} |")
|
||||
lines.append("")
|
||||
if plan.findings:
|
||||
lines.extend(
|
||||
[
|
||||
"## Problemy i rekomendacje",
|
||||
"",
|
||||
"| Waznosc | Obszar | Element | Problem | Rekomendacja |",
|
||||
"| --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for row in plan.findings:
|
||||
lines.append(
|
||||
f"| {row['severity']} | {md_cell(row['area'])} | {md_cell(row['item'])} | "
|
||||
f"{md_cell(row['problem'])} | {md_cell(row['recommendation'])} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.changes:
|
||||
lines.extend(
|
||||
[
|
||||
"## Zmiany do akceptacji",
|
||||
"",
|
||||
"| Akcja konwersji | Kategoria | Obecnie | Docelowo | Powod | Uwaga |",
|
||||
"| --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for change in plan.changes:
|
||||
lines.append(
|
||||
f"| {md_cell(change['name'])} | {change['category']} | "
|
||||
f"{md_cell(change['current_label'])} | {md_cell(change['target_label'])} | "
|
||||
f"{md_cell(change['reason'])} | {md_cell(change['note'])} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.conversion_actions:
|
||||
lines.extend(
|
||||
[
|
||||
"## Akcje konwersji",
|
||||
"",
|
||||
"| Nazwa | Status | Typ | Kategoria | Podstawowa | W konwersjach | Konw. 30d | Wartosc 30d | Wszystkie konw. 30d | Wartosc wszystkich 30d | Okno klik. | Okno view | Problemy |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for action in plan.conversion_actions:
|
||||
lines.append(
|
||||
f"| {md_cell(action['name'])} | {action['status']} | {action['type']} | {action['category']} | "
|
||||
f"{bool_label(action['primary_for_goal'])} | {bool_label(action['include_in_conversions_metric'])} | "
|
||||
f"{format_decimal(action['conversions_30d'])} | {format_money(action['conversion_value_30d'], plan.currency_code)} | "
|
||||
f"{format_decimal(action['all_conversions_30d'])} | {format_money(action['all_conversion_value_30d'], plan.currency_code)} | "
|
||||
f"{action['click_window_days']} | {action['view_window_days']} | {md_cell(', '.join(action['problem_flags']))} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.knowledge_rules:
|
||||
lines.extend(
|
||||
[
|
||||
@@ -134,8 +606,8 @@ def save_conversion_tracking_plan(domain: str, plan: ConversionTrackingPlan) ->
|
||||
)
|
||||
for rule in plan.knowledge_rules:
|
||||
lines.append(
|
||||
f"| {rule.get('id', '')} | {rule.get('topic', '')} | "
|
||||
f"{rule.get('recommendation', '')} | {rule.get('risk', '')} |"
|
||||
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")
|
||||
@@ -147,9 +619,10 @@ def print_conversion_tracking_plan(plan: ConversionTrackingPlan) -> None:
|
||||
print_table(
|
||||
["Metryka", "Liczba"],
|
||||
[
|
||||
["Obszary audytu", str(len(plan.scope))],
|
||||
["Akcje konwersji", str(len(plan.conversion_actions))],
|
||||
["Problemy / rekomendacje", str(len(plan.findings))],
|
||||
["Reguly wiedzy", str(len(plan.knowledge_rules))],
|
||||
["Zmiany do wdrozenia", "0"],
|
||||
["Zmiany do wdrozenia", str(len(plan.changes))],
|
||||
],
|
||||
)
|
||||
if plan.warnings:
|
||||
@@ -160,6 +633,62 @@ def print_conversion_tracking_plan(plan: ConversionTrackingPlan) -> None:
|
||||
["Nr", "Obszar", "Co sprawdzic"],
|
||||
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
|
||||
)
|
||||
if plan.action_summary:
|
||||
print("\nPodsumowanie akcji")
|
||||
print_table(["Metryka", "Liczba"], [[row["metric"], str(row["count"])] for row in plan.action_summary])
|
||||
if plan.changes:
|
||||
print("\nZmiany do akceptacji")
|
||||
print_table(
|
||||
["Nr", "Akcja", "Kategoria", "Obecnie", "Docelowo", "Powod"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
change["name"],
|
||||
change["category"],
|
||||
change["current_label"],
|
||||
change["target_label"],
|
||||
change["reason"],
|
||||
]
|
||||
for index, change in enumerate(plan.changes, 1)
|
||||
],
|
||||
)
|
||||
if plan.findings:
|
||||
print("\nProblemy i rekomendacje")
|
||||
print_table(
|
||||
["Nr", "Waznosc", "Obszar", "Element", "Problem", "Rekomendacja"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
row["severity"],
|
||||
row["area"],
|
||||
row["item"],
|
||||
row["problem"],
|
||||
row["recommendation"],
|
||||
]
|
||||
for index, row in enumerate(plan.findings, 1)
|
||||
],
|
||||
)
|
||||
if plan.conversion_actions:
|
||||
print("\nAkcje konwersji")
|
||||
print_table(
|
||||
["Nr", "Nazwa", "Status", "Kategoria", "Podst.", "Konw. 30d", "Wartosc", "Wszystkie", "Problemy"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
action["name"],
|
||||
action["status"],
|
||||
action["category"],
|
||||
bool_label(action["primary_for_goal"]),
|
||||
format_decimal(action["conversions_30d"]),
|
||||
format_money(action["conversion_value_30d"], plan.currency_code),
|
||||
format_decimal(action["all_conversions_30d"]),
|
||||
", ".join(action["problem_flags"]),
|
||||
]
|
||||
for index, action in enumerate(plan.conversion_actions[:30], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.conversion_actions) > 30:
|
||||
print(f"... oraz {len(plan.conversion_actions) - 30} kolejnych akcji w pliku planu")
|
||||
if plan.knowledge_rules:
|
||||
print("\nReguly z bazy wiedzy")
|
||||
print_table(
|
||||
@@ -188,18 +717,63 @@ def apply_conversion_tracking_plan(
|
||||
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, [])
|
||||
changed = 0
|
||||
errors = []
|
||||
if plan.changes:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
customer_id = client_config.safe_customer_id
|
||||
service = google_client.get_service("ConversionActionService")
|
||||
operations = []
|
||||
for change in plan.changes:
|
||||
if change.get("type") in NO_DYNAMIC_VALUE_TYPES:
|
||||
errors.append(f"Pominieto {change.get('name', '')}: typ {change.get('type')} wymaga stalej wartosci.")
|
||||
continue
|
||||
op = google_client.get_type("ConversionActionOperation")
|
||||
conversion_action = op.update
|
||||
conversion_action.resource_name = change["resource_name"]
|
||||
conversion_action.value_settings.always_use_default_value = False
|
||||
op.update_mask = field_mask_pb2.FieldMask(paths=["value_settings.always_use_default_value"])
|
||||
operations.append(op)
|
||||
if operations:
|
||||
try:
|
||||
response = service.mutate_conversion_actions(customer_id=customer_id, operations=operations)
|
||||
changed = len(response.results)
|
||||
except Exception as exc:
|
||||
errors.append(str(exc))
|
||||
|
||||
if plan.changes:
|
||||
print("\nWynik wdrozenia zmian pomiaru konwersji")
|
||||
print(f"Zmieniono akcji konwersji: {changed}")
|
||||
print(f"Bledy: {len(errors)}")
|
||||
for error in errors:
|
||||
print(f"Blad: {error}")
|
||||
else:
|
||||
print("\nTo zadanie jest audytem pomiaru konwersji i nie ma zmian do wdrozenia.")
|
||||
|
||||
rows = [
|
||||
{
|
||||
"klient": client_config.domain,
|
||||
"kampania": "",
|
||||
"czynnosc": change["description"],
|
||||
"grupa reklam": "",
|
||||
"produkt": f"{change['name']}: {change['current_label']} -> {change['target_label']}. {change.get('note', '')}",
|
||||
}
|
||||
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": "audyt oznaczony jako wykonany",
|
||||
"status": "wdrozono zmiany pomiaru konwersji" if plan.changes and not errors else "audyt oznaczony jako wykonany",
|
||||
"campaign": "",
|
||||
"summary": {
|
||||
"scope_items": len(plan.scope),
|
||||
"conversion_actions": len(plan.conversion_actions),
|
||||
"findings": len(plan.findings),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
"changes": len(plan.changes),
|
||||
"changed": changed,
|
||||
"errors": len(errors),
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -220,7 +794,7 @@ def run_check_conversion_tracking(
|
||||
_ = global_rules
|
||||
if apply_plan_path:
|
||||
if confirm_apply != "TAK":
|
||||
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
|
||||
print("Do wdrozenia planu wymagane jest --confirm-apply TAK.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
@@ -236,7 +810,7 @@ def run_check_conversion_tracking(
|
||||
return
|
||||
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print("Przygotowuje plan sprawdzenia pomiaru konwersji...")
|
||||
print("Pobieram dane pomiaru konwersji z Google Ads API...")
|
||||
plan = build_conversion_tracking_plan(client_config)
|
||||
print_conversion_tracking_plan(plan)
|
||||
json_path, md_path = save_conversion_tracking_plan(client_config.domain, plan)
|
||||
@@ -250,9 +824,10 @@ def run_check_conversion_tracking(
|
||||
"status": "plan przygotowany",
|
||||
"campaign": "",
|
||||
"summary": {
|
||||
"scope_items": len(plan.scope),
|
||||
"conversion_actions": len(plan.conversion_actions),
|
||||
"findings": len(plan.findings),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
"changes": len(plan.changes),
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -263,6 +838,30 @@ def run_check_conversion_tracking(
|
||||
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)
|
||||
if not plan.changes:
|
||||
print("\nBrak zmian do wdrozenia. To zadanie przygotowuje audyt i rekomendacje pomiaru konwersji.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
answer = input("\nWpisz TAK, aby wdrozyc powyzsze zmiany wartosci konwersji: ").strip()
|
||||
if answer != "TAK":
|
||||
print("Przerwano. Zmiany nie zostaly wdrozone.")
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "odrzucono wdrozenie",
|
||||
"campaign": "",
|
||||
"summary": {
|
||||
"conversion_actions": len(plan.conversion_actions),
|
||||
"findings": len(plan.findings),
|
||||
"changes": len(plan.changes),
|
||||
},
|
||||
},
|
||||
)
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
apply_conversion_tracking_plan(client_config, plan, show_navigation=show_navigation)
|
||||
|
||||
450
src/gads_v2/tasks/product_availability_check.py
Normal file
450
src/gads_v2/tasks/product_availability_check.py
Normal file
@@ -0,0 +1,450 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
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 = "check_product_availability"
|
||||
TASK_NAME = "Sprawdzenie dostepnosci produktow"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProductAvailabilityPlan:
|
||||
products_checked: int
|
||||
available: list[dict]
|
||||
unavailable: list[dict]
|
||||
not_mapped: list[dict]
|
||||
changes: list[dict]
|
||||
warnings: list[str]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"task": TASK_ID,
|
||||
"task_name": TASK_NAME,
|
||||
"products_checked": self.products_checked,
|
||||
"available": self.available,
|
||||
"unavailable": self.unavailable,
|
||||
"not_mapped": self.not_mapped,
|
||||
"changes": self.changes,
|
||||
"warnings": self.warnings,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ProductAvailabilityPlan":
|
||||
return cls(
|
||||
products_checked=int(data.get("products_checked", 0)),
|
||||
available=data.get("available", []),
|
||||
unavailable=data.get("unavailable", []),
|
||||
not_mapped=data.get("not_mapped", []),
|
||||
changes=data.get("changes", []),
|
||||
warnings=data.get("warnings", []),
|
||||
)
|
||||
|
||||
|
||||
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 = 60) -> 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_adspro_products(client_config: ClientConfig) -> list[dict]:
|
||||
api_url, api_key, adspro_client_id = adspro_credentials(client_config)
|
||||
data = adspro_request(
|
||||
api_url,
|
||||
{
|
||||
"action": "products_get_all",
|
||||
"api_key": api_key,
|
||||
"client_id": adspro_client_id,
|
||||
"limit": "5000",
|
||||
},
|
||||
)
|
||||
return data.get("products", [])
|
||||
|
||||
|
||||
def fetch_shopify_catalog(shop_url: str) -> list[dict]:
|
||||
base = shop_url.rstrip("/")
|
||||
headers = {"User-Agent": "Mozilla/5.0", "Accept": "application/json"}
|
||||
catalog = []
|
||||
for page in range(1, 100):
|
||||
url = f"{base}/products.json?limit=250&page={page}"
|
||||
response = None
|
||||
for attempt in range(1, 4):
|
||||
response = requests.get(url, timeout=30, headers=headers)
|
||||
if response.status_code == 200:
|
||||
break
|
||||
time.sleep(1.5 * attempt)
|
||||
if response is None or response.status_code != 200:
|
||||
raise RuntimeError(f"Nie udalo sie pobrac katalogu Shopify: {url}, status={response.status_code if response else 'brak'}.")
|
||||
products = response.json().get("products", [])
|
||||
if not products:
|
||||
break
|
||||
catalog.extend(products)
|
||||
time.sleep(0.2)
|
||||
return catalog
|
||||
|
||||
|
||||
def offer_parts(offer_id: str) -> tuple[str, str] | None:
|
||||
parts = offer_id.split("_")
|
||||
if len(parts) >= 4 and parts[0] == "shopify":
|
||||
return parts[2], parts[3]
|
||||
return None
|
||||
|
||||
|
||||
def check_shopify_products(products: list[dict], rules: dict) -> tuple[list[dict], list[dict], list[dict]]:
|
||||
shop_url = str(rules.get("shop_url") or "").rstrip("/")
|
||||
if not shop_url:
|
||||
raise RuntimeError("Brak product_availability.shop_url dla klienta Shopify.")
|
||||
catalog = fetch_shopify_catalog(shop_url)
|
||||
by_product_id = {str(product["id"]): product for product in catalog}
|
||||
by_variant_id = {str(variant["id"]): product for product in catalog for variant in product.get("variants", [])}
|
||||
variant_by_id = {str(variant["id"]): variant for product in catalog for variant in product.get("variants", [])}
|
||||
by_title = {str(product.get("title") or "").strip().lower(): product for product in catalog}
|
||||
|
||||
available = []
|
||||
unavailable = []
|
||||
not_mapped = []
|
||||
for product in products:
|
||||
offer_id = str(product.get("offer_id") or "").strip()
|
||||
matched_product = None
|
||||
matched_variant = None
|
||||
match_type = "none"
|
||||
parts = offer_parts(offer_id)
|
||||
if parts:
|
||||
product_id, variant_id = parts
|
||||
matched_product = by_product_id.get(product_id) or by_variant_id.get(variant_id)
|
||||
matched_variant = variant_by_id.get(variant_id)
|
||||
match_type = "shopify_id" if matched_product else "none"
|
||||
if matched_product is None:
|
||||
matched_product = by_title.get(str(product.get("title") or "").strip().lower())
|
||||
if matched_product:
|
||||
match_type = "title"
|
||||
if matched_product and matched_variant is None:
|
||||
variants = matched_product.get("variants") or []
|
||||
matched_variant = next((variant for variant in variants if variant.get("available")), None) or (variants[0] if variants else None)
|
||||
|
||||
row = availability_row(product)
|
||||
row.update(
|
||||
{
|
||||
"method": "shopify_products_json",
|
||||
"match": match_type,
|
||||
"url": f"{shop_url}/products/{matched_product.get('handle')}" if matched_product else "",
|
||||
"shopify_product_id": str(matched_product.get("id")) if matched_product else "",
|
||||
"shopify_variant_id": str(matched_variant.get("id")) if matched_variant else "",
|
||||
"shopify_variant_available": bool(matched_variant and matched_variant.get("available")),
|
||||
}
|
||||
)
|
||||
if not matched_product or not matched_variant:
|
||||
row["availability_status"] = "not_mapped"
|
||||
not_mapped.append(row)
|
||||
elif matched_variant.get("available"):
|
||||
row["availability_status"] = "available"
|
||||
available.append(row)
|
||||
else:
|
||||
row["availability_status"] = "unavailable"
|
||||
row["reason"] = "wariant Shopify ma available=false"
|
||||
unavailable.append(row)
|
||||
return available, unavailable, not_mapped
|
||||
|
||||
|
||||
def check_html_buy_button(products: list[dict], rules: dict) -> tuple[list[dict], list[dict], list[dict]]:
|
||||
url_template = str(rules.get("url_template") or "")
|
||||
available_text = str(rules.get("available_text") or "DoKoszykaKartaProduktu")
|
||||
hidden_text = str(rules.get("hidden_text") or 'id="PrzyciskKupowania" style="display:none"')
|
||||
if not url_template:
|
||||
raise RuntimeError("Brak product_availability.url_template dla metody html_buy_button.")
|
||||
|
||||
session = requests.Session()
|
||||
session.headers.update({"User-Agent": "Mozilla/5.0"})
|
||||
available = []
|
||||
unavailable = []
|
||||
not_mapped = []
|
||||
for product in products:
|
||||
offer_id = str(product.get("offer_id") or "").strip()
|
||||
url = url_template.format(offer_id=offer_id)
|
||||
row = availability_row(product)
|
||||
row.update({"method": "html_buy_button", "url": url})
|
||||
try:
|
||||
response = session.get(url, timeout=20, allow_redirects=True)
|
||||
response.encoding = "utf-8"
|
||||
text = response.text
|
||||
lower = text.lower()
|
||||
row["http_status"] = response.status_code
|
||||
row["final_url"] = response.url
|
||||
has_button = available_text in text
|
||||
hidden = hidden_text.lower() in lower
|
||||
if response.status_code == 404:
|
||||
row["availability_status"] = "not_mapped"
|
||||
row["reason"] = "strona produktu zwrocila 404"
|
||||
not_mapped.append(row)
|
||||
elif has_button and not hidden:
|
||||
row["availability_status"] = "available"
|
||||
available.append(row)
|
||||
else:
|
||||
row["availability_status"] = "unavailable"
|
||||
row["reason"] = "brak aktywnego przycisku Dodaj do koszyka"
|
||||
unavailable.append(row)
|
||||
except Exception as exc:
|
||||
row["availability_status"] = "not_mapped"
|
||||
row["reason"] = f"blad odczytu strony: {exc}"
|
||||
not_mapped.append(row)
|
||||
return available, unavailable, not_mapped
|
||||
|
||||
|
||||
def availability_row(product: dict) -> dict:
|
||||
return {
|
||||
"offer_id": str(product.get("offer_id") or "").strip(),
|
||||
"title": product.get("title") or "",
|
||||
"current_custom_label_4": product.get("custom_label_4") or "",
|
||||
}
|
||||
|
||||
|
||||
def build_product_availability_plan(client_config: ClientConfig, global_rules: dict) -> ProductAvailabilityPlan:
|
||||
rules = client_config.effective_rules(global_rules, "product_availability")
|
||||
method = str(rules.get("method") or "").strip()
|
||||
if not method:
|
||||
raise RuntimeError(
|
||||
f"Brak konfiguracji product_availability.method dla klienta {client_config.domain}."
|
||||
)
|
||||
products = fetch_adspro_products(client_config)
|
||||
if method == "shopify_products_json":
|
||||
available, unavailable, not_mapped = check_shopify_products(products, rules)
|
||||
elif method == "html_buy_button":
|
||||
available, unavailable, not_mapped = check_html_buy_button(products, rules)
|
||||
else:
|
||||
raise RuntimeError(f"Nieznana metoda product_availability: {method}.")
|
||||
|
||||
target_label = str(rules.get("target_unavailable_label") or "paused")
|
||||
changes = []
|
||||
for row in unavailable:
|
||||
if row.get("current_custom_label_4") == target_label:
|
||||
continue
|
||||
changes.append(
|
||||
{
|
||||
**row,
|
||||
"target_custom_label_4": target_label,
|
||||
}
|
||||
)
|
||||
|
||||
warnings = []
|
||||
if not_mapped:
|
||||
warnings.append(f"Produkty niedopasowane albo bez jednoznacznego odczytu: {len(not_mapped)}.")
|
||||
if unavailable:
|
||||
warnings.append(f"Produkty niedostepne reklamowo: {len(unavailable)}.")
|
||||
|
||||
return ProductAvailabilityPlan(
|
||||
products_checked=len(products),
|
||||
available=available,
|
||||
unavailable=unavailable,
|
||||
not_mapped=not_mapped,
|
||||
changes=changes,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def save_plan(domain: str, plan: ProductAvailabilityPlan) -> 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 = [
|
||||
f"# Plan: {TASK_NAME}",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Produkty sprawdzone: {plan.products_checked}",
|
||||
f"- Dostepne: {len(plan.available)}",
|
||||
f"- Niedostepne reklamowo: {len(plan.unavailable)}",
|
||||
f"- Niedopasowane: {len(plan.not_mapped)}",
|
||||
f"- Do ustawienia CL4=paused: {len(plan.changes)}",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {item}" for item in plan.warnings)
|
||||
lines.append("")
|
||||
if plan.changes:
|
||||
lines.extend(["## Zmiany do wdrozenia", "", "| Produkt | Obecne CL4 | Docelowe CL4 | Powod | URL |", "| --- | --- | --- | --- | --- |"])
|
||||
for row in plan.changes:
|
||||
lines.append(
|
||||
f"| {row['offer_id']} | {row.get('current_custom_label_4', '')} | "
|
||||
f"{row.get('target_custom_label_4', '')} | {row.get('reason', '')} | {row.get('url', '')} |"
|
||||
)
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def print_plan(plan: ProductAvailabilityPlan) -> None:
|
||||
print(f"\nPlan: {TASK_NAME}")
|
||||
print_table(
|
||||
["Zakres", "Liczba"],
|
||||
[
|
||||
["Produkty sprawdzone", str(plan.products_checked)],
|
||||
["Dostepne", str(len(plan.available))],
|
||||
["Niedostepne reklamowo", str(len(plan.unavailable))],
|
||||
["Niedopasowane", str(len(plan.not_mapped))],
|
||||
["Do ustawienia CL4=paused", str(len(plan.changes))],
|
||||
],
|
||||
)
|
||||
if plan.changes:
|
||||
print("\nNajwazniejsze dzialania")
|
||||
rows = [
|
||||
[str(index), row["offer_id"], row.get("current_custom_label_4", ""), row.get("target_custom_label_4", ""), row.get("title", "")]
|
||||
for index, row in enumerate(plan.changes[:10], 1)
|
||||
]
|
||||
print_table(["Nr", "Produkt", "Obecne CL4", "Docelowe CL4", "Tytul"], rows)
|
||||
if len(plan.changes) > 10:
|
||||
print(f"... oraz {len(plan.changes) - 10} kolejnych produktow")
|
||||
for warning in plan.warnings:
|
||||
print(f"Uwaga: {warning}")
|
||||
|
||||
|
||||
def set_cl4(api_url: str, api_key: str, client_id: str, offer_id: str, value: str) -> dict:
|
||||
return adspro_request(
|
||||
api_url,
|
||||
{
|
||||
"action": "product_custom_label_4_set",
|
||||
"api_key": api_key,
|
||||
"client_id": client_id,
|
||||
"offer_id": offer_id,
|
||||
"custom_label_4": value,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def apply_plan(client_config: ClientConfig, plan: ProductAvailabilityPlan, plan_path: str) -> None:
|
||||
api_url, api_key, adspro_client_id = adspro_credentials(client_config)
|
||||
applied = []
|
||||
errors = []
|
||||
for row in plan.changes:
|
||||
try:
|
||||
set_cl4(api_url, api_key, adspro_client_id, row["offer_id"], row["target_custom_label_4"])
|
||||
applied.append(row)
|
||||
except Exception as exc:
|
||||
errors.append({**row, "error": str(exc)})
|
||||
print(f"Blad zapisu {row['offer_id']}: {exc}")
|
||||
|
||||
print("\nWynik wdrozenia zmian")
|
||||
print(f"Wdrozono zmian: {len(applied)}")
|
||||
print(f"Bledy: {len(errors)}")
|
||||
|
||||
change_rows = [
|
||||
{
|
||||
"klient": client_config.domain,
|
||||
"produkt": row["offer_id"],
|
||||
"pole": "custom_label_4",
|
||||
"obecnie": row.get("current_custom_label_4", ""),
|
||||
"docelowo": row.get("target_custom_label_4", ""),
|
||||
}
|
||||
for row in applied
|
||||
]
|
||||
changes_path = append_change_markdown(client_config.domain, TASK_NAME, change_rows)
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "wdrozono zmiany" if not errors else "wdrozono czesciowo",
|
||||
"product": ", ".join(row["offer_id"] for row in applied[:10]),
|
||||
"plan_path": plan_path,
|
||||
"summary": {"applied": len(applied), "errors": len(errors)},
|
||||
},
|
||||
)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
|
||||
|
||||
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_check_product_availability(
|
||||
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
|
||||
data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
|
||||
if data.get("client") != client_config.domain:
|
||||
print(f"Plan jest dla klienta {data.get('client')}, a wybrano {client_config.domain}.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan = ProductAvailabilityPlan.from_dict(data)
|
||||
print_plan(plan)
|
||||
apply_plan(client_config, plan, apply_plan_path)
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print("Sprawdzam dostepnosc produktow i przygotowuje plan CL4=paused...")
|
||||
plan = build_product_availability_plan(client_config, global_rules)
|
||||
print_plan(plan)
|
||||
json_path, md_path = save_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",
|
||||
"summary": {
|
||||
"products_checked": plan.products_checked,
|
||||
"available": len(plan.available),
|
||||
"unavailable": len(plan.unavailable),
|
||||
"not_mapped": len(plan.not_mapped),
|
||||
"changes": len(plan.changes),
|
||||
},
|
||||
},
|
||||
)
|
||||
if plan_only:
|
||||
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
@@ -20,11 +20,11 @@ TASK_NAME = "Sprawdzenie podstawowych ustawien Search"
|
||||
SCOPE = [
|
||||
{
|
||||
"area": "Lokalizacje",
|
||||
"check": "Sprawdz typ kierowania lokalizacji, zwlaszcza Obecnosc vs Obecnosc lub zainteresowanie.",
|
||||
"check": "Wymagaj kierowania lokalizacji na Obecnosc; inne tryby przygotuj jako korekte do wdrozenia.",
|
||||
},
|
||||
{
|
||||
"area": "Sieci",
|
||||
"check": "Sprawdz, czy kampanie Search nie maja niechcaco wlaczonej sieci reklamowej albo partnerow wyszukiwania.",
|
||||
"check": "Wymagaj wylaczonej sieci reklamowej i wylaczonych partnerow wyszukiwania w kampaniach Search.",
|
||||
},
|
||||
{
|
||||
"area": "Jezyki",
|
||||
@@ -479,4 +479,76 @@ def run_check_search_basic_settings(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str |
|
||||
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 = SearchBasicSettingsPlan.from_dict(plan_data)
|
||||
print_search_basic_settings_plan(plan)
|
||||
apply_search_basic_settings_plan(client_config, plan, show_navigation=show_navigation)
|
||||
return
|
||||
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print("Przygotowuje plan sprawdzenia podstawowych ustawien Search...")
|
||||
plan = build_search_basic_settings_plan(client_config)
|
||||
print_search_basic_settings_plan(plan)
|
||||
json_path, md_path = save_search_basic_settings_plan(client_config.domain, plan)
|
||||
print(f"\nPlan JSON: {json_path}")
|
||||
print(f"Plan Markdown: {md_path}")
|
||||
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "plan przygotowany",
|
||||
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
|
||||
"summary": {
|
||||
"campaigns": len(plan.campaigns),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 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, 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",
|
||||
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
|
||||
},
|
||||
)
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
apply_search_basic_settings_plan(client_config, plan, show_navigation=show_navigation)
|
||||
|
||||
Reference in New Issue
Block a user