@koda-sl/baker-cli 0.244.0 → 0.245.0
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.
- package/README.md +47 -20
- package/dist/cli.js +437 -217
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -9679,7 +9679,7 @@ function renderAdvisories(data, lines) {
|
|
|
9679
9679
|
return;
|
|
9680
9680
|
}
|
|
9681
9681
|
lines.push("");
|
|
9682
|
-
lines.push("Advisories (non-blocking \u2014
|
|
9682
|
+
lines.push("Advisories (non-blocking, checked across the whole draft \u2014 settle these before you publish)");
|
|
9683
9683
|
for (const advisory of advisories) {
|
|
9684
9684
|
lines.push(` \u2022 ${advisory.message}`);
|
|
9685
9685
|
}
|
|
@@ -10188,10 +10188,33 @@ function warnDefaults(ctx) {
|
|
|
10188
10188
|
}
|
|
10189
10189
|
}
|
|
10190
10190
|
|
|
10191
|
+
// src/commands/ads/google/keywords/provenance.ts
|
|
10192
|
+
var PLANNER_SOURCE = "keyword_planner_estimate";
|
|
10193
|
+
var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["keyword"]);
|
|
10194
|
+
function stampPlannerRows(rows) {
|
|
10195
|
+
return rows.map((row) => {
|
|
10196
|
+
const stamped = { source: PLANNER_SOURCE };
|
|
10197
|
+
for (const [key, value] of Object.entries(row)) {
|
|
10198
|
+
stamped[IDENTITY_FIELDS.has(key) || key.startsWith("planner_") ? key : `planner_${key}`] = value;
|
|
10199
|
+
}
|
|
10200
|
+
return stamped;
|
|
10201
|
+
});
|
|
10202
|
+
}
|
|
10203
|
+
var PLANNER_FIELD_DESCRIPTIONS = {
|
|
10204
|
+
source: `Always "${PLANNER_SOURCE}". These rows are Keyword Planner ESTIMATES of total market search volume, NOT this account's data. Never report them as impressions, clicks or any account metric \u2014 read those from a query against the account.`,
|
|
10205
|
+
keyword: "The keyword this estimate is for",
|
|
10206
|
+
planner_avg_monthly_searches: "ESTIMATE: average monthly searches across the whole market (string), last 12 months",
|
|
10207
|
+
planner_competition: "ESTIMATE: competition level \u2014 LOW, MEDIUM, HIGH",
|
|
10208
|
+
planner_competition_index: "ESTIMATE: competition index 0-100 as string (higher = more competitive)",
|
|
10209
|
+
planner_low_top_of_page_bid_micros: "ESTIMATE: low-range top-of-page CPC bid in micros (\xF7 1,000,000 for currency)",
|
|
10210
|
+
planner_high_top_of_page_bid_micros: "ESTIMATE: high-range top-of-page CPC bid in micros (\xF7 1,000,000 for currency)",
|
|
10211
|
+
planner_monthly_search_volumes: "ESTIMATE: monthly market search volume breakdown (array of {year, month, monthly_searches})"
|
|
10212
|
+
};
|
|
10213
|
+
|
|
10191
10214
|
// src/commands/ads/google/keywords/discover.ts
|
|
10192
10215
|
registerSchema({
|
|
10193
10216
|
command: "ads.google.keywords.discover",
|
|
10194
|
-
description:
|
|
10217
|
+
description: 'Discover keyword ideas from seed keywords or URLs. Returns { source: "keyword_planner_estimate", keywords, total_results, next_page_token? }. THESE ARE ESTIMATES OF TOTAL MARKET SEARCH VOLUME, NOT THIS ACCOUNT\'S DATA: every row carries a source column and every metric is prefixed planner_ (planner_avg_monthly_searches, planner_competition, ...). Never report a planner_ number as impressions, clicks or any account metric \u2014 read those with `baker ads google query` against the account. IMPORTANT: If --location and --language are omitted, defaults to United States (2840) and English (1000). The response includes a query_context object showing which location/language were used.',
|
|
10195
10218
|
args: {
|
|
10196
10219
|
"customer-id": {
|
|
10197
10220
|
type: "string",
|
|
@@ -10220,15 +10243,9 @@ registerSchema({
|
|
|
10220
10243
|
"no-cache": { type: "boolean", description: "Skip cache", required: false }
|
|
10221
10244
|
}
|
|
10222
10245
|
});
|
|
10223
|
-
|
|
10224
|
-
|
|
10225
|
-
|
|
10226
|
-
competition: "Competition level: LOW, MEDIUM, HIGH",
|
|
10227
|
-
competition_index: "Competition index 0-100 as string (higher = more competitive)",
|
|
10228
|
-
low_top_of_page_bid_micros: "Low-range CPC bid in micros (\xF7 1,000,000 for currency)",
|
|
10229
|
-
high_top_of_page_bid_micros: "High-range CPC bid in micros (\xF7 1,000,000 for currency)",
|
|
10230
|
-
monthly_search_volumes: "Monthly search volume breakdown (array of {year, month, monthly_searches})"
|
|
10231
|
-
};
|
|
10246
|
+
function stampDiscoverResponse(data) {
|
|
10247
|
+
return { ...data, source: PLANNER_SOURCE, keywords: stampPlannerRows(data.keywords ?? []) };
|
|
10248
|
+
}
|
|
10232
10249
|
function buildDiscoverBody(customerId, args) {
|
|
10233
10250
|
const body = {
|
|
10234
10251
|
customerId,
|
|
@@ -10269,6 +10286,8 @@ var discoverCommand = defineCommand23({
|
|
|
10269
10286
|
name: "discover",
|
|
10270
10287
|
description: `Discover new keyword ideas from seed keywords or competitor URLs.
|
|
10271
10288
|
|
|
10289
|
+
Returns Keyword Planner ESTIMATES of market search volume, not this account's numbers. Every row carries source=keyword_planner_estimate and planner_-prefixed metrics; keep those names when you quote a figure, and never present one as an impression or click count.
|
|
10290
|
+
|
|
10272
10291
|
Examples:
|
|
10273
10292
|
baker ads google keywords discover --customer-id 1234567890 --seeds "running shoes,athletic footwear"
|
|
10274
10293
|
baker ads google keywords discover --customer-id 1234567890 --url "https://competitor.com"
|
|
@@ -10303,7 +10322,7 @@ Examples:
|
|
|
10303
10322
|
const cached = cacheGet("keywords", cacheKey);
|
|
10304
10323
|
if (cached) {
|
|
10305
10324
|
warnDefaults(queryContext);
|
|
10306
|
-
writeAdsJson({ ok: true, data: cached.data, cached: true, query_context: queryContext });
|
|
10325
|
+
writeAdsJson({ ok: true, data: stampDiscoverResponse(cached.data), cached: true, query_context: queryContext });
|
|
10307
10326
|
return;
|
|
10308
10327
|
}
|
|
10309
10328
|
}
|
|
@@ -10313,12 +10332,13 @@ Examples:
|
|
|
10313
10332
|
cacheSet("keywords", cacheKey, data, 24 * 60 * 60 * 1e3);
|
|
10314
10333
|
}
|
|
10315
10334
|
warnDefaults(queryContext);
|
|
10335
|
+
const stamped = stampDiscoverResponse(data);
|
|
10316
10336
|
const format = args.output || "json";
|
|
10317
10337
|
if (format !== "json") {
|
|
10318
|
-
writeAdsOutput(
|
|
10338
|
+
writeAdsOutput(stamped.keywords, format);
|
|
10319
10339
|
return;
|
|
10320
10340
|
}
|
|
10321
|
-
writeAdsJson({ ok: true, data, fields:
|
|
10341
|
+
writeAdsJson({ ok: true, data: stamped, fields: PLANNER_FIELD_DESCRIPTIONS, query_context: queryContext });
|
|
10322
10342
|
} catch (err) {
|
|
10323
10343
|
handleKeywordError(err);
|
|
10324
10344
|
}
|
|
@@ -10371,7 +10391,7 @@ var locationsCommand = defineCommand25({
|
|
|
10371
10391
|
import { defineCommand as defineCommand26 } from "citty";
|
|
10372
10392
|
registerSchema({
|
|
10373
10393
|
command: "ads.google.keywords.metrics",
|
|
10374
|
-
description:
|
|
10394
|
+
description: 'Get historical market metrics for specific keywords. Returns { source: "keyword_planner_estimate", historical_metrics: [...] }. THESE ARE KEYWORD PLANNER ESTIMATES OF TOTAL MARKET SEARCH VOLUME, NOT THIS ACCOUNT\'S DATA: every row carries a source column and every metric is prefixed planner_ (planner_avg_monthly_searches, planner_competition, ...). Never report a planner_ number as impressions, clicks or any account metric \u2014 read those with `baker ads google query` against the account. IMPORTANT: If --location and --language are omitted, defaults to United States (2840) and English (1000). The response includes a query_context object showing which location/language were used.',
|
|
10375
10395
|
args: {
|
|
10376
10396
|
"customer-id": {
|
|
10377
10397
|
type: "string",
|
|
@@ -10393,6 +10413,9 @@ registerSchema({
|
|
|
10393
10413
|
"no-cache": { type: "boolean", description: "Skip cache", required: false }
|
|
10394
10414
|
}
|
|
10395
10415
|
});
|
|
10416
|
+
function stampMetricsResponse(data) {
|
|
10417
|
+
return { ...data, source: PLANNER_SOURCE, historical_metrics: stampPlannerRows(data.historical_metrics ?? []) };
|
|
10418
|
+
}
|
|
10396
10419
|
function handleMetricsError(err) {
|
|
10397
10420
|
if (err instanceof ApiError) {
|
|
10398
10421
|
if (isNotConnectedError(err.code, err.message)) {
|
|
@@ -10418,6 +10441,8 @@ var metricsCommand = defineCommand26({
|
|
|
10418
10441
|
name: "metrics",
|
|
10419
10442
|
description: `Get historical search metrics for specific keywords.
|
|
10420
10443
|
|
|
10444
|
+
Returns Keyword Planner ESTIMATES of market search volume, not this account's numbers. Every row carries source=keyword_planner_estimate and planner_-prefixed metrics; keep those names when you quote a figure, and never present one as an impression or click count.
|
|
10445
|
+
|
|
10421
10446
|
Examples:
|
|
10422
10447
|
baker ads google keywords metrics --customer-id 1234567890 --keywords "running shoes,nike shoes,adidas shoes"
|
|
10423
10448
|
baker ads google keywords metrics --customer-id 1234567890 --keywords "seo tools" --location 2826`
|
|
@@ -10454,7 +10479,7 @@ Examples:
|
|
|
10454
10479
|
const cached = cacheGet("keywords", cacheKey);
|
|
10455
10480
|
if (cached) {
|
|
10456
10481
|
warnDefaults(queryContext);
|
|
10457
|
-
writeAdsJson({ ok: true, data: cached.data, cached: true, query_context: queryContext });
|
|
10482
|
+
writeAdsJson({ ok: true, data: stampMetricsResponse(cached.data), cached: true, query_context: queryContext });
|
|
10458
10483
|
return;
|
|
10459
10484
|
}
|
|
10460
10485
|
}
|
|
@@ -10464,21 +10489,13 @@ Examples:
|
|
|
10464
10489
|
cacheSet("keywords", cacheKey, data, 24 * 60 * 60 * 1e3);
|
|
10465
10490
|
}
|
|
10466
10491
|
warnDefaults(queryContext);
|
|
10492
|
+
const stamped = stampMetricsResponse(data);
|
|
10467
10493
|
const format = args.output || "json";
|
|
10468
10494
|
if (format !== "json") {
|
|
10469
|
-
writeAdsOutput(
|
|
10495
|
+
writeAdsOutput(stamped.historical_metrics, format);
|
|
10470
10496
|
return;
|
|
10471
10497
|
}
|
|
10472
|
-
|
|
10473
|
-
keyword: "The keyword analyzed",
|
|
10474
|
-
avg_monthly_searches: "Average monthly search volume as string (last 12 months)",
|
|
10475
|
-
competition: "Competition level: LOW, MEDIUM, HIGH",
|
|
10476
|
-
competition_index: "Competition index 0-100 as string (higher = more competitive)",
|
|
10477
|
-
low_top_of_page_bid_micros: "Low-range CPC bid in micros (\xF7 1,000,000 for currency)",
|
|
10478
|
-
high_top_of_page_bid_micros: "High-range CPC bid in micros (\xF7 1,000,000 for currency)",
|
|
10479
|
-
monthly_search_volumes: "Monthly search volume breakdown (array of {year, month, monthly_searches})"
|
|
10480
|
-
};
|
|
10481
|
-
writeAdsJson({ ok: true, data, fields, query_context: queryContext });
|
|
10498
|
+
writeAdsJson({ ok: true, data: stamped, fields: PLANNER_FIELD_DESCRIPTIONS, query_context: queryContext });
|
|
10482
10499
|
} catch (err) {
|
|
10483
10500
|
handleMetricsError(err);
|
|
10484
10501
|
}
|
|
@@ -10625,10 +10642,211 @@ function keywordServingWarnings(rows, options) {
|
|
|
10625
10642
|
return renderKeywordLimits(collectKeywordLimits(rows, options));
|
|
10626
10643
|
}
|
|
10627
10644
|
|
|
10645
|
+
// src/commands/ads/google/presets.ts
|
|
10646
|
+
var GAQL_DATE_RANGE_RE = /^(?:TODAY|YESTERDAY|LAST_7_DAYS|LAST_14_DAYS|LAST_30_DAYS|LAST_90_DAYS|THIS_MONTH|LAST_MONTH|THIS_QUARTER|LAST_QUARTER|THIS_YEAR|LAST_YEAR|ALL_TIME|BETWEEN\s+'[0-9]{4}-[0-9]{2}-[0-9]{2}'\s+AND\s+'[0-9]{4}-[0-9]{2}-[0-9]{2}')$/i;
|
|
10647
|
+
function isValidDateRange(value) {
|
|
10648
|
+
return GAQL_DATE_RANGE_RE.test(value.trim());
|
|
10649
|
+
}
|
|
10650
|
+
var SERVING_CHAINS = {
|
|
10651
|
+
keyword_view: ["campaign.status", "ad_group.status", "ad_group_criterion.status"],
|
|
10652
|
+
ad_group_criterion: ["campaign.status", "ad_group.status", "ad_group_criterion.status"],
|
|
10653
|
+
ad_group_ad: ["campaign.status", "ad_group.status", "ad_group_ad.status"],
|
|
10654
|
+
search_term_view: ["campaign.status", "ad_group.status"],
|
|
10655
|
+
ad_group: ["campaign.status", "ad_group.status"],
|
|
10656
|
+
asset_group: ["campaign.status", "asset_group.status"],
|
|
10657
|
+
asset_group_asset: ["campaign.status", "asset_group.status"]
|
|
10658
|
+
};
|
|
10659
|
+
var PRESETS = [
|
|
10660
|
+
{
|
|
10661
|
+
name: "campaign-performance",
|
|
10662
|
+
description: "Campaign-level metrics overview",
|
|
10663
|
+
gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, campaign.advertising_channel_type, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.conversions_value, metrics.ctr, metrics.average_cpc FROM campaign WHERE segments.date DURING {dateRange}{statusScope} ORDER BY metrics.cost_micros DESC LIMIT {limit}`,
|
|
10664
|
+
defaultDateRange: "LAST_30_DAYS",
|
|
10665
|
+
defaultLimit: 200,
|
|
10666
|
+
statusFields: ["campaign.status"]
|
|
10667
|
+
},
|
|
10668
|
+
{
|
|
10669
|
+
name: "keyword-analysis",
|
|
10670
|
+
description: "Keyword performance with match type and quality",
|
|
10671
|
+
gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, ad_group.id, ad_group.name, ad_group.status, ad_group_criterion.criterion_id, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, ad_group_criterion.status, ad_group_criterion.quality_info.quality_score, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.ctr FROM keyword_view WHERE segments.date DURING {dateRange}{statusScope} ORDER BY metrics.impressions DESC LIMIT {limit}`,
|
|
10672
|
+
defaultDateRange: "LAST_30_DAYS",
|
|
10673
|
+
defaultLimit: 200,
|
|
10674
|
+
statusFields: ["campaign.status", "ad_group.status", "ad_group_criterion.status"]
|
|
10675
|
+
},
|
|
10676
|
+
{
|
|
10677
|
+
name: "keyword-serving",
|
|
10678
|
+
description: "Why a keyword is limited \u2014 Google Ads' 'Eligible (Limited) / Below first page bid' column, with the first-page bid estimate, the current max CPC and quality score",
|
|
10679
|
+
gaqlTemplate: `SELECT campaign.id, campaign.name, ad_group.id, ad_group.name, ad_group_criterion.criterion_id, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, ad_group_criterion.status, ad_group_criterion.primary_status, ad_group_criterion.primary_status_reasons, ad_group_criterion.system_serving_status, ad_group_criterion.approval_status, ad_group_criterion.effective_cpc_bid_micros, ad_group_criterion.effective_cpc_bid_source, ad_group_criterion.position_estimates.first_page_cpc_micros, ad_group_criterion.position_estimates.top_of_page_cpc_micros, ad_group_criterion.quality_info.quality_score, ad_group_criterion.quality_info.creative_quality_score, ad_group_criterion.quality_info.post_click_quality_score, ad_group_criterion.quality_info.search_predicted_ctr FROM ad_group_criterion WHERE ad_group_criterion.type = 'KEYWORD' AND ad_group_criterion.negative = FALSE{statusScope} ORDER BY campaign.name LIMIT {limit}`,
|
|
10680
|
+
defaultDateRange: "ALL_TIME",
|
|
10681
|
+
defaultLimit: 500,
|
|
10682
|
+
statusFields: ["campaign.status", "ad_group.status", "ad_group_criterion.status"]
|
|
10683
|
+
},
|
|
10684
|
+
{
|
|
10685
|
+
name: "positive-keywords",
|
|
10686
|
+
description: "Positive (targeting) keywords only \u2014 excludes negatives",
|
|
10687
|
+
gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, ad_group.id, ad_group.name, ad_group.status, ad_group_criterion.criterion_id, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, ad_group_criterion.status, ad_group_criterion.negative FROM ad_group_criterion WHERE ad_group_criterion.type = 'KEYWORD' AND ad_group_criterion.negative = FALSE{statusScope} ORDER BY campaign.name LIMIT {limit}`,
|
|
10688
|
+
defaultDateRange: "ALL_TIME",
|
|
10689
|
+
defaultLimit: 500,
|
|
10690
|
+
statusFields: ["campaign.status", "ad_group.status", "ad_group_criterion.status"]
|
|
10691
|
+
},
|
|
10692
|
+
{
|
|
10693
|
+
name: "negative-keywords",
|
|
10694
|
+
description: "Negative keywords only \u2014 campaign and ad group level",
|
|
10695
|
+
gaqlTemplate: `SELECT campaign.id, campaign.name, ad_group.name, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, ad_group_criterion.negative FROM ad_group_criterion WHERE ad_group_criterion.type = 'KEYWORD' AND ad_group_criterion.negative = TRUE AND campaign.status != 'REMOVED' ORDER BY campaign.name LIMIT {limit}`,
|
|
10696
|
+
defaultDateRange: "ALL_TIME",
|
|
10697
|
+
defaultLimit: 500
|
|
10698
|
+
},
|
|
10699
|
+
{
|
|
10700
|
+
name: "negative-keyword-lists",
|
|
10701
|
+
description: "Shared negative keyword lists and the terms inside them (SharedSet members)",
|
|
10702
|
+
gaqlTemplate: `SELECT shared_set.id, shared_set.name, shared_criterion.keyword.text, shared_criterion.keyword.match_type FROM shared_criterion WHERE shared_set.type = 'NEGATIVE_KEYWORDS' AND shared_set.status = 'ENABLED' ORDER BY shared_set.name LIMIT {limit}`,
|
|
10703
|
+
defaultDateRange: "ALL_TIME",
|
|
10704
|
+
defaultLimit: 500
|
|
10705
|
+
},
|
|
10706
|
+
{
|
|
10707
|
+
name: "negative-list-attachments",
|
|
10708
|
+
description: "Which campaigns each shared negative keyword list is attached to",
|
|
10709
|
+
gaqlTemplate: `SELECT campaign.id, campaign.name, shared_set.id, shared_set.name FROM campaign_shared_set WHERE shared_set.type = 'NEGATIVE_KEYWORDS' AND campaign_shared_set.status = 'ENABLED' ORDER BY campaign.name LIMIT {limit}`,
|
|
10710
|
+
defaultDateRange: "ALL_TIME",
|
|
10711
|
+
defaultLimit: 500
|
|
10712
|
+
},
|
|
10713
|
+
{
|
|
10714
|
+
name: "search-terms",
|
|
10715
|
+
description: "Actual user search queries triggering ads",
|
|
10716
|
+
gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, ad_group.name, ad_group.status, search_term_view.search_term, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions FROM search_term_view WHERE segments.date DURING {dateRange}{statusScope} ORDER BY metrics.impressions DESC LIMIT {limit}`,
|
|
10717
|
+
defaultDateRange: "LAST_7_DAYS",
|
|
10718
|
+
defaultLimit: 200,
|
|
10719
|
+
statusFields: ["campaign.status", "ad_group.status"]
|
|
10720
|
+
},
|
|
10721
|
+
{
|
|
10722
|
+
name: "ad-copy-performance",
|
|
10723
|
+
description: "Ad headline and description effectiveness",
|
|
10724
|
+
gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, ad_group.status, ad_group_ad.status, ad_group_ad.ad.responsive_search_ad.headlines, ad_group_ad.ad.responsive_search_ad.descriptions, ad_group_ad.ad.final_urls, metrics.impressions, metrics.clicks, metrics.conversions, metrics.ctr FROM ad_group_ad WHERE segments.date DURING {dateRange}{statusScope} ORDER BY metrics.impressions DESC LIMIT {limit}`,
|
|
10725
|
+
defaultDateRange: "LAST_30_DAYS",
|
|
10726
|
+
defaultLimit: 200,
|
|
10727
|
+
statusFields: ["campaign.status", "ad_group.status", "ad_group_ad.status"]
|
|
10728
|
+
},
|
|
10729
|
+
{
|
|
10730
|
+
name: "asset-performance",
|
|
10731
|
+
description: "Performance Max asset performance labels",
|
|
10732
|
+
gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, asset_group.name, asset_group.status, asset_group_asset.field_type, asset_group_asset.performance_label, asset.type, asset.text_asset.text, asset.image_asset.full_size.url FROM asset_group_asset WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX' AND segments.date DURING {dateRange}{statusScope} LIMIT {limit}`,
|
|
10733
|
+
defaultDateRange: "LAST_30_DAYS",
|
|
10734
|
+
defaultLimit: 200,
|
|
10735
|
+
statusFields: ["campaign.status", "asset_group.status"]
|
|
10736
|
+
},
|
|
10737
|
+
{
|
|
10738
|
+
name: "shopping-products",
|
|
10739
|
+
description: "Product-level shopping performance metrics",
|
|
10740
|
+
gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, segments.product_title, segments.product_item_id, segments.product_brand, segments.product_type_l1, metrics.clicks, metrics.impressions, metrics.cost_micros, metrics.conversions FROM shopping_performance_view WHERE segments.date DURING {dateRange}{statusScope} ORDER BY metrics.cost_micros DESC LIMIT {limit}`,
|
|
10741
|
+
defaultDateRange: "LAST_30_DAYS",
|
|
10742
|
+
defaultLimit: 200,
|
|
10743
|
+
statusFields: ["campaign.status"]
|
|
10744
|
+
},
|
|
10745
|
+
{
|
|
10746
|
+
name: "account-summary",
|
|
10747
|
+
description: "Account-level totals for a date range",
|
|
10748
|
+
gaqlTemplate: `SELECT customer.id, customer.descriptive_name, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.conversions_value FROM customer WHERE segments.date DURING {dateRange} LIMIT 1`,
|
|
10749
|
+
defaultDateRange: "LAST_30_DAYS",
|
|
10750
|
+
defaultLimit: 1
|
|
10751
|
+
}
|
|
10752
|
+
];
|
|
10753
|
+
function getPreset(name) {
|
|
10754
|
+
return PRESETS.find((p) => p.name === name);
|
|
10755
|
+
}
|
|
10756
|
+
function buildStatusScope(statusFields, includePaused) {
|
|
10757
|
+
if (!statusFields || statusFields.length === 0) return "";
|
|
10758
|
+
const predicate = includePaused ? "!= 'REMOVED'" : "= 'ENABLED'";
|
|
10759
|
+
return statusFields.map((field) => ` AND ${field} ${predicate}`).join("");
|
|
10760
|
+
}
|
|
10761
|
+
function expandPreset(preset, params) {
|
|
10762
|
+
const dateRange = params.dateRange ?? preset.defaultDateRange;
|
|
10763
|
+
const limit = params.limit ?? preset.defaultLimit;
|
|
10764
|
+
if (!isValidDateRange(dateRange)) {
|
|
10765
|
+
return {
|
|
10766
|
+
query: "",
|
|
10767
|
+
dateRangeError: `Invalid date range: "${dateRange}". Use a GAQL date literal (e.g. LAST_30_DAYS, TODAY) or BETWEEN 'YYYY-MM-DD' AND 'YYYY-MM-DD'.`
|
|
10768
|
+
};
|
|
10769
|
+
}
|
|
10770
|
+
const statusScope = buildStatusScope(preset.statusFields, params.includePaused ?? false);
|
|
10771
|
+
const query = preset.gaqlTemplate.replace(/\{dateRange\}/g, dateRange).replace(/\{limit\}/g, String(limit)).replace(/\{statusScope\}/g, statusScope);
|
|
10772
|
+
return { query };
|
|
10773
|
+
}
|
|
10774
|
+
|
|
10628
10775
|
// src/commands/ads/google/preflight.ts
|
|
10629
10776
|
function buildCommand2(query, customerId) {
|
|
10630
10777
|
return `baker ads google query "${query}" --customer-id ${customerId}`;
|
|
10631
10778
|
}
|
|
10779
|
+
var RUNG_PIN_FIELDS = {
|
|
10780
|
+
"campaign.status": ["campaign.id", "campaign.resource_name"],
|
|
10781
|
+
"ad_group.status": ["ad_group.id", "ad_group.resource_name"],
|
|
10782
|
+
"ad_group_criterion.status": ["ad_group_criterion.criterion_id", "ad_group_criterion.resource_name"],
|
|
10783
|
+
"ad_group_ad.status": ["ad_group_ad.ad.id", "ad_group_ad.resource_name"],
|
|
10784
|
+
"asset_group.status": ["asset_group.id", "asset_group.resource_name"]
|
|
10785
|
+
};
|
|
10786
|
+
function whereClause(query) {
|
|
10787
|
+
const match = query.match(/\bWHERE\b([\s\S]*)$/i);
|
|
10788
|
+
if (!match?.[1]) return "";
|
|
10789
|
+
let rest = match[1];
|
|
10790
|
+
for (const trailing of [/\bORDER\s+BY\b/i, /\bLIMIT\b/i, /\bPARAMETERS\b/i]) {
|
|
10791
|
+
const idx = rest.search(trailing);
|
|
10792
|
+
if (idx !== -1) rest = rest.slice(0, idx);
|
|
10793
|
+
}
|
|
10794
|
+
return rest;
|
|
10795
|
+
}
|
|
10796
|
+
function selectClause(query) {
|
|
10797
|
+
return query.split(/\bFROM\b/i)[0] ?? "";
|
|
10798
|
+
}
|
|
10799
|
+
function mentions(clause, field) {
|
|
10800
|
+
return new RegExp(`(?<![\\w.])${field.replace(/\./g, "\\.")}\\b`, "i").test(clause);
|
|
10801
|
+
}
|
|
10802
|
+
function analyzeServingChain(query) {
|
|
10803
|
+
const resource = fromResource(query);
|
|
10804
|
+
const chain = resource ? SERVING_CHAINS[resource] : void 0;
|
|
10805
|
+
if (!chain) return null;
|
|
10806
|
+
const where = whereClause(query);
|
|
10807
|
+
if (/ad_group_criterion\.negative\s*=\s*TRUE/i.test(where)) return null;
|
|
10808
|
+
const unscoped = chain.filter((rung) => {
|
|
10809
|
+
if (mentions(where, rung)) return false;
|
|
10810
|
+
return !(RUNG_PIN_FIELDS[rung] ?? []).some((pin) => mentions(where, pin));
|
|
10811
|
+
});
|
|
10812
|
+
const assertsServing = chain.some(
|
|
10813
|
+
(rung) => new RegExp(`${rung.replace(/\./g, "\\.")}\\s*=\\s*'ENABLED'`, "i").test(where)
|
|
10814
|
+
);
|
|
10815
|
+
return { chain, unscoped, assertsServing };
|
|
10816
|
+
}
|
|
10817
|
+
function rejectPartialChain(query, customerId, analysis) {
|
|
10818
|
+
if (!analysis.assertsServing || analysis.unscoped.length === 0) return null;
|
|
10819
|
+
const added = analysis.unscoped.map((rung) => `${rung} = 'ENABLED'`).join(" AND ");
|
|
10820
|
+
const fixed = query.replace(/\bWHERE\b/i, `WHERE ${added} AND`);
|
|
10821
|
+
return {
|
|
10822
|
+
ok: false,
|
|
10823
|
+
error: {
|
|
10824
|
+
code: "INCOMPLETE_STATUS_CHAIN",
|
|
10825
|
+
message: `This query filters on serving status but leaves ${analysis.unscoped.join(", ")} unscoped. Google Ads has no single serving flag \u2014 a row is live only when its whole chain (${analysis.chain.join(" \u2192 ")}) is ENABLED, so rows under a paused parent will come back looking live.`,
|
|
10826
|
+
fix: {
|
|
10827
|
+
action: "retry_with_modified_query",
|
|
10828
|
+
correctedCommand: buildCommand2(fixed, customerId),
|
|
10829
|
+
explanation: `Add the missing rungs: ${added}. To look at paused entities on purpose, scope every rung explicitly instead (e.g. ${analysis.unscoped.map((rung) => `${rung} != 'REMOVED'`).join(" AND ")}), or use --preset ... --include-paused.`
|
|
10830
|
+
},
|
|
10831
|
+
retryable: false
|
|
10832
|
+
}
|
|
10833
|
+
};
|
|
10834
|
+
}
|
|
10835
|
+
function annotateUnscopedChain(query, analysis) {
|
|
10836
|
+
if (analysis.assertsServing || analysis.unscoped.length === 0) return { query, warnings: [] };
|
|
10837
|
+
const select = selectClause(query);
|
|
10838
|
+
const missing = analysis.unscoped.filter((rung) => !mentions(select, rung));
|
|
10839
|
+
if (missing.length === 0) return { query, warnings: [] };
|
|
10840
|
+
return {
|
|
10841
|
+
query: query.replace(/SELECT\s+/i, `SELECT ${missing.join(", ")}, `),
|
|
10842
|
+
warnings: [
|
|
10843
|
+
{
|
|
10844
|
+
code: "SERVING_STATUS_ADDED",
|
|
10845
|
+
message: `Added ${missing.join(", ")} to SELECT \u2014 this query does not scope ${analysis.unscoped.join(", ")}, so it returns paused-parent rows that are not serving. Read those columns before calling any row live; filter every rung = 'ENABLED' to get serving rows only.`
|
|
10846
|
+
}
|
|
10847
|
+
]
|
|
10848
|
+
};
|
|
10849
|
+
}
|
|
10632
10850
|
var SINGLE_ROW_RESOURCES = ["customer"];
|
|
10633
10851
|
function fromResource(query) {
|
|
10634
10852
|
const match = query.match(/\bFROM\s+([A-Za-z_][A-Za-z0-9_]*)/i);
|
|
@@ -10641,10 +10859,10 @@ function isSingleRowRead(query) {
|
|
|
10641
10859
|
}
|
|
10642
10860
|
function addRequiredCampaignFields(query) {
|
|
10643
10861
|
if (!/FROM\s+campaign_budget\b/i.test(query)) return { query, warnings: [] };
|
|
10644
|
-
const
|
|
10645
|
-
const
|
|
10646
|
-
const selectFields = new Set(
|
|
10647
|
-
const missing = [...new Set(
|
|
10862
|
+
const whereClause3 = query.split(/\bWHERE\b/i)[1] ?? "";
|
|
10863
|
+
const selectClause2 = query.split(/\bFROM\b/i)[0] ?? "";
|
|
10864
|
+
const selectFields = new Set(selectClause2.match(/campaign\.[\w.]+/g) ?? []);
|
|
10865
|
+
const missing = [...new Set(whereClause3.match(/campaign\.[\w.]+/g) ?? [])].filter(
|
|
10648
10866
|
(field) => !selectFields.has(field)
|
|
10649
10867
|
);
|
|
10650
10868
|
if (missing.length === 0) return { query, warnings: [] };
|
|
@@ -10794,128 +11012,13 @@ function validatePreflight(query, customerId, limit) {
|
|
|
10794
11012
|
}
|
|
10795
11013
|
const { query: autoFixed, warnings: autoFixWarnings } = applyAutoFixes(corrected, limit);
|
|
10796
11014
|
warnings.push(...autoFixWarnings);
|
|
10797
|
-
|
|
10798
|
-
}
|
|
10799
|
-
|
|
10800
|
-
|
|
10801
|
-
|
|
10802
|
-
|
|
10803
|
-
return
|
|
10804
|
-
}
|
|
10805
|
-
var PRESETS = [
|
|
10806
|
-
{
|
|
10807
|
-
name: "campaign-performance",
|
|
10808
|
-
description: "Campaign-level metrics overview",
|
|
10809
|
-
gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, campaign.advertising_channel_type, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.conversions_value, metrics.ctr, metrics.average_cpc FROM campaign WHERE segments.date DURING {dateRange}{statusScope} ORDER BY metrics.cost_micros DESC LIMIT {limit}`,
|
|
10810
|
-
defaultDateRange: "LAST_30_DAYS",
|
|
10811
|
-
defaultLimit: 200,
|
|
10812
|
-
statusFields: ["campaign.status"]
|
|
10813
|
-
},
|
|
10814
|
-
{
|
|
10815
|
-
name: "keyword-analysis",
|
|
10816
|
-
description: "Keyword performance with match type and quality",
|
|
10817
|
-
gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, ad_group.id, ad_group.name, ad_group.status, ad_group_criterion.criterion_id, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, ad_group_criterion.status, ad_group_criterion.quality_info.quality_score, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.ctr FROM keyword_view WHERE segments.date DURING {dateRange}{statusScope} ORDER BY metrics.impressions DESC LIMIT {limit}`,
|
|
10818
|
-
defaultDateRange: "LAST_30_DAYS",
|
|
10819
|
-
defaultLimit: 200,
|
|
10820
|
-
statusFields: ["campaign.status", "ad_group.status", "ad_group_criterion.status"]
|
|
10821
|
-
},
|
|
10822
|
-
{
|
|
10823
|
-
name: "keyword-serving",
|
|
10824
|
-
description: "Why a keyword is limited \u2014 Google Ads' 'Eligible (Limited) / Below first page bid' column, with the first-page bid estimate, the current max CPC and quality score",
|
|
10825
|
-
gaqlTemplate: `SELECT campaign.id, campaign.name, ad_group.id, ad_group.name, ad_group_criterion.criterion_id, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, ad_group_criterion.status, ad_group_criterion.primary_status, ad_group_criterion.primary_status_reasons, ad_group_criterion.system_serving_status, ad_group_criterion.approval_status, ad_group_criterion.effective_cpc_bid_micros, ad_group_criterion.effective_cpc_bid_source, ad_group_criterion.position_estimates.first_page_cpc_micros, ad_group_criterion.position_estimates.top_of_page_cpc_micros, ad_group_criterion.quality_info.quality_score, ad_group_criterion.quality_info.creative_quality_score, ad_group_criterion.quality_info.post_click_quality_score, ad_group_criterion.quality_info.search_predicted_ctr FROM ad_group_criterion WHERE ad_group_criterion.type = 'KEYWORD' AND ad_group_criterion.negative = FALSE{statusScope} ORDER BY campaign.name LIMIT {limit}`,
|
|
10826
|
-
defaultDateRange: "ALL_TIME",
|
|
10827
|
-
defaultLimit: 500,
|
|
10828
|
-
statusFields: ["campaign.status", "ad_group.status", "ad_group_criterion.status"]
|
|
10829
|
-
},
|
|
10830
|
-
{
|
|
10831
|
-
name: "positive-keywords",
|
|
10832
|
-
description: "Positive (targeting) keywords only \u2014 excludes negatives",
|
|
10833
|
-
gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, ad_group.id, ad_group.name, ad_group.status, ad_group_criterion.criterion_id, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, ad_group_criterion.status, ad_group_criterion.negative FROM ad_group_criterion WHERE ad_group_criterion.type = 'KEYWORD' AND ad_group_criterion.negative = FALSE{statusScope} ORDER BY campaign.name LIMIT {limit}`,
|
|
10834
|
-
defaultDateRange: "ALL_TIME",
|
|
10835
|
-
defaultLimit: 500,
|
|
10836
|
-
statusFields: ["campaign.status", "ad_group.status", "ad_group_criterion.status"]
|
|
10837
|
-
},
|
|
10838
|
-
{
|
|
10839
|
-
name: "negative-keywords",
|
|
10840
|
-
description: "Negative keywords only \u2014 campaign and ad group level",
|
|
10841
|
-
gaqlTemplate: `SELECT campaign.id, campaign.name, ad_group.name, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, ad_group_criterion.negative FROM ad_group_criterion WHERE ad_group_criterion.type = 'KEYWORD' AND ad_group_criterion.negative = TRUE AND campaign.status != 'REMOVED' ORDER BY campaign.name LIMIT {limit}`,
|
|
10842
|
-
defaultDateRange: "ALL_TIME",
|
|
10843
|
-
defaultLimit: 500
|
|
10844
|
-
},
|
|
10845
|
-
{
|
|
10846
|
-
name: "negative-keyword-lists",
|
|
10847
|
-
description: "Shared negative keyword lists and the terms inside them (SharedSet members)",
|
|
10848
|
-
gaqlTemplate: `SELECT shared_set.id, shared_set.name, shared_criterion.keyword.text, shared_criterion.keyword.match_type FROM shared_criterion WHERE shared_set.type = 'NEGATIVE_KEYWORDS' AND shared_set.status = 'ENABLED' ORDER BY shared_set.name LIMIT {limit}`,
|
|
10849
|
-
defaultDateRange: "ALL_TIME",
|
|
10850
|
-
defaultLimit: 500
|
|
10851
|
-
},
|
|
10852
|
-
{
|
|
10853
|
-
name: "negative-list-attachments",
|
|
10854
|
-
description: "Which campaigns each shared negative keyword list is attached to",
|
|
10855
|
-
gaqlTemplate: `SELECT campaign.id, campaign.name, shared_set.id, shared_set.name FROM campaign_shared_set WHERE shared_set.type = 'NEGATIVE_KEYWORDS' AND campaign_shared_set.status = 'ENABLED' ORDER BY campaign.name LIMIT {limit}`,
|
|
10856
|
-
defaultDateRange: "ALL_TIME",
|
|
10857
|
-
defaultLimit: 500
|
|
10858
|
-
},
|
|
10859
|
-
{
|
|
10860
|
-
name: "search-terms",
|
|
10861
|
-
description: "Actual user search queries triggering ads",
|
|
10862
|
-
gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, ad_group.name, ad_group.status, search_term_view.search_term, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions FROM search_term_view WHERE segments.date DURING {dateRange}{statusScope} ORDER BY metrics.impressions DESC LIMIT {limit}`,
|
|
10863
|
-
defaultDateRange: "LAST_7_DAYS",
|
|
10864
|
-
defaultLimit: 200,
|
|
10865
|
-
statusFields: ["campaign.status", "ad_group.status"]
|
|
10866
|
-
},
|
|
10867
|
-
{
|
|
10868
|
-
name: "ad-copy-performance",
|
|
10869
|
-
description: "Ad headline and description effectiveness",
|
|
10870
|
-
gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, ad_group.status, ad_group_ad.status, ad_group_ad.ad.responsive_search_ad.headlines, ad_group_ad.ad.responsive_search_ad.descriptions, ad_group_ad.ad.final_urls, metrics.impressions, metrics.clicks, metrics.conversions, metrics.ctr FROM ad_group_ad WHERE segments.date DURING {dateRange}{statusScope} ORDER BY metrics.impressions DESC LIMIT {limit}`,
|
|
10871
|
-
defaultDateRange: "LAST_30_DAYS",
|
|
10872
|
-
defaultLimit: 200,
|
|
10873
|
-
statusFields: ["campaign.status", "ad_group.status", "ad_group_ad.status"]
|
|
10874
|
-
},
|
|
10875
|
-
{
|
|
10876
|
-
name: "asset-performance",
|
|
10877
|
-
description: "Performance Max asset performance labels",
|
|
10878
|
-
gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, asset_group.name, asset_group.status, asset_group_asset.field_type, asset_group_asset.performance_label, asset.type, asset.text_asset.text, asset.image_asset.full_size.url FROM asset_group_asset WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX' AND segments.date DURING {dateRange}{statusScope} LIMIT {limit}`,
|
|
10879
|
-
defaultDateRange: "LAST_30_DAYS",
|
|
10880
|
-
defaultLimit: 200,
|
|
10881
|
-
statusFields: ["campaign.status", "asset_group.status"]
|
|
10882
|
-
},
|
|
10883
|
-
{
|
|
10884
|
-
name: "shopping-products",
|
|
10885
|
-
description: "Product-level shopping performance metrics",
|
|
10886
|
-
gaqlTemplate: `SELECT campaign.id, campaign.name, campaign.status, segments.product_title, segments.product_item_id, segments.product_brand, segments.product_type_l1, metrics.clicks, metrics.impressions, metrics.cost_micros, metrics.conversions FROM shopping_performance_view WHERE segments.date DURING {dateRange}{statusScope} ORDER BY metrics.cost_micros DESC LIMIT {limit}`,
|
|
10887
|
-
defaultDateRange: "LAST_30_DAYS",
|
|
10888
|
-
defaultLimit: 200,
|
|
10889
|
-
statusFields: ["campaign.status"]
|
|
10890
|
-
},
|
|
10891
|
-
{
|
|
10892
|
-
name: "account-summary",
|
|
10893
|
-
description: "Account-level totals for a date range",
|
|
10894
|
-
gaqlTemplate: `SELECT customer.id, customer.descriptive_name, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.conversions_value FROM customer WHERE segments.date DURING {dateRange} LIMIT 1`,
|
|
10895
|
-
defaultDateRange: "LAST_30_DAYS",
|
|
10896
|
-
defaultLimit: 1
|
|
10897
|
-
}
|
|
10898
|
-
];
|
|
10899
|
-
function getPreset(name) {
|
|
10900
|
-
return PRESETS.find((p) => p.name === name);
|
|
10901
|
-
}
|
|
10902
|
-
function buildStatusScope(statusFields, includePaused) {
|
|
10903
|
-
if (!statusFields || statusFields.length === 0) return "";
|
|
10904
|
-
const predicate = includePaused ? "!= 'REMOVED'" : "= 'ENABLED'";
|
|
10905
|
-
return statusFields.map((field) => ` AND ${field} ${predicate}`).join("");
|
|
10906
|
-
}
|
|
10907
|
-
function expandPreset(preset, params) {
|
|
10908
|
-
const dateRange = params.dateRange ?? preset.defaultDateRange;
|
|
10909
|
-
const limit = params.limit ?? preset.defaultLimit;
|
|
10910
|
-
if (!isValidDateRange(dateRange)) {
|
|
10911
|
-
return {
|
|
10912
|
-
query: "",
|
|
10913
|
-
dateRangeError: `Invalid date range: "${dateRange}". Use a GAQL date literal (e.g. LAST_30_DAYS, TODAY) or BETWEEN 'YYYY-MM-DD' AND 'YYYY-MM-DD'.`
|
|
10914
|
-
};
|
|
10915
|
-
}
|
|
10916
|
-
const statusScope = buildStatusScope(preset.statusFields, params.includePaused ?? false);
|
|
10917
|
-
const query = preset.gaqlTemplate.replace(/\{dateRange\}/g, dateRange).replace(/\{limit\}/g, String(limit)).replace(/\{statusScope\}/g, statusScope);
|
|
10918
|
-
return { query };
|
|
11015
|
+
const analysis = analyzeServingChain(autoFixed);
|
|
11016
|
+
if (!analysis) return { valid: true, correctedQuery: autoFixed, warnings };
|
|
11017
|
+
const rejection = rejectPartialChain(autoFixed, customerId, analysis);
|
|
11018
|
+
if (rejection) return { valid: false, warnings: [], error: rejection };
|
|
11019
|
+
const annotated = annotateUnscopedChain(autoFixed, analysis);
|
|
11020
|
+
warnings.push(...annotated.warnings);
|
|
11021
|
+
return { valid: true, correctedQuery: annotated.query, warnings };
|
|
10919
11022
|
}
|
|
10920
11023
|
|
|
10921
11024
|
// src/commands/ads/google/query.ts
|
|
@@ -10944,12 +11047,17 @@ registerSchema({
|
|
|
10944
11047
|
description: "Include paused entities in preset results. Off by default: presets return only actually-serving entities. Google Ads has no single serving flag \u2014 an entity serves only when its whole chain (campaign, ad group, ad) is ENABLED, so recommendations should target serving entities unless the user asks about paused ones.",
|
|
10945
11048
|
required: false
|
|
10946
11049
|
},
|
|
10947
|
-
limit: {
|
|
11050
|
+
limit: {
|
|
11051
|
+
type: "number",
|
|
11052
|
+
description: "Max rows in ONE page (default 200), AND the LIMIT added to a query that has none \u2014 so it caps the whole read with --all too. Values up to 10000 are accepted. Any read that fills this cap comes back flagged TRUNCATED_RESULTS \u2014 never count or total from one.",
|
|
11053
|
+
required: false,
|
|
11054
|
+
default: 200
|
|
11055
|
+
},
|
|
10948
11056
|
"list-presets": { type: "boolean", description: "List all available query presets", required: false },
|
|
10949
11057
|
cursor: { type: "string", description: "Pagination cursor from previous response", required: false },
|
|
10950
11058
|
all: {
|
|
10951
11059
|
type: "boolean",
|
|
10952
|
-
description: "
|
|
11060
|
+
description: "Follow every page until the data set is exhausted (use with --out for large datasets). Needed for a read you may count or total, but not sufficient: it exhausts the pages, not the statement's LIMIT, so pair it with a --limit above the row count you expect (or your own LIMIT) and check the read came back without TRUNCATED_RESULTS.",
|
|
10953
11061
|
required: false
|
|
10954
11062
|
},
|
|
10955
11063
|
out: {
|
|
@@ -11004,53 +11112,117 @@ function writeRowsToFile(filePath, rows, fields, append) {
|
|
|
11004
11112
|
writeFileSync(filePath, JSON.stringify(rows, null, 2), "utf-8");
|
|
11005
11113
|
}
|
|
11006
11114
|
}
|
|
11115
|
+
function effectiveRowLimit(finalQuery, limit, all = false) {
|
|
11116
|
+
const match = finalQuery.match(/\bLIMIT\s+(\d+)/i);
|
|
11117
|
+
const statementLimit = match?.[1] ? Number(match[1]) : void 0;
|
|
11118
|
+
if (all) return statementLimit;
|
|
11119
|
+
return statementLimit === void 0 ? limit : Math.min(limit, statementLimit);
|
|
11120
|
+
}
|
|
11121
|
+
function warningLines(warnings, format) {
|
|
11122
|
+
return warnings.map((w) => {
|
|
11123
|
+
const message = w.message.replace(/\s*\n\s*/g, " ");
|
|
11124
|
+
if (format === "jsonl") return JSON.stringify({ _baker_warning: { code: w.code, message } });
|
|
11125
|
+
if (format === "md") return `> **${w.code}** \u2014 ${message}`;
|
|
11126
|
+
return `# baker ${w.code}: ${message}`;
|
|
11127
|
+
});
|
|
11128
|
+
}
|
|
11129
|
+
function completenessWarnings(read) {
|
|
11130
|
+
const cap3 = read.effectiveLimit;
|
|
11131
|
+
const filledTheCap = cap3 !== void 0 && cap3 > 0 && read.rowCount >= cap3;
|
|
11132
|
+
if (!read.hasMore && !filledTheCap) return [];
|
|
11133
|
+
const cause = read.hasMore ? `More rows are waiting: the account returned a next-page cursor this run did not follow.` : `The ${read.rowCount} rows returned exactly fill the row cap in force (${cap3}).`;
|
|
11134
|
+
const fix = read.all ? `Every page was followed, so the cap is the LIMIT in the statement itself \u2014 raise it above ${cap3}, or drop it and pass a larger --limit, then re-run.` : `Re-run with --all --out <file> to page through everything, or raise --limit above ${cap3}.`;
|
|
11135
|
+
return [
|
|
11136
|
+
{
|
|
11137
|
+
code: "TRUNCATED_RESULTS",
|
|
11138
|
+
message: `INCOMPLETE READ \u2014 ${cause} These rows are a page, not the data set. Do NOT count, total or describe them as the whole picture. ${fix}`
|
|
11139
|
+
}
|
|
11140
|
+
];
|
|
11141
|
+
}
|
|
11007
11142
|
function buildQueryEnvelope(rows, fieldDescs, warnings, options) {
|
|
11008
|
-
const
|
|
11009
|
-
|
|
11010
|
-
|
|
11011
|
-
|
|
11012
|
-
|
|
11013
|
-
|
|
11014
|
-
|
|
11015
|
-
|
|
11016
|
-
|
|
11017
|
-
}
|
|
11018
|
-
if (options.cached) {
|
|
11019
|
-
envelope.cached = true;
|
|
11020
|
-
}
|
|
11021
|
-
return envelope;
|
|
11143
|
+
const notice = summariseWarnings(warnings);
|
|
11144
|
+
return {
|
|
11145
|
+
ok: true,
|
|
11146
|
+
...options.cached ? { cached: true } : {},
|
|
11147
|
+
...warnings.length > 0 ? { warnings } : {},
|
|
11148
|
+
...options.nextCursor ? { pagination: { hasMore: true, cursor: options.nextCursor } } : {},
|
|
11149
|
+
data: rows,
|
|
11150
|
+
...options.full && Object.keys(fieldDescs).length > 0 ? { fields: fieldDescs } : {},
|
|
11151
|
+
...notice ? { notice } : {}
|
|
11152
|
+
};
|
|
11022
11153
|
}
|
|
11023
|
-
function
|
|
11024
|
-
|
|
11025
|
-
const
|
|
11026
|
-
|
|
11027
|
-
|
|
11028
|
-
|
|
11029
|
-
|
|
11030
|
-
|
|
11154
|
+
function summariseWarnings(warnings) {
|
|
11155
|
+
if (warnings.length === 0) return void 0;
|
|
11156
|
+
const codes = [...new Set(warnings.map((w) => w.code))].join(", ");
|
|
11157
|
+
return `Read the "warnings" above before using these rows: ${codes}.`;
|
|
11158
|
+
}
|
|
11159
|
+
function buildFileSummary(input) {
|
|
11160
|
+
const notice = summariseWarnings(input.warnings);
|
|
11161
|
+
return {
|
|
11162
|
+
ok: true,
|
|
11163
|
+
fields: {},
|
|
11164
|
+
file: input.file,
|
|
11165
|
+
rows: input.rows,
|
|
11166
|
+
complete: input.complete,
|
|
11167
|
+
...input.warnings.length > 0 ? { warnings: input.warnings } : {},
|
|
11168
|
+
...notice ? { notice } : {}
|
|
11169
|
+
};
|
|
11170
|
+
}
|
|
11171
|
+
var REPEAT_NOTICE_ABOVE_ROWS = 20;
|
|
11172
|
+
function emitToStderr(notices) {
|
|
11173
|
+
for (const line of notices) process.stderr.write(`${line}
|
|
11031
11174
|
`);
|
|
11032
|
-
|
|
11033
|
-
|
|
11034
|
-
}
|
|
11035
|
-
if (format === "jsonl") {
|
|
11036
|
-
for (const row of rows) {
|
|
11037
|
-
process.stdout.write(`${JSON.stringify(row)}
|
|
11175
|
+
}
|
|
11176
|
+
function writeLine(line) {
|
|
11177
|
+
process.stdout.write(`${line}
|
|
11038
11178
|
`);
|
|
11039
|
-
|
|
11040
|
-
|
|
11179
|
+
}
|
|
11180
|
+
var writeCsvRows = (rows, fields, notices) => {
|
|
11181
|
+
writeLine(toCsvRow(fields));
|
|
11182
|
+
for (const line of notices) writeLine(line);
|
|
11183
|
+
for (const row of rows) writeLine(toCsvRow(fields.map((f) => String(row[f] ?? ""))));
|
|
11184
|
+
};
|
|
11185
|
+
var writeJsonlRows = (rows, _fields, notices) => {
|
|
11186
|
+
for (const line of notices) writeLine(line);
|
|
11187
|
+
for (const row of rows) writeLine(JSON.stringify(row));
|
|
11188
|
+
};
|
|
11189
|
+
var writeMdRows = (rows, fields, notices) => {
|
|
11190
|
+
for (const line of notices) writeLine(line);
|
|
11191
|
+
writeLine(`| ${fields.join(" | ")} |`);
|
|
11192
|
+
writeLine(`| ${fields.map(() => "---").join(" | ")} |`);
|
|
11193
|
+
for (const row of rows) writeLine(`| ${fields.map((f) => String(row[f] ?? "")).join(" | ")} |`);
|
|
11194
|
+
};
|
|
11195
|
+
var ROW_WRITERS = { csv: writeCsvRows, jsonl: writeJsonlRows, md: writeMdRows };
|
|
11196
|
+
function writeRows(format, rows, notices) {
|
|
11197
|
+
const write2 = ROW_WRITERS[format] ?? writeMdRows;
|
|
11198
|
+
write2(rows, Object.keys(rows[0] ?? {}), notices);
|
|
11199
|
+
if (rows.length > REPEAT_NOTICE_ABOVE_ROWS) {
|
|
11200
|
+
for (const line of notices) writeLine(line);
|
|
11041
11201
|
}
|
|
11042
|
-
|
|
11043
|
-
|
|
11044
|
-
|
|
11045
|
-
|
|
11046
|
-
|
|
11047
|
-
|
|
11048
|
-
|
|
11049
|
-
|
|
11050
|
-
|
|
11202
|
+
}
|
|
11203
|
+
function outputResults(rows, fieldDescs, warnings, args, cached, read) {
|
|
11204
|
+
const format = args.output || "json";
|
|
11205
|
+
const allWarnings = [
|
|
11206
|
+
...warnings,
|
|
11207
|
+
...completenessWarnings({
|
|
11208
|
+
rowCount: rows.length,
|
|
11209
|
+
effectiveLimit: read.effectiveLimit,
|
|
11210
|
+
all: read.all,
|
|
11211
|
+
hasMore: Boolean(read.nextCursor)
|
|
11212
|
+
})
|
|
11213
|
+
];
|
|
11214
|
+
emitToStderr(warningLines(allWarnings, "csv"));
|
|
11215
|
+
if (format === "json") {
|
|
11216
|
+
writeAdsJson(
|
|
11217
|
+
buildQueryEnvelope(rows, fieldDescs, allWarnings, {
|
|
11218
|
+
full: Boolean(args.full),
|
|
11219
|
+
cached,
|
|
11220
|
+
nextCursor: read.nextCursor
|
|
11221
|
+
})
|
|
11222
|
+
);
|
|
11051
11223
|
return;
|
|
11052
11224
|
}
|
|
11053
|
-
|
|
11225
|
+
writeRows(format, rows, warningLines(allWarnings, format));
|
|
11054
11226
|
}
|
|
11055
11227
|
function resolveGaql(args, limit) {
|
|
11056
11228
|
if (args.preset) {
|
|
@@ -11099,8 +11271,8 @@ var SERVING_HIERARCHY_RESOURCES = [
|
|
|
11099
11271
|
"search_term_view",
|
|
11100
11272
|
"shopping_performance_view",
|
|
11101
11273
|
"asset_group_asset"
|
|
11102
|
-
];
|
|
11103
|
-
function
|
|
11274
|
+
].filter((resource) => !(resource in SERVING_CHAINS));
|
|
11275
|
+
function whereClause2(upperQuery) {
|
|
11104
11276
|
const whereIdx = upperQuery.indexOf(" WHERE ");
|
|
11105
11277
|
if (whereIdx === -1) return "";
|
|
11106
11278
|
let rest = upperQuery.slice(whereIdx + 7);
|
|
@@ -11124,7 +11296,7 @@ function servingScopeWarnings(args, finalQuery) {
|
|
|
11124
11296
|
const fromMatch = upper2.match(/\bFROM\s+([A-Z_]+)/);
|
|
11125
11297
|
const resource = fromMatch?.[1]?.toLowerCase();
|
|
11126
11298
|
const hierarchical = resource ? SERVING_HIERARCHY_RESOURCES.includes(resource) : false;
|
|
11127
|
-
const filtersStatus =
|
|
11299
|
+
const filtersStatus = whereClause2(upper2).includes(".STATUS");
|
|
11128
11300
|
if (hierarchical && !filtersStatus) {
|
|
11129
11301
|
return [
|
|
11130
11302
|
{
|
|
@@ -11141,11 +11313,12 @@ function parseSelectedFields(gaql) {
|
|
|
11141
11313
|
if (!list) return [];
|
|
11142
11314
|
return list.split(",").map((field) => field.trim()).filter((field) => /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)*$/.test(field));
|
|
11143
11315
|
}
|
|
11144
|
-
function
|
|
11145
|
-
const observed = /* @__PURE__ */ new Set();
|
|
11316
|
+
function foldObservedFields(rows, observed) {
|
|
11146
11317
|
for (const row of rows) {
|
|
11147
11318
|
for (const key of Object.keys(row)) observed.add(key);
|
|
11148
11319
|
}
|
|
11320
|
+
}
|
|
11321
|
+
function unobservedFields(gaql, observed) {
|
|
11149
11322
|
return parseSelectedFields(gaql).filter((field) => {
|
|
11150
11323
|
if (field.endsWith(".resource_name")) return false;
|
|
11151
11324
|
if (observed.has(field)) return false;
|
|
@@ -11153,8 +11326,13 @@ function unobservedSelectedFields(gaql, rows) {
|
|
|
11153
11326
|
});
|
|
11154
11327
|
}
|
|
11155
11328
|
function missingFieldWarnings(gaql, rows) {
|
|
11156
|
-
|
|
11157
|
-
|
|
11329
|
+
const observed = /* @__PURE__ */ new Set();
|
|
11330
|
+
foldObservedFields(rows, observed);
|
|
11331
|
+
return missingFieldWarningsFor(gaql, observed, rows.length);
|
|
11332
|
+
}
|
|
11333
|
+
function missingFieldWarningsFor(gaql, observed, rowCount2) {
|
|
11334
|
+
if (rowCount2 === 0) return [];
|
|
11335
|
+
const missing = unobservedFields(gaql, observed);
|
|
11158
11336
|
if (missing.length === 0) return [];
|
|
11159
11337
|
return [
|
|
11160
11338
|
{
|
|
@@ -11171,6 +11349,7 @@ async function fetchAllPages(body, args, servingOptions) {
|
|
|
11171
11349
|
let pageToken = args.cursor;
|
|
11172
11350
|
let totalRowsWritten = 0;
|
|
11173
11351
|
let keywordLimits;
|
|
11352
|
+
const observedFields = /* @__PURE__ */ new Set();
|
|
11174
11353
|
do {
|
|
11175
11354
|
const requestBody2 = { ...body };
|
|
11176
11355
|
if (pageToken) requestBody2.pageToken = pageToken;
|
|
@@ -11178,11 +11357,11 @@ async function fetchAllPages(body, args, servingOptions) {
|
|
|
11178
11357
|
const rows = response.rows ?? [];
|
|
11179
11358
|
pageToken = response.pageToken;
|
|
11180
11359
|
if (args.out) {
|
|
11181
|
-
const filePath = resolve(args.out);
|
|
11182
11360
|
const shouldAppend = args.append || totalRowsWritten > 0;
|
|
11183
|
-
writeRowsToFile(
|
|
11361
|
+
writeRowsToFile(resolve(args.out), rows, extractFields(rows), shouldAppend);
|
|
11184
11362
|
totalRowsWritten += rows.length;
|
|
11185
11363
|
keywordLimits = collectKeywordLimits(rows, servingOptions, keywordLimits);
|
|
11364
|
+
foldObservedFields(rows, observedFields);
|
|
11186
11365
|
} else {
|
|
11187
11366
|
allRows = allRows.concat(rows);
|
|
11188
11367
|
}
|
|
@@ -11192,7 +11371,8 @@ async function fetchAllPages(body, args, servingOptions) {
|
|
|
11192
11371
|
allRows,
|
|
11193
11372
|
lastPageToken: pageToken,
|
|
11194
11373
|
totalRowsWritten,
|
|
11195
|
-
keywordLimits: keywordLimits ?? collectKeywordLimits([], servingOptions)
|
|
11374
|
+
keywordLimits: keywordLimits ?? collectKeywordLimits([], servingOptions),
|
|
11375
|
+
observedFields
|
|
11196
11376
|
};
|
|
11197
11377
|
}
|
|
11198
11378
|
async function executeQuery(finalQuery, args, customerId, limit, warnings, useCache, cacheKey) {
|
|
@@ -11202,12 +11382,33 @@ async function executeQuery(finalQuery, args, customerId, limit, warnings, useCa
|
|
|
11202
11382
|
if (args.cursor) body.pageToken = args.cursor;
|
|
11203
11383
|
if (!useCache) body.skipCache = true;
|
|
11204
11384
|
const servingOptions = keywordServingOptions(finalQuery);
|
|
11205
|
-
const { allRows, lastPageToken, totalRowsWritten, keywordLimits } = await fetchAllPages(
|
|
11385
|
+
const { allRows, lastPageToken, totalRowsWritten, keywordLimits, observedFields } = await fetchAllPages(
|
|
11386
|
+
body,
|
|
11387
|
+
args,
|
|
11388
|
+
servingOptions
|
|
11389
|
+
);
|
|
11206
11390
|
if (args.out) {
|
|
11207
|
-
const
|
|
11208
|
-
|
|
11209
|
-
|
|
11210
|
-
|
|
11391
|
+
const truncation = completenessWarnings({
|
|
11392
|
+
rowCount: totalRowsWritten,
|
|
11393
|
+
effectiveLimit: effectiveRowLimit(finalQuery, limit, Boolean(args.all)),
|
|
11394
|
+
all: Boolean(args.all),
|
|
11395
|
+
hasMore: Boolean(lastPageToken)
|
|
11396
|
+
});
|
|
11397
|
+
const fileWarnings = [
|
|
11398
|
+
...warnings,
|
|
11399
|
+
...renderKeywordLimits(keywordLimits),
|
|
11400
|
+
...missingFieldWarningsFor(finalQuery, observedFields, totalRowsWritten),
|
|
11401
|
+
...truncation
|
|
11402
|
+
];
|
|
11403
|
+
emitToStderr(warningLines(fileWarnings, "csv"));
|
|
11404
|
+
writeAdsJson(
|
|
11405
|
+
buildFileSummary({
|
|
11406
|
+
file: resolve(args.out),
|
|
11407
|
+
rows: totalRowsWritten,
|
|
11408
|
+
warnings: fileWarnings,
|
|
11409
|
+
complete: truncation.length === 0
|
|
11410
|
+
})
|
|
11411
|
+
);
|
|
11211
11412
|
return;
|
|
11212
11413
|
}
|
|
11213
11414
|
if (useCache && allRows.length > 0) {
|
|
@@ -11221,9 +11422,16 @@ async function executeQuery(finalQuery, args, customerId, limit, warnings, useCa
|
|
|
11221
11422
|
[...warnings, ...missingFieldWarnings(finalQuery, allRows), ...keywordServingWarnings(allRows, servingOptions)],
|
|
11222
11423
|
args,
|
|
11223
11424
|
false,
|
|
11224
|
-
|
|
11425
|
+
{
|
|
11426
|
+
effectiveLimit: effectiveRowLimit(finalQuery, limit, Boolean(args.all)),
|
|
11427
|
+
all: Boolean(args.all),
|
|
11428
|
+
nextCursor: lastPageToken
|
|
11429
|
+
}
|
|
11225
11430
|
);
|
|
11226
11431
|
}
|
|
11432
|
+
function shouldReadCache(args) {
|
|
11433
|
+
return !args["no-cache"] && !args.out;
|
|
11434
|
+
}
|
|
11227
11435
|
async function validateArgs(args) {
|
|
11228
11436
|
const customerId = await resolveCustomerId(args);
|
|
11229
11437
|
const limit = args.limit ? Number(args.limit) : 200;
|
|
@@ -11286,10 +11494,14 @@ Examples:
|
|
|
11286
11494
|
description: "Include paused entities in preset results (default: serving entities only)",
|
|
11287
11495
|
required: false
|
|
11288
11496
|
},
|
|
11289
|
-
limit: {
|
|
11497
|
+
limit: {
|
|
11498
|
+
type: "string",
|
|
11499
|
+
description: "Max rows in one page (default 200); without --all the read stops there",
|
|
11500
|
+
required: false
|
|
11501
|
+
},
|
|
11290
11502
|
"list-presets": { type: "boolean", description: "List available presets", required: false },
|
|
11291
11503
|
cursor: { type: "string", description: "Pagination cursor", required: false },
|
|
11292
|
-
all: { type: "boolean", description: "
|
|
11504
|
+
all: { type: "boolean", description: "Follow every page (use with --out for large datasets)", required: false },
|
|
11293
11505
|
out: { type: "string", description: "File path for output", required: false },
|
|
11294
11506
|
append: { type: "boolean", description: "Append to file", required: false },
|
|
11295
11507
|
output: { type: "string", description: "Format: json|csv|jsonl|md", required: false, default: "json" },
|
|
@@ -11313,7 +11525,7 @@ Examples:
|
|
|
11313
11525
|
const validated = await validateArgs(args);
|
|
11314
11526
|
if (!validated) return;
|
|
11315
11527
|
const { customerId, limit, finalQuery, warnings, useCache, cacheKey } = validated;
|
|
11316
|
-
if (
|
|
11528
|
+
if (shouldReadCache(args)) {
|
|
11317
11529
|
const cached = cacheGet("queries", cacheKey);
|
|
11318
11530
|
if (cached) {
|
|
11319
11531
|
const fields = extractFields(cached.data);
|
|
@@ -11326,7 +11538,8 @@ Examples:
|
|
|
11326
11538
|
...keywordServingWarnings(cached.data, keywordServingOptions(finalQuery))
|
|
11327
11539
|
],
|
|
11328
11540
|
args,
|
|
11329
|
-
true
|
|
11541
|
+
true,
|
|
11542
|
+
{ effectiveLimit: effectiveRowLimit(finalQuery, limit, Boolean(args.all)), all: Boolean(args.all) }
|
|
11330
11543
|
);
|
|
11331
11544
|
return;
|
|
11332
11545
|
}
|
|
@@ -13402,9 +13615,16 @@ var customerIdArg = { "customer-id": { type: "string", description: "10-digit Go
|
|
|
13402
13615
|
var fileArg = {
|
|
13403
13616
|
file: { type: "string", description: "JSON file with the full op payload (flags override)" }
|
|
13404
13617
|
};
|
|
13618
|
+
var BUDGET_DEMAND_HINTS = [
|
|
13619
|
+
"Check this daily budget against the search volume you measured for these keywords, not against the plan you had before you measured it. A budget many times larger than the traffic the target pool can absorb never spends, and reads to the client as a number nobody costed.",
|
|
13620
|
+
"No volume measured yet? Pull it first \u2014 `baker ads google query --preset search-terms` for what the account already captures, `baker ads google keywords metrics` for the market estimate (those are planner figures, never account performance)."
|
|
13621
|
+
];
|
|
13405
13622
|
var budgetsCommand = defineCommand30({
|
|
13406
13623
|
meta: { name: "budgets", description: "Stage campaign budget create/update" },
|
|
13407
13624
|
subCommands: {
|
|
13625
|
+
// Fires where the budget is actually set, rather than on whatever the chat happened to stage
|
|
13626
|
+
// first. A budget several times what the target pool can absorb never spends, and reads to the
|
|
13627
|
+
// client as a plan nobody costed.
|
|
13408
13628
|
create: defineCommand30({
|
|
13409
13629
|
meta: { name: "create", description: "Stage a campaign budget" },
|
|
13410
13630
|
args: {
|
|
@@ -13423,7 +13643,7 @@ var budgetsCommand = defineCommand30({
|
|
|
13423
13643
|
deliveryMethod: args.delivery,
|
|
13424
13644
|
explicitlyShared: args.shared || void 0
|
|
13425
13645
|
});
|
|
13426
|
-
await stageCreate("google.budget.create", customerId, payload);
|
|
13646
|
+
await stageCreate("google.budget.create", customerId, payload, BUDGET_DEMAND_HINTS);
|
|
13427
13647
|
}
|
|
13428
13648
|
}),
|
|
13429
13649
|
update: defineCommand30({
|
|
@@ -13443,7 +13663,7 @@ var budgetsCommand = defineCommand30({
|
|
|
13443
13663
|
amountMicros: microsFlag(args.amount, "--amount"),
|
|
13444
13664
|
deliveryMethod: args.delivery
|
|
13445
13665
|
});
|
|
13446
|
-
await stageUpdate("google.budget.update", customerId, target, payload);
|
|
13666
|
+
await stageUpdate("google.budget.update", customerId, target, payload, BUDGET_DEMAND_HINTS);
|
|
13447
13667
|
}
|
|
13448
13668
|
})
|
|
13449
13669
|
}
|