mcp-google-ads 1.4.2 → 1.5.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/dist/adGroupBuilder.d.ts +7 -6
- package/dist/adGroupBuilder.js +1 -3
- package/dist/assetHelpers.d.ts +43 -0
- package/dist/assetHelpers.js +93 -0
- package/dist/build-info.json +2 -2
- package/dist/campaignBuilder.js +9 -3
- package/dist/experimentBuilder.d.ts +36 -0
- package/dist/experimentBuilder.js +52 -0
- package/dist/index.js +973 -19
- package/dist/resilience.js +8 -2
- package/dist/resourceNames.d.ts +1 -0
- package/dist/resourceNames.js +7 -0
- package/dist/tools.js +330 -0
- package/dist/writeGate.js +15 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -28,11 +28,22 @@ import {
|
|
|
28
28
|
normalizeUpdateAssetUrlsArgs,
|
|
29
29
|
normalizePauseAssetLinksArgs,
|
|
30
30
|
buildUpdateUrlsDryRun,
|
|
31
|
-
buildPauseLinksDryRun
|
|
31
|
+
buildPauseLinksDryRun,
|
|
32
|
+
normalizeCreateSitelinkArgs,
|
|
33
|
+
buildCreateSitelinkDryRun,
|
|
34
|
+
normalizeReplaceSitelinkArgs,
|
|
35
|
+
buildReplaceSitelinkDryRun
|
|
32
36
|
} from "./assetHelpers.js";
|
|
33
37
|
import {
|
|
34
38
|
buildCampaignCreatePayload
|
|
35
39
|
} from "./campaignBuilder.js";
|
|
40
|
+
import {
|
|
41
|
+
buildExperimentPayload,
|
|
42
|
+
buildControlArmPayload,
|
|
43
|
+
buildTreatmentArmPayload,
|
|
44
|
+
experimentIdFromResourceName,
|
|
45
|
+
campaignIdFromResourceName
|
|
46
|
+
} from "./experimentBuilder.js";
|
|
36
47
|
import {
|
|
37
48
|
buildAdGroupCreatePayload
|
|
38
49
|
} from "./adGroupBuilder.js";
|
|
@@ -45,6 +56,7 @@ import {
|
|
|
45
56
|
isDemandGenAdGroup
|
|
46
57
|
} from "./validateDemandGenAd.js";
|
|
47
58
|
import { GoogleAdsApi, enums } from "google-ads-api";
|
|
59
|
+
import { buildAdGroupAdResourceName } from "./resourceNames.js";
|
|
48
60
|
import { readFileSync, existsSync } from "fs";
|
|
49
61
|
import v8 from "v8";
|
|
50
62
|
const __cliPkg = JSON.parse(readFileSync(join(__moduleDir, "..", "package.json"), "utf-8"));
|
|
@@ -434,7 +446,7 @@ class GoogleAdsManager {
|
|
|
434
446
|
const mm = String(now.getMonth() + 1).padStart(2, "0");
|
|
435
447
|
const dd = String(now.getDate()).padStart(2, "0");
|
|
436
448
|
const yy = String(now.getFullYear()).slice(-2);
|
|
437
|
-
return `
|
|
449
|
+
return `claude-${mm}-${dd}-${yy}`;
|
|
438
450
|
}
|
|
439
451
|
// Apply today's Claude-MM-DD-YY label to newly created assets.
|
|
440
452
|
// Best-effort: log and swallow errors so creation never fails because of labeling.
|
|
@@ -568,13 +580,25 @@ class GoogleAdsManager {
|
|
|
568
580
|
"createCampaign.budget"
|
|
569
581
|
);
|
|
570
582
|
const budgetResourceName = budgetResult.results[0].resource_name;
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
583
|
+
const campaignPayload = { ...plan.campaign, campaign_budget: budgetResourceName };
|
|
584
|
+
const isDemandGen = plan.campaign.advertising_channel_type === 14;
|
|
585
|
+
let campaignResult;
|
|
586
|
+
if (isDemandGen) {
|
|
587
|
+
const mutateResp = await withResilience(
|
|
588
|
+
() => customer.mutateResources([
|
|
589
|
+
{ entity: "campaign", operation: "create", resource: campaignPayload }
|
|
590
|
+
]),
|
|
591
|
+
"createCampaign"
|
|
592
|
+
);
|
|
593
|
+
campaignResult = {
|
|
594
|
+
results: (mutateResp.mutate_operation_responses || []).map((r) => r.campaign_result).filter(Boolean)
|
|
595
|
+
};
|
|
596
|
+
} else {
|
|
597
|
+
campaignResult = await withResilience(
|
|
598
|
+
() => customer.campaigns.create([campaignPayload]),
|
|
599
|
+
"createCampaign"
|
|
600
|
+
);
|
|
601
|
+
}
|
|
578
602
|
const campaignRNs = (campaignResult.results || []).map((r) => r.resource_name).filter(Boolean);
|
|
579
603
|
if (plan.criteria.length > 0 && campaignRNs.length > 0) {
|
|
580
604
|
const campaignResourceName = campaignRNs[0];
|
|
@@ -819,6 +843,18 @@ class GoogleAdsManager {
|
|
|
819
843
|
const result = await withResilience(() => customer.adGroupCriteria.update(operations), "pauseKeywords");
|
|
820
844
|
return result;
|
|
821
845
|
}
|
|
846
|
+
// Enable keywords (auto-labels with today's Claude-MM-DD-YY + any custom labels)
|
|
847
|
+
async enableKeywords(customerId, criterionResourceNames, labels) {
|
|
848
|
+
const customer = this.getCustomer(customerId);
|
|
849
|
+
const operations = criterionResourceNames.map((rn) => ({
|
|
850
|
+
resource_name: rn,
|
|
851
|
+
status: enums.AdGroupCriterionStatus.ENABLED
|
|
852
|
+
}));
|
|
853
|
+
const result = await withResilience(() => customer.adGroupCriteria.update(operations), "enableKeywords");
|
|
854
|
+
await this.autoLabelCreated(customerId, criterionResourceNames, "keyword");
|
|
855
|
+
await this.applyCustomLabels(customerId, criterionResourceNames, "keyword", labels);
|
|
856
|
+
return result;
|
|
857
|
+
}
|
|
822
858
|
// Create a new shared negative keyword list at account level
|
|
823
859
|
async createSharedSet(customerId, name) {
|
|
824
860
|
const customer = this.getCustomer(customerId);
|
|
@@ -888,6 +924,44 @@ class GoogleAdsManager {
|
|
|
888
924
|
const result = await withResilience(() => customer.campaignCriteria.create(criteria), "addCampaignNegativeKeywords");
|
|
889
925
|
return result;
|
|
890
926
|
}
|
|
927
|
+
// Add location (geo) targeting criteria at the AD GROUP level.
|
|
928
|
+
// Required for Demand Gen campaigns — DG sets location at ad group, not campaign.
|
|
929
|
+
// Uses string type "LOCATION" (integer 77) — not in v23 enum map, same pattern
|
|
930
|
+
// as DEMAND_GEN_MULTI_ASSET_AD_GROUP and EuPoliticalAdvertising fixes.
|
|
931
|
+
async setAdGroupLocationTargeting(customerId, adGroupId, geoTargetIds) {
|
|
932
|
+
const customer = this.getCustomer(customerId);
|
|
933
|
+
const cleanId = customerId.replace(/-/g, "");
|
|
934
|
+
const adGroupRN = `customers/${cleanId}/adGroups/${adGroupId}`;
|
|
935
|
+
const criteria = geoTargetIds.map((id) => ({
|
|
936
|
+
ad_group: adGroupRN,
|
|
937
|
+
type: "LOCATION",
|
|
938
|
+
location: { geo_target_constant: `geoTargetConstants/${id}` }
|
|
939
|
+
}));
|
|
940
|
+
const result = await withResilience(
|
|
941
|
+
() => customer.adGroupCriteria.create(criteria),
|
|
942
|
+
"setAdGroupLocationTargeting"
|
|
943
|
+
);
|
|
944
|
+
return result;
|
|
945
|
+
}
|
|
946
|
+
// Add location (geo) targeting criteria to an existing campaign.
|
|
947
|
+
// Uses mutateResources (not campaignCriteria.create) because the v23 typed
|
|
948
|
+
// path doesn't recognize the `location` criterion field — same pattern as
|
|
949
|
+
// DEMAND_GEN campaign/ad-group creation.
|
|
950
|
+
async setCampaignLocationTargeting(customerId, campaignId, geoTargetIds) {
|
|
951
|
+
const customer = this.getCustomer(customerId);
|
|
952
|
+
const cleanId = customerId.replace(/-/g, "");
|
|
953
|
+
const campaignRN = `customers/${cleanId}/campaigns/${campaignId}`;
|
|
954
|
+
const criteria = geoTargetIds.map((id) => ({
|
|
955
|
+
campaign: campaignRN,
|
|
956
|
+
type: "LOCATION",
|
|
957
|
+
location: { geo_target_constant: `geoTargetConstants/${id}` }
|
|
958
|
+
}));
|
|
959
|
+
const result = await withResilience(
|
|
960
|
+
() => customer.campaignCriteria.create(criteria),
|
|
961
|
+
"setCampaignLocationTargeting"
|
|
962
|
+
);
|
|
963
|
+
return result;
|
|
964
|
+
}
|
|
891
965
|
// Remove campaign-level negative keywords by resource name
|
|
892
966
|
async removeCampaignNegativeKeywords(customerId, resourceNames) {
|
|
893
967
|
const customer = this.getCustomer(customerId);
|
|
@@ -900,12 +974,43 @@ class GoogleAdsManager {
|
|
|
900
974
|
const result = await withResilience(() => customer.adGroupCriteria.remove(resourceNames), "removeAdGroupNegativeKeywords");
|
|
901
975
|
return result;
|
|
902
976
|
}
|
|
977
|
+
// Resolve plain ad IDs to the composite ad_group_ad resource name
|
|
978
|
+
// (`customers/{cid}/adGroupAds/{adGroupId}~{adId}`) required by every
|
|
979
|
+
// adGroupAd mutation and label endpoint. Passing just `{adId}` is a
|
|
980
|
+
// Google Ads API error — this helper fixes that by looking up the
|
|
981
|
+
// adGroupId for each adId via GAQL in a single query.
|
|
982
|
+
async resolveAdGroupAdResourceNames(customerId, adIds) {
|
|
983
|
+
if (!adIds || adIds.length === 0) return [];
|
|
984
|
+
const cleanId = customerId.replace(/-/g, "");
|
|
985
|
+
const customer = this.getCustomer(customerId);
|
|
986
|
+
const sanitized = adIds.map((id) => sanitizeNumericId(String(id)));
|
|
987
|
+
const query = `SELECT ad_group.id, ad_group_ad.ad.id FROM ad_group_ad WHERE ad_group_ad.ad.id IN (${sanitized.join(",")})`;
|
|
988
|
+
const rows = await withResilience(
|
|
989
|
+
() => customer.query(query),
|
|
990
|
+
"resolveAdGroupAdResourceNames"
|
|
991
|
+
);
|
|
992
|
+
const resolved = [];
|
|
993
|
+
const foundIds = /* @__PURE__ */ new Set();
|
|
994
|
+
for (const row of rows) {
|
|
995
|
+
const adGroupId = row?.ad_group?.id;
|
|
996
|
+
const adId = row?.ad_group_ad?.ad?.id;
|
|
997
|
+
if (adGroupId != null && adId != null) {
|
|
998
|
+
resolved.push(buildAdGroupAdResourceName(cleanId, adGroupId, adId));
|
|
999
|
+
foundIds.add(String(adId));
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
const missing = sanitized.filter((id) => !foundIds.has(id));
|
|
1003
|
+
if (missing.length > 0) {
|
|
1004
|
+
throw new Error(
|
|
1005
|
+
`Could not resolve ad_group_id for ${missing.length}/${adIds.length} ad IDs: ${missing.join(", ")}`
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
return resolved;
|
|
1009
|
+
}
|
|
903
1010
|
// Enable ads (auto-labels with today's Claude-MM-DD-YY + any custom labels)
|
|
904
1011
|
async enableAds(customerId, adIds, labels) {
|
|
905
1012
|
const customer = this.getCustomer(customerId);
|
|
906
|
-
const resourceNames =
|
|
907
|
-
(adId) => `customers/${customerId.replace(/-/g, "")}/adGroupAds/${adId}`
|
|
908
|
-
);
|
|
1013
|
+
const resourceNames = await this.resolveAdGroupAdResourceNames(customerId, adIds);
|
|
909
1014
|
const operations = resourceNames.map((rn) => ({
|
|
910
1015
|
resource_name: rn,
|
|
911
1016
|
status: enums.AdGroupAdStatus.ENABLED
|
|
@@ -976,9 +1081,7 @@ class GoogleAdsManager {
|
|
|
976
1081
|
// Pause ads (auto-labels with today's Claude-MM-DD-YY + any custom labels)
|
|
977
1082
|
async pauseAds(customerId, adIds, labels) {
|
|
978
1083
|
const customer = this.getCustomer(customerId);
|
|
979
|
-
const resourceNames = adIds.
|
|
980
|
-
(adId) => `customers/${customerId.replace(/-/g, "")}/adGroupAds/${adId}`
|
|
981
|
-
);
|
|
1084
|
+
const resourceNames = adIds.some((id) => id.startsWith("customers/")) ? adIds : await this.resolveAdGroupAdResourceNames(customerId, adIds);
|
|
982
1085
|
const operations = resourceNames.map((rn) => ({
|
|
983
1086
|
resource_name: rn,
|
|
984
1087
|
status: enums.AdGroupAdStatus.PAUSED
|
|
@@ -1021,8 +1124,7 @@ class GoogleAdsManager {
|
|
|
1021
1124
|
// Remove ads permanently. Reports on removed ads still work.
|
|
1022
1125
|
async removeAds(customerId, adIds) {
|
|
1023
1126
|
const customer = this.getCustomer(customerId);
|
|
1024
|
-
const
|
|
1025
|
-
const resourceNames = adIds.map((id) => `customers/${cleanId}/adGroupAds/${id}`);
|
|
1127
|
+
const resourceNames = await this.resolveAdGroupAdResourceNames(customerId, adIds);
|
|
1026
1128
|
return withResilience(() => customer.adGroupAds.remove(resourceNames), "removeAds");
|
|
1027
1129
|
}
|
|
1028
1130
|
// Remove ad groups permanently. Cascades to child ads.
|
|
@@ -1057,7 +1159,7 @@ class GoogleAdsManager {
|
|
|
1057
1159
|
result.ad_groups_labeled = rns.length;
|
|
1058
1160
|
}
|
|
1059
1161
|
if (targets.adIds?.length) {
|
|
1060
|
-
const rns = targets.adIds
|
|
1162
|
+
const rns = await this.resolveAdGroupAdResourceNames(customerId, targets.adIds);
|
|
1061
1163
|
await this.labelAdGroupAds(customerId, rns, labelRN);
|
|
1062
1164
|
result.ads_labeled = rns.length;
|
|
1063
1165
|
}
|
|
@@ -1211,7 +1313,7 @@ class GoogleAdsManager {
|
|
|
1211
1313
|
else if (/\/adGroupAssets\//.test(rn)) byLevel.ad_group_asset.push(rn);
|
|
1212
1314
|
else throw new Error(`Unrecognized asset-link resource name: ${rn}`);
|
|
1213
1315
|
}
|
|
1214
|
-
const PAUSED =
|
|
1316
|
+
const PAUSED = enums.AssetLinkStatus.PAUSED;
|
|
1215
1317
|
const result = { customer_id: cleanId, paused: { customer_asset: 0, campaign_asset: 0, ad_group_asset: 0 } };
|
|
1216
1318
|
if (byLevel.customer_asset.length > 0) {
|
|
1217
1319
|
const ops = byLevel.customer_asset.map((rn) => ({ resource_name: rn, status: PAUSED }));
|
|
@@ -1240,6 +1342,555 @@ class GoogleAdsManager {
|
|
|
1240
1342
|
return result;
|
|
1241
1343
|
}
|
|
1242
1344
|
// ============================================
|
|
1345
|
+
// SITELINK CREATE + REPLACE
|
|
1346
|
+
// ============================================
|
|
1347
|
+
async createSitelink(customerId, args) {
|
|
1348
|
+
const customer = this.getCustomer(customerId);
|
|
1349
|
+
const cleanId = customerId.replace(/-/g, "");
|
|
1350
|
+
const sitelink = { link_text: args.link_text };
|
|
1351
|
+
if (args.description1) sitelink.description1 = args.description1;
|
|
1352
|
+
if (args.description2) sitelink.description2 = args.description2;
|
|
1353
|
+
const result = await withResilience(
|
|
1354
|
+
() => customer.assets.create([
|
|
1355
|
+
{
|
|
1356
|
+
type: enums.AssetType.SITELINK,
|
|
1357
|
+
final_urls: args.final_urls,
|
|
1358
|
+
sitelink_asset: sitelink
|
|
1359
|
+
}
|
|
1360
|
+
]),
|
|
1361
|
+
"createSitelink"
|
|
1362
|
+
);
|
|
1363
|
+
const results = result.results || [];
|
|
1364
|
+
const resourceName = results[0]?.resource_name;
|
|
1365
|
+
const assetId = resourceName ? resourceName.split("/").pop() : void 0;
|
|
1366
|
+
if (resourceName) {
|
|
1367
|
+
await this.autoLabelCreated(customerId, [resourceName], "asset");
|
|
1368
|
+
}
|
|
1369
|
+
return {
|
|
1370
|
+
customer_id: cleanId,
|
|
1371
|
+
asset_id: assetId,
|
|
1372
|
+
resource_name: resourceName,
|
|
1373
|
+
link_text: args.link_text,
|
|
1374
|
+
final_urls: args.final_urls,
|
|
1375
|
+
description1: args.description1,
|
|
1376
|
+
description2: args.description2
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
async replaceSitelinkUrl(customerId, args) {
|
|
1380
|
+
const customer = this.getCustomer(customerId);
|
|
1381
|
+
const cleanId = customerId.replace(/-/g, "");
|
|
1382
|
+
const oldAssetRows = await withResilience(
|
|
1383
|
+
() => customer.query(`
|
|
1384
|
+
SELECT
|
|
1385
|
+
asset.id,
|
|
1386
|
+
asset.type,
|
|
1387
|
+
asset.final_urls,
|
|
1388
|
+
asset.sitelink_asset.link_text,
|
|
1389
|
+
asset.sitelink_asset.description1,
|
|
1390
|
+
asset.sitelink_asset.description2
|
|
1391
|
+
FROM asset
|
|
1392
|
+
WHERE asset.id = ${args.old_asset_id}
|
|
1393
|
+
`),
|
|
1394
|
+
"replaceSitelinkUrl.queryAsset"
|
|
1395
|
+
);
|
|
1396
|
+
const oldAsset = oldAssetRows[0]?.asset;
|
|
1397
|
+
if (!oldAsset) {
|
|
1398
|
+
throw new Error(`Asset ${args.old_asset_id} not found in customer ${cleanId}`);
|
|
1399
|
+
}
|
|
1400
|
+
if (oldAsset.type !== enums.AssetType.SITELINK && oldAsset.type !== 13) {
|
|
1401
|
+
throw new Error(
|
|
1402
|
+
`Asset ${args.old_asset_id} is type ${oldAsset.type} (not SITELINK). This tool only replaces sitelink assets; use google_ads_update_asset_urls for other asset types.`
|
|
1403
|
+
);
|
|
1404
|
+
}
|
|
1405
|
+
const link_text = args.new_link_text ?? oldAsset.sitelink_asset?.link_text;
|
|
1406
|
+
if (!link_text) {
|
|
1407
|
+
throw new Error(
|
|
1408
|
+
`Old asset has no link_text and none was provided. Pass new_link_text to override.`
|
|
1409
|
+
);
|
|
1410
|
+
}
|
|
1411
|
+
const description1 = args.new_description1 ?? oldAsset.sitelink_asset?.description1 ?? void 0;
|
|
1412
|
+
const description2 = args.new_description2 ?? oldAsset.sitelink_asset?.description2 ?? void 0;
|
|
1413
|
+
const campaignLinksRows = await withResilience(
|
|
1414
|
+
() => customer.query(`
|
|
1415
|
+
SELECT
|
|
1416
|
+
campaign_asset.resource_name,
|
|
1417
|
+
campaign_asset.campaign,
|
|
1418
|
+
campaign_asset.field_type,
|
|
1419
|
+
campaign_asset.status
|
|
1420
|
+
FROM campaign_asset
|
|
1421
|
+
WHERE campaign_asset.asset = 'customers/${cleanId}/assets/${args.old_asset_id}'
|
|
1422
|
+
AND campaign_asset.field_type = 'SITELINK'
|
|
1423
|
+
AND campaign_asset.status = 'ENABLED'
|
|
1424
|
+
`),
|
|
1425
|
+
"replaceSitelinkUrl.queryCampaignAssets"
|
|
1426
|
+
);
|
|
1427
|
+
const adGroupLinksRows = await withResilience(
|
|
1428
|
+
() => customer.query(`
|
|
1429
|
+
SELECT
|
|
1430
|
+
ad_group_asset.resource_name,
|
|
1431
|
+
ad_group_asset.ad_group,
|
|
1432
|
+
ad_group_asset.field_type,
|
|
1433
|
+
ad_group_asset.status
|
|
1434
|
+
FROM ad_group_asset
|
|
1435
|
+
WHERE ad_group_asset.asset = 'customers/${cleanId}/assets/${args.old_asset_id}'
|
|
1436
|
+
AND ad_group_asset.field_type = 'SITELINK'
|
|
1437
|
+
AND ad_group_asset.status = 'ENABLED'
|
|
1438
|
+
`),
|
|
1439
|
+
"replaceSitelinkUrl.queryAdGroupAssets"
|
|
1440
|
+
);
|
|
1441
|
+
const customerLinksRows = await withResilience(
|
|
1442
|
+
() => customer.query(`
|
|
1443
|
+
SELECT
|
|
1444
|
+
customer_asset.resource_name,
|
|
1445
|
+
customer_asset.field_type,
|
|
1446
|
+
customer_asset.status
|
|
1447
|
+
FROM customer_asset
|
|
1448
|
+
WHERE customer_asset.asset = 'customers/${cleanId}/assets/${args.old_asset_id}'
|
|
1449
|
+
AND customer_asset.field_type = 'SITELINK'
|
|
1450
|
+
AND customer_asset.status = 'ENABLED'
|
|
1451
|
+
`),
|
|
1452
|
+
"replaceSitelinkUrl.queryCustomerAssets"
|
|
1453
|
+
);
|
|
1454
|
+
const campaignLinks = campaignLinksRows.map((r) => ({
|
|
1455
|
+
resource_name: r.campaign_asset.resource_name,
|
|
1456
|
+
campaign: r.campaign_asset.campaign
|
|
1457
|
+
}));
|
|
1458
|
+
const adGroupLinks = adGroupLinksRows.map((r) => ({
|
|
1459
|
+
resource_name: r.ad_group_asset.resource_name,
|
|
1460
|
+
ad_group: r.ad_group_asset.ad_group
|
|
1461
|
+
}));
|
|
1462
|
+
const customerLinks = customerLinksRows.map((r) => ({
|
|
1463
|
+
resource_name: r.customer_asset.resource_name
|
|
1464
|
+
}));
|
|
1465
|
+
const newSitelink = { link_text };
|
|
1466
|
+
if (description1) newSitelink.description1 = description1;
|
|
1467
|
+
if (description2) newSitelink.description2 = description2;
|
|
1468
|
+
const createResult = await withResilience(
|
|
1469
|
+
() => customer.assets.create([
|
|
1470
|
+
{
|
|
1471
|
+
type: enums.AssetType.SITELINK,
|
|
1472
|
+
final_urls: args.new_final_urls,
|
|
1473
|
+
sitelink_asset: newSitelink
|
|
1474
|
+
}
|
|
1475
|
+
]),
|
|
1476
|
+
"replaceSitelinkUrl.createAsset"
|
|
1477
|
+
);
|
|
1478
|
+
const newAssetResource = createResult.results?.[0]?.resource_name;
|
|
1479
|
+
if (!newAssetResource) {
|
|
1480
|
+
throw new Error("Failed to create replacement sitelink asset (no resource_name in response)");
|
|
1481
|
+
}
|
|
1482
|
+
const newAssetId = newAssetResource.split("/").pop() || "";
|
|
1483
|
+
await this.autoLabelCreated(customerId, [newAssetResource], "asset");
|
|
1484
|
+
const SITELINK_FIELD_TYPE = enums.AssetFieldType.SITELINK;
|
|
1485
|
+
if (campaignLinks.length > 0) {
|
|
1486
|
+
await withResilience(
|
|
1487
|
+
() => customer.campaignAssets.create(
|
|
1488
|
+
campaignLinks.map((l) => ({
|
|
1489
|
+
campaign: l.campaign,
|
|
1490
|
+
asset: newAssetResource,
|
|
1491
|
+
field_type: SITELINK_FIELD_TYPE
|
|
1492
|
+
}))
|
|
1493
|
+
),
|
|
1494
|
+
"replaceSitelinkUrl.createCampaignAssets"
|
|
1495
|
+
);
|
|
1496
|
+
}
|
|
1497
|
+
if (adGroupLinks.length > 0) {
|
|
1498
|
+
await withResilience(
|
|
1499
|
+
() => customer.adGroupAssets.create(
|
|
1500
|
+
adGroupLinks.map((l) => ({
|
|
1501
|
+
ad_group: l.ad_group,
|
|
1502
|
+
asset: newAssetResource,
|
|
1503
|
+
field_type: SITELINK_FIELD_TYPE
|
|
1504
|
+
}))
|
|
1505
|
+
),
|
|
1506
|
+
"replaceSitelinkUrl.createAdGroupAssets"
|
|
1507
|
+
);
|
|
1508
|
+
}
|
|
1509
|
+
if (customerLinks.length > 0) {
|
|
1510
|
+
await withResilience(
|
|
1511
|
+
() => customer.customerAssets.create(
|
|
1512
|
+
customerLinks.map(() => ({
|
|
1513
|
+
asset: newAssetResource,
|
|
1514
|
+
field_type: SITELINK_FIELD_TYPE
|
|
1515
|
+
}))
|
|
1516
|
+
),
|
|
1517
|
+
"replaceSitelinkUrl.createCustomerAssets"
|
|
1518
|
+
);
|
|
1519
|
+
}
|
|
1520
|
+
if (campaignLinks.length > 0) {
|
|
1521
|
+
await withResilience(
|
|
1522
|
+
() => customer.campaignAssets.remove(campaignLinks.map((l) => l.resource_name)),
|
|
1523
|
+
"replaceSitelinkUrl.removeCampaignAssets"
|
|
1524
|
+
);
|
|
1525
|
+
}
|
|
1526
|
+
if (adGroupLinks.length > 0) {
|
|
1527
|
+
await withResilience(
|
|
1528
|
+
() => customer.adGroupAssets.remove(adGroupLinks.map((l) => l.resource_name)),
|
|
1529
|
+
"replaceSitelinkUrl.removeAdGroupAssets"
|
|
1530
|
+
);
|
|
1531
|
+
}
|
|
1532
|
+
if (customerLinks.length > 0) {
|
|
1533
|
+
await withResilience(
|
|
1534
|
+
() => customer.customerAssets.remove(customerLinks.map((l) => l.resource_name)),
|
|
1535
|
+
"replaceSitelinkUrl.removeCustomerAssets"
|
|
1536
|
+
);
|
|
1537
|
+
}
|
|
1538
|
+
return {
|
|
1539
|
+
customer_id: cleanId,
|
|
1540
|
+
old_asset_id: args.old_asset_id,
|
|
1541
|
+
new_asset_id: newAssetId,
|
|
1542
|
+
new_asset_resource_name: newAssetResource,
|
|
1543
|
+
link_text,
|
|
1544
|
+
final_urls: args.new_final_urls,
|
|
1545
|
+
description1,
|
|
1546
|
+
description2,
|
|
1547
|
+
relinked: {
|
|
1548
|
+
campaign_assets: campaignLinks.length,
|
|
1549
|
+
ad_group_assets: adGroupLinks.length,
|
|
1550
|
+
customer_assets: customerLinks.length
|
|
1551
|
+
},
|
|
1552
|
+
note: "The old Asset is not deleted; only its ENABLED links were migrated. Paused/removed links on the old asset were left alone."
|
|
1553
|
+
};
|
|
1554
|
+
}
|
|
1555
|
+
// ============================================
|
|
1556
|
+
// AD GROUP RENAME
|
|
1557
|
+
// ============================================
|
|
1558
|
+
async renameAdGroup(customerId, adGroupId, newName) {
|
|
1559
|
+
const customer = this.getCustomer(customerId);
|
|
1560
|
+
const cleanId = customerId.replace(/-/g, "");
|
|
1561
|
+
const resourceName = `customers/${cleanId}/adGroups/${adGroupId}`;
|
|
1562
|
+
await withResilience(
|
|
1563
|
+
() => customer.adGroups.update([{ resource_name: resourceName, name: newName }]),
|
|
1564
|
+
"renameAdGroup"
|
|
1565
|
+
);
|
|
1566
|
+
await this.autoLabelCreated(customerId, [resourceName], "ad_group");
|
|
1567
|
+
return {
|
|
1568
|
+
customer_id: cleanId,
|
|
1569
|
+
ad_group_id: adGroupId,
|
|
1570
|
+
resource_name: resourceName,
|
|
1571
|
+
new_name: newName
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
// ============================================
|
|
1575
|
+
// LINK ASSET TO CAMPAIGN
|
|
1576
|
+
// ============================================
|
|
1577
|
+
async linkAssetToCampaign(customerId, args) {
|
|
1578
|
+
const customer = this.getCustomer(customerId);
|
|
1579
|
+
const cleanId = customerId.replace(/-/g, "");
|
|
1580
|
+
const fieldTypeMap = {
|
|
1581
|
+
SITELINK: enums.AssetFieldType.SITELINK,
|
|
1582
|
+
CALLOUT: enums.AssetFieldType.CALLOUT,
|
|
1583
|
+
STRUCTURED_SNIPPET: enums.AssetFieldType.STRUCTURED_SNIPPET
|
|
1584
|
+
};
|
|
1585
|
+
const fieldType = fieldTypeMap[args.field_type.toUpperCase()];
|
|
1586
|
+
if (fieldType === void 0) {
|
|
1587
|
+
throw new Error(
|
|
1588
|
+
`Unknown field_type: "${args.field_type}". Supported values: SITELINK, CALLOUT, STRUCTURED_SNIPPET`
|
|
1589
|
+
);
|
|
1590
|
+
}
|
|
1591
|
+
const assetResource = `customers/${cleanId}/assets/${args.asset_id}`;
|
|
1592
|
+
const operations = args.campaign_ids.map((id) => ({
|
|
1593
|
+
campaign: `customers/${cleanId}/campaigns/${id}`,
|
|
1594
|
+
asset: assetResource,
|
|
1595
|
+
field_type: fieldType
|
|
1596
|
+
}));
|
|
1597
|
+
const result = await withResilience(
|
|
1598
|
+
() => customer.campaignAssets.create(operations),
|
|
1599
|
+
"linkAssetToCampaign"
|
|
1600
|
+
);
|
|
1601
|
+
const results = result.results || [];
|
|
1602
|
+
const resourceNames = results.map((r) => r.resource_name).filter(Boolean);
|
|
1603
|
+
return {
|
|
1604
|
+
customer_id: cleanId,
|
|
1605
|
+
asset_id: args.asset_id,
|
|
1606
|
+
asset_resource_name: assetResource,
|
|
1607
|
+
field_type: args.field_type,
|
|
1608
|
+
campaigns_linked: args.campaign_ids.length,
|
|
1609
|
+
resource_names: resourceNames
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
// ============================================
|
|
1613
|
+
// EXPERIMENT METHODS
|
|
1614
|
+
// ============================================
|
|
1615
|
+
async createExperiment(customerId, input) {
|
|
1616
|
+
const customer = this.getCustomer(customerId);
|
|
1617
|
+
const cleanId = customerId.replace(/-/g, "");
|
|
1618
|
+
const trafficSplit = input.traffic_split_percent ?? 50;
|
|
1619
|
+
const experimentPayload = buildExperimentPayload(input);
|
|
1620
|
+
const expResult = await withResilience(
|
|
1621
|
+
() => customer.experiments.create([experimentPayload]),
|
|
1622
|
+
"createExperiment.create"
|
|
1623
|
+
);
|
|
1624
|
+
const expResourceName = expResult.results?.[0]?.resource_name;
|
|
1625
|
+
if (!expResourceName) {
|
|
1626
|
+
throw new Error("Experiment creation returned no resource_name");
|
|
1627
|
+
}
|
|
1628
|
+
const experimentId = experimentIdFromResourceName(expResourceName);
|
|
1629
|
+
const baseCampaignRN = `customers/${cleanId}/campaigns/${input.base_campaign_id}`;
|
|
1630
|
+
const controlArm = buildControlArmPayload(expResourceName, baseCampaignRN, trafficSplit);
|
|
1631
|
+
const treatmentArm = buildTreatmentArmPayload(expResourceName, trafficSplit);
|
|
1632
|
+
const armsResult = await withResilience(
|
|
1633
|
+
() => customer.experimentArms.create([controlArm, treatmentArm]),
|
|
1634
|
+
"createExperiment.arms"
|
|
1635
|
+
);
|
|
1636
|
+
const treatmentArmRN = armsResult.results?.[1]?.resource_name;
|
|
1637
|
+
let treatmentCampaignId = null;
|
|
1638
|
+
if (treatmentArmRN) {
|
|
1639
|
+
const armRows = await withResilience(
|
|
1640
|
+
() => customer.query(`
|
|
1641
|
+
SELECT experiment_arm.resource_name, experiment_arm.in_design_campaigns
|
|
1642
|
+
FROM experiment_arm
|
|
1643
|
+
WHERE experiment_arm.resource_name = '${treatmentArmRN}'
|
|
1644
|
+
`),
|
|
1645
|
+
"createExperiment.queryTreatmentArm"
|
|
1646
|
+
);
|
|
1647
|
+
const inDesign = armRows[0]?.experiment_arm?.in_design_campaigns ?? [];
|
|
1648
|
+
if (inDesign.length > 0) {
|
|
1649
|
+
treatmentCampaignId = campaignIdFromResourceName(inDesign[0]);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
await this.autoLabelCreated(customerId, [expResourceName], "experiment");
|
|
1653
|
+
return {
|
|
1654
|
+
customer_id: cleanId,
|
|
1655
|
+
experiment_id: experimentId,
|
|
1656
|
+
experiment_resource_name: expResourceName,
|
|
1657
|
+
treatment_campaign_id: treatmentCampaignId,
|
|
1658
|
+
name: input.name,
|
|
1659
|
+
traffic_split_percent: trafficSplit,
|
|
1660
|
+
status: "SETUP",
|
|
1661
|
+
note: treatmentCampaignId ? `Treatment campaign ${treatmentCampaignId} created. Use google_ads_update_campaign_ad_urls to change landing pages, then google_ads_schedule_experiment to go live.` : "Treatment campaign not yet available \u2014 query google_ads_get_experiment in a few seconds."
|
|
1662
|
+
};
|
|
1663
|
+
}
|
|
1664
|
+
async listExperiments(customerId, options = {}) {
|
|
1665
|
+
const customer = this.getCustomer(customerId);
|
|
1666
|
+
const statusClause = options.statusFilter && options.statusFilter !== "ALL" ? `AND experiment.status = '${options.statusFilter}'` : `AND experiment.status != 'REMOVED'`;
|
|
1667
|
+
const rows = await withResilience(
|
|
1668
|
+
() => customer.query(`
|
|
1669
|
+
SELECT
|
|
1670
|
+
experiment.resource_name,
|
|
1671
|
+
experiment.experiment_id,
|
|
1672
|
+
experiment.name,
|
|
1673
|
+
experiment.description,
|
|
1674
|
+
experiment.status,
|
|
1675
|
+
experiment.type,
|
|
1676
|
+
experiment.start_date,
|
|
1677
|
+
experiment.end_date,
|
|
1678
|
+
experiment.suffix
|
|
1679
|
+
FROM experiment
|
|
1680
|
+
WHERE experiment.type = 'SEARCH_CUSTOM'
|
|
1681
|
+
${statusClause}
|
|
1682
|
+
ORDER BY experiment.name
|
|
1683
|
+
`),
|
|
1684
|
+
"listExperiments"
|
|
1685
|
+
);
|
|
1686
|
+
let experiments = rows.map((r) => ({
|
|
1687
|
+
experiment_id: String(r.experiment?.experiment_id ?? ""),
|
|
1688
|
+
resource_name: r.experiment?.resource_name ?? "",
|
|
1689
|
+
name: r.experiment?.name ?? "",
|
|
1690
|
+
description: r.experiment?.description ?? "",
|
|
1691
|
+
status: r.experiment?.status ?? "",
|
|
1692
|
+
start_date: r.experiment?.start_date ?? "",
|
|
1693
|
+
end_date: r.experiment?.end_date ?? "",
|
|
1694
|
+
suffix: r.experiment?.suffix ?? ""
|
|
1695
|
+
}));
|
|
1696
|
+
if (options.campaignId) {
|
|
1697
|
+
const armRows = await withResilience(
|
|
1698
|
+
() => customer.query(`
|
|
1699
|
+
SELECT experiment_arm.experiment, experiment_arm.campaigns, experiment_arm.control
|
|
1700
|
+
FROM experiment_arm
|
|
1701
|
+
WHERE experiment_arm.control = TRUE
|
|
1702
|
+
`),
|
|
1703
|
+
"listExperiments.arms"
|
|
1704
|
+
);
|
|
1705
|
+
const cleanCid = customerId.replace(/-/g, "");
|
|
1706
|
+
const targetRN = `customers/${cleanCid}/campaigns/${options.campaignId}`;
|
|
1707
|
+
const expRNsForCampaign = new Set(
|
|
1708
|
+
armRows.filter((r) => (r.experiment_arm?.campaigns ?? []).includes(targetRN)).map((r) => r.experiment_arm?.experiment)
|
|
1709
|
+
);
|
|
1710
|
+
experiments = experiments.filter((e) => expRNsForCampaign.has(e.resource_name));
|
|
1711
|
+
}
|
|
1712
|
+
return { customer_id: customerId.replace(/-/g, ""), experiments };
|
|
1713
|
+
}
|
|
1714
|
+
async getExperiment(customerId, experimentId) {
|
|
1715
|
+
const customer = this.getCustomer(customerId);
|
|
1716
|
+
const cleanId = customerId.replace(/-/g, "");
|
|
1717
|
+
const expRN = `customers/${cleanId}/experiments/${experimentId}`;
|
|
1718
|
+
const [expRows, armRows] = await Promise.all([
|
|
1719
|
+
withResilience(
|
|
1720
|
+
() => customer.query(`
|
|
1721
|
+
SELECT
|
|
1722
|
+
experiment.resource_name,
|
|
1723
|
+
experiment.experiment_id,
|
|
1724
|
+
experiment.name,
|
|
1725
|
+
experiment.description,
|
|
1726
|
+
experiment.status,
|
|
1727
|
+
experiment.type,
|
|
1728
|
+
experiment.start_date,
|
|
1729
|
+
experiment.end_date,
|
|
1730
|
+
experiment.suffix
|
|
1731
|
+
FROM experiment
|
|
1732
|
+
WHERE experiment.resource_name = '${expRN}'
|
|
1733
|
+
`),
|
|
1734
|
+
"getExperiment.exp"
|
|
1735
|
+
),
|
|
1736
|
+
withResilience(
|
|
1737
|
+
() => customer.query(`
|
|
1738
|
+
SELECT
|
|
1739
|
+
experiment_arm.resource_name,
|
|
1740
|
+
experiment_arm.name,
|
|
1741
|
+
experiment_arm.control,
|
|
1742
|
+
experiment_arm.traffic_split,
|
|
1743
|
+
experiment_arm.campaigns,
|
|
1744
|
+
experiment_arm.in_design_campaigns
|
|
1745
|
+
FROM experiment_arm
|
|
1746
|
+
WHERE experiment_arm.experiment = '${expRN}'
|
|
1747
|
+
`),
|
|
1748
|
+
"getExperiment.arms"
|
|
1749
|
+
)
|
|
1750
|
+
]);
|
|
1751
|
+
if (expRows.length === 0) {
|
|
1752
|
+
throw new Error(`Experiment ${experimentId} not found`);
|
|
1753
|
+
}
|
|
1754
|
+
const exp = expRows[0].experiment;
|
|
1755
|
+
const arms = armRows.map((r) => {
|
|
1756
|
+
const arm = r.experiment_arm;
|
|
1757
|
+
const isControl = arm?.control ?? false;
|
|
1758
|
+
const campaigns = arm?.campaigns ?? [];
|
|
1759
|
+
const inDesignCampaigns = arm?.in_design_campaigns ?? [];
|
|
1760
|
+
return {
|
|
1761
|
+
name: arm?.name ?? "",
|
|
1762
|
+
control: isControl,
|
|
1763
|
+
traffic_split: arm?.traffic_split ?? 0,
|
|
1764
|
+
campaign_ids: campaigns.map(campaignIdFromResourceName),
|
|
1765
|
+
treatment_campaign_ids: inDesignCampaigns.map(campaignIdFromResourceName)
|
|
1766
|
+
};
|
|
1767
|
+
});
|
|
1768
|
+
const treatmentArm = arms.find((a) => !a.control);
|
|
1769
|
+
const treatmentCampaignId = treatmentArm?.treatment_campaign_ids?.[0] ?? null;
|
|
1770
|
+
return {
|
|
1771
|
+
experiment_id: String(exp?.experiment_id ?? experimentId),
|
|
1772
|
+
resource_name: exp?.resource_name ?? expRN,
|
|
1773
|
+
name: exp?.name ?? "",
|
|
1774
|
+
description: exp?.description ?? "",
|
|
1775
|
+
status: exp?.status ?? "",
|
|
1776
|
+
start_date: exp?.start_date ?? "",
|
|
1777
|
+
end_date: exp?.end_date ?? "",
|
|
1778
|
+
suffix: exp?.suffix ?? "",
|
|
1779
|
+
arms,
|
|
1780
|
+
treatment_campaign_id: treatmentCampaignId
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
async scheduleExperiment(customerId, experimentId) {
|
|
1784
|
+
const customer = this.getCustomer(customerId);
|
|
1785
|
+
const cleanId = customerId.replace(/-/g, "");
|
|
1786
|
+
const expRN = `customers/${cleanId}/experiments/${experimentId}`;
|
|
1787
|
+
await withResilience(
|
|
1788
|
+
() => customer.experiments.scheduleExperiment({ resource_name: expRN }),
|
|
1789
|
+
"scheduleExperiment"
|
|
1790
|
+
);
|
|
1791
|
+
return { customer_id: cleanId, experiment_id: experimentId, status: "SCHEDULED" };
|
|
1792
|
+
}
|
|
1793
|
+
async endExperiment(customerId, experimentId) {
|
|
1794
|
+
const customer = this.getCustomer(customerId);
|
|
1795
|
+
const cleanId = customerId.replace(/-/g, "");
|
|
1796
|
+
const expRN = `customers/${cleanId}/experiments/${experimentId}`;
|
|
1797
|
+
await withResilience(
|
|
1798
|
+
() => customer.experiments.endExperiment({ resource_name: expRN }),
|
|
1799
|
+
"endExperiment"
|
|
1800
|
+
);
|
|
1801
|
+
return { customer_id: cleanId, experiment_id: experimentId, status: "HALTED" };
|
|
1802
|
+
}
|
|
1803
|
+
async promoteExperiment(customerId, experimentId, validateOnly = false) {
|
|
1804
|
+
const customer = this.getCustomer(customerId);
|
|
1805
|
+
const cleanId = customerId.replace(/-/g, "");
|
|
1806
|
+
const expRN = `customers/${cleanId}/experiments/${experimentId}`;
|
|
1807
|
+
await withResilience(
|
|
1808
|
+
() => customer.experiments.promoteExperiment({ resource_name: expRN, validate_only: validateOnly }),
|
|
1809
|
+
"promoteExperiment"
|
|
1810
|
+
);
|
|
1811
|
+
return {
|
|
1812
|
+
customer_id: cleanId,
|
|
1813
|
+
experiment_id: experimentId,
|
|
1814
|
+
status: validateOnly ? "VALIDATED (not promoted)" : "PROMOTED"
|
|
1815
|
+
};
|
|
1816
|
+
}
|
|
1817
|
+
async removeExperiment(customerId, experimentId) {
|
|
1818
|
+
const customer = this.getCustomer(customerId);
|
|
1819
|
+
const cleanId = customerId.replace(/-/g, "");
|
|
1820
|
+
const expRN = `customers/${cleanId}/experiments/${experimentId}`;
|
|
1821
|
+
await withResilience(
|
|
1822
|
+
() => customer.experiments.remove([expRN]),
|
|
1823
|
+
"removeExperiment"
|
|
1824
|
+
);
|
|
1825
|
+
return {
|
|
1826
|
+
customer_id: cleanId,
|
|
1827
|
+
experiment_id: experimentId,
|
|
1828
|
+
status: "REMOVED"
|
|
1829
|
+
};
|
|
1830
|
+
}
|
|
1831
|
+
async updateCampaignAdUrls(customerId, campaignId, newFinalUrl, execute = false) {
|
|
1832
|
+
const customer = this.getCustomer(customerId);
|
|
1833
|
+
const cleanId = customerId.replace(/-/g, "");
|
|
1834
|
+
const rows = await withResilience(
|
|
1835
|
+
() => customer.query(`
|
|
1836
|
+
SELECT
|
|
1837
|
+
ad_group_ad.resource_name,
|
|
1838
|
+
ad_group_ad.ad.id,
|
|
1839
|
+
ad_group_ad.ad.final_urls,
|
|
1840
|
+
ad_group_ad.status,
|
|
1841
|
+
ad_group.name,
|
|
1842
|
+
campaign.name
|
|
1843
|
+
FROM ad_group_ad
|
|
1844
|
+
WHERE campaign.id = ${sanitizeNumericId(campaignId)}
|
|
1845
|
+
AND ad_group_ad.status != 'REMOVED'
|
|
1846
|
+
ORDER BY ad_group.name
|
|
1847
|
+
`),
|
|
1848
|
+
"updateCampaignAdUrls.query"
|
|
1849
|
+
);
|
|
1850
|
+
const ads = rows.map((r) => ({
|
|
1851
|
+
resource_name: r.ad_group_ad?.resource_name,
|
|
1852
|
+
ad_id: String(r.ad_group_ad?.ad?.id ?? ""),
|
|
1853
|
+
current_final_urls: r.ad_group_ad?.ad?.final_urls ?? [],
|
|
1854
|
+
ad_group_name: r.ad_group?.name ?? "",
|
|
1855
|
+
status: r.ad_group_ad?.status ?? ""
|
|
1856
|
+
}));
|
|
1857
|
+
const preview = ads.map((a) => ({
|
|
1858
|
+
ad_id: a.ad_id,
|
|
1859
|
+
ad_group: a.ad_group_name,
|
|
1860
|
+
from: a.current_final_urls,
|
|
1861
|
+
to: [newFinalUrl]
|
|
1862
|
+
}));
|
|
1863
|
+
if (!execute) {
|
|
1864
|
+
return {
|
|
1865
|
+
customer_id: cleanId,
|
|
1866
|
+
campaign_id: campaignId,
|
|
1867
|
+
dry_run: true,
|
|
1868
|
+
ads_to_update: ads.length,
|
|
1869
|
+
preview,
|
|
1870
|
+
note: "Set execute=true to apply these changes."
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
const updates = ads.map((a) => ({
|
|
1874
|
+
resource_name: a.resource_name,
|
|
1875
|
+
ad: { final_urls: [newFinalUrl] }
|
|
1876
|
+
}));
|
|
1877
|
+
if (updates.length > 0) {
|
|
1878
|
+
await withResilience(
|
|
1879
|
+
() => customer.adGroupAds.update(updates),
|
|
1880
|
+
"updateCampaignAdUrls.update"
|
|
1881
|
+
);
|
|
1882
|
+
}
|
|
1883
|
+
await this.autoLabelCreated(customerId, ads.map((a) => a.resource_name).filter(Boolean), "ad");
|
|
1884
|
+
return {
|
|
1885
|
+
customer_id: cleanId,
|
|
1886
|
+
campaign_id: campaignId,
|
|
1887
|
+
dry_run: false,
|
|
1888
|
+
ads_updated: updates.length,
|
|
1889
|
+
new_final_url: newFinalUrl,
|
|
1890
|
+
preview
|
|
1891
|
+
};
|
|
1892
|
+
}
|
|
1893
|
+
// ============================================
|
|
1243
1894
|
// REPORTING METHODS
|
|
1244
1895
|
// ============================================
|
|
1245
1896
|
// Get keyword performance report
|
|
@@ -2131,6 +2782,42 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2131
2782
|
}]
|
|
2132
2783
|
};
|
|
2133
2784
|
}
|
|
2785
|
+
case "google_ads_set_ad_group_location_targeting": {
|
|
2786
|
+
const customerId = args?.customer_id || "";
|
|
2787
|
+
const result = await adsManager.setAdGroupLocationTargeting(
|
|
2788
|
+
customerId,
|
|
2789
|
+
args?.ad_group_id,
|
|
2790
|
+
args?.geo_target_ids
|
|
2791
|
+
);
|
|
2792
|
+
return {
|
|
2793
|
+
content: [{
|
|
2794
|
+
type: "text",
|
|
2795
|
+
text: JSON.stringify({
|
|
2796
|
+
success: true,
|
|
2797
|
+
message: `Location targeting set: ${(args?.geo_target_ids).length} geo targets added to ad group ${args?.ad_group_id}`,
|
|
2798
|
+
results: result
|
|
2799
|
+
}, null, 2)
|
|
2800
|
+
}]
|
|
2801
|
+
};
|
|
2802
|
+
}
|
|
2803
|
+
case "google_ads_set_campaign_location_targeting": {
|
|
2804
|
+
const customerId = args?.customer_id || "";
|
|
2805
|
+
const result = await adsManager.setCampaignLocationTargeting(
|
|
2806
|
+
customerId,
|
|
2807
|
+
args?.campaign_id,
|
|
2808
|
+
args?.geo_target_ids
|
|
2809
|
+
);
|
|
2810
|
+
return {
|
|
2811
|
+
content: [{
|
|
2812
|
+
type: "text",
|
|
2813
|
+
text: JSON.stringify({
|
|
2814
|
+
success: true,
|
|
2815
|
+
message: `Location targeting set: ${(args?.geo_target_ids).length} geo targets added to campaign ${args?.campaign_id}`,
|
|
2816
|
+
results: result
|
|
2817
|
+
}, null, 2)
|
|
2818
|
+
}]
|
|
2819
|
+
};
|
|
2820
|
+
}
|
|
2134
2821
|
case "google_ads_add_campaign_negatives": {
|
|
2135
2822
|
const customerId = args?.customer_id || "";
|
|
2136
2823
|
const result = await adsManager.addCampaignNegativeKeywords(
|
|
@@ -2202,6 +2889,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2202
2889
|
}]
|
|
2203
2890
|
};
|
|
2204
2891
|
}
|
|
2892
|
+
case "google_ads_enable_keywords": {
|
|
2893
|
+
const customerId = args?.customer_id || "";
|
|
2894
|
+
const result = await adsManager.enableKeywords(
|
|
2895
|
+
customerId,
|
|
2896
|
+
args?.criterion_resource_names,
|
|
2897
|
+
args?.labels
|
|
2898
|
+
);
|
|
2899
|
+
return {
|
|
2900
|
+
content: [{
|
|
2901
|
+
type: "text",
|
|
2902
|
+
text: JSON.stringify({
|
|
2903
|
+
success: true,
|
|
2904
|
+
message: "Keywords enabled and now LIVE",
|
|
2905
|
+
results: result
|
|
2906
|
+
}, null, 2)
|
|
2907
|
+
}]
|
|
2908
|
+
};
|
|
2909
|
+
}
|
|
2205
2910
|
// ============================================
|
|
2206
2911
|
// REPORTING HANDLERS
|
|
2207
2912
|
// ============================================
|
|
@@ -2444,6 +3149,41 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2444
3149
|
const result = await adsManager.updateAssetUrls(customerId, normalized.updates);
|
|
2445
3150
|
return { content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }] };
|
|
2446
3151
|
}
|
|
3152
|
+
case "google_ads_create_sitelink": {
|
|
3153
|
+
const normalized = normalizeCreateSitelinkArgs(args);
|
|
3154
|
+
if ("error" in normalized) {
|
|
3155
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: normalized.error }, null, 2) }] };
|
|
3156
|
+
}
|
|
3157
|
+
if (!normalized.confirm) {
|
|
3158
|
+
return { content: [{ type: "text", text: JSON.stringify(buildCreateSitelinkDryRun(normalized), null, 2) }] };
|
|
3159
|
+
}
|
|
3160
|
+
const customerId = normalized.customer_id || "";
|
|
3161
|
+
const result = await adsManager.createSitelink(customerId, {
|
|
3162
|
+
link_text: normalized.link_text,
|
|
3163
|
+
final_urls: normalized.final_urls,
|
|
3164
|
+
description1: normalized.description1,
|
|
3165
|
+
description2: normalized.description2
|
|
3166
|
+
});
|
|
3167
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }] };
|
|
3168
|
+
}
|
|
3169
|
+
case "google_ads_replace_sitelink_url": {
|
|
3170
|
+
const normalized = normalizeReplaceSitelinkArgs(args);
|
|
3171
|
+
if ("error" in normalized) {
|
|
3172
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: normalized.error }, null, 2) }] };
|
|
3173
|
+
}
|
|
3174
|
+
if (!normalized.confirm) {
|
|
3175
|
+
return { content: [{ type: "text", text: JSON.stringify(buildReplaceSitelinkDryRun(normalized), null, 2) }] };
|
|
3176
|
+
}
|
|
3177
|
+
const customerId = normalized.customer_id || "";
|
|
3178
|
+
const result = await adsManager.replaceSitelinkUrl(customerId, {
|
|
3179
|
+
old_asset_id: normalized.old_asset_id,
|
|
3180
|
+
new_final_urls: normalized.new_final_urls,
|
|
3181
|
+
new_link_text: normalized.new_link_text,
|
|
3182
|
+
new_description1: normalized.new_description1,
|
|
3183
|
+
new_description2: normalized.new_description2
|
|
3184
|
+
});
|
|
3185
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }] };
|
|
3186
|
+
}
|
|
2447
3187
|
case "google_ads_pause_asset_links": {
|
|
2448
3188
|
const normalized = normalizePauseAssetLinksArgs(args);
|
|
2449
3189
|
if ("error" in normalized) {
|
|
@@ -2456,6 +3196,61 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2456
3196
|
const result = await adsManager.pauseAssetLinks(customerId, normalized.resource_names);
|
|
2457
3197
|
return { content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }] };
|
|
2458
3198
|
}
|
|
3199
|
+
case "google_ads_rename_ad_group": {
|
|
3200
|
+
const customerId = args?.customer_id || "";
|
|
3201
|
+
const adGroupId = args?.ad_group_id;
|
|
3202
|
+
const newName = args?.new_name;
|
|
3203
|
+
if (!adGroupId || !newName) {
|
|
3204
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "ad_group_id and new_name are required." }, null, 2) }] };
|
|
3205
|
+
}
|
|
3206
|
+
if (!args?.confirm) {
|
|
3207
|
+
return {
|
|
3208
|
+
content: [{
|
|
3209
|
+
type: "text",
|
|
3210
|
+
text: JSON.stringify({
|
|
3211
|
+
dry_run: true,
|
|
3212
|
+
action: "rename_ad_group",
|
|
3213
|
+
ad_group_id: adGroupId,
|
|
3214
|
+
new_name: newName,
|
|
3215
|
+
message: "Pass confirm: true to apply the rename."
|
|
3216
|
+
}, null, 2)
|
|
3217
|
+
}]
|
|
3218
|
+
};
|
|
3219
|
+
}
|
|
3220
|
+
const renameResult = await adsManager.renameAdGroup(customerId, adGroupId, newName);
|
|
3221
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: true, ...renameResult }, null, 2) }] };
|
|
3222
|
+
}
|
|
3223
|
+
case "google_ads_link_asset_to_campaign": {
|
|
3224
|
+
const customerId = args?.customer_id || "";
|
|
3225
|
+
const assetId = args?.asset_id;
|
|
3226
|
+
const campaignIds = args?.campaign_ids;
|
|
3227
|
+
const fieldType = args?.field_type;
|
|
3228
|
+
if (!assetId || !campaignIds?.length || !fieldType) {
|
|
3229
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "asset_id, campaign_ids (non-empty), and field_type are required." }, null, 2) }] };
|
|
3230
|
+
}
|
|
3231
|
+
if (!args?.confirm) {
|
|
3232
|
+
return {
|
|
3233
|
+
content: [{
|
|
3234
|
+
type: "text",
|
|
3235
|
+
text: JSON.stringify({
|
|
3236
|
+
dry_run: true,
|
|
3237
|
+
action: "link_asset_to_campaign",
|
|
3238
|
+
asset_id: assetId,
|
|
3239
|
+
campaign_ids: campaignIds,
|
|
3240
|
+
field_type: fieldType,
|
|
3241
|
+
campaigns_to_link: campaignIds.length,
|
|
3242
|
+
message: "Pass confirm: true to apply the links."
|
|
3243
|
+
}, null, 2)
|
|
3244
|
+
}]
|
|
3245
|
+
};
|
|
3246
|
+
}
|
|
3247
|
+
const linkResult = await adsManager.linkAssetToCampaign(customerId, {
|
|
3248
|
+
asset_id: assetId,
|
|
3249
|
+
campaign_ids: campaignIds,
|
|
3250
|
+
field_type: fieldType
|
|
3251
|
+
});
|
|
3252
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: true, ...linkResult }, null, 2) }] };
|
|
3253
|
+
}
|
|
2459
3254
|
case "google_ads_gaql_query": {
|
|
2460
3255
|
const customerId = args?.customer_id || "";
|
|
2461
3256
|
const query = args?.query;
|
|
@@ -2533,6 +3328,70 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2533
3328
|
};
|
|
2534
3329
|
}
|
|
2535
3330
|
}
|
|
3331
|
+
case "google_ads_create_page_feed": {
|
|
3332
|
+
const customerId = args?.customer_id || "";
|
|
3333
|
+
const campaignId = args?.campaign_id;
|
|
3334
|
+
const feedName = args?.name;
|
|
3335
|
+
const urls = args?.urls;
|
|
3336
|
+
const label = args?.label ?? "page-feed";
|
|
3337
|
+
try {
|
|
3338
|
+
const customer = adsManager.getCustomer(customerId);
|
|
3339
|
+
const assetCreateResult = await withResilience(
|
|
3340
|
+
() => customer.assets.create(
|
|
3341
|
+
urls.map((url) => ({
|
|
3342
|
+
type: enums.AssetType.PAGE_FEED,
|
|
3343
|
+
page_feed_asset: { page_url: url, labels: [label] }
|
|
3344
|
+
}))
|
|
3345
|
+
),
|
|
3346
|
+
"createPageFeedAssets"
|
|
3347
|
+
);
|
|
3348
|
+
const assetResources = (assetCreateResult.results || []).map((r) => r.resource_name);
|
|
3349
|
+
const assetSetCreateResult = await withResilience(
|
|
3350
|
+
() => customer.asset_sets.create([{
|
|
3351
|
+
name: feedName,
|
|
3352
|
+
type: enums.AssetSetType.PAGE_FEED
|
|
3353
|
+
}]),
|
|
3354
|
+
"createAssetSet"
|
|
3355
|
+
);
|
|
3356
|
+
const assetSetResource = (assetSetCreateResult.results || [])[0]?.resource_name;
|
|
3357
|
+
await withResilience(
|
|
3358
|
+
() => customer.asset_set_assets.create(
|
|
3359
|
+
assetResources.map((assetResource) => ({
|
|
3360
|
+
asset_set: assetSetResource,
|
|
3361
|
+
asset: assetResource
|
|
3362
|
+
}))
|
|
3363
|
+
),
|
|
3364
|
+
"linkAssetsToSet"
|
|
3365
|
+
);
|
|
3366
|
+
const casCreateResult = await withResilience(
|
|
3367
|
+
() => customer.campaign_asset_sets.create([{
|
|
3368
|
+
campaign: `customers/${customerId}/campaigns/${campaignId}`,
|
|
3369
|
+
asset_set: assetSetResource
|
|
3370
|
+
}]),
|
|
3371
|
+
"attachAssetSetToCampaign"
|
|
3372
|
+
);
|
|
3373
|
+
const casResource = (casCreateResult.results || [])[0]?.resource_name;
|
|
3374
|
+
return {
|
|
3375
|
+
content: [{
|
|
3376
|
+
type: "text",
|
|
3377
|
+
text: JSON.stringify({
|
|
3378
|
+
success: true,
|
|
3379
|
+
asset_set_resource: assetSetResource,
|
|
3380
|
+
campaign_asset_set_resource: casResource,
|
|
3381
|
+
urls_added: urls.length,
|
|
3382
|
+
label
|
|
3383
|
+
}, null, 2)
|
|
3384
|
+
}]
|
|
3385
|
+
};
|
|
3386
|
+
} catch (e) {
|
|
3387
|
+
return {
|
|
3388
|
+
content: [{
|
|
3389
|
+
type: "text",
|
|
3390
|
+
text: JSON.stringify({ success: false, error: e.message }, null, 2)
|
|
3391
|
+
}]
|
|
3392
|
+
};
|
|
3393
|
+
}
|
|
3394
|
+
}
|
|
2536
3395
|
case "google_ads_create_image_asset": {
|
|
2537
3396
|
const customerId = args?.customer_id || "";
|
|
2538
3397
|
try {
|
|
@@ -2556,6 +3415,101 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2556
3415
|
};
|
|
2557
3416
|
}
|
|
2558
3417
|
}
|
|
3418
|
+
// ============================================
|
|
3419
|
+
// EXPERIMENT DISPATCH
|
|
3420
|
+
// ============================================
|
|
3421
|
+
case "google_ads_create_experiment": {
|
|
3422
|
+
assertWriteAllowed(name);
|
|
3423
|
+
const customerId = args?.customer_id || "";
|
|
3424
|
+
try {
|
|
3425
|
+
const result = await adsManager.createExperiment(customerId, {
|
|
3426
|
+
base_campaign_id: args?.base_campaign_id,
|
|
3427
|
+
name: args?.name,
|
|
3428
|
+
description: args?.description,
|
|
3429
|
+
suffix: args?.suffix,
|
|
3430
|
+
traffic_split_percent: args?.traffic_split_percent,
|
|
3431
|
+
start_date: args?.start_date,
|
|
3432
|
+
end_date: args?.end_date
|
|
3433
|
+
});
|
|
3434
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }] };
|
|
3435
|
+
} catch (e) {
|
|
3436
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: false, error: e.message }, null, 2) }] };
|
|
3437
|
+
}
|
|
3438
|
+
}
|
|
3439
|
+
case "google_ads_list_experiments": {
|
|
3440
|
+
const customerId = args?.customer_id || "";
|
|
3441
|
+
const result = await adsManager.listExperiments(customerId, {
|
|
3442
|
+
campaignId: args?.campaign_id,
|
|
3443
|
+
statusFilter: args?.status_filter
|
|
3444
|
+
});
|
|
3445
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
3446
|
+
}
|
|
3447
|
+
case "google_ads_get_experiment": {
|
|
3448
|
+
const customerId = args?.customer_id || "";
|
|
3449
|
+
try {
|
|
3450
|
+
const result = await adsManager.getExperiment(customerId, args?.experiment_id);
|
|
3451
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
3452
|
+
} catch (e) {
|
|
3453
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: false, error: e.message }, null, 2) }] };
|
|
3454
|
+
}
|
|
3455
|
+
}
|
|
3456
|
+
case "google_ads_schedule_experiment": {
|
|
3457
|
+
assertWriteAllowed(name);
|
|
3458
|
+
const customerId = args?.customer_id || "";
|
|
3459
|
+
try {
|
|
3460
|
+
const result = await adsManager.scheduleExperiment(customerId, args?.experiment_id);
|
|
3461
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }] };
|
|
3462
|
+
} catch (e) {
|
|
3463
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: false, error: e.message }, null, 2) }] };
|
|
3464
|
+
}
|
|
3465
|
+
}
|
|
3466
|
+
case "google_ads_end_experiment": {
|
|
3467
|
+
assertWriteAllowed(name);
|
|
3468
|
+
const customerId = args?.customer_id || "";
|
|
3469
|
+
try {
|
|
3470
|
+
const result = await adsManager.endExperiment(customerId, args?.experiment_id);
|
|
3471
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }] };
|
|
3472
|
+
} catch (e) {
|
|
3473
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: false, error: e.message }, null, 2) }] };
|
|
3474
|
+
}
|
|
3475
|
+
}
|
|
3476
|
+
case "google_ads_promote_experiment": {
|
|
3477
|
+
assertWriteAllowed(name);
|
|
3478
|
+
const customerId = args?.customer_id || "";
|
|
3479
|
+
try {
|
|
3480
|
+
const validateOnly = args?.validate_only === true;
|
|
3481
|
+
const result = await adsManager.promoteExperiment(customerId, args?.experiment_id, validateOnly);
|
|
3482
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }] };
|
|
3483
|
+
} catch (e) {
|
|
3484
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: false, error: e.message }, null, 2) }] };
|
|
3485
|
+
}
|
|
3486
|
+
}
|
|
3487
|
+
case "google_ads_remove_experiment": {
|
|
3488
|
+
assertWriteAllowed(name);
|
|
3489
|
+
const customerId = args?.customer_id || "";
|
|
3490
|
+
try {
|
|
3491
|
+
const result = await adsManager.removeExperiment(customerId, args?.experiment_id);
|
|
3492
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }] };
|
|
3493
|
+
} catch (e) {
|
|
3494
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: false, error: e.message }, null, 2) }] };
|
|
3495
|
+
}
|
|
3496
|
+
}
|
|
3497
|
+
case "google_ads_update_campaign_ad_urls": {
|
|
3498
|
+
const customerId = args?.customer_id || "";
|
|
3499
|
+
const execute = args?.execute === true;
|
|
3500
|
+
if (execute) assertWriteAllowed(name);
|
|
3501
|
+
try {
|
|
3502
|
+
const result = await adsManager.updateCampaignAdUrls(
|
|
3503
|
+
customerId,
|
|
3504
|
+
args?.campaign_id,
|
|
3505
|
+
args?.new_final_url,
|
|
3506
|
+
execute
|
|
3507
|
+
);
|
|
3508
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }] };
|
|
3509
|
+
} catch (e) {
|
|
3510
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: false, error: e.message }, null, 2) }] };
|
|
3511
|
+
}
|
|
3512
|
+
}
|
|
2559
3513
|
default:
|
|
2560
3514
|
throw new Error(`Unknown tool: ${name}`);
|
|
2561
3515
|
}
|