@topy-ai/maggie 0.7.48 → 0.7.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-zh-TW.md +2 -2
- package/README.md +31 -4
- package/bin/maggie.js +17 -4
- package/bundled-contracts/google-integrations/capability-report-v2.schema.json +2 -1
- package/bundled-contracts/maggie-content/email-localization-v1.schema.json +89 -0
- package/bundled-contracts/maggie-content/lifecycle-email-v1.schema.json +76 -0
- package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +6 -1
- package/bundled-contracts/maggie-ops/marketing-run-v1.schema.json +89 -0
- package/bundled-contracts/maggie-ops/notification-catalogue-example-v1.json +941 -0
- package/bundled-contracts/maggie-ops/notification-catalogue-v1.schema.json +273 -0
- package/bundled-contracts/maggie-service-booking/fact-provenance-v1.schema.json +128 -0
- package/bundled-contracts/maggie-service-booking/import-stages-v1.schema.json +191 -0
- package/bundled-references/google-integrations-runbook.md +54 -0
- package/bundled-skills/maggie-content-localization/SKILL.md +17 -1
- package/bundled-skills/maggie-content-localization/references/email-and-lifecycle.md +83 -0
- package/bundled-skills/maggie-deployment/SKILL.md +89 -7
- package/bundled-skills/maggie-deployment/references/vps.md +38 -6
- package/bundled-skills/maggie-design/SKILL.md +55 -0
- package/bundled-skills/maggie-feedback/SKILL.md +29 -0
- package/bundled-skills/maggie-ops/SKILL.md +37 -0
- package/bundled-skills/maggie-ops/references/notifications-operations.md +154 -0
- package/bundled-skills/maggie-service-booking/SKILL.md +59 -0
- package/bundled-skills/maggie-social-share/SKILL.md +19 -2
- package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +51 -0
- package/bundled-tools/clis/maggie_deployment.py +58 -17
- package/bundled-tools/clis/maggie_deployment_auth_canary.py +216 -0
- package/bundled-tools/clis/maggie_deployment_collect.py +225 -0
- package/bundled-tools/clis/maggie_deployment_readiness.py +53 -3
- package/bundled-tools/clis/maggie_design.py +8 -0
- package/bundled-tools/clis/maggie_design_verify.py +359 -0
- package/bundled-tools/clis/maggie_docs.py +82 -4
- package/bundled-tools/clis/maggie_email_localization.py +382 -0
- package/bundled-tools/clis/maggie_feedback.py +9 -2
- package/bundled-tools/clis/maggie_icon_glyph_check.py +382 -0
- package/bundled-tools/clis/maggie_icon_release_gate.py +28 -1
- package/bundled-tools/clis/maggie_localization.py +5 -0
- package/bundled-tools/clis/maggie_marketing_run.py +272 -0
- package/bundled-tools/clis/maggie_notifications.py +528 -0
- package/bundled-tools/clis/maggie_ops.py +87 -0
- package/bundled-tools/clis/maggie_schedule.py +12 -2
- package/bundled-tools/clis/maggie_service_import.py +520 -0
- package/bundled-tools/clis/maggie_social_queue.py +104 -0
- package/bundled-tools/clis/maggie_social_share.py +6 -2
- package/bundled-tools/runtime/google_ads.py +306 -0
- package/bundled-tools/runtime/google_capabilities.py +18 -2
- package/bundled-tools/runtime/google_remediation.py +152 -0
- package/package.json +1 -1
- package/references/google-integrations-runbook.md +54 -0
|
@@ -98,6 +98,8 @@ def validate_adapters(manifest: dict) -> dict:
|
|
|
98
98
|
if shared:
|
|
99
99
|
warnings.append(f"{label} OAuth client is shared with {len(shared)} other product(s); their sign-ins can "
|
|
100
100
|
"revoke this product's tokens unless they request the identical scope set — prefer a dedicated client")
|
|
101
|
+
if adapter.get("publishes"):
|
|
102
|
+
errors.extend(f"{label} {problem}" for problem in publish_contract_errors(adapter))
|
|
101
103
|
hosts = sorted({urlsplit(uri).hostname or "" for uri in adapter.get("redirect_uris") or [] if isinstance(uri, str)} - {""})
|
|
102
104
|
if len(hosts) > 1 and not shared:
|
|
103
105
|
warnings.append(f"{label} redirect URIs span {len(hosts)} hosts ({', '.join(hosts)}); confirm they belong to "
|
|
@@ -105,6 +107,108 @@ def validate_adapters(manifest: dict) -> dict:
|
|
|
105
107
|
return {"valid": not errors, "errors": errors, "warnings": warnings}
|
|
106
108
|
|
|
107
109
|
|
|
110
|
+
# --- D33: publish contract and reconciliation ----------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
RETRY_CLASSES = ("retryable", "terminal", "unknown_outcome")
|
|
114
|
+
READBACK_LOOKUPS = {"by_idempotency_key", "by_marker", "by_recent_posts"}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def publish_contract_errors(adapter: dict) -> list[str]:
|
|
118
|
+
"""A publishing adapter declares limits, media rules, pending behaviour, retry classes, idempotency, and readback."""
|
|
119
|
+
problems: list[str] = []
|
|
120
|
+
limits = adapter.get("limits")
|
|
121
|
+
if not isinstance(limits, dict) or not isinstance(limits.get("text_max"), int) or limits["text_max"] <= 0:
|
|
122
|
+
problems.append("publish contract needs limits.text_max")
|
|
123
|
+
media = adapter.get("media")
|
|
124
|
+
if not isinstance(media, dict) or not isinstance(media.get("types"), list) or not isinstance(media.get("max_bytes"), int):
|
|
125
|
+
problems.append("publish contract needs media.types and media.max_bytes")
|
|
126
|
+
pending = adapter.get("pending")
|
|
127
|
+
if not isinstance(pending, dict) or not isinstance(pending.get("max_pending_seconds"), int) or not pending.get("states"):
|
|
128
|
+
problems.append("publish contract needs pending.states and pending.max_pending_seconds")
|
|
129
|
+
retry = adapter.get("retry_classes")
|
|
130
|
+
if not isinstance(retry, dict) or any(not isinstance(retry.get(name), list) for name in RETRY_CLASSES):
|
|
131
|
+
problems.append("publish contract needs retry_classes.retryable, .terminal and .unknown_outcome lists")
|
|
132
|
+
else:
|
|
133
|
+
overlap = (set(retry["retryable"]) & set(retry["terminal"])) | (set(retry["retryable"]) & set(retry["unknown_outcome"]))
|
|
134
|
+
if overlap:
|
|
135
|
+
problems.append(f"retry classes overlap: {', '.join(sorted(overlap))}")
|
|
136
|
+
if "timeout" in retry["retryable"]:
|
|
137
|
+
problems.append("timeout must be classed unknown_outcome, not retryable: the post may already exist")
|
|
138
|
+
idem = adapter.get("idempotency")
|
|
139
|
+
if not isinstance(idem, dict) or not idem.get("key_source"):
|
|
140
|
+
problems.append("publish contract needs idempotency.key_source")
|
|
141
|
+
idem = {}
|
|
142
|
+
readback = adapter.get("readback")
|
|
143
|
+
if not isinstance(readback, dict) or readback.get("lookup") not in READBACK_LOOKUPS:
|
|
144
|
+
problems.append(f"publish contract needs readback.lookup ({', '.join(sorted(READBACK_LOOKUPS))})")
|
|
145
|
+
elif not idem.get("provider_supported") and readback["lookup"] == "by_idempotency_key":
|
|
146
|
+
problems.append("provider has no idempotency support; readback must look up by_marker or by_recent_posts")
|
|
147
|
+
return problems
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _matches(post: dict, key: str) -> bool:
|
|
151
|
+
return post.get("idempotency_key") == key or (isinstance(post.get("marker"), str) and key in post["marker"])
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def reconcile(document: dict) -> dict:
|
|
155
|
+
"""Decide the next safe action per queue item from the attempt log and provider readback.
|
|
156
|
+
|
|
157
|
+
Timeout-after-success never produces a duplicate: an unknown outcome waits
|
|
158
|
+
for readback, a readback post carrying the idempotency key is adopted, and
|
|
159
|
+
a retry always reuses the original key.
|
|
160
|
+
"""
|
|
161
|
+
errors: list[str] = []
|
|
162
|
+
warnings: list[str] = []
|
|
163
|
+
attempts = document.get("attempts")
|
|
164
|
+
readback = document.get("readback") or {}
|
|
165
|
+
posts = readback.get("posts") if isinstance(readback, dict) else None
|
|
166
|
+
if not isinstance(attempts, list) or not isinstance(posts, list):
|
|
167
|
+
return {"valid": False, "errors": ["attempts and readback.posts must be lists"], "warnings": [], "items": []}
|
|
168
|
+
complete = bool(readback.get("complete"))
|
|
169
|
+
by_item: dict[str, list[dict]] = {}
|
|
170
|
+
for attempt in attempts:
|
|
171
|
+
if not isinstance(attempt, dict) or not attempt.get("queue_item_id") or not attempt.get("idempotency_key"):
|
|
172
|
+
errors.append("each attempt needs queue_item_id and idempotency_key")
|
|
173
|
+
continue
|
|
174
|
+
by_item.setdefault(attempt["queue_item_id"], []).append(attempt)
|
|
175
|
+
items = []
|
|
176
|
+
for item_id, history in by_item.items():
|
|
177
|
+
keys = {attempt["idempotency_key"] for attempt in history}
|
|
178
|
+
if len(keys) > 1:
|
|
179
|
+
errors.append(f"queue item {item_id} used {len(keys)} idempotency keys; retries must reuse the original key")
|
|
180
|
+
key = history[0]["idempotency_key"]
|
|
181
|
+
matched = [post for post in posts if isinstance(post, dict) and _matches(post, key)]
|
|
182
|
+
claimed = sorted({attempt["provider_post_id"] for attempt in history if attempt.get("provider_post_id")})
|
|
183
|
+
ids = sorted({post.get("id") for post in matched} | set(claimed))
|
|
184
|
+
if len(matched) > 1 or len(ids) > 1:
|
|
185
|
+
errors.append(f"queue item {item_id} has duplicate provider posts ({', '.join(map(str, ids))}); delete extras and keep the first")
|
|
186
|
+
items.append({"queue_item_id": item_id, "action": "resolve_duplicate", "provider_post_ids": ids, "retry_allowed": False})
|
|
187
|
+
continue
|
|
188
|
+
last = history[-1].get("status")
|
|
189
|
+
if matched:
|
|
190
|
+
action, status = "adopt", "published"
|
|
191
|
+
elif claimed:
|
|
192
|
+
action, status = "verify_readback", "published"
|
|
193
|
+
warnings.append(f"queue item {item_id} reports {claimed[0]} but readback did not find it")
|
|
194
|
+
elif last in {"timeout", "pending", "unknown"}:
|
|
195
|
+
if complete:
|
|
196
|
+
action, status = "retry_same_key", "retrying"
|
|
197
|
+
else:
|
|
198
|
+
action, status = "await_readback", "pending"
|
|
199
|
+
elif last == "failed_retryable":
|
|
200
|
+
action, status = "retry_same_key", "retrying"
|
|
201
|
+
elif last == "failed_terminal":
|
|
202
|
+
action, status = "fail", "failed"
|
|
203
|
+
else:
|
|
204
|
+
action, status = "await_readback", "pending"
|
|
205
|
+
warnings.append(f"queue item {item_id} has unclassified last status {last!r}")
|
|
206
|
+
items.append({"queue_item_id": item_id, "idempotency_key": key, "action": action, "status": status,
|
|
207
|
+
"provider_post_id": (matched[0].get("id") if matched else (claimed[0] if claimed else None)),
|
|
208
|
+
"retry_allowed": action == "retry_same_key"})
|
|
209
|
+
return {"valid": not errors, "errors": errors, "warnings": warnings, "readback_complete": complete, "items": items}
|
|
210
|
+
|
|
211
|
+
|
|
108
212
|
# --- T02: connection health --------------------------------------------------------------------
|
|
109
213
|
|
|
110
214
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Create and validate deterministic Maggie Social Share drafts, adapters, connections, campaigns,
|
|
2
|
+
"""Create and validate deterministic Maggie Social Share drafts, adapters, connections, campaigns, the slot queue, and publish reconciliation."""
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
@@ -10,7 +10,7 @@ from pathlib import Path
|
|
|
10
10
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
11
11
|
|
|
12
12
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
13
|
-
from maggie_social_queue import check_campaigns, connection_health, parse_instant, plan_queue, validate_adapters # noqa: E402
|
|
13
|
+
from maggie_social_queue import check_campaigns, connection_health, parse_instant, plan_queue, reconcile, validate_adapters # noqa: E402
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
LIMITS = {"linkedin": 3000, "facebook": 63206, "x": 280}
|
|
@@ -82,6 +82,8 @@ def main() -> int:
|
|
|
82
82
|
health.add_argument("--now", help="ISO 8601 instant used for expiry checks (default: current time)")
|
|
83
83
|
campaigns = sub.add_parser("campaigns", help="check the campaign idea stage and Start gate")
|
|
84
84
|
campaigns.add_argument("input")
|
|
85
|
+
rec = sub.add_parser("reconcile", help="decide adopt/await/retry per queue item from attempts and provider readback")
|
|
86
|
+
rec.add_argument("input")
|
|
85
87
|
queue = sub.add_parser("queue", help="assign queued and campaign posts to the next posting slots")
|
|
86
88
|
queue.add_argument("input")
|
|
87
89
|
queue.add_argument("--from", dest="start", help="ISO 8601 instant to plan from (default: current time)")
|
|
@@ -95,6 +97,8 @@ def main() -> int:
|
|
|
95
97
|
result = validate_adapters(value)
|
|
96
98
|
elif args.command == "connections":
|
|
97
99
|
result = connection_health(value, parse_instant(args.now, "--now") if args.now else now)
|
|
100
|
+
elif args.command == "reconcile":
|
|
101
|
+
result = reconcile(value)
|
|
98
102
|
elif args.command == "campaigns":
|
|
99
103
|
result = check_campaigns(value)
|
|
100
104
|
elif args.command == "queue":
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Read-only Google Ads preflight and Keyword Planner adapter.
|
|
2
|
+
|
|
3
|
+
Every request goes through an injectable transport so tests run offline. The
|
|
4
|
+
default transport uses urllib and is only exercised when an operator runs the
|
|
5
|
+
CLI with credentials. The adapter refuses any request that is not one of the
|
|
6
|
+
three read-only calls below, so it cannot mutate Ads state.
|
|
7
|
+
|
|
8
|
+
Credential values are read from environment variables named by the caller and
|
|
9
|
+
are never returned, logged, or written to a report.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.parse
|
|
19
|
+
import urllib.request
|
|
20
|
+
from typing import Any, Callable
|
|
21
|
+
|
|
22
|
+
from google_remediation import classify
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
ADS_SCOPE = "https://www.googleapis.com/auth/adwords"
|
|
26
|
+
ADS_HOST = "googleads.googleapis.com"
|
|
27
|
+
TOKENINFO_URL = "https://oauth2.googleapis.com/tokeninfo"
|
|
28
|
+
DEFAULT_API_VERSION = os.environ.get("MAGGIE_GOOGLE_ADS_API_VERSION", "v21")
|
|
29
|
+
COMPETITION = {"UNSPECIFIED": "unknown", "UNKNOWN": "unknown", "LOW": "low", "MEDIUM": "medium", "HIGH": "high"}
|
|
30
|
+
|
|
31
|
+
# (method, path regex relative to https://googleads.googleapis.com/<version>/)
|
|
32
|
+
READ_ONLY_ROUTES = (
|
|
33
|
+
("GET", r"customers:listAccessibleCustomers"),
|
|
34
|
+
("POST", r"customers/\d{10}/googleAds:search"),
|
|
35
|
+
("POST", r"customers/\d{10}:generateKeywordIdeas"),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
Transport = Callable[[str, str, dict[str, str], Any], tuple[int, Any]]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AdsRequestRefused(ValueError):
|
|
42
|
+
"""Raised when a request is not on the read-only allowlist."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def normalize_customer_id(value: Any) -> str | None:
|
|
46
|
+
"""Return a 10-digit customer ID without hyphens, or None when invalid."""
|
|
47
|
+
if not isinstance(value, str):
|
|
48
|
+
return None
|
|
49
|
+
digits = value.replace("-", "").strip()
|
|
50
|
+
return digits if re.fullmatch(r"\d{10}", digits) else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def urllib_transport(method: str, url: str, headers: dict[str, str], body: Any) -> tuple[int, Any]:
|
|
54
|
+
data = None
|
|
55
|
+
if body is not None:
|
|
56
|
+
if headers.get("content-type") == "application/x-www-form-urlencoded":
|
|
57
|
+
data = urllib.parse.urlencode(body).encode()
|
|
58
|
+
else:
|
|
59
|
+
data = json.dumps(body).encode()
|
|
60
|
+
headers = {**headers, "content-type": "application/json"}
|
|
61
|
+
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
62
|
+
try:
|
|
63
|
+
with urllib.request.urlopen(request, timeout=30) as response:
|
|
64
|
+
return response.status, json.loads(response.read() or b"{}")
|
|
65
|
+
except urllib.error.HTTPError as error:
|
|
66
|
+
try:
|
|
67
|
+
payload = json.loads(error.read() or b"{}")
|
|
68
|
+
except json.JSONDecodeError:
|
|
69
|
+
payload = {}
|
|
70
|
+
return error.code, payload
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _error_signal(payload: Any) -> dict[str, Any]:
|
|
74
|
+
"""Extract only error codes/reasons from a provider payload for classification."""
|
|
75
|
+
signal: list[str] = []
|
|
76
|
+
if isinstance(payload, dict):
|
|
77
|
+
error = payload.get("error")
|
|
78
|
+
if isinstance(error, str):
|
|
79
|
+
signal.append(error)
|
|
80
|
+
if isinstance(error, dict):
|
|
81
|
+
signal.append(str(error.get("status") or ""))
|
|
82
|
+
signal.append(str(error.get("message") or "")[:300])
|
|
83
|
+
for detail in error.get("details") or []:
|
|
84
|
+
if not isinstance(detail, dict):
|
|
85
|
+
continue
|
|
86
|
+
signal.append(str(detail.get("reason") or ""))
|
|
87
|
+
for item in detail.get("errors") or []:
|
|
88
|
+
if isinstance(item, dict) and isinstance(item.get("errorCode"), dict):
|
|
89
|
+
signal.extend(f"{value}" for value in item["errorCode"].values())
|
|
90
|
+
if isinstance(payload.get("error_description"), str):
|
|
91
|
+
signal.append(payload["error_description"][:300])
|
|
92
|
+
return {"reason": " ".join(part for part in signal if part)[:300]}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class AdsClient:
|
|
96
|
+
def __init__(self, *, access_token: str, developer_token: str | None, login_customer_id: str | None = None,
|
|
97
|
+
api_version: str = DEFAULT_API_VERSION, transport: Transport | None = None) -> None:
|
|
98
|
+
self._access_token = access_token
|
|
99
|
+
self._developer_token = developer_token
|
|
100
|
+
self._login_customer_id = login_customer_id
|
|
101
|
+
self.api_version = api_version
|
|
102
|
+
self._transport = transport or urllib_transport
|
|
103
|
+
|
|
104
|
+
def _headers(self) -> dict[str, str]:
|
|
105
|
+
headers = {"authorization": f"Bearer {self._access_token}"}
|
|
106
|
+
if self._developer_token:
|
|
107
|
+
headers["developer-token"] = self._developer_token
|
|
108
|
+
if self._login_customer_id:
|
|
109
|
+
headers["login-customer-id"] = self._login_customer_id
|
|
110
|
+
return headers
|
|
111
|
+
|
|
112
|
+
def request(self, method: str, path: str, body: Any = None) -> tuple[int, Any]:
|
|
113
|
+
if not any(method == allowed and re.fullmatch(pattern, path) for allowed, pattern in READ_ONLY_ROUTES):
|
|
114
|
+
raise AdsRequestRefused(f"refused non-allowlisted Google Ads request: {method} {path.split('/')[-1].split(':')[-1]}")
|
|
115
|
+
return self._transport(method, f"https://{ADS_HOST}/{self.api_version}/{path}", self._headers(), body)
|
|
116
|
+
|
|
117
|
+
def token_scopes(self) -> tuple[int, list[str]]:
|
|
118
|
+
# POST body keeps the token out of URLs and access logs.
|
|
119
|
+
status, payload = self._transport("POST", TOKENINFO_URL, {"content-type": "application/x-www-form-urlencoded"}, {"access_token": self._access_token})
|
|
120
|
+
scopes = str(payload.get("scope") or "").split() if isinstance(payload, dict) else []
|
|
121
|
+
return status, scopes
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _check(checks: list[dict[str, Any]], name: str, status: str, detail: str, remediation: dict[str, Any] | None = None) -> None:
|
|
125
|
+
item = {"check": name, "status": status, "detail": detail}
|
|
126
|
+
if remediation:
|
|
127
|
+
item["remediation"] = {key: remediation[key] for key in ("code", "prerequisite", "nextAction", "httpStatus")}
|
|
128
|
+
checks.append(item)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _failure(status: int, payload: Any) -> dict[str, Any]:
|
|
132
|
+
return classify(_error_signal(payload), status=status, provider="google-ads")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def preflight(*, customer_id: str | None, login_customer_id: str | None, access_token: str | None,
|
|
136
|
+
developer_token: str | None, api_version: str = DEFAULT_API_VERSION,
|
|
137
|
+
transport: Transport | None = None, probe_keyword_planner: bool = False) -> dict[str, Any]:
|
|
138
|
+
"""Run ordered, independent read-only checks and return a redacted report."""
|
|
139
|
+
checks: list[dict[str, Any]] = []
|
|
140
|
+
customer = normalize_customer_id(customer_id)
|
|
141
|
+
login = normalize_customer_id(login_customer_id) if login_customer_id else None
|
|
142
|
+
if customer:
|
|
143
|
+
_check(checks, "customer_id_format", "passed", "customer ID is 10 digits")
|
|
144
|
+
else:
|
|
145
|
+
_check(checks, "customer_id_format", "failed", "customer ID must be 10 digits (hyphens allowed in input)",
|
|
146
|
+
{"code": "customer_id_format", "prerequisite": "ads-customer-id", "nextAction": "Pass the target Ads customer ID as 10 digits, e.g. 123-456-7890.", "httpStatus": None})
|
|
147
|
+
if login_customer_id is None:
|
|
148
|
+
_check(checks, "login_customer_id_format", "skipped", "no manager login-customer-id supplied")
|
|
149
|
+
elif login:
|
|
150
|
+
_check(checks, "login_customer_id_format", "passed", "login-customer-id is 10 digits")
|
|
151
|
+
else:
|
|
152
|
+
_check(checks, "login_customer_id_format", "failed", "login-customer-id must be 10 digits",
|
|
153
|
+
{"code": "login_customer_mismatch", "prerequisite": "ads-login-customer-id", "nextAction": "Pass the manager account ID as 10 digits, or omit it for direct access.", "httpStatus": None})
|
|
154
|
+
if developer_token:
|
|
155
|
+
_check(checks, "developer_token_present", "passed", "developer token is present (value not recorded)")
|
|
156
|
+
else:
|
|
157
|
+
_check(checks, "developer_token_present", "failed", "developer token environment variable is empty", classify("DEVELOPER_TOKEN_PARAMETER_MISSING", provider="google-ads"))
|
|
158
|
+
if not access_token:
|
|
159
|
+
_check(checks, "oauth_scope", "failed", "access token environment variable is empty", classify("invalid_grant", provider="google-ads"))
|
|
160
|
+
return _report(checks, customer, login, api_version)
|
|
161
|
+
|
|
162
|
+
client = AdsClient(access_token=access_token, developer_token=developer_token, login_customer_id=login, api_version=api_version, transport=transport)
|
|
163
|
+
status, scopes = client.token_scopes()
|
|
164
|
+
if status != 200:
|
|
165
|
+
_check(checks, "oauth_scope", "failed", "token introspection failed", classify("invalid_grant", status=status, provider="google-ads"))
|
|
166
|
+
return _report(checks, customer, login, api_version)
|
|
167
|
+
if ADS_SCOPE in scopes:
|
|
168
|
+
_check(checks, "oauth_scope", "passed", "token carries the adwords scope")
|
|
169
|
+
else:
|
|
170
|
+
_check(checks, "oauth_scope", "failed", "token lacks the adwords scope", classify("ACCESS_TOKEN_SCOPE_INSUFFICIENT", provider="google-ads"))
|
|
171
|
+
return _report(checks, customer, login, api_version)
|
|
172
|
+
if not developer_token or not customer:
|
|
173
|
+
return _report(checks, customer, login, api_version)
|
|
174
|
+
|
|
175
|
+
status, payload = client.request("GET", "customers:listAccessibleCustomers")
|
|
176
|
+
accessible = sorted(name.split("/")[-1] for name in (payload.get("resourceNames") or [])) if status == 200 and isinstance(payload, dict) else []
|
|
177
|
+
if status != 200:
|
|
178
|
+
_check(checks, "accessible_customers", "failed", "listAccessibleCustomers was rejected", _failure(status, payload))
|
|
179
|
+
return _report(checks, customer, login, api_version)
|
|
180
|
+
direct = customer in accessible
|
|
181
|
+
via_manager = bool(login and login in accessible)
|
|
182
|
+
if direct or via_manager:
|
|
183
|
+
_check(checks, "accessible_customers", "passed", f"{len(accessible)} accessible customer(s); target reachable {'directly' if direct else 'via manager'}")
|
|
184
|
+
else:
|
|
185
|
+
_check(checks, "accessible_customers", "failed", f"{len(accessible)} accessible customer(s); target not among them",
|
|
186
|
+
classify("USER_PERMISSION_DENIED" if not login else "USER_PERMISSION_DENIED manager", provider="google-ads"))
|
|
187
|
+
return _report(checks, customer, login, api_version)
|
|
188
|
+
|
|
189
|
+
currency = None
|
|
190
|
+
if via_manager and not direct:
|
|
191
|
+
query = f"SELECT customer_client.id, customer_client.manager, customer_client.status FROM customer_client WHERE customer_client.id = {customer}"
|
|
192
|
+
status, payload = client.request("POST", f"customers/{login}/googleAds:search", {"query": query})
|
|
193
|
+
rows = (payload.get("results") or []) if status == 200 and isinstance(payload, dict) else []
|
|
194
|
+
if status == 200 and rows:
|
|
195
|
+
_check(checks, "account_hierarchy", "passed", "target is linked under the manager account")
|
|
196
|
+
else:
|
|
197
|
+
_check(checks, "account_hierarchy", "failed", "target is not linked under the manager account",
|
|
198
|
+
_failure(status, payload) if status != 200 else classify("login-customer-id", provider="google-ads"))
|
|
199
|
+
return _report(checks, customer, login, api_version)
|
|
200
|
+
else:
|
|
201
|
+
_check(checks, "account_hierarchy", "skipped", "direct access; no manager hierarchy needed")
|
|
202
|
+
status, payload = client.request("POST", f"customers/{customer}/googleAds:search",
|
|
203
|
+
{"query": "SELECT customer.id, customer.currency_code, customer.manager, customer.test_account FROM customer LIMIT 1"})
|
|
204
|
+
if status == 200 and isinstance(payload, dict) and payload.get("results"):
|
|
205
|
+
info = payload["results"][0].get("customer") or {}
|
|
206
|
+
currency = info.get("currencyCode")
|
|
207
|
+
if info.get("manager"):
|
|
208
|
+
_check(checks, "customer_readable", "failed", "target is a manager account; Keyword Planner needs a client account",
|
|
209
|
+
{"code": "manager_account_target", "prerequisite": "ads-client-account", "nextAction": "Select a client (non-manager) customer ID as the Keyword Planner target.", "httpStatus": None})
|
|
210
|
+
return _report(checks, customer, login, api_version, currency)
|
|
211
|
+
_check(checks, "customer_readable", "passed", "customer row readable" + (" (test account)" if info.get("testAccount") else ""))
|
|
212
|
+
else:
|
|
213
|
+
_check(checks, "customer_readable", "failed", "customer query was rejected", _failure(status, payload))
|
|
214
|
+
return _report(checks, customer, login, api_version)
|
|
215
|
+
|
|
216
|
+
if probe_keyword_planner:
|
|
217
|
+
status, payload = client.request("POST", f"customers/{customer}:generateKeywordIdeas",
|
|
218
|
+
{"keywordSeed": {"keywords": ["test"]}, "pageSize": 1, "includeAdultKeywords": False})
|
|
219
|
+
if status == 200:
|
|
220
|
+
_check(checks, "keyword_planner", "passed", "one-result KeywordPlanIdeaService probe succeeded")
|
|
221
|
+
else:
|
|
222
|
+
_check(checks, "keyword_planner", "failed", "KeywordPlanIdeaService probe was rejected", _failure(status, payload))
|
|
223
|
+
else:
|
|
224
|
+
_check(checks, "keyword_planner", "ready", "all prerequisites passed; pass --probe-keyword-planner to spend one read-only request")
|
|
225
|
+
return _report(checks, customer, login, api_version, currency)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _report(checks: list[dict[str, Any]], customer: str | None, login: str | None, api_version: str, currency: str | None = None) -> dict[str, Any]:
|
|
229
|
+
failed = [item for item in checks if item["status"] == "failed"]
|
|
230
|
+
return {
|
|
231
|
+
"schemaVersion": "maggie-google-ads-preflight.v1",
|
|
232
|
+
"passed": not failed,
|
|
233
|
+
"apiVersion": api_version,
|
|
234
|
+
"customerId": customer,
|
|
235
|
+
"loginCustomerId": login,
|
|
236
|
+
"currency": currency,
|
|
237
|
+
"checks": checks,
|
|
238
|
+
"firstFailure": failed[0]["check"] if failed else None,
|
|
239
|
+
"nextAction": failed[0]["remediation"]["nextAction"] if failed and failed[0].get("remediation") else None,
|
|
240
|
+
"mutationsAllowed": False,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _micros(value: Any) -> float | None:
|
|
245
|
+
try:
|
|
246
|
+
return round(int(value) / 1_000_000, 2)
|
|
247
|
+
except (TypeError, ValueError):
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def normalize_ideas(results: list[Any], currency: str | None) -> list[dict[str, Any]]:
|
|
252
|
+
ideas = []
|
|
253
|
+
for item in results:
|
|
254
|
+
if not isinstance(item, dict) or not isinstance(item.get("text"), str):
|
|
255
|
+
continue
|
|
256
|
+
metrics = item.get("keywordIdeaMetrics") or {}
|
|
257
|
+
searches = metrics.get("avgMonthlySearches")
|
|
258
|
+
ideas.append({
|
|
259
|
+
"text": item["text"],
|
|
260
|
+
"avgMonthlySearches": int(searches) if str(searches or "").isdigit() else None,
|
|
261
|
+
"competition": COMPETITION.get(str(metrics.get("competition") or "UNKNOWN"), "unknown"),
|
|
262
|
+
"competitionIndex": int(metrics["competitionIndex"]) if str(metrics.get("competitionIndex") or "").isdigit() else None,
|
|
263
|
+
"lowTopOfPageBid": _micros(metrics.get("lowTopOfPageBidMicros")),
|
|
264
|
+
"highTopOfPageBid": _micros(metrics.get("highTopOfPageBidMicros")),
|
|
265
|
+
"currency": currency,
|
|
266
|
+
})
|
|
267
|
+
return ideas
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def keyword_ideas(*, customer_id: str, login_customer_id: str | None, access_token: str, developer_token: str,
|
|
271
|
+
keywords: list[str], url: str | None = None, language: str | None = None, geo_targets: list[str] | None = None,
|
|
272
|
+
currency: str | None = None, max_pages: int = 1, page_size: int = 100,
|
|
273
|
+
api_version: str = DEFAULT_API_VERSION, transport: Transport | None = None) -> dict[str, Any]:
|
|
274
|
+
customer = normalize_customer_id(customer_id)
|
|
275
|
+
if not customer:
|
|
276
|
+
raise ValueError("customer ID must be 10 digits")
|
|
277
|
+
if not keywords and not url:
|
|
278
|
+
raise ValueError("provide at least one seed keyword or a seed URL")
|
|
279
|
+
login = normalize_customer_id(login_customer_id) if login_customer_id else None
|
|
280
|
+
client = AdsClient(access_token=access_token, developer_token=developer_token, login_customer_id=login, api_version=api_version, transport=transport)
|
|
281
|
+
body: dict[str, Any] = {"pageSize": max(1, min(page_size, 1000)), "includeAdultKeywords": False, "keywordPlanNetwork": "GOOGLE_SEARCH"}
|
|
282
|
+
if keywords and url:
|
|
283
|
+
body["keywordAndUrlSeed"] = {"keywords": keywords, "url": url}
|
|
284
|
+
elif keywords:
|
|
285
|
+
body["keywordSeed"] = {"keywords": keywords}
|
|
286
|
+
else:
|
|
287
|
+
body["urlSeed"] = {"url": url}
|
|
288
|
+
if language:
|
|
289
|
+
body["language"] = language if language.startswith("languageConstants/") else f"languageConstants/{language}"
|
|
290
|
+
if geo_targets:
|
|
291
|
+
body["geoTargetConstants"] = [item if item.startswith("geoTargetConstants/") else f"geoTargetConstants/{item}" for item in geo_targets]
|
|
292
|
+
results: list[Any] = []
|
|
293
|
+
pages = 0
|
|
294
|
+
while pages < max(1, max_pages):
|
|
295
|
+
status, payload = client.request("POST", f"customers/{customer}:generateKeywordIdeas", body)
|
|
296
|
+
pages += 1
|
|
297
|
+
if status != 200:
|
|
298
|
+
return {"schemaVersion": "maggie-google-ads-keyword-ideas.v1", "passed": False, "customerId": customer, "ideas": normalize_ideas(results, currency),
|
|
299
|
+
"pages": pages, "remediation": {key: value for key, value in _failure(status, payload).items() if key != "provider"}, "mutationsAllowed": False}
|
|
300
|
+
results.extend(payload.get("results") or [])
|
|
301
|
+
token = payload.get("nextPageToken")
|
|
302
|
+
if not token:
|
|
303
|
+
break
|
|
304
|
+
body = {**body, "pageToken": token}
|
|
305
|
+
return {"schemaVersion": "maggie-google-ads-keyword-ideas.v1", "passed": True, "customerId": customer, "pages": pages,
|
|
306
|
+
"ideas": normalize_ideas(results, currency), "mutationsAllowed": False}
|
|
@@ -6,6 +6,8 @@ import re
|
|
|
6
6
|
from typing import Any
|
|
7
7
|
from urllib.parse import urlsplit
|
|
8
8
|
|
|
9
|
+
from google_remediation import classify as classify_failure
|
|
10
|
+
|
|
9
11
|
|
|
10
12
|
SCHEMA_VERSION = "maggie-google-capability-report.v2"
|
|
11
13
|
CAPABILITY_NAMES = ("read", "report", "edit", "publish")
|
|
@@ -172,7 +174,7 @@ def validate_report(report: Any) -> dict[str, Any]:
|
|
|
172
174
|
errors.append(f"{prefix} must be an object")
|
|
173
175
|
continue
|
|
174
176
|
required = {"provider", "resource", "activeAccount", "target", "authMode", "scopes", "productRole", "evidence", "capabilities", "nextAction"}
|
|
175
|
-
unknown = sorted(set(row) - required)
|
|
177
|
+
unknown = sorted(set(row) - required - {"failure"})
|
|
176
178
|
if unknown:
|
|
177
179
|
errors.append(f"{prefix} contains unsupported fields")
|
|
178
180
|
provider = row.get("provider")
|
|
@@ -253,6 +255,15 @@ def validate_report(report: Any) -> dict[str, Any]:
|
|
|
253
255
|
errors.append(f"{prefix}.evidence.adsPrerequisites requires developer token and customer ID presence")
|
|
254
256
|
elif prerequisites is not None:
|
|
255
257
|
errors.append(f"{prefix}.evidence.adsPrerequisites is only valid for google-ads")
|
|
258
|
+
remediation = None
|
|
259
|
+
failure = row.get("failure")
|
|
260
|
+
if failure is not None:
|
|
261
|
+
if not isinstance(failure, dict) or set(failure) - {"code", "reason", "httpStatus"} or not any(failure.get(key) for key in ("code", "reason", "httpStatus")):
|
|
262
|
+
errors.append(f"{prefix}.failure must contain only code, reason, and httpStatus")
|
|
263
|
+
elif any(not isinstance(failure.get(key), str) or len(failure[key]) > 300 for key in ("code", "reason") if key in failure):
|
|
264
|
+
errors.append(f"{prefix}.failure.code and reason must be bounded strings")
|
|
265
|
+
else:
|
|
266
|
+
remediation = classify_failure(failure, provider=provider)
|
|
256
267
|
normalized.append({
|
|
257
268
|
"provider": provider,
|
|
258
269
|
"resource": resource,
|
|
@@ -269,7 +280,8 @@ def validate_report(report: Any) -> dict[str, Any]:
|
|
|
269
280
|
"writeTested": isinstance(evidence.get("writeTest"), dict),
|
|
270
281
|
},
|
|
271
282
|
"capabilities": safe_capabilities,
|
|
272
|
-
"nextAction": row.get("nextAction"),
|
|
283
|
+
"nextAction": remediation["nextAction"] if remediation else row.get("nextAction"),
|
|
284
|
+
**({"remediation": remediation} if remediation else {}),
|
|
273
285
|
})
|
|
274
286
|
|
|
275
287
|
return {
|
|
@@ -277,5 +289,9 @@ def validate_report(report: Any) -> dict[str, Any]:
|
|
|
277
289
|
"passed": not errors,
|
|
278
290
|
"errors": sorted(set(errors)),
|
|
279
291
|
"providerResults": normalized,
|
|
292
|
+
"remediation": [
|
|
293
|
+
{"provider": item["provider"], "resource": item["resource"], **{key: item["remediation"][key] for key in ("code", "prerequisite", "nextAction", "httpStatus")}}
|
|
294
|
+
for item in normalized if item.get("remediation")
|
|
295
|
+
],
|
|
280
296
|
"mutationsAllowed": False,
|
|
281
297
|
}
|