adloop 0.6.5__tar.gz → 0.8.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.6.5 → adloop-0.8.0}/PKG-INFO +17 -2
- {adloop-0.6.5 → adloop-0.8.0}/README.md +16 -1
- {adloop-0.6.5 → adloop-0.8.0}/pyproject.toml +1 -1
- adloop-0.8.0/src/adloop/__init__.py +47 -0
- adloop-0.8.0/src/adloop/ads/enums.py +64 -0
- {adloop-0.6.5 → adloop-0.8.0}/src/adloop/ads/forecast.py +139 -44
- {adloop-0.6.5 → adloop-0.8.0}/src/adloop/ads/write.py +519 -24
- {adloop-0.6.5 → adloop-0.8.0}/src/adloop/cli.py +120 -2
- adloop-0.8.0/src/adloop/rules/__init__.py +6 -0
- adloop-0.8.0/src/adloop/rules/adloop.md +584 -0
- adloop-0.8.0/src/adloop/rules/commands/analyze-performance.md +32 -0
- adloop-0.8.0/src/adloop/rules/commands/budget-plan.md +34 -0
- adloop-0.8.0/src/adloop/rules/commands/create-ad.md +42 -0
- adloop-0.8.0/src/adloop/rules/commands/create-campaign.md +46 -0
- adloop-0.8.0/src/adloop/rules/commands/diagnose-tracking.md +44 -0
- adloop-0.8.0/src/adloop/rules/commands/optimize-campaign.md +52 -0
- adloop-0.8.0/src/adloop/rules_install.py +470 -0
- {adloop-0.6.5 → adloop-0.8.0}/src/adloop/server.py +204 -31
- adloop-0.6.5/src/adloop/__init__.py +0 -29
- {adloop-0.6.5 → adloop-0.8.0}/src/adloop/__main__.py +0 -0
- {adloop-0.6.5 → adloop-0.8.0}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.6.5 → adloop-0.8.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.6.5 → adloop-0.8.0}/src/adloop/ads/client.py +0 -0
- {adloop-0.6.5 → adloop-0.8.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.6.5 → adloop-0.8.0}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.6.5 → adloop-0.8.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.6.5 → adloop-0.8.0}/src/adloop/ads/read.py +0 -0
- {adloop-0.6.5 → adloop-0.8.0}/src/adloop/auth.py +0 -0
- {adloop-0.6.5 → adloop-0.8.0}/src/adloop/bundled_credentials.json +0 -0
- {adloop-0.6.5 → adloop-0.8.0}/src/adloop/config.py +0 -0
- {adloop-0.6.5 → adloop-0.8.0}/src/adloop/crossref.py +0 -0
- {adloop-0.6.5 → adloop-0.8.0}/src/adloop/diagnostics.py +0 -0
- {adloop-0.6.5 → adloop-0.8.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.6.5 → adloop-0.8.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.6.5 → adloop-0.8.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.6.5 → adloop-0.8.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.6.5 → adloop-0.8.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.6.5 → adloop-0.8.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.6.5 → adloop-0.8.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.6.5 → adloop-0.8.0}/src/adloop/safety/preview.py +0 -0
- {adloop-0.6.5 → adloop-0.8.0}/src/adloop/tracking.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: adloop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Stop switching between Google Ads, GA4, and your code editor to figure out why conversions dropped.
|
|
5
5
|
Keywords: mcp,google-ads,google-analytics,ga4,cursor,marketing
|
|
6
6
|
Author: Daniel Klose
|
|
@@ -318,7 +318,22 @@ Or add to your project's `.mcp.json`:
|
|
|
318
318
|
}
|
|
319
319
|
```
|
|
320
320
|
|
|
321
|
-
Then
|
|
321
|
+
Then install the orchestration rules + slash commands globally so every Claude Code session inherits them:
|
|
322
|
+
|
|
323
|
+
```bash
|
|
324
|
+
adloop install-rules
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
This writes a managed block to `~/.claude/CLAUDE.md` and copies the slash commands (prefixed `adloop-*`) into `~/.claude/commands/`. The block is delimited by sentinel comments so it's safe to run multiple times — re-running just refreshes the content. Two install modes:
|
|
328
|
+
|
|
329
|
+
- **inline** (default) — full rules embedded in `~/.claude/CLAUDE.md`. Reliable but adds ~10K tokens to every Claude Code session.
|
|
330
|
+
- **lazy** (`adloop install-rules --lazy`) — small directive in `CLAUDE.md` pointing at `~/.claude/rules/adloop.md`. Cheaper baseline cost; the LLM reads the rules file only when AdLoop tools are in scope.
|
|
331
|
+
|
|
332
|
+
To refresh after upgrading AdLoop: `adloop update-rules`. To remove cleanly: `adloop uninstall-rules` — only the managed block and `adloop-*` commands are touched, never your own content.
|
|
333
|
+
|
|
334
|
+
If you'd rather manage things by hand instead, copy `.claude/rules/adloop.md` and `.claude/commands/` from this repo into your project's `.claude/` directory.
|
|
335
|
+
|
|
336
|
+
**Claude Desktop / claude.ai** has no programmatic rules location. Run `adloop install-rules` and it will print the rules content for you to paste into Project settings → Custom instructions on claude.ai.
|
|
322
337
|
|
|
323
338
|
</details>
|
|
324
339
|
|
|
@@ -294,7 +294,22 @@ Or add to your project's `.mcp.json`:
|
|
|
294
294
|
}
|
|
295
295
|
```
|
|
296
296
|
|
|
297
|
-
Then
|
|
297
|
+
Then install the orchestration rules + slash commands globally so every Claude Code session inherits them:
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
adloop install-rules
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
This writes a managed block to `~/.claude/CLAUDE.md` and copies the slash commands (prefixed `adloop-*`) into `~/.claude/commands/`. The block is delimited by sentinel comments so it's safe to run multiple times — re-running just refreshes the content. Two install modes:
|
|
304
|
+
|
|
305
|
+
- **inline** (default) — full rules embedded in `~/.claude/CLAUDE.md`. Reliable but adds ~10K tokens to every Claude Code session.
|
|
306
|
+
- **lazy** (`adloop install-rules --lazy`) — small directive in `CLAUDE.md` pointing at `~/.claude/rules/adloop.md`. Cheaper baseline cost; the LLM reads the rules file only when AdLoop tools are in scope.
|
|
307
|
+
|
|
308
|
+
To refresh after upgrading AdLoop: `adloop update-rules`. To remove cleanly: `adloop uninstall-rules` — only the managed block and `adloop-*` commands are touched, never your own content.
|
|
309
|
+
|
|
310
|
+
If you'd rather manage things by hand instead, copy `.claude/rules/adloop.md` and `.claude/commands/` from this repo into your project's `.claude/` directory.
|
|
311
|
+
|
|
312
|
+
**Claude Desktop / claude.ai** has no programmatic rules location. Run `adloop install-rules` and it will print the rules content for you to paste into Project settings → Custom instructions on claude.ai.
|
|
298
313
|
|
|
299
314
|
</details>
|
|
300
315
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""AdLoop — MCP server connecting Google Ads + GA4 + codebase."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
__version__ = "0.8.0"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> None:
|
|
9
|
+
"""Entry point for `adloop` console script.
|
|
10
|
+
|
|
11
|
+
Subcommands:
|
|
12
|
+
adloop Start the MCP server (default).
|
|
13
|
+
adloop init Run the interactive setup wizard.
|
|
14
|
+
adloop install-rules Install Claude orchestration rules globally.
|
|
15
|
+
adloop update-rules Refresh the installed rules block.
|
|
16
|
+
adloop uninstall-rules Remove the installed rules block + commands.
|
|
17
|
+
adloop --version, -V Print version and exit.
|
|
18
|
+
"""
|
|
19
|
+
args = sys.argv[1:]
|
|
20
|
+
|
|
21
|
+
if args and args[0] in ("--version", "-V"):
|
|
22
|
+
print(f"adloop {__version__}")
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
if args and args[0] == "init":
|
|
26
|
+
from adloop.cli import run_init_wizard
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
run_init_wizard()
|
|
30
|
+
except KeyboardInterrupt:
|
|
31
|
+
print("\n\n Setup cancelled.\n")
|
|
32
|
+
sys.exit(130)
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
if args and args[0] in ("install-rules", "update-rules", "uninstall-rules"):
|
|
36
|
+
from adloop.cli import run_rules_command
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
sys.exit(run_rules_command(args[0], args[1:]))
|
|
40
|
+
except KeyboardInterrupt:
|
|
41
|
+
print("\n\n Cancelled.\n")
|
|
42
|
+
sys.exit(130)
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
from adloop.server import mcp
|
|
46
|
+
|
|
47
|
+
mcp.run()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Google Ads enum introspection — pulls valid enum names from the SDK.
|
|
2
|
+
|
|
3
|
+
Avoids hardcoded enum sets in validators. The google-ads SDK ships the
|
|
4
|
+
canonical list for every enum at the API version we're pinned to; this
|
|
5
|
+
module surfaces those lists directly so AdLoop validation stays in sync
|
|
6
|
+
with whatever the SDK supports without us maintaining a parallel copy.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from adloop.ads.enums import enum_names
|
|
10
|
+
_VALID_TYPES = enum_names("ConversionActionTypeEnum")
|
|
11
|
+
|
|
12
|
+
The result is a frozenset of member name strings (e.g. {"AD_CALL", ...}),
|
|
13
|
+
with sentinel values UNSPECIFIED + UNKNOWN dropped by default.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import functools
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@functools.lru_cache(maxsize=None)
|
|
21
|
+
def _enum_introspection_client():
|
|
22
|
+
"""Memoized no-auth GoogleAdsClient used purely for enum introspection.
|
|
23
|
+
|
|
24
|
+
The client constructor doesn't make any network calls and doesn't
|
|
25
|
+
validate credentials beyond requiring SOMETHING in the developer-token
|
|
26
|
+
field — perfect for reading the bundled enum protos. We cache it so
|
|
27
|
+
every enum_names() call after the first is essentially free.
|
|
28
|
+
"""
|
|
29
|
+
from google.ads.googleads.client import GoogleAdsClient
|
|
30
|
+
|
|
31
|
+
from adloop.ads.client import GOOGLE_ADS_API_VERSION
|
|
32
|
+
|
|
33
|
+
return GoogleAdsClient(
|
|
34
|
+
credentials=None,
|
|
35
|
+
developer_token="adloop-enum-introspection-not-used",
|
|
36
|
+
use_proto_plus=True,
|
|
37
|
+
version=GOOGLE_ADS_API_VERSION,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@functools.lru_cache(maxsize=None)
|
|
42
|
+
def enum_names(
|
|
43
|
+
enum_attr: str, *, exclude_unspecified: bool = True
|
|
44
|
+
) -> frozenset[str]:
|
|
45
|
+
"""Return all member names of a Google Ads enum, as a frozenset.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
enum_attr: the attribute on ``client.enums``, e.g.
|
|
49
|
+
``"ConversionActionTypeEnum"`` or ``"AssetFieldTypeEnum"``.
|
|
50
|
+
exclude_unspecified: when True (default) drops the sentinel values
|
|
51
|
+
``UNSPECIFIED`` and ``UNKNOWN`` — those are protobuf defaults,
|
|
52
|
+
never valid for user input.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
AttributeError: if ``enum_attr`` doesn't exist on ``client.enums``.
|
|
56
|
+
|
|
57
|
+
Caching: the result is memoized for the lifetime of the process, so
|
|
58
|
+
repeated calls return the same frozenset instance.
|
|
59
|
+
"""
|
|
60
|
+
enum_cls = getattr(_enum_introspection_client().enums, enum_attr)
|
|
61
|
+
return frozenset(
|
|
62
|
+
m.name for m in enum_cls
|
|
63
|
+
if not exclude_unspecified or m.name not in ("UNSPECIFIED", "UNKNOWN")
|
|
64
|
+
)
|
|
@@ -159,6 +159,90 @@ def estimate_budget(
|
|
|
159
159
|
_COMPETITION_LABELS = {0: "UNSPECIFIED", 1: "LOW", 2: "MEDIUM", 3: "HIGH"}
|
|
160
160
|
|
|
161
161
|
|
|
162
|
+
_KEYWORD_IDEAS_REST_URL = (
|
|
163
|
+
"https://googleads.googleapis.com/{version}/customers/{cid}:generateKeywordIdeas"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _build_keyword_ideas_rest_body(
|
|
168
|
+
*,
|
|
169
|
+
language_id: str,
|
|
170
|
+
geo_target_id: str,
|
|
171
|
+
page_size: int,
|
|
172
|
+
seed_keywords: list[str],
|
|
173
|
+
url: str,
|
|
174
|
+
page_token: str = "",
|
|
175
|
+
) -> dict:
|
|
176
|
+
"""Build the JSON body for the REST generateKeywordIdeas endpoint.
|
|
177
|
+
|
|
178
|
+
Schema follows google-ads REST v23 (camelCase). Exactly one of
|
|
179
|
+
``keywordSeed`` / ``urlSeed`` / ``keywordAndUrlSeed`` is set based on
|
|
180
|
+
which inputs were provided.
|
|
181
|
+
"""
|
|
182
|
+
body: dict = {
|
|
183
|
+
"language": f"languageConstants/{language_id}",
|
|
184
|
+
"geoTargetConstants": [f"geoTargetConstants/{geo_target_id}"],
|
|
185
|
+
"keywordPlanNetwork": "GOOGLE_SEARCH",
|
|
186
|
+
"pageSize": page_size,
|
|
187
|
+
}
|
|
188
|
+
if seed_keywords and url:
|
|
189
|
+
body["keywordAndUrlSeed"] = {"url": url, "keywords": list(seed_keywords)}
|
|
190
|
+
elif seed_keywords:
|
|
191
|
+
body["keywordSeed"] = {"keywords": list(seed_keywords)}
|
|
192
|
+
else:
|
|
193
|
+
body["urlSeed"] = {"url": url}
|
|
194
|
+
if page_token:
|
|
195
|
+
body["pageToken"] = page_token
|
|
196
|
+
return body
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _post_keyword_ideas_rest_page(
|
|
200
|
+
config: AdLoopConfig, cid: str, body: dict
|
|
201
|
+
) -> dict:
|
|
202
|
+
"""POST a single page request to the REST generateKeywordIdeas endpoint.
|
|
203
|
+
|
|
204
|
+
Issue #37: ``KeywordPlanIdeaService.GenerateKeywordIdeas`` over gRPC sits
|
|
205
|
+
in a tight quota bucket that exhausts after a small number of sequential
|
|
206
|
+
calls and returns ``RESOURCE_EXHAUSTED`` regardless of QPS. The REST v23
|
|
207
|
+
endpoint for the same method lives in a separate, much larger quota
|
|
208
|
+
bucket, so this swap eliminates the 429s that made ``discover_keywords``
|
|
209
|
+
unusable for any multi-geo or repeat-call workflow. Filed against
|
|
210
|
+
google-ads-python; see https://github.com/kLOsk/adloop/issues/37.
|
|
211
|
+
|
|
212
|
+
Re-raises HTTP 429s as a string-formatted error so the existing
|
|
213
|
+
``call_with_retry`` helper can apply exponential backoff and re-attempt.
|
|
214
|
+
"""
|
|
215
|
+
import requests
|
|
216
|
+
from google.auth.transport.requests import AuthorizedSession
|
|
217
|
+
|
|
218
|
+
from adloop.ads.client import GOOGLE_ADS_API_VERSION
|
|
219
|
+
from adloop.auth import get_ads_credentials
|
|
220
|
+
|
|
221
|
+
credentials = get_ads_credentials(config)
|
|
222
|
+
session = AuthorizedSession(credentials)
|
|
223
|
+
|
|
224
|
+
headers = {
|
|
225
|
+
"developer-token": config.ads.developer_token,
|
|
226
|
+
"Content-Type": "application/json",
|
|
227
|
+
}
|
|
228
|
+
if config.ads.login_customer_id:
|
|
229
|
+
headers["login-customer-id"] = config.ads.login_customer_id.replace("-", "")
|
|
230
|
+
|
|
231
|
+
url = _KEYWORD_IDEAS_REST_URL.format(version=GOOGLE_ADS_API_VERSION, cid=cid)
|
|
232
|
+
response = session.post(url, json=body, headers=headers, timeout=60)
|
|
233
|
+
|
|
234
|
+
if response.status_code == 429:
|
|
235
|
+
# Surface as a RESOURCE_EXHAUSTED string so call_with_retry recognises
|
|
236
|
+
# this as a rate-limit error and backs off. The REST bucket is much
|
|
237
|
+
# larger than gRPC so this branch should be rare, but handle it.
|
|
238
|
+
raise requests.HTTPError(
|
|
239
|
+
f"RESOURCE_EXHAUSTED (HTTP 429) from REST generateKeywordIdeas: "
|
|
240
|
+
f"{response.text[:500]}"
|
|
241
|
+
)
|
|
242
|
+
response.raise_for_status()
|
|
243
|
+
return response.json()
|
|
244
|
+
|
|
245
|
+
|
|
162
246
|
def discover_keywords(
|
|
163
247
|
config: AdLoopConfig,
|
|
164
248
|
*,
|
|
@@ -184,57 +268,68 @@ def discover_keywords(
|
|
|
184
268
|
geo_target_id: geo target constant (2276=Germany, 2840=USA, 2826=UK)
|
|
185
269
|
language_id: language constant (1000=English, 1001=German, 1002=French)
|
|
186
270
|
page_size: max number of keyword ideas to return (default 50, max 1000)
|
|
271
|
+
|
|
272
|
+
Network: this tool intentionally bypasses the google-ads gRPC client for
|
|
273
|
+
KeywordPlanIdeaService and calls the v23 REST endpoint directly. The
|
|
274
|
+
gRPC quota bucket for this single method exhausts almost immediately
|
|
275
|
+
under sequential single-geo calls (issue #37); REST sits in a separate,
|
|
276
|
+
much larger bucket and works without issue. All other Ads tools still
|
|
277
|
+
use the gRPC client.
|
|
187
278
|
"""
|
|
188
|
-
from adloop.ads.client import call_with_retry,
|
|
279
|
+
from adloop.ads.client import call_with_retry, normalize_customer_id
|
|
189
280
|
|
|
190
281
|
seed_keywords = list(seed_keywords)
|
|
191
282
|
if not seed_keywords and not url:
|
|
192
283
|
return {"error": "Provide at least one of: seed_keywords or url"}
|
|
193
284
|
|
|
194
|
-
client = get_ads_client(config)
|
|
195
285
|
cid = normalize_customer_id(customer_id or config.ads.customer_id)
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
286
|
+
capped_page_size = min(max(1, page_size), 1000)
|
|
287
|
+
|
|
288
|
+
ideas: list[dict] = []
|
|
289
|
+
page_token = ""
|
|
290
|
+
while True:
|
|
291
|
+
body = _build_keyword_ideas_rest_body(
|
|
292
|
+
language_id=language_id,
|
|
293
|
+
geo_target_id=geo_target_id,
|
|
294
|
+
page_size=capped_page_size,
|
|
295
|
+
seed_keywords=seed_keywords,
|
|
296
|
+
url=url,
|
|
297
|
+
page_token=page_token,
|
|
298
|
+
)
|
|
299
|
+
payload = call_with_retry(_post_keyword_ideas_rest_page, config, cid, body)
|
|
300
|
+
|
|
301
|
+
for idea in payload.get("results", []):
|
|
302
|
+
metrics = idea.get("keywordIdeaMetrics", {}) or {}
|
|
303
|
+
avg_monthly = metrics.get("avgMonthlySearches")
|
|
304
|
+
competition = metrics.get("competition") or "UNSPECIFIED"
|
|
305
|
+
competition_index = metrics.get("competitionIndex")
|
|
306
|
+
low_bid_micros = metrics.get("lowTopOfPageBidMicros")
|
|
307
|
+
high_bid_micros = metrics.get("highTopOfPageBidMicros")
|
|
308
|
+
|
|
309
|
+
# int64 fields come back as JSON strings in REST — normalize.
|
|
310
|
+
avg_monthly_int = int(avg_monthly) if avg_monthly else None
|
|
311
|
+
competition_index_int = (
|
|
312
|
+
int(competition_index) if competition_index else None
|
|
313
|
+
)
|
|
314
|
+
low_bid_int = int(low_bid_micros) if low_bid_micros else None
|
|
315
|
+
high_bid_int = int(high_bid_micros) if high_bid_micros else None
|
|
316
|
+
|
|
317
|
+
ideas.append({
|
|
318
|
+
"keyword": idea.get("text", ""),
|
|
319
|
+
"avg_monthly_searches": avg_monthly_int,
|
|
320
|
+
"competition": competition,
|
|
321
|
+
"competition_index": competition_index_int,
|
|
322
|
+
"low_top_of_page_bid": (
|
|
323
|
+
round(low_bid_int / 1_000_000, 2) if low_bid_int else None
|
|
324
|
+
),
|
|
325
|
+
"high_top_of_page_bid": (
|
|
326
|
+
round(high_bid_int / 1_000_000, 2) if high_bid_int else None
|
|
327
|
+
),
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
page_token = payload.get("nextPageToken") or ""
|
|
331
|
+
if not page_token:
|
|
332
|
+
break
|
|
238
333
|
|
|
239
334
|
# Sort by avg monthly searches descending (None last)
|
|
240
335
|
ideas.sort(key=lambda x: x["avg_monthly_searches"] or 0, reverse=True)
|