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.
- {adloop-0.18.1 → adloop-0.19.1}/PKG-INFO +7 -7
- {adloop-0.18.1 → adloop-0.19.1}/README.md +5 -5
- {adloop-0.18.1 → adloop-0.19.1}/pyproject.toml +2 -2
- {adloop-0.18.1 → adloop-0.19.1}/pyproject.toml.orig +2 -2
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/_mcp_patches.py +7 -1
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/custom_conversion_goals.py +0 -2
- adloop-0.19.1/src/adloop/ads/image_fetch.py +331 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/read.py +6 -27
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/write.py +116 -27
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/rules/adloop.md +1 -1
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/server.py +40 -15
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/__init__.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/__main__.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/ai_max.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/brands.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/client.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/conversion_actions.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/currency.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/enums.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ads/validate_only.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/auth.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/cli.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/config.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/crossref.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/diagnostics.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ga4/client.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/ga4/write.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/gsc/__init__.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/gsc/client.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/gsc/reports.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/gtm/__init__.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/gtm/client.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/gtm/read.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/merchant/__init__.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/merchant/client.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/merchant/read.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/pagespeed.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/reddit/__init__.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/reddit/auth.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/reddit/client.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/reddit/read.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/reddit/schedule.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/reddit/write.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/rules_install.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/runtime.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/safety/audit.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/safety/guards.py +0 -0
- {adloop-0.18.1 → adloop-0.19.1}/src/adloop/safety/preview.py +0 -0
- {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.
|
|
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>=
|
|
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
|
|
258
|
-
| `draft_keywords` | Propose keyword additions with match types. Proactively checks bidding strategy —
|
|
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
|
|
318
|
-
- **Broad Match
|
|
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~~ ✓ —
|
|
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
|
|
231
|
-
| `draft_keywords` | Propose keyword additions with match types. Proactively checks bidding strategy —
|
|
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
|
|
291
|
-
- **Broad Match
|
|
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~~ ✓ —
|
|
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.
|
|
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>=
|
|
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.
|
|
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>=
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
"
|
|
2415
|
-
"
|
|
2416
|
-
"
|
|
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(
|
|
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
|
|
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
|
-
|
|
4583
|
-
|
|
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(
|
|
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`
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
712
|
-
|
|
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.
|
|
903
|
-
|
|
904
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2129
|
-
|
|
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
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|