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.
- {adloop-0.18.1 → adloop-0.19.0}/PKG-INFO +3 -3
- {adloop-0.18.1 → adloop-0.19.0}/README.md +1 -1
- {adloop-0.18.1 → adloop-0.19.0}/pyproject.toml +2 -2
- {adloop-0.18.1 → adloop-0.19.0}/pyproject.toml.orig +2 -2
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/_mcp_patches.py +7 -1
- adloop-0.19.0/src/adloop/ads/image_fetch.py +331 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/write.py +116 -26
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/rules/adloop.md +1 -1
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/server.py +18 -2
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/__init__.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/__main__.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/ai_max.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/brands.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/client.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/conversion_actions.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/enums.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/read.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ads/validate_only.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/auth.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/cli.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/config.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/crossref.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/diagnostics.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/ga4/write.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/gsc/__init__.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/gsc/client.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/gsc/reports.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/gtm/__init__.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/gtm/client.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/gtm/read.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/merchant/__init__.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/merchant/client.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/merchant/read.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/pagespeed.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/reddit/__init__.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/reddit/auth.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/reddit/client.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/reddit/read.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/reddit/schedule.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/reddit/write.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/rules_install.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/runtime.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.18.1 → adloop-0.19.0}/src/adloop/safety/preview.py +0 -0
- {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.
|
|
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>=
|
|
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
|
|
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
|
|
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.
|
|
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>=
|
|
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.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>=
|
|
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
|
|
|
@@ -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
|
|
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
|
|
|
@@ -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
|
-
|
|
4583
|
-
|
|
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(
|
|
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`
|
|
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
|
|
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
|
|
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
|