adloop 0.18.1__tar.gz → 0.19.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. {adloop-0.18.1 → adloop-0.19.0}/PKG-INFO +3 -3
  2. {adloop-0.18.1 → adloop-0.19.0}/README.md +1 -1
  3. {adloop-0.18.1 → adloop-0.19.0}/pyproject.toml +2 -2
  4. {adloop-0.18.1 → adloop-0.19.0}/pyproject.toml.orig +2 -2
  5. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/_mcp_patches.py +7 -1
  6. adloop-0.19.0/src/adloop/ads/image_fetch.py +331 -0
  7. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/write.py +116 -26
  8. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/rules/adloop.md +1 -1
  9. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/server.py +18 -2
  10. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/__init__.py +0 -0
  11. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/__main__.py +0 -0
  12. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/__init__.py +0 -0
  13. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/ai_max.py +0 -0
  14. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/brands.py +0 -0
  15. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/client.py +0 -0
  16. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/conversion_actions.py +0 -0
  17. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/currency.py +0 -0
  18. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
  19. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/enums.py +0 -0
  20. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/forecast.py +0 -0
  21. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/gaql.py +0 -0
  22. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/pmax.py +0 -0
  23. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/read.py +0 -0
  24. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/validate_only.py +0 -0
  25. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/auth.py +0 -0
  26. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/cli.py +0 -0
  27. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/config.py +0 -0
  28. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/crossref.py +0 -0
  29. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/diagnostics.py +0 -0
  30. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ga4/__init__.py +0 -0
  31. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ga4/client.py +0 -0
  32. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ga4/reports.py +0 -0
  33. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ga4/tracking.py +0 -0
  34. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ga4/write.py +0 -0
  35. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/gsc/__init__.py +0 -0
  36. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/gsc/client.py +0 -0
  37. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/gsc/reports.py +0 -0
  38. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/gtm/__init__.py +0 -0
  39. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/gtm/client.py +0 -0
  40. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/gtm/read.py +0 -0
  41. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/merchant/__init__.py +0 -0
  42. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/merchant/client.py +0 -0
  43. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/merchant/read.py +0 -0
  44. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/pagespeed.py +0 -0
  45. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/reddit/__init__.py +0 -0
  46. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/reddit/auth.py +0 -0
  47. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/reddit/client.py +0 -0
  48. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/reddit/read.py +0 -0
  49. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/reddit/schedule.py +0 -0
  50. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/reddit/write.py +0 -0
  51. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/rules/__init__.py +0 -0
  52. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
  53. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/rules/commands/budget-plan.md +0 -0
  54. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/rules/commands/create-ad.md +0 -0
  55. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/rules/commands/create-campaign.md +0 -0
  56. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  57. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  58. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/rules_install.py +0 -0
  59. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/runtime.py +0 -0
  60. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/safety/__init__.py +0 -0
  61. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/safety/audit.py +0 -0
  62. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/safety/guards.py +0 -0
  63. {adloop-0.18.1 → adloop-0.19.0}/src/adloop/safety/preview.py +0 -0
  64. {adloop-0.18.1 → adloop-0.19.0}/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.0
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,7 +254,7 @@ 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. |
257
+ | `draft_image_assets` | Create campaign image assets from local files or public image URLs (PNG, JPEG, or GIF). |
258
258
  | `draft_keywords` | Propose keyword additions with match types. Proactively checks bidding strategy — blocks BROAD match on Manual CPC campaigns. |
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 |
@@ -227,7 +227,7 @@ 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. |
230
+ | `draft_image_assets` | Create campaign image assets from local files or public image URLs (PNG, JPEG, or GIF). |
231
231
  | `draft_keywords` | Propose keyword additions with match types. Proactively checks bidding strategy — blocks BROAD match on Manual CPC campaigns. |
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 |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.18.1"
3
+ version = "0.19.0"
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.0"
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
 
@@ -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")
@@ -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
 
@@ -4576,13 +4649,30 @@ def _apply_create_structured_snippets(
4576
4649
 
4577
4650
 
4578
4651
  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."""
4652
+ """Create image assets from local files or URLs and link them to a campaign.
4653
+
4654
+ URL images are re-fetched through the same safe fetcher and must match
4655
+ the sha256 approved in the preview.
4656
+ """
4657
+
4658
+ from adloop.runtime import deployment_mode
4580
4659
 
4581
4660
  def populate(asset: object, payload: dict) -> None:
4582
- image_path = Path(str(payload["path"]))
4583
- image_bytes = image_path.read_bytes()
4661
+ if payload.get("url"):
4662
+ image_bytes = _fetch_approved_image_url(payload)
4663
+ name_source = _url_path(str(payload["url"]))
4664
+ elif deployment_mode() == "server":
4665
+ # Drafts refuse paths on a server; never read server files even
4666
+ # if a path-based plan reached apply some other way.
4667
+ raise ValueError(
4668
+ "local file paths aren't available on a hosted server; "
4669
+ "pass image_urls"
4670
+ )
4671
+ else:
4672
+ name_source = Path(str(payload["path"]))
4673
+ image_bytes = name_source.read_bytes()
4584
4674
  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))
4675
+ asset.name = str(payload.get("name") or _build_image_asset_name(name_source, image_bytes))
4586
4676
  asset.type_ = client.enums.AssetTypeEnum.IMAGE
4587
4677
  asset.image_asset.data = image_bytes
4588
4678
  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` |
@@ -2458,10 +2458,25 @@ def draft_structured_snippets(
2458
2458
  @_safe
2459
2459
  def draft_image_assets(
2460
2460
  campaign_id: str,
2461
- image_paths: _StrList,
2461
+ image_paths: _StrList = [], # noqa: B006 — mutable default required for MCP JSON schema
2462
+ image_urls: _StrList = [], # noqa: B006
2462
2463
  customer_id: str = "",
2463
2464
  ) -> dict:
2464
- """Draft campaign image assets from local PNG, JPEG, or GIF files."""
2465
+ """Draft campaign image assets from local files or public image URLs — returns a PREVIEW.
2466
+
2467
+ Pass at least one image in total across image_paths and image_urls.
2468
+ Images must be PNG, JPEG or GIF and at most 5120 KB (Google's limit).
2469
+ URL images are downloaded now and checked; applying re-downloads them
2470
+ and refuses if the image changed since the preview.
2471
+
2472
+ Args:
2473
+ campaign_id: Numeric ID of the campaign to attach the images to.
2474
+ image_paths: Local PNG/JPEG/GIF file paths on the machine running
2475
+ AdLoop. Not available on a hosted server — use image_urls there.
2476
+ image_urls: Public http(s) URLs of PNG/JPEG/GIF images. Links to
2477
+ private, local or internal addresses are refused.
2478
+ customer_id: Ads account ID. Defaults to the configured account.
2479
+ """
2465
2480
  from adloop.ads.write import draft_image_assets as _impl
2466
2481
 
2467
2482
  return _impl(
@@ -2469,6 +2484,7 @@ def draft_image_assets(
2469
2484
  customer_id=customer_id or current_config().ads.customer_id,
2470
2485
  campaign_id=campaign_id,
2471
2486
  image_paths=image_paths,
2487
+ image_urls=image_urls,
2472
2488
  )
2473
2489
 
2474
2490
 
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