adloop 0.13.0__tar.gz → 0.13.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {adloop-0.13.0 → adloop-0.13.2}/PKG-INFO +3 -1
- {adloop-0.13.0 → adloop-0.13.2}/README.md +2 -0
- adloop-0.13.2/pyproject.toml +50 -0
- adloop-0.13.0/pyproject.toml → adloop-0.13.2/pyproject.toml.orig +1 -1
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/__init__.py +21 -2
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ads/write.py +72 -15
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/merchant/client.py +36 -9
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/merchant/read.py +6 -1
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/__main__.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ads/client.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ads/currency.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ads/enums.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ads/read.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/auth.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/cli.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/config.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/crossref.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/diagnostics.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ga4/client.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ga4/write.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/gsc/__init__.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/gsc/client.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/gsc/reports.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/gtm/__init__.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/gtm/client.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/gtm/read.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/merchant/__init__.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/pagespeed.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/rules/adloop.md +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/rules_install.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/runtime.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/safety/audit.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/safety/guards.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/safety/preview.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/server.py +0 -0
- {adloop-0.13.0 → adloop-0.13.2}/src/adloop/tracking.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: adloop
|
|
3
|
-
Version: 0.13.
|
|
3
|
+
Version: 0.13.2
|
|
4
4
|
Summary: The AI command center for Google Ads, GA4, and tracking code.
|
|
5
5
|
Keywords: mcp,google-ads,google-analytics,ga4,cursor,marketing
|
|
6
6
|
Author: Daniel Klose
|
|
@@ -47,6 +47,8 @@ An MCP server that gives your AI assistant read + write access to Google Ads and
|
|
|
47
47
|
> [!TIP]
|
|
48
48
|
> **[AdLoop Cloud](https://getadloop.com) is the hosted version of this project — live now, free during beta (limited seats).** Connect Google in two clicks and use the full toolset from claude.ai, ChatGPT, Claude Code, Cursor, or Gemini. No Google Cloud project, no developer token, no OAuth verification wait. EU-hosted, GDPR-first, DPA included.
|
|
49
49
|
|
|
50
|
+
> 📚 **Documentation: [docs.getadloop.com](https://docs.getadloop.com)** — setup guides per AI client, toolsets, the safety model, and troubleshooting for both editions.
|
|
51
|
+
|
|
50
52
|
---
|
|
51
53
|
|
|
52
54
|
## Cloud or Self-Hosted?
|
|
@@ -21,6 +21,8 @@ An MCP server that gives your AI assistant read + write access to Google Ads and
|
|
|
21
21
|
> [!TIP]
|
|
22
22
|
> **[AdLoop Cloud](https://getadloop.com) is the hosted version of this project — live now, free during beta (limited seats).** Connect Google in two clicks and use the full toolset from claude.ai, ChatGPT, Claude Code, Cursor, or Gemini. No Google Cloud project, no developer token, no OAuth verification wait. EU-hosted, GDPR-first, DPA included.
|
|
23
23
|
|
|
24
|
+
> 📚 **Documentation: [docs.getadloop.com](https://docs.getadloop.com)** — setup guides per AI client, toolsets, the safety model, and troubleshooting for both editions.
|
|
25
|
+
|
|
24
26
|
---
|
|
25
27
|
|
|
26
28
|
## Cloud or Self-Hosted?
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "adloop"
|
|
3
|
+
version = "0.13.2"
|
|
4
|
+
description = "The AI command center for Google Ads, GA4, and tracking code."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
keywords = [
|
|
8
|
+
"mcp",
|
|
9
|
+
"google-ads",
|
|
10
|
+
"google-analytics",
|
|
11
|
+
"ga4",
|
|
12
|
+
"cursor",
|
|
13
|
+
"marketing",
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"fastmcp>=3.0.0",
|
|
17
|
+
"google-ads>=31.1.0",
|
|
18
|
+
"google-analytics-data>=0.20.0",
|
|
19
|
+
"google-analytics-admin>=0.27.0",
|
|
20
|
+
"google-api-python-client>=2.100.0",
|
|
21
|
+
"google-auth-oauthlib>=1.0.0",
|
|
22
|
+
"google-api-python-client>=2.0.0",
|
|
23
|
+
"pyyaml>=6.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[[project.authors]]
|
|
27
|
+
name = "Daniel Klose"
|
|
28
|
+
email = "info@daniel-klose.com"
|
|
29
|
+
|
|
30
|
+
[project.license]
|
|
31
|
+
text = "MIT"
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/kLOsk/adloop"
|
|
35
|
+
Repository = "https://github.com/kLOsk/adloop"
|
|
36
|
+
Issues = "https://github.com/kLOsk/adloop/issues"
|
|
37
|
+
Changelog = "https://github.com/kLOsk/adloop/releases"
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
adloop = "adloop:main"
|
|
41
|
+
|
|
42
|
+
[project.optional-dependencies]
|
|
43
|
+
dev = [
|
|
44
|
+
"pytest>=8.0",
|
|
45
|
+
"pytest-asyncio>=0.23",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[build-system]
|
|
49
|
+
requires = ["uv_build>=0.10.8,<0.11.0"]
|
|
50
|
+
build-backend = "uv_build"
|
|
@@ -9,6 +9,25 @@ except PackageNotFoundError: # running from a source tree without install
|
|
|
9
9
|
__version__ = "0.0.0.dev0"
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
def install_runtime_patches() -> None:
|
|
13
|
+
"""Arm the upstream-bug workarounds for a long-running host process.
|
|
14
|
+
|
|
15
|
+
Importing ``adloop.server`` is deliberately free of process-global side
|
|
16
|
+
effects so the server can be embedded in an ASGI app. That leaves the
|
|
17
|
+
workarounds in ``_mcp_patches`` unarmed for any embedder, including a
|
|
18
|
+
hosted deployment, which is exactly where they matter most: the
|
|
19
|
+
cancellation race they guard against (python-sdk#2416) fires when a
|
|
20
|
+
host cancels a slow tool call, and it takes the transport down with it.
|
|
21
|
+
|
|
22
|
+
Embedders should call this once during application startup. It is
|
|
23
|
+
idempotent, never raises, and each patch inside self-disarms once the
|
|
24
|
+
upstream bug it tracks is fixed.
|
|
25
|
+
"""
|
|
26
|
+
from adloop import _mcp_patches
|
|
27
|
+
|
|
28
|
+
_mcp_patches.install()
|
|
29
|
+
|
|
30
|
+
|
|
12
31
|
def main() -> None:
|
|
13
32
|
"""Entry point for `adloop` console script.
|
|
14
33
|
|
|
@@ -50,10 +69,10 @@ def main() -> None:
|
|
|
50
69
|
# the stdio cancellation-race monkeypatch) are deliberately installed
|
|
51
70
|
# here — in the stdio entry point — rather than at adloop.server import
|
|
52
71
|
# time, so embedding the server in an ASGI app stays side-effect-free.
|
|
53
|
-
from adloop import
|
|
72
|
+
from adloop import diagnostics
|
|
54
73
|
|
|
55
74
|
diagnostics.install()
|
|
56
|
-
|
|
75
|
+
install_runtime_patches()
|
|
57
76
|
|
|
58
77
|
from adloop.server import mcp
|
|
59
78
|
|
|
@@ -270,17 +270,49 @@ def _build_public_only_opener():
|
|
|
270
270
|
return urllib.request.build_opener(_PublicOnlyRedirectHandler)
|
|
271
271
|
|
|
272
272
|
|
|
273
|
-
|
|
273
|
+
# Statuses that say nothing about whether the landing page is any good:
|
|
274
|
+
# the site is throttling us, or is briefly unwell. Refusing to draft an ad
|
|
275
|
+
# over one punishes the advertiser for their own rate limiting — and we
|
|
276
|
+
# are frequently the cause of it, since drafting several ads at once fires
|
|
277
|
+
# several HEAD requests at the same origin within a second or two.
|
|
278
|
+
#
|
|
279
|
+
# A genuinely dead URL still blocks: 404 and 410 are the cases this check
|
|
280
|
+
# exists for, and they do not resolve themselves on a retry.
|
|
281
|
+
_INCONCLUSIVE_STATUSES = frozenset({408, 425, 429, 500, 502, 503, 504})
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _validate_urls(
|
|
285
|
+
urls: list[str], timeout: int = 10
|
|
286
|
+
) -> tuple[dict[str, str | None], dict[str, str]]:
|
|
274
287
|
"""Check that each URL returns a 2xx/3xx status.
|
|
275
288
|
|
|
276
|
-
Returns
|
|
289
|
+
Returns (errors, warnings). ``errors`` maps url -> message for URLs
|
|
290
|
+
that should block the operation, None when fine. ``warnings`` maps
|
|
291
|
+
url -> message for checks that came back inconclusive; callers should
|
|
292
|
+
surface those but proceed, because the alternative is refusing to work
|
|
293
|
+
whenever the advertiser's own site is briefly throttling or flaky.
|
|
277
294
|
"""
|
|
278
295
|
import urllib.request
|
|
279
296
|
import urllib.error
|
|
280
297
|
|
|
281
298
|
opener = _build_public_only_opener()
|
|
282
299
|
|
|
283
|
-
results = {}
|
|
300
|
+
results: dict[str, str | None] = {}
|
|
301
|
+
warnings: dict[str, str] = {}
|
|
302
|
+
|
|
303
|
+
def _record_status(url: str, status: int) -> None:
|
|
304
|
+
if status in _INCONCLUSIVE_STATUSES:
|
|
305
|
+
results[url] = None
|
|
306
|
+
warnings[url] = (
|
|
307
|
+
f"HTTP {status} — could not verify the URL right now "
|
|
308
|
+
"(the site may be rate-limiting or temporarily down). "
|
|
309
|
+
"Proceeding without the check; confirm the page is live."
|
|
310
|
+
)
|
|
311
|
+
elif status >= 400:
|
|
312
|
+
results[url] = f"HTTP {status}"
|
|
313
|
+
else:
|
|
314
|
+
results[url] = None
|
|
315
|
+
|
|
284
316
|
for url in urls:
|
|
285
317
|
if not url:
|
|
286
318
|
continue
|
|
@@ -292,10 +324,7 @@ def _validate_urls(urls: list[str], timeout: int = 10) -> dict[str, str | None]:
|
|
|
292
324
|
req = urllib.request.Request(url, method="HEAD")
|
|
293
325
|
req.add_header("User-Agent", "AdLoop-URLCheck/1.0")
|
|
294
326
|
resp = opener.open(req, timeout=timeout)
|
|
295
|
-
|
|
296
|
-
results[url] = f"HTTP {resp.status}"
|
|
297
|
-
else:
|
|
298
|
-
results[url] = None
|
|
327
|
+
_record_status(url, resp.status)
|
|
299
328
|
except urllib.error.HTTPError as e:
|
|
300
329
|
if e.code == 405:
|
|
301
330
|
# HEAD not allowed, try GET
|
|
@@ -303,18 +332,17 @@ def _validate_urls(urls: list[str], timeout: int = 10) -> dict[str, str | None]:
|
|
|
303
332
|
req = urllib.request.Request(url, method="GET")
|
|
304
333
|
req.add_header("User-Agent", "AdLoop-URLCheck/1.0")
|
|
305
334
|
resp = opener.open(req, timeout=timeout)
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
results[url] = None
|
|
335
|
+
_record_status(url, resp.status)
|
|
336
|
+
except urllib.error.HTTPError as e2:
|
|
337
|
+
_record_status(url, e2.code)
|
|
310
338
|
except Exception as e2:
|
|
311
339
|
results[url] = str(e2)
|
|
312
340
|
else:
|
|
313
|
-
|
|
341
|
+
_record_status(url, e.code)
|
|
314
342
|
except Exception as e:
|
|
315
343
|
results[url] = str(e)
|
|
316
344
|
|
|
317
|
-
return results
|
|
345
|
+
return results, warnings
|
|
318
346
|
|
|
319
347
|
|
|
320
348
|
def _normalize_display_network_setting(
|
|
@@ -467,7 +495,7 @@ def draft_responsive_search_ad(
|
|
|
467
495
|
if errors:
|
|
468
496
|
return {"error": "Validation failed", "details": errors}
|
|
469
497
|
|
|
470
|
-
url_check = _validate_urls([final_url])
|
|
498
|
+
url_check, url_warnings = _validate_urls([final_url])
|
|
471
499
|
if url_check.get(final_url):
|
|
472
500
|
return {
|
|
473
501
|
"error": "URL validation failed",
|
|
@@ -478,6 +506,8 @@ def draft_responsive_search_ad(
|
|
|
478
506
|
}
|
|
479
507
|
|
|
480
508
|
warnings = []
|
|
509
|
+
if url_warnings.get(final_url):
|
|
510
|
+
warnings.append(f"final_url '{final_url}': {url_warnings[final_url]}")
|
|
481
511
|
if len(headlines) < 8:
|
|
482
512
|
warnings.append(
|
|
483
513
|
f"Only {len(headlines)} headlines provided. Google recommends 8-15 "
|
|
@@ -1703,7 +1733,7 @@ def draft_sitelinks(
|
|
|
1703
1733
|
return {"error": "Validation failed", "details": errors}
|
|
1704
1734
|
|
|
1705
1735
|
sitelink_urls = [sl["final_url"] for sl in validated]
|
|
1706
|
-
url_checks = _validate_urls(sitelink_urls)
|
|
1736
|
+
url_checks, url_warnings = _validate_urls(sitelink_urls)
|
|
1707
1737
|
bad_urls = {u: err for u, err in url_checks.items() if err}
|
|
1708
1738
|
if bad_urls:
|
|
1709
1739
|
return {
|
|
@@ -1712,6 +1742,7 @@ def draft_sitelinks(
|
|
|
1712
1742
|
f"'{url}' is not reachable: {err}" for url, err in bad_urls.items()
|
|
1713
1743
|
],
|
|
1714
1744
|
}
|
|
1745
|
+
warnings.extend(f"'{url}': {msg}" for url, msg in url_warnings.items())
|
|
1715
1746
|
|
|
1716
1747
|
if len(validated) < 2:
|
|
1717
1748
|
warnings.append(
|
|
@@ -2291,6 +2322,32 @@ def _validate_campaign(
|
|
|
2291
2322
|
f"channel_type must be one of {sorted(_VALID_CHANNEL_TYPES)}, "
|
|
2292
2323
|
f"got '{channel_type}'"
|
|
2293
2324
|
)
|
|
2325
|
+
|
|
2326
|
+
# Performance Max has no ad groups. Every campaign needs at least one
|
|
2327
|
+
# asset group, and the API requires the asset group plus all of its
|
|
2328
|
+
# required assets in a single atomic mutate. We cannot supply the
|
|
2329
|
+
# images, so creating the campaign alone would leave the user with
|
|
2330
|
+
# something that can never serve while reporting success.
|
|
2331
|
+
if ct == "PERFORMANCE_MAX":
|
|
2332
|
+
errors.append(
|
|
2333
|
+
"Performance Max campaigns cannot be created yet. PMax has no ad "
|
|
2334
|
+
"groups: a campaign needs at least one asset group, and Google "
|
|
2335
|
+
"requires the asset group and all its assets (headlines, "
|
|
2336
|
+
"descriptions, business name, logo, and images in several aspect "
|
|
2337
|
+
"ratios) in one atomic request. Creating the campaign on its own "
|
|
2338
|
+
"would produce a campaign that can never serve. Create it in the "
|
|
2339
|
+
"Google Ads interface, then use AdLoop to analyse it — "
|
|
2340
|
+
"get_pmax_performance reports per-asset-group Ad Strength."
|
|
2341
|
+
)
|
|
2342
|
+
elif ct != "SEARCH":
|
|
2343
|
+
# These do produce a usable campaign, but nothing here can populate
|
|
2344
|
+
# them: draft_ad_group refuses anything that is not SEARCH.
|
|
2345
|
+
warnings.append(
|
|
2346
|
+
f"{ct} campaigns are created as a shell only. AdLoop can add ad "
|
|
2347
|
+
f"groups, ads and keywords to SEARCH campaigns; finish this one "
|
|
2348
|
+
f"in the Google Ads interface."
|
|
2349
|
+
)
|
|
2350
|
+
|
|
2294
2351
|
if ct != "SEARCH" and search_partners_enabled:
|
|
2295
2352
|
errors.append("search_partners_enabled is only supported for SEARCH campaigns")
|
|
2296
2353
|
if ct != "SEARCH" and display_network_enabled:
|
|
@@ -13,7 +13,15 @@ from typing import TYPE_CHECKING
|
|
|
13
13
|
if TYPE_CHECKING:
|
|
14
14
|
from adloop.config import AdLoopConfig
|
|
15
15
|
|
|
16
|
-
_BASE = "https://merchantapi.googleapis.com
|
|
16
|
+
_BASE = "https://merchantapi.googleapis.com"
|
|
17
|
+
|
|
18
|
+
# The Merchant API is split into independently versioned sub-APIs, and the
|
|
19
|
+
# sub-API is part of the path: accounts/v1 serves accounts and account
|
|
20
|
+
# issues, but product-status aggregation lives under issueresolution/v1.
|
|
21
|
+
# Guessing wrong yields a bare 404 with no error message, so every call
|
|
22
|
+
# states its sub-API and API_ACCOUNTS is only the default.
|
|
23
|
+
API_ACCOUNTS = "accounts/v1"
|
|
24
|
+
API_ISSUE_RESOLUTION = "issueresolution/v1"
|
|
17
25
|
|
|
18
26
|
# Google blocks every Merchant API call from a GCP project that is not
|
|
19
27
|
# registered as a developer with the merchant account (401 UNAUTHENTICATED,
|
|
@@ -32,6 +40,7 @@ def _request(
|
|
|
32
40
|
method: str,
|
|
33
41
|
path: str,
|
|
34
42
|
*,
|
|
43
|
+
api: str = API_ACCOUNTS,
|
|
35
44
|
params: dict | None = None,
|
|
36
45
|
json_body: dict | None = None,
|
|
37
46
|
) -> dict:
|
|
@@ -39,10 +48,11 @@ def _request(
|
|
|
39
48
|
|
|
40
49
|
from adloop.auth import get_merchant_credentials
|
|
41
50
|
|
|
51
|
+
url = f"{_BASE}/{api.strip('/')}/{path.lstrip('/')}"
|
|
42
52
|
session = AuthorizedSession(get_merchant_credentials(config))
|
|
43
53
|
response = session.request(
|
|
44
54
|
method,
|
|
45
|
-
|
|
55
|
+
url,
|
|
46
56
|
params=params or {},
|
|
47
57
|
json=json_body,
|
|
48
58
|
timeout=30,
|
|
@@ -55,18 +65,35 @@ def _request(
|
|
|
55
65
|
pass
|
|
56
66
|
if _REGISTRATION_MARKER in detail:
|
|
57
67
|
raise MerchantNotRegistered(detail)
|
|
58
|
-
|
|
68
|
+
# A wrong sub-API prefix answers 404 with an empty message, so name
|
|
69
|
+
# the URL that failed rather than reporting a bare status code.
|
|
70
|
+
raise RuntimeError(
|
|
71
|
+
f"Merchant API returned {response.status_code} for {url}"
|
|
72
|
+
f"{f': {detail}' if detail else ''}"
|
|
73
|
+
)
|
|
59
74
|
return response.json()
|
|
60
75
|
|
|
61
76
|
|
|
62
|
-
def merchant_get(
|
|
63
|
-
|
|
64
|
-
|
|
77
|
+
def merchant_get(
|
|
78
|
+
config: AdLoopConfig,
|
|
79
|
+
path: str,
|
|
80
|
+
params: dict | None = None,
|
|
81
|
+
*,
|
|
82
|
+
api: str = API_ACCOUNTS,
|
|
83
|
+
) -> dict:
|
|
84
|
+
"""Authorized GET against a Merchant API sub-API (default: accounts)."""
|
|
85
|
+
return _request(config, "GET", path, api=api, params=params)
|
|
65
86
|
|
|
66
87
|
|
|
67
|
-
def merchant_post(
|
|
68
|
-
|
|
69
|
-
|
|
88
|
+
def merchant_post(
|
|
89
|
+
config: AdLoopConfig,
|
|
90
|
+
path: str,
|
|
91
|
+
body: dict | None = None,
|
|
92
|
+
*,
|
|
93
|
+
api: str = API_ACCOUNTS,
|
|
94
|
+
) -> dict:
|
|
95
|
+
"""Authorized POST against a Merchant API sub-API (default: accounts)."""
|
|
96
|
+
return _request(config, "POST", path, api=api, json_body=body or {})
|
|
70
97
|
|
|
71
98
|
|
|
72
99
|
def register_gcp(config: AdLoopConfig, account_id: str) -> dict:
|
|
@@ -118,6 +118,7 @@ def get_merchant_feed_health(
|
|
|
118
118
|
them) with account-level issues that can suspend the whole account.
|
|
119
119
|
"""
|
|
120
120
|
from adloop.merchant.client import (
|
|
121
|
+
API_ISSUE_RESOLUTION,
|
|
121
122
|
MerchantNotRegistered,
|
|
122
123
|
merchant_get,
|
|
123
124
|
register_gcp,
|
|
@@ -132,8 +133,12 @@ def get_merchant_feed_health(
|
|
|
132
133
|
|
|
133
134
|
def _fetch() -> tuple[dict, dict]:
|
|
134
135
|
return (
|
|
136
|
+
# Product-status aggregation is served by the issueresolution
|
|
137
|
+
# sub-API, not accounts — the wrong prefix 404s.
|
|
135
138
|
merchant_get(
|
|
136
|
-
config,
|
|
139
|
+
config,
|
|
140
|
+
f"accounts/{account_id}/aggregateProductStatuses",
|
|
141
|
+
api=API_ISSUE_RESOLUTION,
|
|
137
142
|
),
|
|
138
143
|
merchant_get(config, f"accounts/{account_id}/issues"),
|
|
139
144
|
)
|
|
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
|