adloop 0.4.1__tar.gz → 0.4.3__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.
Files changed (28) hide show
  1. {adloop-0.4.1 → adloop-0.4.3}/PKG-INFO +21 -12
  2. {adloop-0.4.1 → adloop-0.4.3}/README.md +20 -11
  3. {adloop-0.4.1 → adloop-0.4.3}/pyproject.toml +1 -1
  4. {adloop-0.4.1 → adloop-0.4.3}/src/adloop/__init__.py +10 -2
  5. {adloop-0.4.1 → adloop-0.4.3}/src/adloop/ads/read.py +5 -0
  6. {adloop-0.4.1 → adloop-0.4.3}/src/adloop/ads/write.py +42 -21
  7. {adloop-0.4.1 → adloop-0.4.3}/src/adloop/cli.py +83 -39
  8. {adloop-0.4.1 → adloop-0.4.3}/src/adloop/server.py +2 -1
  9. {adloop-0.4.1 → adloop-0.4.3}/src/adloop/__main__.py +0 -0
  10. {adloop-0.4.1 → adloop-0.4.3}/src/adloop/ads/__init__.py +0 -0
  11. {adloop-0.4.1 → adloop-0.4.3}/src/adloop/ads/client.py +0 -0
  12. {adloop-0.4.1 → adloop-0.4.3}/src/adloop/ads/currency.py +0 -0
  13. {adloop-0.4.1 → adloop-0.4.3}/src/adloop/ads/forecast.py +0 -0
  14. {adloop-0.4.1 → adloop-0.4.3}/src/adloop/ads/gaql.py +0 -0
  15. {adloop-0.4.1 → adloop-0.4.3}/src/adloop/ads/pmax.py +0 -0
  16. {adloop-0.4.1 → adloop-0.4.3}/src/adloop/auth.py +0 -0
  17. {adloop-0.4.1 → adloop-0.4.3}/src/adloop/bundled_credentials.json +0 -0
  18. {adloop-0.4.1 → adloop-0.4.3}/src/adloop/config.py +0 -0
  19. {adloop-0.4.1 → adloop-0.4.3}/src/adloop/crossref.py +0 -0
  20. {adloop-0.4.1 → adloop-0.4.3}/src/adloop/ga4/__init__.py +0 -0
  21. {adloop-0.4.1 → adloop-0.4.3}/src/adloop/ga4/client.py +0 -0
  22. {adloop-0.4.1 → adloop-0.4.3}/src/adloop/ga4/reports.py +0 -0
  23. {adloop-0.4.1 → adloop-0.4.3}/src/adloop/ga4/tracking.py +0 -0
  24. {adloop-0.4.1 → adloop-0.4.3}/src/adloop/safety/__init__.py +0 -0
  25. {adloop-0.4.1 → adloop-0.4.3}/src/adloop/safety/audit.py +0 -0
  26. {adloop-0.4.1 → adloop-0.4.3}/src/adloop/safety/guards.py +0 -0
  27. {adloop-0.4.1 → adloop-0.4.3}/src/adloop/safety/preview.py +0 -0
  28. {adloop-0.4.1 → adloop-0.4.3}/src/adloop/tracking.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.4.1
3
+ Version: 0.4.3
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
@@ -224,6 +224,25 @@ The wizard:
224
224
 
225
225
  - Python 3.11+
226
226
  - A Google Ads account with an MCC (Manager Account)
227
+ - A Google Ads Developer Token (see below)
228
+
229
+ ### Google Ads Developer Token
230
+
231
+ A developer token is **always required** — even when using AdLoop's built-in OAuth credentials. The built-in credentials handle Google sign-in; the developer token is a separate key that grants API access to your Google Ads data.
232
+
233
+ 1. **Create an MCC** (free) at [ads.google.com/home/tools/manager-accounts](https://ads.google.com/home/tools/manager-accounts/) if you don't have one. Link your regular Google Ads account to it.
234
+ 2. In the MCC, go to **Tools & Settings → API Center**
235
+ 3. Your **developer token** is shown there. Copy it — the wizard will ask for it.
236
+
237
+ **Access levels** — your token's access level determines what it can do:
238
+
239
+ | Level | How to Get | What It Allows |
240
+ |-------|-----------|----------------|
241
+ | **Test Account** | Default for new tokens | Can only access test accounts — **not production accounts**. If you see `DEVELOPER_TOKEN_NOT_APPROVED`, this is why. |
242
+ | **Explorer** | Automatic after first API call with a production account | 2,880 operations/day on production accounts. Enough to get started. |
243
+ | **Basic** | [Apply through API Center](https://ads.google.com/aw/apicenter) | 15,000 operations/day. Apply if you need more. |
244
+
245
+ > **Getting `DEVELOPER_TOKEN_NOT_APPROVED`?** Your token is at "Test Account" level. Go to [API Center](https://ads.google.com/aw/apicenter) in your MCC and check your access level. If it shows "Test Account", you need to apply for Basic access or wait for Explorer access to be granted after your first production API call.
227
246
 
228
247
  ### Headless Servers
229
248
 
@@ -253,17 +272,7 @@ When you run `adloop init`, choose "No" when asked about built-in credentials. T
253
272
 
254
273
  > Service accounts are also supported — just place the service account key JSON at the same `credentials_path`. AdLoop detects the file type automatically.
255
274
 
256
- #### Step 3 — Google Ads Developer Token
257
-
258
- 1. **Create an MCC** (free) at [ads.google.com/home/tools/manager-accounts](https://ads.google.com/home/tools/manager-accounts/) if you don't have one. Link your regular Google Ads account to it.
259
- 2. In the MCC, go to **Tools & Settings → API Center**
260
- 3. Your **developer token** is shown there. Copy it.
261
-
262
- Access levels:
263
- - **Explorer** (automatic) — 2,880 operations/day on production accounts. Enough to get started.
264
- - **Basic** (requires application) — 15,000 operations/day. Apply through the same API Center page if you need more.
265
-
266
- #### Step 4 — Connect to Your Editor
275
+ #### Step 3 — Connect to Your Editor
267
276
 
268
277
  **Cursor** — Add to your project's `.cursor/mcp.json`:
269
278
 
@@ -200,6 +200,25 @@ The wizard:
200
200
 
201
201
  - Python 3.11+
202
202
  - A Google Ads account with an MCC (Manager Account)
203
+ - A Google Ads Developer Token (see below)
204
+
205
+ ### Google Ads Developer Token
206
+
207
+ A developer token is **always required** — even when using AdLoop's built-in OAuth credentials. The built-in credentials handle Google sign-in; the developer token is a separate key that grants API access to your Google Ads data.
208
+
209
+ 1. **Create an MCC** (free) at [ads.google.com/home/tools/manager-accounts](https://ads.google.com/home/tools/manager-accounts/) if you don't have one. Link your regular Google Ads account to it.
210
+ 2. In the MCC, go to **Tools & Settings → API Center**
211
+ 3. Your **developer token** is shown there. Copy it — the wizard will ask for it.
212
+
213
+ **Access levels** — your token's access level determines what it can do:
214
+
215
+ | Level | How to Get | What It Allows |
216
+ |-------|-----------|----------------|
217
+ | **Test Account** | Default for new tokens | Can only access test accounts — **not production accounts**. If you see `DEVELOPER_TOKEN_NOT_APPROVED`, this is why. |
218
+ | **Explorer** | Automatic after first API call with a production account | 2,880 operations/day on production accounts. Enough to get started. |
219
+ | **Basic** | [Apply through API Center](https://ads.google.com/aw/apicenter) | 15,000 operations/day. Apply if you need more. |
220
+
221
+ > **Getting `DEVELOPER_TOKEN_NOT_APPROVED`?** Your token is at "Test Account" level. Go to [API Center](https://ads.google.com/aw/apicenter) in your MCC and check your access level. If it shows "Test Account", you need to apply for Basic access or wait for Explorer access to be granted after your first production API call.
203
222
 
204
223
  ### Headless Servers
205
224
 
@@ -229,17 +248,7 @@ When you run `adloop init`, choose "No" when asked about built-in credentials. T
229
248
 
230
249
  > Service accounts are also supported — just place the service account key JSON at the same `credentials_path`. AdLoop detects the file type automatically.
231
250
 
232
- #### Step 3 — Google Ads Developer Token
233
-
234
- 1. **Create an MCC** (free) at [ads.google.com/home/tools/manager-accounts](https://ads.google.com/home/tools/manager-accounts/) if you don't have one. Link your regular Google Ads account to it.
235
- 2. In the MCC, go to **Tools & Settings → API Center**
236
- 3. Your **developer token** is shown there. Copy it.
237
-
238
- Access levels:
239
- - **Explorer** (automatic) — 2,880 operations/day on production accounts. Enough to get started.
240
- - **Basic** (requires application) — 15,000 operations/day. Apply through the same API Center page if you need more.
241
-
242
- #### Step 4 — Connect to Your Editor
251
+ #### Step 3 — Connect to Your Editor
243
252
 
244
253
  **Cursor** — Add to your project's `.cursor/mcp.json`:
245
254
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.4.1"
3
+ version = "0.4.3"
4
4
  description = "Stop switching between Google Ads, GA4, and your code editor to figure out why conversions dropped."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -2,7 +2,7 @@
2
2
 
3
3
  import sys
4
4
 
5
- __version__ = "0.4.1"
5
+ __version__ = "0.4.3"
6
6
 
7
7
 
8
8
  def main() -> None:
@@ -11,10 +11,18 @@ def main() -> None:
11
11
  Routes to the setup wizard when called as ``adloop init``,
12
12
  otherwise starts the MCP server.
13
13
  """
14
+ if len(sys.argv) > 1 and sys.argv[1] in ("--version", "-V"):
15
+ print(f"adloop {__version__}")
16
+ return
17
+
14
18
  if len(sys.argv) > 1 and sys.argv[1] == "init":
15
19
  from adloop.cli import run_init_wizard
16
20
 
17
- run_init_wizard()
21
+ try:
22
+ run_init_wizard()
23
+ except KeyboardInterrupt:
24
+ print("\n\n Setup cancelled.\n")
25
+ sys.exit(130)
18
26
  else:
19
27
  from adloop.server import mcp
20
28
 
@@ -214,6 +214,11 @@ def get_negative_keywords(
214
214
  """
215
215
 
216
216
  rows = execute_query(config, customer_id, query)
217
+ for row in rows:
218
+ cid = row.get("campaign.id")
219
+ crit_id = row.get("campaign_criterion.criterion_id")
220
+ if cid and crit_id:
221
+ row["resource_id"] = f"{cid}~{crit_id}"
217
222
  return {"negative_keywords": rows, "total_negative_keywords": len(rows)}
218
223
 
219
224
 
@@ -1626,6 +1626,35 @@ def _draft_status_change(
1626
1626
  # ---------------------------------------------------------------------------
1627
1627
 
1628
1628
 
1629
+ _MUTATE_RESPONSE_RESULT_FIELDS = [
1630
+ "campaign_budget_result",
1631
+ "campaign_result",
1632
+ "ad_group_result",
1633
+ "ad_group_ad_result",
1634
+ "ad_group_criterion_result",
1635
+ "campaign_criterion_result",
1636
+ "asset_result",
1637
+ "campaign_asset_result",
1638
+ "customer_asset_result",
1639
+ ]
1640
+
1641
+
1642
+ def _extract_resource_name(resp: object) -> str:
1643
+ """Extract the resource_name from a MutateOperationResponse.
1644
+
1645
+ Uses direct field access instead of WhichOneof, which doesn't work on
1646
+ proto-plus wrapped messages returned by the google-ads library.
1647
+ """
1648
+ for field in _MUTATE_RESPONSE_RESULT_FIELDS:
1649
+ try:
1650
+ result = getattr(resp, field, None)
1651
+ if result and result.resource_name:
1652
+ return result.resource_name
1653
+ except Exception:
1654
+ continue
1655
+ return ""
1656
+
1657
+
1629
1658
  def _execute_plan(config: AdLoopConfig, plan: object) -> dict:
1630
1659
  """Dispatch to the right Google Ads mutate call based on plan.operation."""
1631
1660
  from adloop.ads.client import get_ads_client, normalize_customer_id
@@ -1818,22 +1847,20 @@ def _apply_create_campaign(client: object, cid: str, changes: dict) -> dict:
1818
1847
  num_geo = len(changes.get("geo_target_ids") or [])
1819
1848
  num_lang = len(changes.get("language_ids") or [])
1820
1849
  for i, resp in enumerate(response.mutate_operation_responses):
1821
- resp_type = resp.WhichOneof("response")
1822
- if resp_type:
1823
- inner = getattr(resp, resp_type)
1824
- resource = getattr(inner, "resource_name", str(inner))
1850
+ rn = _extract_resource_name(resp)
1851
+ if rn:
1825
1852
  if i == 0:
1826
- results["campaign_budget"] = resource
1853
+ results["campaign_budget"] = rn
1827
1854
  elif i == 1:
1828
- results["campaign"] = resource
1855
+ results["campaign"] = rn
1829
1856
  elif i == 2:
1830
- results["ad_group"] = resource
1857
+ results["ad_group"] = rn
1831
1858
  elif i < 3 + num_keywords:
1832
- results.setdefault("keywords", []).append(resource)
1859
+ results.setdefault("keywords", []).append(rn)
1833
1860
  elif i < 3 + num_keywords + num_geo:
1834
- results.setdefault("geo_targets", []).append(resource)
1861
+ results.setdefault("geo_targets", []).append(rn)
1835
1862
  else:
1836
- results.setdefault("language_targets", []).append(resource)
1863
+ results.setdefault("language_targets", []).append(rn)
1837
1864
 
1838
1865
  return results
1839
1866
 
@@ -1874,14 +1901,12 @@ def _apply_create_ad_group(client: object, cid: str, changes: dict) -> dict:
1874
1901
 
1875
1902
  results: dict = {}
1876
1903
  for i, resp in enumerate(response.mutate_operation_responses):
1877
- resp_type = resp.WhichOneof("response")
1878
- if resp_type:
1879
- inner = getattr(resp, resp_type)
1880
- resource = getattr(inner, "resource_name", str(inner))
1904
+ rn = _extract_resource_name(resp)
1905
+ if rn:
1881
1906
  if i == 0:
1882
- results["ad_group"] = resource
1907
+ results["ad_group"] = rn
1883
1908
  else:
1884
- results.setdefault("keywords", []).append(resource)
1909
+ results.setdefault("keywords", []).append(rn)
1885
1910
 
1886
1911
  return results
1887
1912
 
@@ -2039,11 +2064,7 @@ def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict:
2039
2064
 
2040
2065
  results = {"updated": []}
2041
2066
  for resp in response.mutate_operation_responses:
2042
- rn = (
2043
- resp.campaign_result.resource_name
2044
- or resp.campaign_budget_result.resource_name
2045
- or resp.campaign_criterion_result.resource_name
2046
- )
2067
+ rn = _extract_resource_name(resp)
2047
2068
  if rn:
2048
2069
  results["updated"].append(rn)
2049
2070
  return results
@@ -150,39 +150,42 @@ def _generate_config_yaml(
150
150
  ) -> str:
151
151
  dry_run_str = "true" if require_dry_run else "false"
152
152
 
153
- google_section = "google:\n"
153
+ lines = [
154
+ "# AdLoop configuration",
155
+ "# Generated by: adloop init",
156
+ "# Docs: https://github.com/kLOsk/adloop",
157
+ "",
158
+ "google:",
159
+ ]
154
160
  if project_id:
155
- google_section += f' project_id: "{project_id}"\n'
161
+ lines.append(f' project_id: "{project_id}"')
156
162
  if credentials_path:
157
- google_section += f' credentials_path: "{credentials_path}"\n'
163
+ lines.append(f' credentials_path: "{credentials_path}"')
158
164
  else:
159
- google_section += " # Using built-in credentials (no credentials_path needed)\n"
160
- google_section += ' token_path: "~/.adloop/token.json"\n'
161
-
162
- return textwrap.dedent(f"""\
163
- # AdLoop configuration
164
- # Generated by: adloop init
165
- # Docs: https://github.com/kLOsk/adloop
166
-
167
- {google_section}
168
- ga4:
169
- property_id: "{property_id}"
170
-
171
- ads:
172
- developer_token: "{developer_token}"
173
- customer_id: "{customer_id}"
174
- # MCC / Manager Account ID (required if using a manager account)
175
- login_customer_id: "{login_customer_id}"
176
-
177
- safety:
178
- # Maximum daily budget AdLoop can set (safety cap)
179
- max_daily_budget: {max_daily_budget}
180
- max_bid_increase_pct: 100
181
- # When true, confirm_and_apply always runs as dry_run regardless of the parameter
182
- require_dry_run: {dry_run_str}
183
- log_file: "~/.adloop/audit.log"
184
- blocked_operations: []
185
- """)
165
+ lines.append(" # Using built-in credentials (no credentials_path needed)")
166
+ lines.append(' token_path: "~/.adloop/token.json"')
167
+ lines += [
168
+ "",
169
+ "ga4:",
170
+ f' property_id: "{property_id}"',
171
+ "",
172
+ "ads:",
173
+ f' developer_token: "{developer_token}"',
174
+ f' customer_id: "{customer_id}"',
175
+ " # MCC / Manager Account ID (required if using a manager account)",
176
+ f' login_customer_id: "{login_customer_id}"',
177
+ "",
178
+ "safety:",
179
+ " # Maximum daily budget AdLoop can set (safety cap)",
180
+ f" max_daily_budget: {max_daily_budget}",
181
+ " max_bid_increase_pct: 100",
182
+ " # When true, confirm_and_apply always runs as dry_run regardless of the parameter",
183
+ f" require_dry_run: {dry_run_str}",
184
+ ' log_file: "~/.adloop/audit.log"',
185
+ " blocked_operations: []",
186
+ "",
187
+ ]
188
+ return "\n".join(lines)
186
189
 
187
190
 
188
191
  def _generate_cursor_snippet() -> str:
@@ -291,16 +294,17 @@ def run_init_wizard() -> None:
291
294
  _print()
292
295
 
293
296
  existing_config = None
297
+ _original_config_backup: str | None = None
294
298
  if _CONFIG_PATH.exists():
295
299
  _print(f" Found existing config at {_CONFIG_PATH}")
296
300
  if not _prompt_bool("Overwrite existing configuration?", default=False):
297
301
  _print(" Keeping existing config. Exiting.")
298
302
  return
303
+ _original_config_backup = _CONFIG_PATH.read_text()
299
304
  try:
300
305
  import yaml
301
306
 
302
- with open(_CONFIG_PATH) as f:
303
- existing_config = yaml.safe_load(f) or {}
307
+ existing_config = yaml.safe_load(_original_config_backup) or {}
304
308
  except Exception:
305
309
  existing_config = {}
306
310
 
@@ -361,7 +365,8 @@ def run_init_wizard() -> None:
361
365
  step_num += 1
362
366
  _step_header(step_num, "Authorization & Account Discovery")
363
367
 
364
- # Write a minimal temporary config for OAuth + discovery
368
+ # Write a temporary config for OAuth + discovery. If the wizard is
369
+ # interrupted after this point, _cleanup_on_cancel restores the original.
365
370
  _ADLOOP_DIR.mkdir(parents=True, exist_ok=True)
366
371
  temp_config_yaml = _generate_config_yaml(
367
372
  project_id=project_id,
@@ -375,6 +380,40 @@ def run_init_wizard() -> None:
375
380
  )
376
381
  _CONFIG_PATH.write_text(temp_config_yaml)
377
382
 
383
+ # Everything below uses the temp config for OAuth and discovery.
384
+ # If the wizard is interrupted, restore the original config (or remove
385
+ # the temp) so we never leave a half-baked config on disk.
386
+ try:
387
+ _run_wizard_post_config(
388
+ use_bundled=use_bundled,
389
+ credentials_path=credentials_path,
390
+ project_id=project_id,
391
+ developer_token=developer_token,
392
+ login_customer_id=login_customer_id,
393
+ step_num=step_num,
394
+ _existing=_existing,
395
+ )
396
+ except KeyboardInterrupt:
397
+ if _original_config_backup is not None:
398
+ _CONFIG_PATH.write_text(_original_config_backup)
399
+ elif _CONFIG_PATH.exists():
400
+ _CONFIG_PATH.unlink()
401
+ raise
402
+
403
+
404
+ def _run_wizard_post_config(
405
+ *,
406
+ use_bundled: bool,
407
+ credentials_path: str,
408
+ project_id: str,
409
+ developer_token: str,
410
+ login_customer_id: str,
411
+ step_num: int,
412
+ _existing: object,
413
+ ) -> None:
414
+ """Run the wizard steps after the temp config has been written."""
415
+ from adloop.config import load_config
416
+
378
417
  # Optional: copy custom credentials to ~/.adloop/
379
418
  if not use_bundled and credentials_path:
380
419
  creds_expanded = Path(credentials_path).expanduser()
@@ -392,13 +431,14 @@ def run_init_wizard() -> None:
392
431
  _print()
393
432
  oauth_ok = False
394
433
  try:
395
- from adloop.config import load_config
396
434
  from adloop.auth import _oauth_flow
397
435
 
398
436
  cfg = load_config(str(_CONFIG_PATH))
399
437
  _oauth_flow(cfg)
400
438
  _print(" ✓ OAuth token saved")
401
439
  oauth_ok = True
440
+ except KeyboardInterrupt:
441
+ raise
402
442
  except Exception as exc:
403
443
  _print(f" ✗ OAuth failed: {exc}")
404
444
  _print(" You can retry later — any AdLoop tool call will trigger auth.")
@@ -424,10 +464,12 @@ def run_init_wizard() -> None:
424
464
  else:
425
465
  _print(" No GA4 properties found. Enter manually:")
426
466
  property_id = _prompt_property_id()
467
+ except KeyboardInterrupt:
468
+ raise
427
469
  except Exception as exc:
428
470
  _print(f" Could not auto-discover GA4 properties: {exc}")
429
471
  property_id = _prompt_property_id(
430
- default=_existing("ga4", "property_id"),
472
+ default=_existing("ga4", "property_id"), # type: ignore[operator]
431
473
  )
432
474
  else:
433
475
  step_num += 1
@@ -436,7 +478,7 @@ def run_init_wizard() -> None:
436
478
  _print(" → https://analytics.google.com → Admin → Property Settings")
437
479
  _print()
438
480
  property_id = _prompt_property_id(
439
- default=_existing("ga4", "property_id"),
481
+ default=_existing("ga4", "property_id"), # type: ignore[operator]
440
482
  )
441
483
 
442
484
  # Auto-discover Ads accounts
@@ -460,18 +502,20 @@ def run_init_wizard() -> None:
460
502
  else:
461
503
  _print(" No Ads accounts found. Enter manually:")
462
504
  customer_id = _prompt_customer_id("Ads Customer ID (XXX-XXX-XXXX)")
505
+ except KeyboardInterrupt:
506
+ raise
463
507
  except Exception as exc:
464
508
  _print(f" Could not auto-discover Ads accounts: {exc}")
465
509
  customer_id = _prompt_customer_id(
466
510
  "Ads Customer ID (XXX-XXX-XXXX)",
467
- default=_existing("ads", "customer_id"),
511
+ default=_existing("ads", "customer_id"), # type: ignore[operator]
468
512
  )
469
513
  else:
470
514
  step_num += 1
471
515
  _step_header(step_num, "Google Ads Account")
472
516
  customer_id = _prompt_customer_id(
473
517
  "Ads Customer ID (XXX-XXX-XXXX)",
474
- default=_existing("ads", "customer_id"),
518
+ default=_existing("ads", "customer_id"), # type: ignore[operator]
475
519
  )
476
520
 
477
521
  # Safety defaults
@@ -479,7 +523,7 @@ def run_init_wizard() -> None:
479
523
  _step_header(step_num, "Safety Defaults")
480
524
  budget_str = _prompt(
481
525
  "Max daily budget cap (safety limit)",
482
- default=str(_existing("safety", "max_daily_budget", "50")),
526
+ default=str(_existing("safety", "max_daily_budget", "50")), # type: ignore[operator]
483
527
  required=False,
484
528
  )
485
529
  try:
@@ -1039,7 +1039,8 @@ def remove_entity(
1039
1039
  "campaign_asset", "asset", or "customer_asset"
1040
1040
  entity_id: The resource ID.
1041
1041
  For keywords: "adGroupId~criterionId"
1042
- For negative_keywords: the campaign criterion ID
1042
+ For negative_keywords: "campaignId~criterionId"
1043
+ (use the resource_id field from get_negative_keywords)
1043
1044
  For campaign_asset: "campaignId~assetId~fieldType"
1044
1045
  For asset: simple asset ID
1045
1046
  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