@koda-sl/baker-cli 0.330.0 → 0.331.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 +1 -1
- package/dist/cli.js +159 -157
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -300,7 +300,7 @@ Read it before interpreting any null. Field coverage is folded page by page on t
|
|
|
300
300
|
}
|
|
301
301
|
```
|
|
302
302
|
|
|
303
|
-
Never total or count from a response carrying it — re-run with `--all --limit 10000 --out data.csv` (the API accepts page sizes up to 10,000) and check the re-run came back without the warning. Warnings reach every output format, so no `--output` choice can hide one:
|
|
303
|
+
Never total or count from a response carrying it — re-run with `--all --limit 10000 --out data.csv` (the API accepts page sizes up to 10,000) and check the re-run came back without the warning. A read that returns one row by construction — `FROM customer` with no segment selected, such as `--preset account-summary` — has no page to fill, so it never carries `TRUNCATED_RESULTS`, nor `CONVERSION_MIX`, since one account-wide row has no campaigns to total or compare. Warnings reach every output format, so no `--output` choice can hide one:
|
|
304
304
|
|
|
305
305
|
| `--output` | Where warnings land |
|
|
306
306
|
|------------|---------------------|
|
package/dist/cli.js
CHANGED
|
@@ -11846,160 +11846,6 @@ import { appendFileSync, existsSync as existsSync2, readFileSync as readFileSync
|
|
|
11846
11846
|
import { resolve } from "path";
|
|
11847
11847
|
import { defineCommand as defineCommand28 } from "citty";
|
|
11848
11848
|
|
|
11849
|
-
// src/commands/ads/google/conversion-mix.ts
|
|
11850
|
-
var CONVERSION_METRICS = [
|
|
11851
|
-
"METRICS.CONVERSIONS",
|
|
11852
|
-
"METRICS.ALL_CONVERSIONS",
|
|
11853
|
-
"METRICS.CONVERSIONS_VALUE",
|
|
11854
|
-
"METRICS.ALL_CONVERSIONS_VALUE",
|
|
11855
|
-
"METRICS.COST_PER_CONVERSION",
|
|
11856
|
-
"METRICS.COST_PER_ALL_CONVERSIONS",
|
|
11857
|
-
"METRICS.CONVERSIONS_FROM_INTERACTIONS_RATE",
|
|
11858
|
-
"METRICS.ALL_CONVERSIONS_FROM_INTERACTIONS_RATE",
|
|
11859
|
-
"METRICS.VALUE_PER_CONVERSION",
|
|
11860
|
-
"METRICS.VALUE_PER_ALL_CONVERSIONS"
|
|
11861
|
-
];
|
|
11862
|
-
var CONVERSION_SEGMENTS = ["SEGMENTS.CONVERSION_ACTION_NAME", "SEGMENTS.CONVERSION_ACTION="];
|
|
11863
|
-
var CAMPAIGN_EQUALITY = /\bCAMPAIGN\.(ID|NAME|RESOURCE_NAME)\s*=/;
|
|
11864
|
-
function whereClause(upperQuery) {
|
|
11865
|
-
const whereIdx = upperQuery.indexOf(" WHERE ");
|
|
11866
|
-
if (whereIdx === -1) return "";
|
|
11867
|
-
let rest = upperQuery.slice(whereIdx + 7);
|
|
11868
|
-
for (const trailing of [" ORDER BY ", " GROUP BY ", " LIMIT ", " PARAMETERS "]) {
|
|
11869
|
-
const idx = rest.indexOf(trailing);
|
|
11870
|
-
if (idx !== -1) rest = rest.slice(0, idx);
|
|
11871
|
-
}
|
|
11872
|
-
return rest;
|
|
11873
|
-
}
|
|
11874
|
-
function conversionMixWarnings(finalQuery) {
|
|
11875
|
-
const upper2 = ` ${finalQuery.toUpperCase().replace(/\s+/g, " ")} `;
|
|
11876
|
-
const readsConversions = CONVERSION_METRICS.some((metric) => upper2.includes(metric));
|
|
11877
|
-
if (!readsConversions) return [];
|
|
11878
|
-
if (CONVERSION_SEGMENTS.some((segment) => upper2.includes(segment))) return [];
|
|
11879
|
-
if (CAMPAIGN_EQUALITY.test(whereClause(upper2))) return [];
|
|
11880
|
-
return [
|
|
11881
|
-
{
|
|
11882
|
-
code: "CONVERSION_MIX",
|
|
11883
|
-
message: "MIXED CONVERSION ACTIONS \u2014 this read covers more than one campaign, and a campaign's conversion figures count whatever conversion actions are primary for it. Those differ campaign by campaign (a webinar registration, an info request, a CRM-stage SQL), so a total across these rows adds different events and a conversion rate from one campaign is not comparable with another's. Before totalling or comparing, re-run segmented by segments.conversion_action_name and check the campaigns you are putting side by side count the same action. If they do not, compare only within one action \u2014 and carry that caveat into anything you write for the user, not just the analysis."
|
|
11884
|
-
}
|
|
11885
|
-
];
|
|
11886
|
-
}
|
|
11887
|
-
|
|
11888
|
-
// src/commands/ads/google/keyword-serving.ts
|
|
11889
|
-
var BELOW_FIRST_PAGE_BID = "AD_GROUP_CRITERION_BELOW_FIRST_PAGE_BID";
|
|
11890
|
-
var ACTIONABLE_REASONS = {
|
|
11891
|
-
[BELOW_FIRST_PAGE_BID]: {
|
|
11892
|
-
label: "Below first page bid",
|
|
11893
|
-
advice: "raise the max CPC (keywords update --cpc-bid) to at least the first-page estimate, or move the ad group onto a smart bidding strategy that sets the bid itself"
|
|
11894
|
-
},
|
|
11895
|
-
AD_GROUP_CRITERION_LOW_QUALITY: {
|
|
11896
|
-
label: "Low quality score",
|
|
11897
|
-
advice: "a bid raise does not lift quality \u2014 tighten ad relevance to the keyword and the landing page it points at (ad_group_criterion.quality_info.* breaks the score into its three parts)"
|
|
11898
|
-
},
|
|
11899
|
-
AD_GROUP_CRITERION_RARELY_SERVED: {
|
|
11900
|
-
label: "Rarely served",
|
|
11901
|
-
advice: "too few people search this term \u2014 broaden the match type or drop it, don't bid it up"
|
|
11902
|
-
},
|
|
11903
|
-
AD_GROUP_CRITERION_DISAPPROVED: {
|
|
11904
|
-
label: "Disapproved",
|
|
11905
|
-
advice: "select ad_group_criterion.disapproval_reasons to see which policy blocked it"
|
|
11906
|
-
},
|
|
11907
|
-
AD_GROUP_CRITERION_RESTRICTED: {
|
|
11908
|
-
label: "Restricted",
|
|
11909
|
-
advice: "policy limits where and to whom this keyword may show"
|
|
11910
|
-
},
|
|
11911
|
-
AD_GROUP_CRITERION_UNDER_REVIEW: {
|
|
11912
|
-
label: "Under review",
|
|
11913
|
-
advice: "waiting on Google's review \u2014 nothing to change yet"
|
|
11914
|
-
},
|
|
11915
|
-
AD_GROUP_CRITERION_PENDING_REVIEW: {
|
|
11916
|
-
label: "Pending review",
|
|
11917
|
-
advice: "waiting on Google's review \u2014 nothing to change yet"
|
|
11918
|
-
},
|
|
11919
|
-
AD_GROUP_CRITERION_PAUSED_DUE_TO_LOW_ACTIVITY: {
|
|
11920
|
-
label: "Auto-paused for low activity",
|
|
11921
|
-
advice: "Google paused it after prolonged low serving \u2014 resume it only alongside a change that gives it traffic"
|
|
11922
|
-
}
|
|
11923
|
-
};
|
|
11924
|
-
var REASON_ORDER = Object.keys(ACTIONABLE_REASONS);
|
|
11925
|
-
var MAX_NAMED_KEYWORDS = 5;
|
|
11926
|
-
function toMicros2(value) {
|
|
11927
|
-
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
11928
|
-
if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) return Number(value);
|
|
11929
|
-
return null;
|
|
11930
|
-
}
|
|
11931
|
-
function formatMoney(micros) {
|
|
11932
|
-
return (micros / 1e6).toFixed(2);
|
|
11933
|
-
}
|
|
11934
|
-
function keywordLabel(row) {
|
|
11935
|
-
const text2 = row["ad_group_criterion.keyword.text"];
|
|
11936
|
-
const id = row["ad_group_criterion.criterion_id"];
|
|
11937
|
-
const name = typeof text2 === "string" && text2 !== "" ? text2 : id == null ? "(unnamed keyword)" : `criterion ${id}`;
|
|
11938
|
-
const adGroup = row["ad_group.name"];
|
|
11939
|
-
return typeof adGroup === "string" && adGroup !== "" ? `${name} (ad group ${adGroup})` : name;
|
|
11940
|
-
}
|
|
11941
|
-
function keywordKey(row) {
|
|
11942
|
-
const adGroup = row["ad_group.id"] ?? row["ad_group.name"] ?? "";
|
|
11943
|
-
const criterion = row["ad_group_criterion.criterion_id"] ?? row["ad_group_criterion.keyword.text"] ?? "";
|
|
11944
|
-
return `${String(adGroup)}~${String(criterion)}` === "~" ? keywordLabel(row) : `${adGroup}~${criterion}`;
|
|
11945
|
-
}
|
|
11946
|
-
function reasonsOn(row) {
|
|
11947
|
-
const raw = row["ad_group_criterion.primary_status_reasons"];
|
|
11948
|
-
if (!Array.isArray(raw)) return null;
|
|
11949
|
-
return raw.filter((r) => typeof r === "string");
|
|
11950
|
-
}
|
|
11951
|
-
function bidShortfall(row) {
|
|
11952
|
-
const bid = toMicros2(row["ad_group_criterion.effective_cpc_bid_micros"] ?? row["ad_group_criterion.cpc_bid_micros"]);
|
|
11953
|
-
const firstPage = toMicros2(row["ad_group_criterion.position_estimates.first_page_cpc_micros"]);
|
|
11954
|
-
if (bid == null || firstPage == null || bid >= firstPage) return null;
|
|
11955
|
-
return `bids ${formatMoney(bid)} against a ${formatMoney(firstPage)} first-page estimate`;
|
|
11956
|
-
}
|
|
11957
|
-
function limitsOn(row, options) {
|
|
11958
|
-
const reasons = reasonsOn(row);
|
|
11959
|
-
const shortfall = bidShortfall(row);
|
|
11960
|
-
const label = keywordLabel(row);
|
|
11961
|
-
const applicable = reasons ? reasons.filter((reason) => reason in ACTIONABLE_REASONS) : options.statusReasonsSelected || shortfall === null ? [] : [BELOW_FIRST_PAGE_BID];
|
|
11962
|
-
return applicable.map((reason) => ({
|
|
11963
|
-
reason,
|
|
11964
|
-
detail: reason === BELOW_FIRST_PAGE_BID && shortfall ? `"${label}" ${shortfall}` : `"${label}"`
|
|
11965
|
-
}));
|
|
11966
|
-
}
|
|
11967
|
-
function describeLimit(reason, details) {
|
|
11968
|
-
const named = details.slice(0, MAX_NAMED_KEYWORDS).join("; ");
|
|
11969
|
-
const rest = details.length > MAX_NAMED_KEYWORDS ? ` (+${details.length - MAX_NAMED_KEYWORDS} more)` : "";
|
|
11970
|
-
const { label, advice } = ACTIONABLE_REASONS[reason];
|
|
11971
|
-
return `${label} \u2014 ${details.length}: ${named}${rest}. Fix: ${advice}.`;
|
|
11972
|
-
}
|
|
11973
|
-
function collectKeywordLimits(rows, options, acc = { detailsByReason: /* @__PURE__ */ new Map(), keywords: /* @__PURE__ */ new Set() }) {
|
|
11974
|
-
for (const row of rows) {
|
|
11975
|
-
const limits = limitsOn(row, options);
|
|
11976
|
-
if (limits.length === 0) continue;
|
|
11977
|
-
acc.keywords.add(keywordKey(row));
|
|
11978
|
-
for (const { reason, detail } of limits) {
|
|
11979
|
-
const bucket = acc.detailsByReason.get(reason);
|
|
11980
|
-
if (bucket) bucket.push(detail);
|
|
11981
|
-
else acc.detailsByReason.set(reason, [detail]);
|
|
11982
|
-
}
|
|
11983
|
-
}
|
|
11984
|
-
return acc;
|
|
11985
|
-
}
|
|
11986
|
-
function renderKeywordLimits(limits) {
|
|
11987
|
-
if (limits.detailsByReason.size === 0) return [];
|
|
11988
|
-
const sentences = REASON_ORDER.filter((reason) => limits.detailsByReason.has(reason)).map(
|
|
11989
|
-
(reason) => describeLimit(reason, limits.detailsByReason.get(reason) ?? [])
|
|
11990
|
-
);
|
|
11991
|
-
const count2 = limits.keywords.size;
|
|
11992
|
-
return [
|
|
11993
|
-
{
|
|
11994
|
-
code: "KEYWORD_SERVING_LIMITED",
|
|
11995
|
-
message: `${count2} ${count2 === 1 ? "keyword is" : "keywords are"} limited and not getting the traffic they could. ${sentences.join(" ")} Amounts are in the account currency (baker ads google currency).`
|
|
11996
|
-
}
|
|
11997
|
-
];
|
|
11998
|
-
}
|
|
11999
|
-
function keywordServingWarnings(rows, options) {
|
|
12000
|
-
return renderKeywordLimits(collectKeywordLimits(rows, options));
|
|
12001
|
-
}
|
|
12002
|
-
|
|
12003
11849
|
// src/commands/ads/google/presets.ts
|
|
12004
11850
|
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;
|
|
12005
11851
|
function isValidDateRange(value) {
|
|
@@ -12167,7 +12013,7 @@ var RUNG_PIN_FIELDS = {
|
|
|
12167
12013
|
"campaign_asset.status": ["campaign_asset.resource_name"],
|
|
12168
12014
|
"ad_group_asset.status": ["ad_group_asset.resource_name"]
|
|
12169
12015
|
};
|
|
12170
|
-
function
|
|
12016
|
+
function whereClause(query) {
|
|
12171
12017
|
const match = query.match(/\bWHERE\b([\s\S]*)$/i);
|
|
12172
12018
|
if (!match?.[1]) return "";
|
|
12173
12019
|
let rest = match[1];
|
|
@@ -12187,7 +12033,7 @@ function analyzeServingChain(query) {
|
|
|
12187
12033
|
const resource = fromResource(query);
|
|
12188
12034
|
const chain = resource ? SERVING_CHAINS[resource] : void 0;
|
|
12189
12035
|
if (!chain) return null;
|
|
12190
|
-
const where =
|
|
12036
|
+
const where = whereClause(query);
|
|
12191
12037
|
if (/ad_group_criterion\.negative\s*=\s*TRUE/i.test(where)) return null;
|
|
12192
12038
|
const unscoped = chain.filter((rung) => {
|
|
12193
12039
|
if (mentions(where, rung)) return false;
|
|
@@ -12239,7 +12085,7 @@ function fromResource(query) {
|
|
|
12239
12085
|
function isSingleRowRead(query) {
|
|
12240
12086
|
const resource = fromResource(query);
|
|
12241
12087
|
if (!resource || !SINGLE_ROW_RESOURCES.includes(resource)) return false;
|
|
12242
|
-
return !/\bsegments\./i.test(query);
|
|
12088
|
+
return !/\bsegments\./i.test(selectClause(query));
|
|
12243
12089
|
}
|
|
12244
12090
|
var SEGMENTING_ANCESTORS = {
|
|
12245
12091
|
campaign_budget: ["campaign"],
|
|
@@ -12414,6 +12260,161 @@ function validatePreflight(query, customerId, limit) {
|
|
|
12414
12260
|
return { valid: true, correctedQuery: annotated.query, warnings };
|
|
12415
12261
|
}
|
|
12416
12262
|
|
|
12263
|
+
// src/commands/ads/google/conversion-mix.ts
|
|
12264
|
+
var CONVERSION_METRICS = [
|
|
12265
|
+
"METRICS.CONVERSIONS",
|
|
12266
|
+
"METRICS.ALL_CONVERSIONS",
|
|
12267
|
+
"METRICS.CONVERSIONS_VALUE",
|
|
12268
|
+
"METRICS.ALL_CONVERSIONS_VALUE",
|
|
12269
|
+
"METRICS.COST_PER_CONVERSION",
|
|
12270
|
+
"METRICS.COST_PER_ALL_CONVERSIONS",
|
|
12271
|
+
"METRICS.CONVERSIONS_FROM_INTERACTIONS_RATE",
|
|
12272
|
+
"METRICS.ALL_CONVERSIONS_FROM_INTERACTIONS_RATE",
|
|
12273
|
+
"METRICS.VALUE_PER_CONVERSION",
|
|
12274
|
+
"METRICS.VALUE_PER_ALL_CONVERSIONS"
|
|
12275
|
+
];
|
|
12276
|
+
var CONVERSION_SEGMENTS = ["SEGMENTS.CONVERSION_ACTION_NAME", "SEGMENTS.CONVERSION_ACTION="];
|
|
12277
|
+
var CAMPAIGN_EQUALITY = /\bCAMPAIGN\.(ID|NAME|RESOURCE_NAME)\s*=/;
|
|
12278
|
+
function whereClause2(upperQuery) {
|
|
12279
|
+
const whereIdx = upperQuery.indexOf(" WHERE ");
|
|
12280
|
+
if (whereIdx === -1) return "";
|
|
12281
|
+
let rest = upperQuery.slice(whereIdx + 7);
|
|
12282
|
+
for (const trailing of [" ORDER BY ", " GROUP BY ", " LIMIT ", " PARAMETERS "]) {
|
|
12283
|
+
const idx = rest.indexOf(trailing);
|
|
12284
|
+
if (idx !== -1) rest = rest.slice(0, idx);
|
|
12285
|
+
}
|
|
12286
|
+
return rest;
|
|
12287
|
+
}
|
|
12288
|
+
function conversionMixWarnings(finalQuery) {
|
|
12289
|
+
const upper2 = ` ${finalQuery.toUpperCase().replace(/\s+/g, " ")} `;
|
|
12290
|
+
const readsConversions = CONVERSION_METRICS.some((metric) => upper2.includes(metric));
|
|
12291
|
+
if (!readsConversions) return [];
|
|
12292
|
+
if (CONVERSION_SEGMENTS.some((segment) => upper2.includes(segment))) return [];
|
|
12293
|
+
if (CAMPAIGN_EQUALITY.test(whereClause2(upper2))) return [];
|
|
12294
|
+
if (isSingleRowRead(finalQuery)) return [];
|
|
12295
|
+
return [
|
|
12296
|
+
{
|
|
12297
|
+
code: "CONVERSION_MIX",
|
|
12298
|
+
message: "MIXED CONVERSION ACTIONS \u2014 this read covers more than one campaign, and a campaign's conversion figures count whatever conversion actions are primary for it. Those differ campaign by campaign (a webinar registration, an info request, a CRM-stage SQL), so a total across these rows adds different events and a conversion rate from one campaign is not comparable with another's. Before totalling or comparing, re-run segmented by segments.conversion_action_name and check the campaigns you are putting side by side count the same action. If they do not, compare only within one action \u2014 and carry that caveat into anything you write for the user, not just the analysis."
|
|
12299
|
+
}
|
|
12300
|
+
];
|
|
12301
|
+
}
|
|
12302
|
+
|
|
12303
|
+
// src/commands/ads/google/keyword-serving.ts
|
|
12304
|
+
var BELOW_FIRST_PAGE_BID = "AD_GROUP_CRITERION_BELOW_FIRST_PAGE_BID";
|
|
12305
|
+
var ACTIONABLE_REASONS = {
|
|
12306
|
+
[BELOW_FIRST_PAGE_BID]: {
|
|
12307
|
+
label: "Below first page bid",
|
|
12308
|
+
advice: "raise the max CPC (keywords update --cpc-bid) to at least the first-page estimate, or move the ad group onto a smart bidding strategy that sets the bid itself"
|
|
12309
|
+
},
|
|
12310
|
+
AD_GROUP_CRITERION_LOW_QUALITY: {
|
|
12311
|
+
label: "Low quality score",
|
|
12312
|
+
advice: "a bid raise does not lift quality \u2014 tighten ad relevance to the keyword and the landing page it points at (ad_group_criterion.quality_info.* breaks the score into its three parts)"
|
|
12313
|
+
},
|
|
12314
|
+
AD_GROUP_CRITERION_RARELY_SERVED: {
|
|
12315
|
+
label: "Rarely served",
|
|
12316
|
+
advice: "too few people search this term \u2014 broaden the match type or drop it, don't bid it up"
|
|
12317
|
+
},
|
|
12318
|
+
AD_GROUP_CRITERION_DISAPPROVED: {
|
|
12319
|
+
label: "Disapproved",
|
|
12320
|
+
advice: "select ad_group_criterion.disapproval_reasons to see which policy blocked it"
|
|
12321
|
+
},
|
|
12322
|
+
AD_GROUP_CRITERION_RESTRICTED: {
|
|
12323
|
+
label: "Restricted",
|
|
12324
|
+
advice: "policy limits where and to whom this keyword may show"
|
|
12325
|
+
},
|
|
12326
|
+
AD_GROUP_CRITERION_UNDER_REVIEW: {
|
|
12327
|
+
label: "Under review",
|
|
12328
|
+
advice: "waiting on Google's review \u2014 nothing to change yet"
|
|
12329
|
+
},
|
|
12330
|
+
AD_GROUP_CRITERION_PENDING_REVIEW: {
|
|
12331
|
+
label: "Pending review",
|
|
12332
|
+
advice: "waiting on Google's review \u2014 nothing to change yet"
|
|
12333
|
+
},
|
|
12334
|
+
AD_GROUP_CRITERION_PAUSED_DUE_TO_LOW_ACTIVITY: {
|
|
12335
|
+
label: "Auto-paused for low activity",
|
|
12336
|
+
advice: "Google paused it after prolonged low serving \u2014 resume it only alongside a change that gives it traffic"
|
|
12337
|
+
}
|
|
12338
|
+
};
|
|
12339
|
+
var REASON_ORDER = Object.keys(ACTIONABLE_REASONS);
|
|
12340
|
+
var MAX_NAMED_KEYWORDS = 5;
|
|
12341
|
+
function toMicros2(value) {
|
|
12342
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
12343
|
+
if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) return Number(value);
|
|
12344
|
+
return null;
|
|
12345
|
+
}
|
|
12346
|
+
function formatMoney(micros) {
|
|
12347
|
+
return (micros / 1e6).toFixed(2);
|
|
12348
|
+
}
|
|
12349
|
+
function keywordLabel(row) {
|
|
12350
|
+
const text2 = row["ad_group_criterion.keyword.text"];
|
|
12351
|
+
const id = row["ad_group_criterion.criterion_id"];
|
|
12352
|
+
const name = typeof text2 === "string" && text2 !== "" ? text2 : id == null ? "(unnamed keyword)" : `criterion ${id}`;
|
|
12353
|
+
const adGroup = row["ad_group.name"];
|
|
12354
|
+
return typeof adGroup === "string" && adGroup !== "" ? `${name} (ad group ${adGroup})` : name;
|
|
12355
|
+
}
|
|
12356
|
+
function keywordKey(row) {
|
|
12357
|
+
const adGroup = row["ad_group.id"] ?? row["ad_group.name"] ?? "";
|
|
12358
|
+
const criterion = row["ad_group_criterion.criterion_id"] ?? row["ad_group_criterion.keyword.text"] ?? "";
|
|
12359
|
+
return `${String(adGroup)}~${String(criterion)}` === "~" ? keywordLabel(row) : `${adGroup}~${criterion}`;
|
|
12360
|
+
}
|
|
12361
|
+
function reasonsOn(row) {
|
|
12362
|
+
const raw = row["ad_group_criterion.primary_status_reasons"];
|
|
12363
|
+
if (!Array.isArray(raw)) return null;
|
|
12364
|
+
return raw.filter((r) => typeof r === "string");
|
|
12365
|
+
}
|
|
12366
|
+
function bidShortfall(row) {
|
|
12367
|
+
const bid = toMicros2(row["ad_group_criterion.effective_cpc_bid_micros"] ?? row["ad_group_criterion.cpc_bid_micros"]);
|
|
12368
|
+
const firstPage = toMicros2(row["ad_group_criterion.position_estimates.first_page_cpc_micros"]);
|
|
12369
|
+
if (bid == null || firstPage == null || bid >= firstPage) return null;
|
|
12370
|
+
return `bids ${formatMoney(bid)} against a ${formatMoney(firstPage)} first-page estimate`;
|
|
12371
|
+
}
|
|
12372
|
+
function limitsOn(row, options) {
|
|
12373
|
+
const reasons = reasonsOn(row);
|
|
12374
|
+
const shortfall = bidShortfall(row);
|
|
12375
|
+
const label = keywordLabel(row);
|
|
12376
|
+
const applicable = reasons ? reasons.filter((reason) => reason in ACTIONABLE_REASONS) : options.statusReasonsSelected || shortfall === null ? [] : [BELOW_FIRST_PAGE_BID];
|
|
12377
|
+
return applicable.map((reason) => ({
|
|
12378
|
+
reason,
|
|
12379
|
+
detail: reason === BELOW_FIRST_PAGE_BID && shortfall ? `"${label}" ${shortfall}` : `"${label}"`
|
|
12380
|
+
}));
|
|
12381
|
+
}
|
|
12382
|
+
function describeLimit(reason, details) {
|
|
12383
|
+
const named = details.slice(0, MAX_NAMED_KEYWORDS).join("; ");
|
|
12384
|
+
const rest = details.length > MAX_NAMED_KEYWORDS ? ` (+${details.length - MAX_NAMED_KEYWORDS} more)` : "";
|
|
12385
|
+
const { label, advice } = ACTIONABLE_REASONS[reason];
|
|
12386
|
+
return `${label} \u2014 ${details.length}: ${named}${rest}. Fix: ${advice}.`;
|
|
12387
|
+
}
|
|
12388
|
+
function collectKeywordLimits(rows, options, acc = { detailsByReason: /* @__PURE__ */ new Map(), keywords: /* @__PURE__ */ new Set() }) {
|
|
12389
|
+
for (const row of rows) {
|
|
12390
|
+
const limits = limitsOn(row, options);
|
|
12391
|
+
if (limits.length === 0) continue;
|
|
12392
|
+
acc.keywords.add(keywordKey(row));
|
|
12393
|
+
for (const { reason, detail } of limits) {
|
|
12394
|
+
const bucket = acc.detailsByReason.get(reason);
|
|
12395
|
+
if (bucket) bucket.push(detail);
|
|
12396
|
+
else acc.detailsByReason.set(reason, [detail]);
|
|
12397
|
+
}
|
|
12398
|
+
}
|
|
12399
|
+
return acc;
|
|
12400
|
+
}
|
|
12401
|
+
function renderKeywordLimits(limits) {
|
|
12402
|
+
if (limits.detailsByReason.size === 0) return [];
|
|
12403
|
+
const sentences = REASON_ORDER.filter((reason) => limits.detailsByReason.has(reason)).map(
|
|
12404
|
+
(reason) => describeLimit(reason, limits.detailsByReason.get(reason) ?? [])
|
|
12405
|
+
);
|
|
12406
|
+
const count2 = limits.keywords.size;
|
|
12407
|
+
return [
|
|
12408
|
+
{
|
|
12409
|
+
code: "KEYWORD_SERVING_LIMITED",
|
|
12410
|
+
message: `${count2} ${count2 === 1 ? "keyword is" : "keywords are"} limited and not getting the traffic they could. ${sentences.join(" ")} Amounts are in the account currency (baker ads google currency).`
|
|
12411
|
+
}
|
|
12412
|
+
];
|
|
12413
|
+
}
|
|
12414
|
+
function keywordServingWarnings(rows, options) {
|
|
12415
|
+
return renderKeywordLimits(collectKeywordLimits(rows, options));
|
|
12416
|
+
}
|
|
12417
|
+
|
|
12417
12418
|
// src/commands/ads/google/query.ts
|
|
12418
12419
|
registerSchema({
|
|
12419
12420
|
command: "ads.google.query",
|
|
@@ -12518,6 +12519,7 @@ function writeRowsToFile(filePath, rows, fields, append) {
|
|
|
12518
12519
|
}
|
|
12519
12520
|
}
|
|
12520
12521
|
function effectiveRowLimit(finalQuery, limit, all = false) {
|
|
12522
|
+
if (isSingleRowRead(finalQuery)) return void 0;
|
|
12521
12523
|
const match = finalQuery.match(/\bLIMIT\s+(\d+)/i);
|
|
12522
12524
|
const statementLimit = match?.[1] ? Number(match[1]) : void 0;
|
|
12523
12525
|
if (all) return statementLimit;
|