adloop 0.18.1__tar.gz → 0.19.1__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 (64) hide show
  1. {adloop-0.18.1 → adloop-0.19.1}/PKG-INFO +7 -7
  2. {adloop-0.18.1 → adloop-0.19.1}/README.md +5 -5
  3. {adloop-0.18.1 → adloop-0.19.1}/pyproject.toml +2 -2
  4. {adloop-0.18.1 → adloop-0.19.1}/pyproject.toml.orig +2 -2
  5. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/_mcp_patches.py +7 -1
  6. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/custom_conversion_goals.py +0 -2
  7. adloop-0.19.1/src/adloop/ads/image_fetch.py +331 -0
  8. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/read.py +6 -27
  9. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/write.py +116 -27
  10. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/rules/adloop.md +1 -1
  11. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/server.py +40 -15
  12. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/__init__.py +0 -0
  13. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/__main__.py +0 -0
  14. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/__init__.py +0 -0
  15. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/ai_max.py +0 -0
  16. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/brands.py +0 -0
  17. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/client.py +0 -0
  18. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/conversion_actions.py +0 -0
  19. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/currency.py +0 -0
  20. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/enums.py +0 -0
  21. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/forecast.py +0 -0
  22. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/gaql.py +0 -0
  23. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/pmax.py +0 -0
  24. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/validate_only.py +0 -0
  25. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/auth.py +0 -0
  26. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/cli.py +0 -0
  27. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/config.py +0 -0
  28. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/crossref.py +0 -0
  29. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/diagnostics.py +0 -0
  30. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ga4/__init__.py +0 -0
  31. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ga4/client.py +0 -0
  32. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ga4/reports.py +0 -0
  33. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ga4/tracking.py +0 -0
  34. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ga4/write.py +0 -0
  35. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/gsc/__init__.py +0 -0
  36. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/gsc/client.py +0 -0
  37. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/gsc/reports.py +0 -0
  38. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/gtm/__init__.py +0 -0
  39. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/gtm/client.py +0 -0
  40. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/gtm/read.py +0 -0
  41. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/merchant/__init__.py +0 -0
  42. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/merchant/client.py +0 -0
  43. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/merchant/read.py +0 -0
  44. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/pagespeed.py +0 -0
  45. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/reddit/__init__.py +0 -0
  46. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/reddit/auth.py +0 -0
  47. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/reddit/client.py +0 -0
  48. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/reddit/read.py +0 -0
  49. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/reddit/schedule.py +0 -0
  50. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/reddit/write.py +0 -0
  51. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/rules/__init__.py +0 -0
  52. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/rules/commands/analyze-performance.md +0 -0
  53. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/rules/commands/budget-plan.md +0 -0
  54. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/rules/commands/create-ad.md +0 -0
  55. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/rules/commands/create-campaign.md +0 -0
  56. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  57. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  58. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/rules_install.py +0 -0
  59. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/runtime.py +0 -0
  60. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/safety/__init__.py +0 -0
  61. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/safety/audit.py +0 -0
  62. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/safety/guards.py +0 -0
  63. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/safety/preview.py +0 -0
  64. {adloop-0.18.1 → adloop-0.19.1}/src/adloop/tracking.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.18.1
3
+ Version: 0.19.1
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
7
7
  Author-email: Daniel Klose <info@daniel-klose.com>
8
8
  License: MIT
9
- Requires-Dist: fastmcp>=3.0.0
9
+ Requires-Dist: fastmcp>=4.0
10
10
  Requires-Dist: google-ads>=32.0.0
11
11
  Requires-Dist: google-analytics-data>=0.20.0
12
12
  Requires-Dist: google-analytics-admin>=0.27.0
@@ -254,8 +254,8 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
254
254
  | `draft_responsive_search_ad` | Create RSA preview (3-15 headlines ≤30 chars, 2-4 descriptions ≤90 chars). Warns if headline/description count is below best practice. |
255
255
  | `draft_callouts` | Create campaign callout assets from 1-25 character text snippets. |
256
256
  | `draft_structured_snippets` | Create campaign structured snippet assets using official header values and 3-10 snippet values. |
257
- | `draft_image_assets` | Create campaign image assets from local PNG, JPEG, or GIF files. |
258
- | `draft_keywords` | Propose keyword additions with match types. Proactively checks bidding strategy — blocks BROAD match on Manual CPC campaigns. |
257
+ | `draft_image_assets` | Create campaign image assets from local files or public image URLs (PNG, JPEG, or GIF). |
258
+ | `draft_keywords` | Propose keyword additions with match types. Proactively checks bidding strategy — flags BROAD match on campaigns without Smart Bidding as dangerous in the preview. |
259
259
  | `add_negative_keywords` | Propose negative keywords directly on a campaign |
260
260
  | `add_negative_locations` | Propose negative geo exclusions on a campaign — exclude cities/regions while keeping broader positive targets |
261
261
  | `draft_key_event` | Mark a GA4 event as a key event (conversion) — the fix for "fires but isn't tracked as a conversion" |
@@ -314,8 +314,8 @@ AdLoop manages real ad spend, so safety is not optional.
314
314
  - **Budget caps.** Configurable maximum daily budget — the server rejects anything above the cap.
315
315
  - **Audit log.** Every operation (including dry runs) is logged to `~/.adloop/audit.log`.
316
316
  - **New campaigns and ads are PAUSED.** Nothing goes live without manual enablement.
317
- - **Destructive ops require double confirmation.** Removing entities or large budget increases trigger extra warnings.
318
- - **Broad Match + Manual CPC blocked.** The #1 cause of wasted ad spend is automatically prevented — `draft_keywords` refuses to add BROAD match keywords to campaigns without Smart Bidding.
317
+ - **Destructive ops are flagged for double confirmation.** Removing entities or large budget increases come back with extra warnings, and the AI is instructed to confirm twice.
318
+ - **Broad Match without Smart Bidding caught.** The #1 cause of wasted ad spend: `draft_campaign` refuses BROAD keywords unless the campaign uses Smart Bidding, and `draft_keywords` / `draft_ad_group` mark them as dangerous in the preview, so the AI has to raise it before anything is applied.
319
319
  - **Pre-write validation.** Before any write, the AI checks bidding strategy, conversion tracking status, and quality scores. If the campaign is fundamentally broken, AdLoop warns you instead of making things worse.
320
320
  - **Structured error handling.** All tools return actionable error messages with hints instead of raw exceptions. Auth errors include specific re-authorization steps.
321
321
  - **API version pinning.** The Google Ads API version is pinned to prevent silent breaking changes from library updates. `health_check` warns when a newer version is available.
@@ -578,7 +578,7 @@ What's been shipped and what's next:
578
578
  - ~~PyPI package~~ ✓ — `pip install adloop`
579
579
  - ~~[AdLoop Cloud](https://getadloop.com)~~ ✓ — the hosted version: no Google Cloud project, no API access application, connect Google in two clicks (EU-hosted, GDPR-first)
580
580
  - ~~Headless server support~~ ✓ — manual URL copy-paste flow for servers without a browser
581
- - ~~Behavioral eval suites~~ ✓ — 28 prompt-and-expectation tests covering read, write, tracking, and planning workflows
581
+ - ~~Behavioral eval suites~~ ✓ — prompt-and-expectation tests in `tests/evals/` covering read, write, tracking, and planning workflows
582
582
  - ~~Google Tag Manager integration~~ ✓ — read tools for tags, triggers, variables, workspaces, and version history, plus the `audit_event_coverage` three-way join across codebase events, GTM tags, and GA4 actual fires
583
583
  - **Community launch** — HN, Indie Hackers, r/cursor, Twitter
584
584
  - **Video walkthrough**
@@ -227,8 +227,8 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
227
227
  | `draft_responsive_search_ad` | Create RSA preview (3-15 headlines ≤30 chars, 2-4 descriptions ≤90 chars). Warns if headline/description count is below best practice. |
228
228
  | `draft_callouts` | Create campaign callout assets from 1-25 character text snippets. |
229
229
  | `draft_structured_snippets` | Create campaign structured snippet assets using official header values and 3-10 snippet values. |
230
- | `draft_image_assets` | Create campaign image assets from local PNG, JPEG, or GIF files. |
231
- | `draft_keywords` | Propose keyword additions with match types. Proactively checks bidding strategy — blocks BROAD match on Manual CPC campaigns. |
230
+ | `draft_image_assets` | Create campaign image assets from local files or public image URLs (PNG, JPEG, or GIF). |
231
+ | `draft_keywords` | Propose keyword additions with match types. Proactively checks bidding strategy — flags BROAD match on campaigns without Smart Bidding as dangerous in the preview. |
232
232
  | `add_negative_keywords` | Propose negative keywords directly on a campaign |
233
233
  | `add_negative_locations` | Propose negative geo exclusions on a campaign — exclude cities/regions while keeping broader positive targets |
234
234
  | `draft_key_event` | Mark a GA4 event as a key event (conversion) — the fix for "fires but isn't tracked as a conversion" |
@@ -287,8 +287,8 @@ AdLoop manages real ad spend, so safety is not optional.
287
287
  - **Budget caps.** Configurable maximum daily budget — the server rejects anything above the cap.
288
288
  - **Audit log.** Every operation (including dry runs) is logged to `~/.adloop/audit.log`.
289
289
  - **New campaigns and ads are PAUSED.** Nothing goes live without manual enablement.
290
- - **Destructive ops require double confirmation.** Removing entities or large budget increases trigger extra warnings.
291
- - **Broad Match + Manual CPC blocked.** The #1 cause of wasted ad spend is automatically prevented — `draft_keywords` refuses to add BROAD match keywords to campaigns without Smart Bidding.
290
+ - **Destructive ops are flagged for double confirmation.** Removing entities or large budget increases come back with extra warnings, and the AI is instructed to confirm twice.
291
+ - **Broad Match without Smart Bidding caught.** The #1 cause of wasted ad spend: `draft_campaign` refuses BROAD keywords unless the campaign uses Smart Bidding, and `draft_keywords` / `draft_ad_group` mark them as dangerous in the preview, so the AI has to raise it before anything is applied.
292
292
  - **Pre-write validation.** Before any write, the AI checks bidding strategy, conversion tracking status, and quality scores. If the campaign is fundamentally broken, AdLoop warns you instead of making things worse.
293
293
  - **Structured error handling.** All tools return actionable error messages with hints instead of raw exceptions. Auth errors include specific re-authorization steps.
294
294
  - **API version pinning.** The Google Ads API version is pinned to prevent silent breaking changes from library updates. `health_check` warns when a newer version is available.
@@ -551,7 +551,7 @@ What's been shipped and what's next:
551
551
  - ~~PyPI package~~ ✓ — `pip install adloop`
552
552
  - ~~[AdLoop Cloud](https://getadloop.com)~~ ✓ — the hosted version: no Google Cloud project, no API access application, connect Google in two clicks (EU-hosted, GDPR-first)
553
553
  - ~~Headless server support~~ ✓ — manual URL copy-paste flow for servers without a browser
554
- - ~~Behavioral eval suites~~ ✓ — 28 prompt-and-expectation tests covering read, write, tracking, and planning workflows
554
+ - ~~Behavioral eval suites~~ ✓ — prompt-and-expectation tests in `tests/evals/` covering read, write, tracking, and planning workflows
555
555
  - ~~Google Tag Manager integration~~ ✓ — read tools for tags, triggers, variables, workspaces, and version history, plus the `audit_event_coverage` three-way join across codebase events, GTM tags, and GA4 actual fires
556
556
  - **Community launch** — HN, Indie Hackers, r/cursor, Twitter
557
557
  - **Video walkthrough**
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.18.1"
3
+ version = "0.19.1"
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"
@@ -14,7 +14,7 @@ keywords = [
14
14
  "marketing",
15
15
  ]
16
16
  dependencies = [
17
- "fastmcp>=3.0.0",
17
+ "fastmcp>=4.0",
18
18
  "google-ads>=32.0.0",
19
19
  "google-analytics-data>=0.20.0",
20
20
  "google-analytics-admin>=0.27.0",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.18.1"
3
+ version = "0.19.1"
4
4
  description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -10,7 +10,7 @@ requires-python = ">=3.11"
10
10
  license = { text = "MIT" }
11
11
  keywords = ["mcp", "google-ads", "reddit-ads", "google-analytics", "ga4", "cursor", "marketing"]
12
12
  dependencies = [
13
- "fastmcp>=3.0.0",
13
+ "fastmcp>=4.0",
14
14
  "google-ads>=32.0.0",
15
15
  "google-analytics-data>=0.20.0",
16
16
  "google-analytics-admin>=0.27.0",
@@ -71,7 +71,13 @@ def _patch_request_responder_cancel_race() -> None:
71
71
  """
72
72
  try:
73
73
  from mcp.shared import session as _session_module
74
- except Exception as exc: # pragma: no cover — mcp is a hard dep
74
+ except ModuleNotFoundError:
75
+ # mcp 2 rewrote the session layer: the racing RequestResponder is gone
76
+ # and the new dispatcher has a single response writer, so there is
77
+ # nothing to patch. Not an error.
78
+ _log("pysdk-2416 not applicable: mcp>=2 has no RequestResponder race")
79
+ return
80
+ except Exception as exc: # pragma: no cover — unexpected import failure
75
81
  _log(f"skip pysdk-2416: cannot import mcp.shared.session ({exc})")
76
82
  return
77
83
 
@@ -537,7 +537,6 @@ def draft_clear_custom_conversion_goal(
537
537
 
538
538
  Call ``confirm_and_apply`` with the returned plan_id to execute.
539
539
  """
540
- from adloop.ads.client import normalize_customer_id
541
540
  from adloop.safety.guards import SafetyViolation, check_blocked_operation
542
541
  from adloop.safety.preview import ChangePlan, store_plan
543
542
 
@@ -553,7 +552,6 @@ def draft_clear_custom_conversion_goal(
553
552
  "details": ["campaign_id must be a numeric ID"],
554
553
  }
555
554
 
556
- cid = normalize_customer_id(customer_id or config.ads.customer_id)
557
555
  state = _custom_goal_state(config, customer_id, campaign_id=campaign_id)
558
556
  campaign = find_campaign(state, campaign_id)
559
557
  if campaign is None:
@@ -0,0 +1,331 @@
1
+ """Fetch ad images from public URLs without letting the fetch reach private hosts.
2
+
3
+ The fetched bytes are uploaded into the user's Google Ads account, where
4
+ they become visible. A fetch that could be steered at an internal address
5
+ (cloud metadata, a database admin page, a loopback service) would therefore
6
+ be a way to exfiltrate whatever lives there. So, unlike the up/down
7
+ reachability check for landing pages, this fetcher closes the DNS-rebinding
8
+ gap:
9
+
10
+ * the hostname is resolved exactly once per request, and every resolved
11
+ address must be public;
12
+ * the TCP connection goes to that resolved address (pinning) — the socket
13
+ never resolves the name again — while the request still carries the
14
+ original ``Host`` header and, for https, TLS uses the original hostname
15
+ for SNI and certificate verification;
16
+ * redirects are followed by hand (at most ``MAX_REDIRECTS``), each hop
17
+ re-validated and re-pinned;
18
+ * the body is streamed under a hard size cap and a wall-clock deadline;
19
+ * the content is trusted only after sniffing the bytes, never by
20
+ ``Content-Type``.
21
+
22
+ No cookies, credentials or auth headers are ever sent.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import hashlib
28
+ import http.client
29
+ import ipaddress
30
+ import socket
31
+ import ssl
32
+ import time
33
+ from dataclasses import dataclass
34
+ from urllib.parse import urljoin, urlsplit
35
+
36
+ # Google Ads rejects image assets above 5120 KB.
37
+ MAX_IMAGE_BYTES = 5120 * 1024
38
+ MAX_REDIRECTS = 3
39
+ TIMEOUT_SECONDS = 10.0
40
+
41
+ _REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308})
42
+ _CHUNK_SIZE = 64 * 1024
43
+ _NAT64_PREFIX = ipaddress.ip_network("64:ff9b::/96")
44
+
45
+
46
+ class ImageFetchError(ValueError):
47
+ """The image URL could not be fetched safely or is not a usable image."""
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class FetchedImage:
52
+ """Bytes fetched from ``url`` (after following ``final_url`` redirects)."""
53
+
54
+ url: str
55
+ final_url: str
56
+ data: bytes
57
+
58
+ @property
59
+ def sha256(self) -> str:
60
+ return hashlib.sha256(self.data).hexdigest()
61
+
62
+
63
+ def non_public_reason(
64
+ addr: ipaddress.IPv4Address | ipaddress.IPv6Address,
65
+ ) -> str | None:
66
+ """Return why ``addr`` must not be fetched from, or None if it is public.
67
+
68
+ IPv6 forms that embed an IPv4 address (IPv4-mapped, 6to4, Teredo,
69
+ NAT64) are judged by the embedded address too, so ``::ffff:127.0.0.1``
70
+ cannot slip past as "an IPv6 address that isn't loopback".
71
+ """
72
+ if isinstance(addr, ipaddress.IPv6Address):
73
+ if addr.ipv4_mapped is not None:
74
+ # The socket really talks IPv4 to the mapped address, so that
75
+ # address alone decides.
76
+ inner_reason = non_public_reason(addr.ipv4_mapped)
77
+ if inner_reason is not None:
78
+ return f"maps to {addr.ipv4_mapped}, {inner_reason}"
79
+ return None
80
+ embedded = [addr.sixtofour, *(addr.teredo or ())]
81
+ if addr in _NAT64_PREFIX:
82
+ embedded.append(ipaddress.IPv4Address(int(addr) & 0xFFFFFFFF))
83
+ for inner in embedded:
84
+ if inner is not None and non_public_reason(inner) is not None:
85
+ return f"embeds non-public address {inner}"
86
+
87
+ for label in (
88
+ "unspecified",
89
+ "loopback",
90
+ "link_local",
91
+ "multicast",
92
+ "private",
93
+ "reserved",
94
+ ):
95
+ if getattr(addr, f"is_{label}"):
96
+ return label.replace("_", "-")
97
+ if not addr.is_global:
98
+ return "not globally routable"
99
+ return None
100
+
101
+
102
+ def resolve_public_address(host: str, port: int) -> str:
103
+ """Resolve ``host`` once and return the address to connect to.
104
+
105
+ Raises ImageFetchError when the name does not resolve or when ANY of
106
+ its addresses is non-public — a hostname that mixes public and private
107
+ records is refused outright rather than having one picked.
108
+ """
109
+ try:
110
+ literal = ipaddress.ip_address(host)
111
+ except ValueError:
112
+ literal = None
113
+
114
+ if literal is not None:
115
+ addresses = [literal]
116
+ else:
117
+ try:
118
+ infos = socket.getaddrinfo(
119
+ host, port, type=socket.SOCK_STREAM, proto=socket.IPPROTO_TCP
120
+ )
121
+ except (OSError, UnicodeError) as exc:
122
+ raise ImageFetchError(f"hostname {host!r} does not resolve: {exc}") from exc
123
+ addresses = []
124
+ for info in infos:
125
+ try:
126
+ addresses.append(ipaddress.ip_address(info[4][0]))
127
+ except ValueError as exc:
128
+ raise ImageFetchError(
129
+ f"hostname {host!r} resolved to an unusable address {info[4][0]!r}"
130
+ ) from exc
131
+ if not addresses:
132
+ raise ImageFetchError(f"hostname {host!r} has no addresses")
133
+
134
+ for addr in addresses:
135
+ reason = non_public_reason(addr)
136
+ if reason is not None:
137
+ raise ImageFetchError(
138
+ f"{host} resolves to a non-public address ({addr}, {reason}) — "
139
+ "refusing to fetch"
140
+ )
141
+ return str(addresses[0])
142
+
143
+
144
+ class _PinnedHTTPConnection(http.client.HTTPConnection):
145
+ """HTTP connection that dials a pre-resolved IP instead of ``host``.
146
+
147
+ ``self.host`` stays the original hostname, so http.client still sends
148
+ the right ``Host`` header.
149
+ """
150
+
151
+ def __init__(self, host: str, port: int, *, pinned_ip: str, timeout: float) -> None:
152
+ super().__init__(host, port, timeout=timeout)
153
+ self.pinned_ip = pinned_ip
154
+
155
+ def connect(self) -> None:
156
+ self.sock = socket.create_connection((self.pinned_ip, self.port), self.timeout)
157
+
158
+
159
+ class _PinnedHTTPSConnection(http.client.HTTPSConnection):
160
+ """HTTPS connection that dials a pre-resolved IP.
161
+
162
+ TLS still uses the original hostname for SNI and certificate
163
+ verification, so pinning cannot be abused to accept a certificate for
164
+ a different name.
165
+ """
166
+
167
+ def __init__(
168
+ self,
169
+ host: str,
170
+ port: int,
171
+ *,
172
+ pinned_ip: str,
173
+ timeout: float,
174
+ context: ssl.SSLContext,
175
+ ) -> None:
176
+ super().__init__(host, port, timeout=timeout, context=context)
177
+ self.pinned_ip = pinned_ip
178
+ self.tls_context = context
179
+
180
+ def connect(self) -> None:
181
+ raw = socket.create_connection((self.pinned_ip, self.port), self.timeout)
182
+ try:
183
+ self.sock = self.tls_context.wrap_socket(raw, server_hostname=self.host)
184
+ except BaseException:
185
+ raw.close()
186
+ raise
187
+
188
+
189
+ def _tls_context() -> ssl.SSLContext:
190
+ """Default verifying context: CERT_REQUIRED + hostname checking."""
191
+ return ssl.create_default_context()
192
+
193
+
194
+ def _user_agent() -> str:
195
+ from adloop import __version__
196
+
197
+ return f"AdLoop/{__version__} (image asset fetch)"
198
+
199
+
200
+ @dataclass(frozen=True)
201
+ class _Target:
202
+ scheme: str
203
+ host: str
204
+ port: int
205
+ path: str
206
+
207
+
208
+ def _parse_target(url: str) -> _Target:
209
+ try:
210
+ parts = urlsplit(url)
211
+ port = parts.port
212
+ except ValueError as exc:
213
+ raise ImageFetchError(f"unparseable URL {url!r}: {exc}") from exc
214
+
215
+ scheme = parts.scheme.lower()
216
+ if scheme not in ("http", "https"):
217
+ raise ImageFetchError(
218
+ f"unsupported URL scheme {parts.scheme!r} in {url!r} (only http/https)"
219
+ )
220
+ if parts.username is not None or parts.password is not None:
221
+ raise ImageFetchError(f"URL {url!r} contains credentials — refusing to fetch")
222
+ host = parts.hostname
223
+ if not host:
224
+ raise ImageFetchError(f"URL {url!r} has no hostname")
225
+ try:
226
+ ipaddress.ip_address(host)
227
+ except ValueError:
228
+ try:
229
+ host = host.encode("idna").decode("ascii")
230
+ except UnicodeError as exc:
231
+ raise ImageFetchError(f"invalid hostname in {url!r}: {exc}") from exc
232
+
233
+ path = parts.path or "/"
234
+ if parts.query:
235
+ path = f"{path}?{parts.query}"
236
+ return _Target(
237
+ scheme=scheme,
238
+ host=host,
239
+ port=port or (443 if scheme == "https" else 80),
240
+ path=path,
241
+ )
242
+
243
+
244
+ def _open_connection(target: _Target, pinned_ip: str) -> http.client.HTTPConnection:
245
+ if target.scheme == "https":
246
+ return _PinnedHTTPSConnection(
247
+ target.host,
248
+ target.port,
249
+ pinned_ip=pinned_ip,
250
+ timeout=TIMEOUT_SECONDS,
251
+ context=_tls_context(),
252
+ )
253
+ return _PinnedHTTPConnection(
254
+ target.host, target.port, pinned_ip=pinned_ip, timeout=TIMEOUT_SECONDS
255
+ )
256
+
257
+
258
+ def _read_capped(response: http.client.HTTPResponse, url: str, deadline: float) -> bytes:
259
+ """Stream the body, aborting the moment it exceeds the size cap."""
260
+ declared = response.getheader("Content-Length")
261
+ if declared is not None and declared.strip().isdigit():
262
+ if int(declared) > MAX_IMAGE_BYTES:
263
+ raise ImageFetchError(
264
+ f"image at {url} is {int(declared) // 1024} KB; Google Ads "
265
+ f"accepts at most {MAX_IMAGE_BYTES // 1024} KB"
266
+ )
267
+
268
+ read = getattr(response, "read1", None) or response.read
269
+ chunks: list[bytes] = []
270
+ total = 0
271
+ while True:
272
+ if time.monotonic() > deadline:
273
+ raise ImageFetchError(
274
+ f"fetching {url} took longer than {TIMEOUT_SECONDS:.0f}s"
275
+ )
276
+ # read1 returns whatever has arrived instead of waiting for a full
277
+ # chunk, so a server trickling a byte at a time cannot outlast the
278
+ # deadline: the per-recv socket timeout alone would never fire.
279
+ chunk = read(_CHUNK_SIZE)
280
+ if not chunk:
281
+ break
282
+ total += len(chunk)
283
+ if total > MAX_IMAGE_BYTES:
284
+ raise ImageFetchError(
285
+ f"image at {url} exceeds {MAX_IMAGE_BYTES // 1024} KB, the "
286
+ "Google Ads image asset limit"
287
+ )
288
+ chunks.append(chunk)
289
+ return b"".join(chunks)
290
+
291
+
292
+ def fetch_image_bytes(url: str) -> FetchedImage:
293
+ """Fetch ``url`` safely and return the raw bytes (not yet sniffed)."""
294
+ current = url
295
+ for _hop in range(MAX_REDIRECTS + 1):
296
+ target = _parse_target(current)
297
+ pinned_ip = resolve_public_address(target.host, target.port)
298
+ deadline = time.monotonic() + TIMEOUT_SECONDS
299
+ connection = _open_connection(target, pinned_ip)
300
+ try:
301
+ connection.request(
302
+ "GET",
303
+ target.path,
304
+ headers={
305
+ "User-Agent": _user_agent(),
306
+ "Accept": "image/png, image/jpeg, image/gif",
307
+ "Accept-Encoding": "identity",
308
+ "Connection": "close",
309
+ },
310
+ )
311
+ response = connection.getresponse()
312
+ if response.status in _REDIRECT_STATUSES:
313
+ location = response.getheader("Location")
314
+ if not location:
315
+ raise ImageFetchError(
316
+ f"{current} answered HTTP {response.status} without a Location"
317
+ )
318
+ current = urljoin(current, location.strip())
319
+ continue
320
+ if response.status != 200:
321
+ raise ImageFetchError(f"{current} answered HTTP {response.status}")
322
+ data = _read_capped(response, current, deadline)
323
+ except ImageFetchError:
324
+ raise
325
+ except (OSError, http.client.HTTPException) as exc:
326
+ raise ImageFetchError(f"could not fetch {current}: {exc}") from exc
327
+ finally:
328
+ connection.close()
329
+ return FetchedImage(url=url, final_url=current, data=data)
330
+
331
+ raise ImageFetchError(f"{url} redirected more than {MAX_REDIRECTS} times")
@@ -373,7 +373,11 @@ def get_search_terms(
373
373
  """Get search terms report — what users actually typed before clicking ads."""
374
374
  from adloop.ads.gaql import execute_query
375
375
 
376
- date_clause = _date_clause(date_range_start, date_range_end)
376
+ # search_term_view requires an explicit date segment, so the default is
377
+ # DURING LAST_30_DAYS and a given range replaces it. ``_date_clause``
378
+ # prefixes its fragment with AND (the other read tools append it to a
379
+ # WHERE); here it is the only predicate, so the AND comes off.
380
+ where = _date_clause(date_range_start, date_range_end).removeprefix("AND ")
377
381
 
378
382
  query = f"""
379
383
  SELECT search_term_view.search_term,
@@ -381,35 +385,10 @@ def get_search_terms(
381
385
  metrics.impressions, metrics.clicks,
382
386
  metrics.cost_micros, metrics.conversions
383
387
  FROM search_term_view
384
- WHERE segments.date DURING LAST_30_DAYS
385
- {f"AND segments.date BETWEEN '{date_range_start}' AND '{date_range_end}'" if date_range_start and date_range_end else ""}
388
+ WHERE {where}
386
389
  ORDER BY metrics.clicks DESC
387
390
  LIMIT 200
388
391
  """
389
- # search_term_view requires an explicit date segment, so we always
390
- # include DURING LAST_30_DAYS as baseline and override if dates given.
391
- if date_range_start and date_range_end:
392
- query = f"""
393
- SELECT search_term_view.search_term,
394
- campaign.name, ad_group.name,
395
- metrics.impressions, metrics.clicks,
396
- metrics.cost_micros, metrics.conversions
397
- FROM search_term_view
398
- WHERE segments.date BETWEEN '{date_range_start}' AND '{date_range_end}'
399
- ORDER BY metrics.clicks DESC
400
- LIMIT 200
401
- """
402
- else:
403
- query = """
404
- SELECT search_term_view.search_term,
405
- campaign.name, ad_group.name,
406
- metrics.impressions, metrics.clicks,
407
- metrics.cost_micros, metrics.conversions
408
- FROM search_term_view
409
- WHERE segments.date DURING LAST_30_DAYS
410
- ORDER BY metrics.clicks DESC
411
- LIMIT 200
412
- """
413
392
 
414
393
  rows = execute_query(config, customer_id, query)
415
394
  currency_code = get_currency_code(config, customer_id)
@@ -8,7 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  import hashlib
10
10
  import struct
11
- from pathlib import Path
11
+ from pathlib import Path, PurePath, PurePosixPath
12
12
  from typing import TYPE_CHECKING
13
13
 
14
14
  if TYPE_CHECKING:
@@ -209,7 +209,9 @@ def _ssrf_error(url: str) -> str | None:
209
209
  Note: the fetch re-resolves DNS after this check, so a hostile DNS
210
210
  server could still rebind between check and fetch — acceptable here
211
211
  because the fetch result is only an up/down signal, never returned
212
- to the caller.
212
+ to the caller. Fetches whose bytes ARE used (image assets) go through
213
+ ``adloop.ads.image_fetch``, which pins the connection to the checked
214
+ address.
213
215
  """
214
216
  import ipaddress
215
217
  import socket
@@ -238,15 +240,10 @@ def _ssrf_error(url: str) -> str | None:
238
240
  ipaddress.ip_address(info[4][0]) for info in addr_info
239
241
  ]
240
242
 
243
+ from adloop.ads.image_fetch import non_public_reason
244
+
241
245
  for addr in addresses:
242
- if (
243
- addr.is_private
244
- or addr.is_loopback
245
- or addr.is_link_local
246
- or addr.is_multicast
247
- or addr.is_reserved
248
- or addr.is_unspecified
249
- ):
246
+ if non_public_reason(addr) is not None:
250
247
  return (
251
248
  f"URL resolves to a non-public address ({addr}) — "
252
249
  "refusing to fetch"
@@ -383,10 +380,67 @@ def _parse_image_metadata(path_str: str) -> dict[str, object]:
383
380
  "mime_type": mime_type,
384
381
  "width": width,
385
382
  "height": height,
383
+ "size_kb": _size_kb(data),
386
384
  }
387
385
 
388
386
 
389
- def _build_image_asset_name(path: Path, data: bytes) -> str:
387
+ def _parse_image_url_metadata(url: str) -> dict[str, object]:
388
+ """Fetch an image URL safely and return metadata used for asset creation.
389
+
390
+ The bytes themselves are NOT returned: plans are persisted, so only a
391
+ sha256 of the approved bytes is kept. Apply re-fetches the URL and
392
+ refuses if the digest no longer matches.
393
+ """
394
+ from adloop.ads.image_fetch import fetch_image_bytes
395
+
396
+ fetched = fetch_image_bytes(url)
397
+ mime_type, width, height = _detect_image_type_and_size(fetched.data)
398
+ return {
399
+ "url": url,
400
+ "sha256": fetched.sha256,
401
+ "name": _build_image_asset_name(_url_path(url), fetched.data),
402
+ "mime_type": mime_type,
403
+ "width": width,
404
+ "height": height,
405
+ "size_kb": _size_kb(fetched.data),
406
+ }
407
+
408
+
409
+ def _fetch_approved_image_url(payload: dict) -> bytes:
410
+ """Re-fetch a URL image at apply time and check it is the previewed one."""
411
+ from adloop.ads.image_fetch import fetch_image_bytes
412
+
413
+ url = str(payload["url"])
414
+ fetched = fetch_image_bytes(url)
415
+ if fetched.sha256 != payload.get("sha256"):
416
+ raise ValueError(
417
+ f"The image at {url} changed since the preview; draft again."
418
+ )
419
+ return fetched.data
420
+
421
+
422
+ def _url_path(url: str) -> PurePosixPath:
423
+ """The URL's path, so asset names use its file stem like local images do."""
424
+ from urllib.parse import unquote, urlsplit
425
+
426
+ return PurePosixPath(unquote(urlsplit(url).path))
427
+
428
+
429
+ def _size_kb(data: bytes) -> float:
430
+ return round(len(data) / 1024, 1)
431
+
432
+
433
+ def _describe_image(image: dict[str, object]) -> str:
434
+ """One preview line: source, type, dimensions and size."""
435
+ source = image.get("url") or image.get("path")
436
+ kind = str(image.get("mime_type", "")).removeprefix("image/").upper()
437
+ return (
438
+ f"{source} — {kind} {image.get('width')}x{image.get('height')}, "
439
+ f"{image.get('size_kb')} KB"
440
+ )
441
+
442
+
443
+ def _build_image_asset_name(path: PurePath, data: bytes) -> str:
390
444
  """Build a deterministic asset name required by Google Ads image assets."""
391
445
  digest = hashlib.sha1(data).hexdigest()[:12]
392
446
  stem = path.stem.strip() or "image"
@@ -394,7 +448,7 @@ def _build_image_asset_name(path: Path, data: bytes) -> str:
394
448
 
395
449
 
396
450
  def _detect_image_type_and_size(data: bytes) -> tuple[str, int, int]:
397
- """Return MIME type plus width/height for supported local image files."""
451
+ """Return MIME type plus width/height, sniffed from the bytes alone."""
398
452
  if data.startswith(b"\x89PNG\r\n\x1a\n") and len(data) >= 24:
399
453
  width, height = struct.unpack(">II", data[16:24])
400
454
  return "image/png", width, height
@@ -439,7 +493,7 @@ def _detect_image_type_and_size(data: bytes) -> tuple[str, int, int]:
439
493
  index += segment_length
440
494
 
441
495
  raise ValueError(
442
- "Unsupported image type. Use a local PNG, JPEG, or GIF file."
496
+ "Unsupported image type. Use a PNG, JPEG, or GIF image."
443
497
  )
444
498
 
445
499
 
@@ -2402,18 +2456,27 @@ def draft_image_assets(
2402
2456
  customer_id: str = "",
2403
2457
  campaign_id: str = "",
2404
2458
  image_paths: list[str] | None = None,
2459
+ image_urls: list[str] | None = None,
2405
2460
  ) -> dict:
2406
- """Draft campaign image assets from local files."""
2461
+ """Draft campaign image assets from local files and/or public image URLs.
2462
+
2463
+ URL images are fetched now (public addresses only, pinned against DNS
2464
+ rebinding, capped at Google's 5120 KB limit) and their sha256 is kept
2465
+ in the plan; apply re-fetches and refuses if the bytes changed.
2466
+ """
2407
2467
  from adloop.runtime import deployment_mode
2408
2468
  from adloop.safety.guards import SafetyViolation, check_blocked_operation
2409
2469
  from adloop.safety.preview import ChangePlan, store_plan
2410
2470
 
2411
- if deployment_mode() == "server":
2471
+ image_paths = list(image_paths or [])
2472
+ image_urls = list(image_urls or [])
2473
+
2474
+ if image_paths and deployment_mode() == "server":
2412
2475
  return {
2413
2476
  "error": (
2414
- "draft_image_assets reads image files from the local "
2415
- "filesystem and is not available on the hosted server. "
2416
- "Use the self-hosted AdLoop MCP server for image assets."
2477
+ "image_paths can't be used here: local file paths aren't "
2478
+ "available on a hosted server; pass image_urls (public "
2479
+ "https/http links to PNG, JPEG or GIF images) instead."
2417
2480
  )
2418
2481
  }
2419
2482
 
@@ -2422,7 +2485,9 @@ def draft_image_assets(
2422
2485
  except SafetyViolation as e:
2423
2486
  return {"error": str(e)}
2424
2487
 
2425
- validated_images, errors = _validate_image_assets(campaign_id, image_paths or [])
2488
+ validated_images, errors = _validate_image_assets(
2489
+ campaign_id, image_paths, image_urls
2490
+ )
2426
2491
  if errors:
2427
2492
  return {"error": "Validation failed", "details": errors}
2428
2493
 
@@ -2434,6 +2499,7 @@ def draft_image_assets(
2434
2499
  changes={
2435
2500
  "campaign_id": campaign_id,
2436
2501
  "images": validated_images,
2502
+ "summary": [_describe_image(image) for image in validated_images],
2437
2503
  },
2438
2504
  )
2439
2505
  store_plan(plan)
@@ -3018,15 +3084,16 @@ def _validate_structured_snippets(
3018
3084
 
3019
3085
 
3020
3086
  def _validate_image_assets(
3021
- campaign_id: str, image_paths: list[str]
3087
+ campaign_id: str, image_paths: list[str], image_urls: list[str] | None = None
3022
3088
  ) -> tuple[list[dict[str, object]], list[str]]:
3023
3089
  errors = []
3024
3090
  validated = []
3091
+ image_urls = image_urls or []
3025
3092
 
3026
3093
  if not campaign_id:
3027
3094
  errors.append("campaign_id is required")
3028
- if not image_paths:
3029
- errors.append("At least one image path is required")
3095
+ if not image_paths and not image_urls:
3096
+ errors.append("At least one image (image_paths or image_urls) is required")
3030
3097
 
3031
3098
  for index, image_path in enumerate(image_paths):
3032
3099
  try:
@@ -3034,6 +3101,12 @@ def _validate_image_assets(
3034
3101
  except ValueError as exc:
3035
3102
  errors.append(f"Image {index + 1}: {exc}")
3036
3103
 
3104
+ for index, image_url in enumerate(image_urls):
3105
+ try:
3106
+ validated.append(_parse_image_url_metadata(image_url))
3107
+ except ValueError as exc:
3108
+ errors.append(f"Image URL {index + 1} ({image_url}): {exc}")
3109
+
3037
3110
  return validated, errors
3038
3111
 
3039
3112
 
@@ -3794,7 +3867,6 @@ def _apply_create_campaign(client: object, cid: str, changes: dict) -> dict:
3794
3867
  results = {}
3795
3868
  num_keywords = len(kw_list)
3796
3869
  num_geo = len(changes.get("geo_target_ids") or [])
3797
- num_lang = len(changes.get("language_ids") or [])
3798
3870
  for i, resp in enumerate(response.mutate_operation_responses):
3799
3871
  rn = _extract_resource_name(resp)
3800
3872
  if rn:
@@ -4576,13 +4648,30 @@ def _apply_create_structured_snippets(
4576
4648
 
4577
4649
 
4578
4650
  def _apply_create_image_assets(client: object, cid: str, changes: dict) -> dict:
4579
- """Create image assets from local files and link them to a campaign."""
4651
+ """Create image assets from local files or URLs and link them to a campaign.
4652
+
4653
+ URL images are re-fetched through the same safe fetcher and must match
4654
+ the sha256 approved in the preview.
4655
+ """
4656
+
4657
+ from adloop.runtime import deployment_mode
4580
4658
 
4581
4659
  def populate(asset: object, payload: dict) -> None:
4582
- image_path = Path(str(payload["path"]))
4583
- image_bytes = image_path.read_bytes()
4660
+ if payload.get("url"):
4661
+ image_bytes = _fetch_approved_image_url(payload)
4662
+ name_source = _url_path(str(payload["url"]))
4663
+ elif deployment_mode() == "server":
4664
+ # Drafts refuse paths on a server; never read server files even
4665
+ # if a path-based plan reached apply some other way.
4666
+ raise ValueError(
4667
+ "local file paths aren't available on a hosted server; "
4668
+ "pass image_urls"
4669
+ )
4670
+ else:
4671
+ name_source = Path(str(payload["path"]))
4672
+ image_bytes = name_source.read_bytes()
4584
4673
  mime_type_name = _VALID_IMAGE_MIME_TYPES[str(payload["mime_type"])]
4585
- asset.name = str(payload.get("name") or _build_image_asset_name(image_path, image_bytes))
4674
+ asset.name = str(payload.get("name") or _build_image_asset_name(name_source, image_bytes))
4586
4675
  asset.type_ = client.enums.AssetTypeEnum.IMAGE
4587
4676
  asset.image_asset.data = image_bytes
4588
4677
  asset.image_asset.mime_type = getattr(client.enums.MimeTypeEnum, mime_type_name)
@@ -203,7 +203,7 @@ Reddit is a second ad platform with its own connection (own OAuth app, no develo
203
203
  | `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each). Each headline/description may be a plain string (unpinned) or `{"text": "...", "pinned_field": "HEADLINE_1"}` (pinned). Valid pin slots: `HEADLINE_1/2/3`, `DESCRIPTION_1/2`. Google permits ≤2 headlines per slot, ≤1 description per slot. |
204
204
  | `draft_callouts` | Create callout assets for a campaign (does NOT publish) | `campaign_id`, `callouts` list with 1-25 chars each |
205
205
  | `draft_structured_snippets` | Create structured snippet assets for a campaign (does NOT publish) | `campaign_id`, `snippets` list of `{header, values}` with official header values and 3-10 values |
206
- | `draft_image_assets` | Create image assets for a campaign from local files (does NOT publish) | `campaign_id`, `image_paths` list of local PNG/JPEG/GIF files |
206
+ | `draft_image_assets` | Create image assets for a campaign from local files or public image URLs (does NOT publish) | `campaign_id`, `image_paths` (local PNG/JPEG/GIF files; not on a hosted server) and/or `image_urls` (public http(s) image links, ≤5120 KB; re-checked at apply — if the image changed since the preview, draft again) |
207
207
  | `draft_sitelinks` | Create sitelink extensions for a campaign (does NOT publish) | `campaign_id`, `sitelinks` list of {link_text ≤25 chars, final_url, description1 ≤35 chars, description2 ≤35 chars} |
208
208
  | `draft_keywords` | Propose keyword additions (does NOT add) | Each keyword needs `text` and `match_type` (EXACT/PHRASE/BROAD) |
209
209
  | `add_negative_keywords` | Propose negative keywords directly on a campaign (does NOT add) | `campaign_id`, keyword list, `match_type` |
@@ -13,9 +13,11 @@ from pydantic import BeforeValidator
13
13
  from adloop import diagnostics
14
14
  from adloop.runtime import current_config
15
15
 
16
- _READONLY = ToolAnnotations(readOnlyHint=True, destructiveHint=False)
17
- _WRITE = ToolAnnotations(readOnlyHint=False, destructiveHint=False)
18
- _DESTRUCTIVE = ToolAnnotations(readOnlyHint=False, destructiveHint=True)
16
+ # openWorldHint: every tool talks to an external service (Google, Reddit,
17
+ # a live web page), which directories such as ChatGPT's require to be said.
18
+ _READONLY = ToolAnnotations(readOnlyHint=True, destructiveHint=False, openWorldHint=True)
19
+ _WRITE = ToolAnnotations(readOnlyHint=False, destructiveHint=False, openWorldHint=True)
20
+ _DESTRUCTIVE = ToolAnnotations(readOnlyHint=False, destructiveHint=True, openWorldHint=True)
19
21
 
20
22
  # Toolset taxonomy: every tool carries exactly one of these tags (or "core",
21
23
  # which survives every ADLOOP_TOOLSETS selection — health_check and
@@ -552,6 +554,8 @@ def run_ga4_report(
552
554
 
553
555
  Date formats: "today", "yesterday", "7daysAgo", "28daysAgo", "90daysAgo", or "YYYY-MM-DD".
554
556
  If property_id is empty, uses the default from config.
557
+ Queries the GA4 Data API. Dimensions and metrics:
558
+ https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema
555
559
  """
556
560
  from adloop.ga4.reports import run_ga4_report as _impl
557
561
 
@@ -660,6 +664,8 @@ def run_gsc_report(
660
664
  [{"filters": [{"dimension": "query", "operator": "contains",
661
665
  "expression": "analytics"}]}]
662
666
  limit: maximum rows to return (default 100, max 25000).
667
+ Queries the Search Console API:
668
+ https://developers.google.com/webmaster-tools/v1/searchanalytics/query
663
669
  """
664
670
  from adloop.gsc.reports import run_gsc_report as _impl
665
671
 
@@ -708,8 +714,9 @@ def analyze_page_speed(url: str, strategy: str = "mobile") -> dict:
708
714
  def list_merchant_accounts() -> dict:
709
715
  """List Google Merchant Center accounts the connected user can access.
710
716
 
711
- Call first to discover merchant IDs for get_merchant_feed_health.
712
- Distinguishes standalone accounts from aggregator (MCA) accounts.
717
+ Returns each account's numeric ID, name and whether it is a test
718
+ account. Call it to find the account_id for get_merchant_feed_health;
719
+ not needed when merchant.account_id is set in the config.
713
720
  """
714
721
  from adloop.merchant.read import list_merchant_accounts as _impl
715
722
 
@@ -899,9 +906,9 @@ def get_negative_keyword_lists(
899
906
  ) -> dict:
900
907
  """List all shared negative keyword lists (SharedSets) in the account.
901
908
 
902
- Returns each list's ID, name, status, and keyword count. Always call
903
- this before propose_negative_keyword_list to avoid creating duplicates —
904
- a suitable list may already exist and just need attaching to a campaign.
909
+ Returns each list's ID, name, status, and keyword count. Useful before
910
+ propose_negative_keyword_list: a suitable list may already exist and
911
+ just need attaching to a campaign.
905
912
  """
906
913
  from adloop.ads.read import get_negative_keyword_lists as _impl
907
914
 
@@ -1543,7 +1550,8 @@ def run_gaql(
1543
1550
  """Execute an arbitrary GAQL (Google Ads Query Language) query.
1544
1551
 
1545
1552
  Use this for advanced queries not covered by the other tools.
1546
- See the GAQL reference in the AdLoop cursor rules for syntax help.
1553
+ Queries the Google Ads API. GAQL syntax and fields:
1554
+ https://developers.google.com/google-ads/api/docs/query/overview
1547
1555
 
1548
1556
  format: "table" (default, readable), "json" (structured), "csv" (exportable)
1549
1557
  """
@@ -2125,8 +2133,8 @@ def add_to_negative_keyword_list(
2125
2133
 
2126
2134
  Use this when a suitable list already exists and only needs more keywords
2127
2135
  (instead of propose_negative_keyword_list, which creates a new list).
2128
- Always call get_negative_keyword_lists first to find the right shared_set_id
2129
- and get_negative_keyword_list_keywords to avoid duplicating existing terms.
2136
+ The shared_set_id comes from get_negative_keyword_lists;
2137
+ get_negative_keyword_list_keywords shows the terms already in a list.
2130
2138
 
2131
2139
  shared_set_id: numeric ID from get_negative_keyword_lists (shared_set.id).
2132
2140
  keywords: list of keyword strings to append (duplicates in the input list
@@ -2458,10 +2466,25 @@ def draft_structured_snippets(
2458
2466
  @_safe
2459
2467
  def draft_image_assets(
2460
2468
  campaign_id: str,
2461
- image_paths: _StrList,
2469
+ image_paths: _StrList = [], # noqa: B006 — mutable default required for MCP JSON schema
2470
+ image_urls: _StrList = [], # noqa: B006
2462
2471
  customer_id: str = "",
2463
2472
  ) -> dict:
2464
- """Draft campaign image assets from local PNG, JPEG, or GIF files."""
2473
+ """Draft campaign image assets from local files or public image URLs — returns a PREVIEW.
2474
+
2475
+ Pass at least one image in total across image_paths and image_urls.
2476
+ Images must be PNG, JPEG or GIF and at most 5120 KB (Google's limit).
2477
+ URL images are downloaded now and checked; applying re-downloads them
2478
+ and refuses if the image changed since the preview.
2479
+
2480
+ Args:
2481
+ campaign_id: Numeric ID of the campaign to attach the images to.
2482
+ image_paths: Local PNG/JPEG/GIF file paths on the machine running
2483
+ AdLoop. Not available on a hosted server — use image_urls there.
2484
+ image_urls: Public http(s) URLs of PNG/JPEG/GIF images. Links to
2485
+ private, local or internal addresses are refused.
2486
+ customer_id: Ads account ID. Defaults to the configured account.
2487
+ """
2465
2488
  from adloop.ads.write import draft_image_assets as _impl
2466
2489
 
2467
2490
  return _impl(
@@ -2469,6 +2492,7 @@ def draft_image_assets(
2469
2492
  customer_id=customer_id or current_config().ads.customer_id,
2470
2493
  campaign_id=campaign_id,
2471
2494
  image_paths=image_paths,
2495
+ image_urls=image_urls,
2472
2496
  )
2473
2497
 
2474
2498
 
@@ -2799,6 +2823,7 @@ def run_reddit_report(
2799
2823
  (HOUR and DATE cannot be combined). Dates are account-local days.
2800
2824
  filter: Reddit filter expression (e.g. "campaign_id==abc123").
2801
2825
  Microcurrency fields are converted to currency amounts.
2826
+ Queries the Reddit Ads API: https://ads-api.reddit.com/docs/v3/
2802
2827
  """
2803
2828
  from adloop.reddit.read import run_reddit_report as _impl
2804
2829
 
@@ -3718,8 +3743,8 @@ def get_brand_lists(
3718
3743
  ) -> dict:
3719
3744
  """List all brand lists (SharedSets of type BRANDS) in the account.
3720
3745
 
3721
- Returns each list's ID, name, status, and member count. Call this before
3722
- propose_brand_list so an existing list can be reused instead of duplicated.
3746
+ Returns each list's ID, name, status, and member count. Useful before
3747
+ propose_brand_list, so an existing list can be reused instead of duplicated.
3723
3748
 
3724
3749
  Args:
3725
3750
  customer_id: Ads account ID. Defaults to the configured account.
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