adloop 0.5.2__tar.gz → 0.6.0__tar.gz
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.
- {adloop-0.5.2 → adloop-0.6.0}/PKG-INFO +1 -1
- {adloop-0.5.2 → adloop-0.6.0}/pyproject.toml +1 -1
- {adloop-0.5.2 → adloop-0.6.0}/src/adloop/__init__.py +1 -1
- {adloop-0.5.2 → adloop-0.6.0}/src/adloop/ads/read.py +7 -1
- {adloop-0.5.2 → adloop-0.6.0}/src/adloop/ads/write.py +135 -3
- {adloop-0.5.2 → adloop-0.6.0}/src/adloop/server.py +36 -1
- {adloop-0.5.2 → adloop-0.6.0}/README.md +0 -0
- {adloop-0.5.2 → adloop-0.6.0}/src/adloop/__main__.py +0 -0
- {adloop-0.5.2 → adloop-0.6.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.5.2 → adloop-0.6.0}/src/adloop/ads/client.py +0 -0
- {adloop-0.5.2 → adloop-0.6.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.5.2 → adloop-0.6.0}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.5.2 → adloop-0.6.0}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.5.2 → adloop-0.6.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.5.2 → adloop-0.6.0}/src/adloop/auth.py +0 -0
- {adloop-0.5.2 → adloop-0.6.0}/src/adloop/bundled_credentials.json +0 -0
- {adloop-0.5.2 → adloop-0.6.0}/src/adloop/cli.py +0 -0
- {adloop-0.5.2 → adloop-0.6.0}/src/adloop/config.py +0 -0
- {adloop-0.5.2 → adloop-0.6.0}/src/adloop/crossref.py +0 -0
- {adloop-0.5.2 → adloop-0.6.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.5.2 → adloop-0.6.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.5.2 → adloop-0.6.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.5.2 → adloop-0.6.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.5.2 → adloop-0.6.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.5.2 → adloop-0.6.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.5.2 → adloop-0.6.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.5.2 → adloop-0.6.0}/src/adloop/safety/preview.py +0 -0
- {adloop-0.5.2 → adloop-0.6.0}/src/adloop/tracking.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: adloop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Stop switching between Google Ads, GA4, and your code editor to figure out why conversions dropped.
|
|
5
5
|
Keywords: mcp,google-ads,google-analytics,ga4,cursor,marketing
|
|
6
6
|
Author: Daniel Klose
|
|
@@ -267,7 +267,8 @@ def get_negative_keyword_list_keywords(
|
|
|
267
267
|
return {"error": "shared_set_id must be a numeric ID"}
|
|
268
268
|
|
|
269
269
|
query = f"""
|
|
270
|
-
SELECT shared_criterion.
|
|
270
|
+
SELECT shared_criterion.criterion_id,
|
|
271
|
+
shared_criterion.keyword.text,
|
|
271
272
|
shared_criterion.keyword.match_type,
|
|
272
273
|
shared_criterion.type,
|
|
273
274
|
shared_set.id, shared_set.name
|
|
@@ -277,6 +278,11 @@ def get_negative_keyword_list_keywords(
|
|
|
277
278
|
"""
|
|
278
279
|
|
|
279
280
|
rows = execute_query(config, customer_id, query)
|
|
281
|
+
for row in rows:
|
|
282
|
+
ssid = row.get("shared_set.id")
|
|
283
|
+
crit_id = row.get("shared_criterion.criterion_id")
|
|
284
|
+
if ssid and crit_id:
|
|
285
|
+
row["resource_id"] = f"{ssid}~{crit_id}"
|
|
280
286
|
return {
|
|
281
287
|
"keywords": rows,
|
|
282
288
|
"total_keywords": len(rows),
|
|
@@ -396,6 +396,77 @@ def propose_negative_keyword_list(
|
|
|
396
396
|
return plan.to_preview()
|
|
397
397
|
|
|
398
398
|
|
|
399
|
+
def add_to_negative_keyword_list(
|
|
400
|
+
config: AdLoopConfig,
|
|
401
|
+
*,
|
|
402
|
+
customer_id: str = "",
|
|
403
|
+
shared_set_id: str = "",
|
|
404
|
+
keywords: list[str] | None = None,
|
|
405
|
+
match_type: str = "EXACT",
|
|
406
|
+
) -> dict:
|
|
407
|
+
"""Draft adding keywords to an existing shared negative keyword list — returns PREVIEW.
|
|
408
|
+
|
|
409
|
+
Unlike ``propose_negative_keyword_list`` (which creates a NEW list), this
|
|
410
|
+
appends keywords to an existing SharedSet identified by ``shared_set_id``.
|
|
411
|
+
Use ``get_negative_keyword_lists`` to find the list's ID. Call
|
|
412
|
+
``confirm_and_apply`` with the returned plan_id to execute.
|
|
413
|
+
"""
|
|
414
|
+
from adloop.safety.guards import SafetyViolation, check_blocked_operation
|
|
415
|
+
from adloop.safety.preview import ChangePlan, store_plan
|
|
416
|
+
|
|
417
|
+
try:
|
|
418
|
+
check_blocked_operation("add_to_negative_keyword_list", config.safety)
|
|
419
|
+
except SafetyViolation as e:
|
|
420
|
+
return {"error": str(e)}
|
|
421
|
+
|
|
422
|
+
keywords = keywords or []
|
|
423
|
+
match_type = match_type.upper()
|
|
424
|
+
|
|
425
|
+
errors = []
|
|
426
|
+
if not shared_set_id:
|
|
427
|
+
errors.append("shared_set_id is required")
|
|
428
|
+
elif not str(shared_set_id).isdigit():
|
|
429
|
+
errors.append("shared_set_id must be a numeric ID (from get_negative_keyword_lists)")
|
|
430
|
+
if not keywords:
|
|
431
|
+
errors.append("At least one keyword is required")
|
|
432
|
+
if match_type not in _VALID_MATCH_TYPES:
|
|
433
|
+
errors.append(f"Invalid match_type '{match_type}' — use EXACT, PHRASE, or BROAD")
|
|
434
|
+
if errors:
|
|
435
|
+
return {"error": "Validation failed", "details": errors}
|
|
436
|
+
|
|
437
|
+
seen: set[str] = set()
|
|
438
|
+
deduped: list[str] = []
|
|
439
|
+
for kw in keywords:
|
|
440
|
+
text = kw.strip()
|
|
441
|
+
if not text:
|
|
442
|
+
continue
|
|
443
|
+
key = text.lower()
|
|
444
|
+
if key in seen:
|
|
445
|
+
continue
|
|
446
|
+
seen.add(key)
|
|
447
|
+
deduped.append(text)
|
|
448
|
+
|
|
449
|
+
if not deduped:
|
|
450
|
+
return {
|
|
451
|
+
"error": "Validation failed",
|
|
452
|
+
"details": ["At least one non-empty keyword is required"],
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
plan = ChangePlan(
|
|
456
|
+
operation="add_to_negative_keyword_list",
|
|
457
|
+
entity_type="negative_keyword_list",
|
|
458
|
+
entity_id=str(shared_set_id),
|
|
459
|
+
customer_id=customer_id,
|
|
460
|
+
changes={
|
|
461
|
+
"shared_set_id": str(shared_set_id),
|
|
462
|
+
"keywords": deduped,
|
|
463
|
+
"match_type": match_type,
|
|
464
|
+
},
|
|
465
|
+
)
|
|
466
|
+
store_plan(plan)
|
|
467
|
+
return plan.to_preview()
|
|
468
|
+
|
|
469
|
+
|
|
399
470
|
def update_ad_group(
|
|
400
471
|
config: AdLoopConfig,
|
|
401
472
|
*,
|
|
@@ -484,11 +555,26 @@ def remove_entity(
|
|
|
484
555
|
entity_type: str = "",
|
|
485
556
|
entity_id: str = "",
|
|
486
557
|
) -> dict:
|
|
487
|
-
"""Draft removing an entity
|
|
558
|
+
"""Draft removing an entity — returns preview.
|
|
559
|
+
|
|
560
|
+
Supported ``entity_type`` values: ``campaign``, ``ad_group``, ``ad``,
|
|
561
|
+
``keyword``, ``negative_keyword``, ``shared_criterion``, ``campaign_asset``,
|
|
562
|
+
``asset``, ``customer_asset``.
|
|
563
|
+
|
|
564
|
+
Composite ``entity_id`` formats:
|
|
565
|
+
|
|
566
|
+
- ``keyword``: ``adGroupId~criterionId``
|
|
567
|
+
- ``negative_keyword``: ``campaignId~criterionId`` (use the ``resource_id``
|
|
568
|
+
field from ``get_negative_keywords``)
|
|
569
|
+
- ``shared_criterion``: ``sharedSetId~criterionId`` (use the ``resource_id``
|
|
570
|
+
field from ``get_negative_keyword_list_keywords``)
|
|
571
|
+
- ``campaign_asset``: ``campaignId~assetId~fieldType``
|
|
572
|
+
- ``customer_asset``: ``assetId~fieldType``
|
|
573
|
+
- ``asset``: bare asset ID
|
|
488
574
|
|
|
489
575
|
This is a DESTRUCTIVE operation — removed entities cannot be re-enabled.
|
|
490
|
-
|
|
491
|
-
|
|
576
|
+
Prefer ``pause_entity`` unless the user explicitly wants permanent removal.
|
|
577
|
+
Call ``confirm_and_apply`` with the returned plan_id to execute.
|
|
492
578
|
"""
|
|
493
579
|
from adloop.safety.guards import SafetyViolation, check_blocked_operation
|
|
494
580
|
from adloop.safety.preview import ChangePlan, store_plan
|
|
@@ -1167,6 +1253,7 @@ _VALID_MATCH_TYPES = {"EXACT", "PHRASE", "BROAD"}
|
|
|
1167
1253
|
_VALID_ENTITY_TYPES = {"campaign", "ad_group", "ad", "keyword"}
|
|
1168
1254
|
_REMOVABLE_ENTITY_TYPES = _VALID_ENTITY_TYPES | {
|
|
1169
1255
|
"negative_keyword", "campaign_asset", "asset", "customer_asset",
|
|
1256
|
+
"shared_criterion",
|
|
1170
1257
|
}
|
|
1171
1258
|
|
|
1172
1259
|
_SMART_BIDDING_STRATEGIES = {
|
|
@@ -1726,6 +1813,7 @@ def _execute_plan(config: AdLoopConfig, plan: object) -> dict:
|
|
|
1726
1813
|
"add_keywords": _apply_add_keywords,
|
|
1727
1814
|
"add_negative_keywords": _apply_add_negative_keywords,
|
|
1728
1815
|
"create_negative_keyword_list": _apply_create_negative_keyword_list,
|
|
1816
|
+
"add_to_negative_keyword_list": _apply_add_to_negative_keyword_list,
|
|
1729
1817
|
"pause_entity": _apply_status_change,
|
|
1730
1818
|
"enable_entity": _apply_status_change,
|
|
1731
1819
|
"remove_entity": _apply_remove,
|
|
@@ -2282,6 +2370,19 @@ def _apply_remove(
|
|
|
2282
2370
|
customer_id=cid, operations=[operation]
|
|
2283
2371
|
)
|
|
2284
2372
|
|
|
2373
|
+
elif entity_type == "shared_criterion":
|
|
2374
|
+
if "~" not in entity_id:
|
|
2375
|
+
raise ValueError(
|
|
2376
|
+
f"shared_criterion entity_id must be "
|
|
2377
|
+
f"'sharedSetId~criterionId', got '{entity_id}'"
|
|
2378
|
+
)
|
|
2379
|
+
service = client.get_service("SharedCriterionService")
|
|
2380
|
+
operation = client.get_type("SharedCriterionOperation")
|
|
2381
|
+
operation.remove = f"customers/{cid}/sharedCriteria/{entity_id}"
|
|
2382
|
+
response = service.mutate_shared_criteria(
|
|
2383
|
+
customer_id=cid, operations=[operation]
|
|
2384
|
+
)
|
|
2385
|
+
|
|
2285
2386
|
elif entity_type == "campaign_asset":
|
|
2286
2387
|
parts = entity_id.split("~")
|
|
2287
2388
|
if len(parts) != 3:
|
|
@@ -2599,3 +2700,34 @@ def _apply_create_negative_keyword_list(
|
|
|
2599
2700
|
"campaign_shared_set_resource": css_response.results[0].resource_name,
|
|
2600
2701
|
"keyword_count": len(changes["keywords"]),
|
|
2601
2702
|
}
|
|
2703
|
+
|
|
2704
|
+
|
|
2705
|
+
def _apply_add_to_negative_keyword_list(
|
|
2706
|
+
client: object, cid: str, changes: dict
|
|
2707
|
+
) -> dict:
|
|
2708
|
+
"""Append keywords to an existing shared negative keyword list."""
|
|
2709
|
+
shared_set_service = client.get_service("SharedSetService")
|
|
2710
|
+
shared_set_resource = shared_set_service.shared_set_path(
|
|
2711
|
+
cid, changes["shared_set_id"]
|
|
2712
|
+
)
|
|
2713
|
+
|
|
2714
|
+
sc_service = client.get_service("SharedCriterionService")
|
|
2715
|
+
operations = []
|
|
2716
|
+
for kw_text in changes["keywords"]:
|
|
2717
|
+
op = client.get_type("SharedCriterionOperation")
|
|
2718
|
+
criterion = op.create
|
|
2719
|
+
criterion.shared_set = shared_set_resource
|
|
2720
|
+
criterion.keyword.text = kw_text
|
|
2721
|
+
criterion.keyword.match_type = getattr(
|
|
2722
|
+
client.enums.KeywordMatchTypeEnum, changes["match_type"]
|
|
2723
|
+
)
|
|
2724
|
+
operations.append(op)
|
|
2725
|
+
|
|
2726
|
+
response = sc_service.mutate_shared_criteria(
|
|
2727
|
+
customer_id=cid, operations=operations
|
|
2728
|
+
)
|
|
2729
|
+
return {
|
|
2730
|
+
"shared_set_resource": shared_set_resource,
|
|
2731
|
+
"resource_names": [r.resource_name for r in response.results],
|
|
2732
|
+
"keyword_count": len(response.results),
|
|
2733
|
+
}
|
|
@@ -979,6 +979,39 @@ def propose_negative_keyword_list(
|
|
|
979
979
|
)
|
|
980
980
|
|
|
981
981
|
|
|
982
|
+
@mcp.tool(annotations=_WRITE)
|
|
983
|
+
@_safe
|
|
984
|
+
def add_to_negative_keyword_list(
|
|
985
|
+
shared_set_id: str,
|
|
986
|
+
keywords: list[str],
|
|
987
|
+
customer_id: str = "",
|
|
988
|
+
match_type: str = "EXACT",
|
|
989
|
+
) -> dict:
|
|
990
|
+
"""Append keywords to an EXISTING shared negative keyword list — returns a PREVIEW.
|
|
991
|
+
|
|
992
|
+
Use this when a suitable list already exists and only needs more keywords
|
|
993
|
+
(instead of propose_negative_keyword_list, which creates a new list).
|
|
994
|
+
Always call get_negative_keyword_lists first to find the right shared_set_id
|
|
995
|
+
and get_negative_keyword_list_keywords to avoid duplicating existing terms.
|
|
996
|
+
|
|
997
|
+
shared_set_id: numeric ID from get_negative_keyword_lists (shared_set.id).
|
|
998
|
+
keywords: list of keyword strings to append (duplicates in the input list
|
|
999
|
+
are collapsed).
|
|
1000
|
+
match_type: "EXACT", "PHRASE", or "BROAD"
|
|
1001
|
+
|
|
1002
|
+
Call confirm_and_apply with the returned plan_id to execute.
|
|
1003
|
+
"""
|
|
1004
|
+
from adloop.ads.write import add_to_negative_keyword_list as _impl
|
|
1005
|
+
|
|
1006
|
+
return _impl(
|
|
1007
|
+
_config,
|
|
1008
|
+
customer_id=customer_id or _config.ads.customer_id,
|
|
1009
|
+
shared_set_id=shared_set_id,
|
|
1010
|
+
keywords=keywords,
|
|
1011
|
+
match_type=match_type,
|
|
1012
|
+
)
|
|
1013
|
+
|
|
1014
|
+
|
|
982
1015
|
@mcp.tool(annotations=_WRITE)
|
|
983
1016
|
@_safe
|
|
984
1017
|
def update_ad_group(
|
|
@@ -1119,11 +1152,13 @@ def remove_entity(
|
|
|
1119
1152
|
"""Draft REMOVING an entity — returns a PREVIEW. This is IRREVERSIBLE.
|
|
1120
1153
|
|
|
1121
1154
|
entity_type: "campaign", "ad_group", "ad", "keyword", "negative_keyword",
|
|
1122
|
-
"campaign_asset", "asset", or "customer_asset"
|
|
1155
|
+
"shared_criterion", "campaign_asset", "asset", or "customer_asset"
|
|
1123
1156
|
entity_id: The resource ID.
|
|
1124
1157
|
For keywords: "adGroupId~criterionId"
|
|
1125
1158
|
For negative_keywords: "campaignId~criterionId"
|
|
1126
1159
|
(use the resource_id field from get_negative_keywords)
|
|
1160
|
+
For shared_criterion: "sharedSetId~criterionId"
|
|
1161
|
+
(use the resource_id field from get_negative_keyword_list_keywords)
|
|
1127
1162
|
For campaign_asset: "campaignId~assetId~fieldType"
|
|
1128
1163
|
For asset: simple asset ID
|
|
1129
1164
|
For customer_asset: "assetId~fieldType"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|