adloop 0.27.0__tar.gz → 0.27.2__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 (71) hide show
  1. {adloop-0.27.0 → adloop-0.27.2}/PKG-INFO +6 -5
  2. {adloop-0.27.0 → adloop-0.27.2}/README.md +5 -4
  3. {adloop-0.27.0 → adloop-0.27.2}/pyproject.toml +1 -1
  4. {adloop-0.27.0 → adloop-0.27.2}/pyproject.toml.orig +1 -1
  5. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/ads/write.py +32 -4
  6. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/cli.py +136 -58
  7. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/server.py +10 -5
  8. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/__init__.py +0 -0
  9. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/__main__.py +0 -0
  10. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/_mcp_patches.py +0 -0
  11. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/ads/__init__.py +0 -0
  12. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/ads/ai_max.py +0 -0
  13. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/ads/brands.py +0 -0
  14. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/ads/client.py +0 -0
  15. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/ads/conversion_actions.py +0 -0
  16. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/ads/currency.py +0 -0
  17. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/ads/custom_conversion_goals.py +0 -0
  18. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/ads/enums.py +0 -0
  19. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/ads/forecast.py +0 -0
  20. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/ads/gaql.py +0 -0
  21. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/ads/image_fetch.py +0 -0
  22. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/ads/pmax.py +0 -0
  23. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/ads/read.py +0 -0
  24. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/ads/shopping.py +0 -0
  25. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/ads/validate_only.py +0 -0
  26. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/auth.py +0 -0
  27. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/config.py +0 -0
  28. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/crossref.py +0 -0
  29. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/diagnostics.py +0 -0
  30. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/ga4/__init__.py +0 -0
  31. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/ga4/client.py +0 -0
  32. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/ga4/reports.py +0 -0
  33. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/ga4/tracking.py +0 -0
  34. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/ga4/write.py +0 -0
  35. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/gsc/__init__.py +0 -0
  36. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/gsc/client.py +0 -0
  37. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/gsc/reports.py +0 -0
  38. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/gtm/__init__.py +0 -0
  39. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/gtm/client.py +0 -0
  40. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/gtm/entities.py +0 -0
  41. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/gtm/read.py +0 -0
  42. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/gtm/write.py +0 -0
  43. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/merchant/__init__.py +0 -0
  44. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/merchant/client.py +0 -0
  45. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/merchant/read.py +0 -0
  46. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/pagespeed.py +0 -0
  47. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/reddit/__init__.py +0 -0
  48. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/reddit/auth.py +0 -0
  49. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/reddit/client.py +0 -0
  50. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/reddit/read.py +0 -0
  51. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/reddit/schedule.py +0 -0
  52. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/reddit/write.py +0 -0
  53. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/rules/__init__.py +0 -0
  54. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/rules/adloop.md +0 -0
  55. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/rules/commands/analyze-performance.md +0 -0
  56. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/rules/commands/budget-plan.md +0 -0
  57. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/rules/commands/create-ad.md +0 -0
  58. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/rules/commands/create-campaign.md +0 -0
  59. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  60. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  61. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/rules_install.py +0 -0
  62. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/runtime.py +0 -0
  63. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/safety/__init__.py +0 -0
  64. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/safety/approval.py +0 -0
  65. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/safety/approval_card.html +0 -0
  66. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/safety/audit.py +0 -0
  67. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/safety/guards.py +0 -0
  68. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/safety/preview.py +0 -0
  69. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/tracking.py +0 -0
  70. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/upload_card.html +0 -0
  71. {adloop-0.27.0 → adloop-0.27.2}/src/adloop/uploads.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.27.0
3
+ Version: 0.27.2
4
4
  Summary: The AI command center for Google Ads, Reddit Ads, GA4, and tracking code.
5
5
  Keywords: mcp,google-ads,reddit-ads,google-analytics,ga4,cursor,marketing
6
6
  Author: Daniel Klose
@@ -404,7 +404,7 @@ uv run adloop init
404
404
 
405
405
  ### What `adloop init` does
406
406
 
407
- The wizard walks you through:
407
+ The wizard first asks whether to set up Google. Answer no to run AdLoop on Reddit Ads alone: it skips steps 1 to 6 and goes straight to the Reddit step. Otherwise it walks you through:
408
408
 
409
409
  1. **Google Cloud setup** — creates a project, enables the three APIs, generates an OAuth client (see [Custom Google Cloud Project Setup](#custom-google-cloud-project-setup) below for the exact steps the wizard refers you to)
410
410
  2. **Google Ads API access** — applied for on your Cloud project's [Google Ads API Overview page](https://console.cloud.google.com/google/ads-apis/overview); the legacy developer-token prompt can be left empty
@@ -412,9 +412,10 @@ The wizard walks you through:
412
412
  4. **OAuth sign-in** — opens a browser to sign in with Google (or prints a URL for headless servers)
413
413
  5. **Auto-discovers your accounts** — finds your GA4 properties and Ads accounts automatically
414
414
  6. **Optional services** — pin a GTM container, a Search Console property (both auto-discovered too), and a PageSpeed API key; skip any of them with Enter
415
- 7. **Safety defaults** — budget cap and dry-run preference
416
- 8. **Toolsets** — optionally expose only part of the tool catalog to your AI client (see [Toolsets](#toolsets--trim-the-context-footprint))
417
- 9. **Editor config snippets** — prints MCP configuration for both Cursor and Claude Code, including your toolset selection
415
+ 7. **Reddit Ads** — optional next to Google, required without it: your own Reddit developer app, sign-in, and the default ad account
416
+ 8. **Safety defaults** — budget cap and dry-run preference
417
+ 9. **Toolsets** — optionally expose only part of the tool catalog to your AI client (see [Toolsets](#toolsets--trim-the-context-footprint))
418
+ 10. **Editor config snippets** — prints MCP configuration for both Cursor and Claude Code, including your toolset selection
418
419
 
419
420
  ### Requirements
420
421
 
@@ -376,7 +376,7 @@ uv run adloop init
376
376
 
377
377
  ### What `adloop init` does
378
378
 
379
- The wizard walks you through:
379
+ The wizard first asks whether to set up Google. Answer no to run AdLoop on Reddit Ads alone: it skips steps 1 to 6 and goes straight to the Reddit step. Otherwise it walks you through:
380
380
 
381
381
  1. **Google Cloud setup** — creates a project, enables the three APIs, generates an OAuth client (see [Custom Google Cloud Project Setup](#custom-google-cloud-project-setup) below for the exact steps the wizard refers you to)
382
382
  2. **Google Ads API access** — applied for on your Cloud project's [Google Ads API Overview page](https://console.cloud.google.com/google/ads-apis/overview); the legacy developer-token prompt can be left empty
@@ -384,9 +384,10 @@ The wizard walks you through:
384
384
  4. **OAuth sign-in** — opens a browser to sign in with Google (or prints a URL for headless servers)
385
385
  5. **Auto-discovers your accounts** — finds your GA4 properties and Ads accounts automatically
386
386
  6. **Optional services** — pin a GTM container, a Search Console property (both auto-discovered too), and a PageSpeed API key; skip any of them with Enter
387
- 7. **Safety defaults** — budget cap and dry-run preference
388
- 8. **Toolsets** — optionally expose only part of the tool catalog to your AI client (see [Toolsets](#toolsets--trim-the-context-footprint))
389
- 9. **Editor config snippets** — prints MCP configuration for both Cursor and Claude Code, including your toolset selection
387
+ 7. **Reddit Ads** — optional next to Google, required without it: your own Reddit developer app, sign-in, and the default ad account
388
+ 8. **Safety defaults** — budget cap and dry-run preference
389
+ 9. **Toolsets** — optionally expose only part of the tool catalog to your AI client (see [Toolsets](#toolsets--trim-the-context-footprint))
390
+ 10. **Editor config snippets** — prints MCP configuration for both Cursor and Claude Code, including your toolset selection
390
391
 
391
392
  ### Requirements
392
393
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.27.0"
3
+ version = "0.27.2"
4
4
  description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.27.0"
3
+ version = "0.27.2"
4
4
  description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -7,6 +7,7 @@ Every write tool returns a preview/plan. Nothing executes until
7
7
  from __future__ import annotations
8
8
 
9
9
  import hashlib
10
+ import re
10
11
  import struct
11
12
  from pathlib import Path, PurePath, PurePosixPath
12
13
  from typing import TYPE_CHECKING
@@ -40,6 +41,31 @@ _VALID_IMAGE_MIME_TYPES = {
40
41
  _VALID_HEADLINE_PINS = {"HEADLINE_1", "HEADLINE_2", "HEADLINE_3"}
41
42
  _VALID_DESCRIPTION_PINS = {"DESCRIPTION_1", "DESCRIPTION_2"}
42
43
 
44
+ # Insertion tags Google expands at serve time. Only the default text after
45
+ # the colon counts toward the 30/90 character caps, e.g.
46
+ # "{KeyWord:Clogged Drain Service}" is 21 characters, not 31. Written with
47
+ # spaces ("{ KeyWord : x }") it is not a tag and counts as plain text.
48
+ _INSERTION_TAG_RE = re.compile(r"\{(keyword|location\([a-z]+\)):([^{}]*)\}", re.IGNORECASE)
49
+ _BARE_INSERTION_TAG_RE = re.compile(r"\{(keyword|location\([a-z]+\))\}", re.IGNORECASE)
50
+
51
+
52
+ def _counted_length(text: str) -> int:
53
+ """Length Google checks against the cap: insertion tags count as their default text."""
54
+ return len(_INSERTION_TAG_RE.sub(lambda m: m.group(2), text))
55
+
56
+
57
+ def _insertion_tag_errors(label: str, text: str) -> list[str]:
58
+ """RSA keyword/location insertion needs default text, e.g. {KeyWord:Drain Cleaning}."""
59
+ missing = _BARE_INSERTION_TAG_RE.search(text) or any(
60
+ not m.group(2).strip() for m in _INSERTION_TAG_RE.finditer(text)
61
+ )
62
+ if missing:
63
+ return [
64
+ f"{label} uses an insertion tag without default text: '{text}'. "
65
+ "Default text after a colon is required, e.g. {KeyWord:Drain Cleaning}"
66
+ ]
67
+ return []
68
+
43
69
 
44
70
  def _normalize_rsa_assets(items: list) -> list[dict]:
45
71
  """Accept str or {text, pinned_field?} dict entries; return list of dicts.
@@ -3435,9 +3461,10 @@ def _validate_rsa_assets(
3435
3461
  for i, h in enumerate(headlines):
3436
3462
  text = h["text"]
3437
3463
  pin = h["pinned_field"]
3438
- if len(text) > 30:
3464
+ errors.extend(_insertion_tag_errors(f"Headline {i + 1}", text))
3465
+ if _counted_length(text) > 30:
3439
3466
  errors.append(
3440
- f"Headline {i + 1} exceeds 30 chars ({len(text)}): '{text}'"
3467
+ f"Headline {i + 1} exceeds 30 chars ({_counted_length(text)}): '{text}'"
3441
3468
  )
3442
3469
  if pin is not None:
3443
3470
  if pin not in _VALID_HEADLINE_PINS:
@@ -3455,9 +3482,10 @@ def _validate_rsa_assets(
3455
3482
  for i, d in enumerate(descriptions):
3456
3483
  text = d["text"]
3457
3484
  pin = d["pinned_field"]
3458
- if len(text) > 90:
3485
+ errors.extend(_insertion_tag_errors(f"Description {i + 1}", text))
3486
+ if _counted_length(text) > 90:
3459
3487
  errors.append(
3460
- f"Description {i + 1} exceeds 90 chars ({len(text)}): '{text}'"
3488
+ f"Description {i + 1} exceeds 90 chars ({_counted_length(text)}): '{text}'"
3461
3489
  )
3462
3490
  if pin is not None:
3463
3491
  if pin not in _VALID_DESCRIPTION_PINS:
@@ -414,7 +414,7 @@ def _wizard_gsc_step(oauth_ok: bool, _existing) -> str:
414
414
  )
415
415
 
416
416
 
417
- def _wizard_reddit_step(_existing) -> dict[str, str]:
417
+ def _wizard_reddit_step(_existing, required: bool = False) -> dict[str, str]:
418
418
  """Optionally connect Reddit Ads: own developer app, own OAuth, own token file.
419
419
 
420
420
  Returns the ``reddit:`` values for the config. Reddit's redirect URI
@@ -430,7 +430,8 @@ def _wizard_reddit_step(_existing) -> dict[str, str]:
430
430
  "ad_account_id": _existing("reddit", "ad_account_id"),
431
431
  "username": _existing("reddit", "username"),
432
432
  }
433
- if not _prompt_bool("Connect Reddit Ads? (optional)", default=bool(existing_id)):
433
+ # Without Google, Reddit is the platform being set up: no opt-out.
434
+ if not required and not _prompt_bool("Connect Reddit Ads? (optional)", default=bool(existing_id)):
434
435
  return values
435
436
 
436
437
  _print()
@@ -589,6 +590,70 @@ def run_init_wizard() -> None:
589
590
  return str(existing_config[section].get(key, fallback))
590
591
  return fallback
591
592
 
593
+ # Both platforms are optional; a workspace can run on Reddit alone.
594
+ _print(" AdLoop connects Google (Ads, Analytics, Tag Manager, Search Console)")
595
+ _print(" and Reddit Ads. Set up one or both.")
596
+ _print()
597
+ use_google = _prompt_bool(
598
+ "Set up Google?", default=_existing("google", "credentials_path") != "" or existing_config is None
599
+ )
600
+ credentials_path = project_id = developer_token = login_customer_id = ""
601
+ step_num = 0
602
+ if use_google:
603
+ step_num = _wizard_google_steps(_existing)
604
+ credentials_path, project_id, developer_token, login_customer_id = _WIZARD_GOOGLE
605
+
606
+ # OAuth + auto-discovery
607
+ step_num += 1
608
+ _step_header(step_num, "Authorization & Account Discovery" if use_google else "Writing a first config")
609
+
610
+ # Write a temporary config for OAuth + discovery. If the wizard is
611
+ # interrupted after this point, _cleanup_on_cancel restores the original.
612
+ _ADLOOP_DIR.mkdir(parents=True, exist_ok=True)
613
+ temp_config_yaml = _generate_config_yaml(
614
+ project_id=project_id,
615
+ credentials_path=credentials_path,
616
+ property_id="",
617
+ developer_token=developer_token,
618
+ customer_id="",
619
+ login_customer_id=login_customer_id,
620
+ max_daily_budget=50.0,
621
+ require_dry_run=True,
622
+ )
623
+ _CONFIG_PATH.write_text(temp_config_yaml)
624
+
625
+ # Everything below uses the temp config for OAuth and discovery.
626
+ # If the wizard is interrupted, restore the original config (or remove
627
+ # the temp) so we never leave a half-baked config on disk.
628
+ try:
629
+ _run_wizard_post_config(
630
+ credentials_path=credentials_path,
631
+ project_id=project_id,
632
+ developer_token=developer_token,
633
+ login_customer_id=login_customer_id,
634
+ step_num=step_num,
635
+ _existing=_existing,
636
+ use_google=use_google,
637
+ )
638
+ except KeyboardInterrupt:
639
+ if _original_config_backup is not None:
640
+ _CONFIG_PATH.write_text(_original_config_backup)
641
+ elif _CONFIG_PATH.exists():
642
+ _CONFIG_PATH.unlink()
643
+ raise
644
+
645
+
646
+ _WIZARD_GOOGLE: tuple[str, str, str, str] = ("", "", "", "")
647
+
648
+
649
+ def _wizard_google_steps(_existing) -> int:
650
+ """The Google project, credentials and API access steps; returns the last step number.
651
+
652
+ The collected values land in ``_WIZARD_GOOGLE`` (credentials path,
653
+ project id, developer token, login customer id).
654
+ """
655
+ global _WIZARD_GOOGLE
656
+
592
657
  # Step 1: Google Cloud project
593
658
  #
594
659
  # AdLoop no longer ships OAuth credentials. Every install brings its
@@ -641,44 +706,8 @@ def run_init_wizard() -> None:
641
706
  default=_existing("ads", "login_customer_id"),
642
707
  required=False,
643
708
  )
644
-
645
- # OAuth + auto-discovery
646
- step_num += 1
647
- _step_header(step_num, "Authorization & Account Discovery")
648
-
649
- # Write a temporary config for OAuth + discovery. If the wizard is
650
- # interrupted after this point, _cleanup_on_cancel restores the original.
651
- _ADLOOP_DIR.mkdir(parents=True, exist_ok=True)
652
- temp_config_yaml = _generate_config_yaml(
653
- project_id=project_id,
654
- credentials_path=credentials_path,
655
- property_id="",
656
- developer_token=developer_token,
657
- customer_id="",
658
- login_customer_id=login_customer_id,
659
- max_daily_budget=50.0,
660
- require_dry_run=True,
661
- )
662
- _CONFIG_PATH.write_text(temp_config_yaml)
663
-
664
- # Everything below uses the temp config for OAuth and discovery.
665
- # If the wizard is interrupted, restore the original config (or remove
666
- # the temp) so we never leave a half-baked config on disk.
667
- try:
668
- _run_wizard_post_config(
669
- credentials_path=credentials_path,
670
- project_id=project_id,
671
- developer_token=developer_token,
672
- login_customer_id=login_customer_id,
673
- step_num=step_num,
674
- _existing=_existing,
675
- )
676
- except KeyboardInterrupt:
677
- if _original_config_backup is not None:
678
- _CONFIG_PATH.write_text(_original_config_backup)
679
- elif _CONFIG_PATH.exists():
680
- _CONFIG_PATH.unlink()
681
- raise
709
+ _WIZARD_GOOGLE = (credentials_path, project_id, developer_token, login_customer_id)
710
+ return step_num
682
711
 
683
712
 
684
713
  def _run_wizard_post_config(
@@ -689,10 +718,9 @@ def _run_wizard_post_config(
689
718
  login_customer_id: str,
690
719
  step_num: int,
691
720
  _existing: object,
721
+ use_google: bool = True,
692
722
  ) -> None:
693
723
  """Run the wizard steps after the temp config has been written."""
694
- from adloop.config import load_config
695
-
696
724
  # Optional: copy custom credentials to ~/.adloop/
697
725
  if credentials_path:
698
726
  creds_expanded = Path(credentials_path).expanduser()
@@ -706,6 +734,54 @@ def _run_wizard_post_config(
706
734
  shutil.copy2(creds_expanded, adloop_creds)
707
735
  _print(f" ✓ Credentials copied to {adloop_creds}")
708
736
 
737
+ oauth_ok = False
738
+ property_id = ""
739
+ customer_id = ""
740
+ gtm_account_id = gtm_container_id = gsc_site_url = ""
741
+ if use_google:
742
+ oauth_ok, property_id, customer_id, step_num = _wizard_google_discovery(step_num, _existing)
743
+
744
+ # Optional service pins: GTM / Search Console / PageSpeed. All three
745
+ # work without config (explicit args or discovery per call) — a pin
746
+ # just saves the model a lookup and locks tools to the right property.
747
+ step_num += 1
748
+ if use_google:
749
+ _step_header(step_num, "Optional Services (GTM, Search Console, PageSpeed)")
750
+ gtm_account_id, gtm_container_id = _wizard_gtm_step(oauth_ok, _existing)
751
+ gsc_site_url = _wizard_gsc_step(oauth_ok, _existing)
752
+ else:
753
+ _step_header(step_num, "PageSpeed (optional)")
754
+ pagespeed_api_key = _prompt(
755
+ "PageSpeed Insights API key (optional, Enter to skip)",
756
+ default=_existing("pagespeed", "api_key"), # type: ignore[operator]
757
+ required=False,
758
+ )
759
+
760
+ # Reddit Ads: a second ad platform with its own OAuth app and token file.
761
+ step_num += 1
762
+ _step_header(step_num, "Reddit Ads" if not use_google else "Reddit Ads (optional)")
763
+ reddit_values = _wizard_reddit_step(_existing, required=not use_google)
764
+ _wizard_finish(
765
+ step_num=step_num,
766
+ _existing=_existing,
767
+ credentials_path=credentials_path,
768
+ project_id=project_id,
769
+ developer_token=developer_token,
770
+ login_customer_id=login_customer_id,
771
+ property_id=property_id,
772
+ customer_id=customer_id,
773
+ gtm_account_id=gtm_account_id,
774
+ gtm_container_id=gtm_container_id,
775
+ gsc_site_url=gsc_site_url,
776
+ pagespeed_api_key=pagespeed_api_key,
777
+ reddit_values=reddit_values,
778
+ )
779
+
780
+
781
+ def _wizard_google_discovery(step_num: int, _existing) -> tuple[bool, str, str, int]:
782
+ """Google sign-in plus GA4 and Ads account discovery: (oauth_ok, property_id, customer_id, step)."""
783
+ from adloop.config import load_config
784
+
709
785
  _print(" Signing in with Google (this may open a browser)...")
710
786
  _print()
711
787
  oauth_ok = False
@@ -797,24 +873,26 @@ def _run_wizard_post_config(
797
873
  default=_existing("ads", "customer_id"), # type: ignore[operator]
798
874
  )
799
875
 
800
- # Optional service pins: GTM / Search Console / PageSpeed. All three
801
- # work without config (explicit args or discovery per call) — a pin
802
- # just saves the model a lookup and locks tools to the right property.
803
- step_num += 1
804
- _step_header(step_num, "Optional Services (GTM, Search Console, PageSpeed)")
805
- gtm_account_id, gtm_container_id = _wizard_gtm_step(oauth_ok, _existing)
806
- gsc_site_url = _wizard_gsc_step(oauth_ok, _existing)
807
- pagespeed_api_key = _prompt(
808
- "PageSpeed Insights API key (optional, Enter to skip)",
809
- default=_existing("pagespeed", "api_key"), # type: ignore[operator]
810
- required=False,
811
- )
876
+ return oauth_ok, property_id, customer_id, step_num
812
877
 
813
- # Reddit Ads: a second ad platform with its own OAuth app and token file.
814
- step_num += 1
815
- _step_header(step_num, "Reddit Ads (optional)")
816
- reddit_values = _wizard_reddit_step(_existing)
817
878
 
879
+ def _wizard_finish(
880
+ *,
881
+ step_num: int,
882
+ _existing,
883
+ credentials_path: str,
884
+ project_id: str,
885
+ developer_token: str,
886
+ login_customer_id: str,
887
+ property_id: str,
888
+ customer_id: str,
889
+ gtm_account_id: str,
890
+ gtm_container_id: str,
891
+ gsc_site_url: str,
892
+ pagespeed_api_key: str,
893
+ reddit_values: dict,
894
+ ) -> None:
895
+ """Safety defaults, the final config and the client snippets."""
818
896
  # Safety defaults
819
897
  step_num += 1
820
898
  _step_header(step_num, "Safety Defaults")
@@ -3117,10 +3117,13 @@ def draft_responsive_search_ad(
3117
3117
  ad_group_id: Numeric ID of the ad group that gets the ad.
3118
3118
  headlines: 3-15 headlines, max 30 chars each. Each entry is a plain
3119
3119
  string (unpinned) or {"text": "...", "pinned_field": "HEADLINE_1"};
3120
- valid pins are HEADLINE_1, HEADLINE_2, HEADLINE_3.
3120
+ valid pins are HEADLINE_1, HEADLINE_2, HEADLINE_3. Keyword or
3121
+ location insertion such as {KeyWord:Drain Cleaning} counts as its
3122
+ default text, which is required.
3121
3123
  descriptions: 2-4 descriptions, max 90 chars each. Each entry is a plain
3122
3124
  string (unpinned) or {"text": "...", "pinned_field": "DESCRIPTION_1"};
3123
- valid pins are DESCRIPTION_1, DESCRIPTION_2.
3125
+ valid pins are DESCRIPTION_1, DESCRIPTION_2. Insertion tags count
3126
+ as their default text, as for headlines.
3124
3127
  final_url: Landing page URL the ad links to; must be reachable.
3125
3128
  customer_id: Google Ads customer ID (digits, e.g. "1234567890"). Empty
3126
3129
  uses the configured default account.
@@ -3183,11 +3186,13 @@ def update_responsive_search_ad(
3183
3186
  fully swaps in for the existing headlines. Google's RSA constraints
3184
3187
  apply: 3-15 headlines, max 30 chars each, at most 2 per pin slot.
3185
3188
  Each entry is a plain string (unpinned) or {"text": "...",
3186
- "pinned_field": "HEADLINE_1"} (HEADLINE_1-3).
3189
+ "pinned_field": "HEADLINE_1"} (HEADLINE_1-3). Insertion tags such as
3190
+ {KeyWord:Clogged Drain Service} count as their default text, which
3191
+ is required.
3187
3192
  descriptions: LIST-REPLACE: None or [] means no change; a non-empty list
3188
3193
  fully swaps in. 2-4 descriptions, max 90 chars each, at most 1 per
3189
- pin slot (DESCRIPTION_1, DESCRIPTION_2). Same entry format as
3190
- headlines.
3194
+ pin slot (DESCRIPTION_1, DESCRIPTION_2). Same entry format and
3195
+ insertion-tag counting as headlines.
3191
3196
  final_url: Empty means no change; non-empty replaces the final URL and
3192
3197
  is checked for reachability.
3193
3198
  path1: Empty means no change; non-empty sets the first display path
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