siluzan-tso-cli 1.1.21-beta.1 → 1.1.21-beta.2

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 CHANGED
@@ -51,7 +51,7 @@ siluzan-tso init -d /path/to/skills # 写入自定义目录
51
51
  siluzan-tso init --force # 强制覆盖已存在文件
52
52
  ```
53
53
 
54
- > **注意**:当前为测试版(1.1.21-beta.1),供内部测试使用。正式发布后安装命令将改为 `npm install -g siluzan-tso-cli`。
54
+ > **注意**:当前为测试版(1.1.21-beta.2),供内部测试使用。正式发布后安装命令将改为 `npm install -g siluzan-tso-cli`。
55
55
 
56
56
  | 助手 | 建议 `--ai` |
57
57
  | ----------------------- | ------------------------------------ |
package/dist/index.js CHANGED
@@ -110184,6 +110184,81 @@ function inferMatchTypeFromDisplayText(text) {
110184
110184
  if (t.length >= 2 && t.startsWith("[") && t.endsWith("]")) return "Exact";
110185
110185
  return "Broad";
110186
110186
  }
110187
+ var MATCH_TYPE_SPLIT_ORDER = ["Broad", "Phrase", "Exact"];
110188
+ function resolveTargetMatchTypeForSplit(trimmedRaw, declaredUi) {
110189
+ const inferred = inferMatchTypeFromDisplayText(trimmedRaw);
110190
+ if (declaredUi === null) return inferred;
110191
+ if (inferred === "Broad") return declaredUi;
110192
+ if (inferred !== declaredUi) return inferred;
110193
+ return declaredUi;
110194
+ }
110195
+ function collectKeywordEntriesFromBlock(block) {
110196
+ const entries = [];
110197
+ const texts = readBlockKeywordTextArray(block);
110198
+ if (Array.isArray(texts)) {
110199
+ for (const raw of texts) {
110200
+ if (typeof raw === "string" && raw.trim()) entries.push({ kind: "text", raw });
110201
+ }
110202
+ }
110203
+ const items = readBlockItemsArray(block);
110204
+ if (Array.isArray(items)) {
110205
+ for (const item of items) {
110206
+ if (!item || typeof item !== "object") continue;
110207
+ const raw = readItemText(item);
110208
+ if (raw !== null && raw.trim()) entries.push({ kind: "item", raw, item });
110209
+ }
110210
+ }
110211
+ return entries;
110212
+ }
110213
+ function cloneKeywordBlockShell(block) {
110214
+ const shell = { ...block };
110215
+ delete shell["KeywordText"];
110216
+ delete shell["keywordText"];
110217
+ delete shell["Items"];
110218
+ delete shell["items"];
110219
+ delete shell["MatchTypeV2"];
110220
+ delete shell["matchTypeV2"];
110221
+ return shell;
110222
+ }
110223
+ function splitKeywordsForBatchJobBlockIfMixed(block, path22, warnings) {
110224
+ const declaredUi = matchTypeV2ToUi(readBlockMatchTypeRaw(block));
110225
+ const entries = collectKeywordEntriesFromBlock(block);
110226
+ if (entries.length === 0) return [block];
110227
+ const groups = /* @__PURE__ */ new Map();
110228
+ for (const entry of entries) {
110229
+ const trimmedRaw = collapseDuplicateSpacesInKeywordText(entry.raw.trim());
110230
+ const target = resolveTargetMatchTypeForSplit(trimmedRaw, declaredUi);
110231
+ const list = groups.get(target) ?? [];
110232
+ list.push(
110233
+ entry.kind === "text" ? { kind: "text", raw: trimmedRaw } : { kind: "item", raw: trimmedRaw, item: { ...entry.item } }
110234
+ );
110235
+ groups.set(target, list);
110236
+ }
110237
+ if (groups.size <= 1) return [block];
110238
+ const labels = MATCH_TYPE_SPLIT_ORDER.filter((ui) => groups.has(ui)).map(
110239
+ (ui) => matchTypeUiToV2(ui)
110240
+ );
110241
+ warnings.push(
110242
+ `${path22} \u542B\u591A\u79CD\u5339\u914D\u7C7B\u578B\uFF0C\u5DF2\u81EA\u52A8\u62C6\u5206\u4E3A ${groups.size} \u4E2A KeywordsForBatchJob \u5757\uFF08${labels.join("\u3001")}\uFF09`
110243
+ );
110244
+ const shell = cloneKeywordBlockShell(block);
110245
+ const splitBlocks = [];
110246
+ for (const ui of MATCH_TYPE_SPLIT_ORDER) {
110247
+ const list = groups.get(ui);
110248
+ if (!list?.length) continue;
110249
+ const newBlock = { ...shell, MatchTypeV2: matchTypeUiToV2(ui) };
110250
+ const texts = [];
110251
+ const items = [];
110252
+ for (const e of list) {
110253
+ if (e.kind === "text") texts.push(e.raw);
110254
+ else items.push(e.item);
110255
+ }
110256
+ if (texts.length > 0) newBlock["KeywordText"] = texts;
110257
+ if (items.length > 0) newBlock["Items"] = items;
110258
+ splitBlocks.push(newBlock);
110259
+ }
110260
+ return splitBlocks;
110261
+ }
110187
110262
  function validateKeywordCore(core, path22, errors, lengthViolations) {
110188
110263
  const trimmed = core.trim();
110189
110264
  if (!trimmed) {
@@ -110221,66 +110296,193 @@ function normalizeKeywordSurface(raw, matchTypeV2) {
110221
110296
  const formatted = formatKeywordTextForMatchType(raw, matchType);
110222
110297
  return { formatted, matchType, inferredMatchType: fromField === null };
110223
110298
  }
110224
- function normalizeKeywordsForBatchJobBlock(block, path22, errors, warnings, lengthViolations) {
110225
- const texts = block["KeywordText"];
110226
- if (!Array.isArray(texts)) return;
110227
- const declaredUi = matchTypeV2ToUi(block["MatchTypeV2"]);
110228
- if (block["MatchTypeV2"] !== void 0 && declaredUi === null) {
110229
- errors.push(
110230
- `${path22}.MatchTypeV2 \u65E0\u6548\uFF08${String(block["MatchTypeV2"])}\uFF09\uFF0C\u5408\u6CD5\u503C\uFF1ABROAD | PHRASE | EXACT`
110299
+ function readBlockMatchTypeRaw(block) {
110300
+ if (block["MatchTypeV2"] !== void 0) return block["MatchTypeV2"];
110301
+ if (block["matchTypeV2"] !== void 0) return block["matchTypeV2"];
110302
+ return void 0;
110303
+ }
110304
+ function readBlockKeywordTextArray(block) {
110305
+ const pascal = block["KeywordText"];
110306
+ if (Array.isArray(pascal)) return pascal;
110307
+ const camel = block["keywordText"];
110308
+ if (Array.isArray(camel)) return camel;
110309
+ return null;
110310
+ }
110311
+ function readBlockItemsArray(block) {
110312
+ const pascal = block["Items"];
110313
+ if (Array.isArray(pascal)) return pascal;
110314
+ const camel = block["items"];
110315
+ if (Array.isArray(camel)) return camel;
110316
+ return null;
110317
+ }
110318
+ function readItemText(item) {
110319
+ const t = item["Text"] ?? item["text"];
110320
+ return typeof t === "string" ? t : null;
110321
+ }
110322
+ function writeItemText(item, formatted) {
110323
+ item["Text"] = formatted;
110324
+ delete item["text"];
110325
+ }
110326
+ function canonicalizeKeywordBatchBlock(block, resolvedMatchV2, normalizedTexts, hadItems) {
110327
+ if (resolvedMatchV2) block["MatchTypeV2"] = resolvedMatchV2;
110328
+ delete block["matchTypeV2"];
110329
+ if (normalizedTexts !== void 0) {
110330
+ block["KeywordText"] = normalizedTexts;
110331
+ delete block["keywordText"];
110332
+ }
110333
+ if (hadItems) {
110334
+ const items = readBlockItemsArray(block);
110335
+ if (items) {
110336
+ block["Items"] = items;
110337
+ delete block["items"];
110338
+ for (const item of items) {
110339
+ delete item["text"];
110340
+ }
110341
+ }
110342
+ }
110343
+ }
110344
+ function pushKeywordAutoFixWarning(warnings, path22, fieldLabel, trimmedRaw, formatted, declaredUi, beforeUi, matchType, inferredMatchType) {
110345
+ if (inferredMatchType) {
110346
+ warnings.push(
110347
+ `${path22}.${fieldLabel} \u672A\u6307\u5B9A MatchTypeV2\uFF0C\u5DF2\u6309\u8BCD\u9762\u63A8\u65AD\u4E3A ${matchTypeUiToV2(matchType)}`
110348
+ );
110349
+ return;
110350
+ }
110351
+ if (declaredUi && beforeUi !== declaredUi) {
110352
+ warnings.push(
110353
+ `${path22}.${fieldLabel} MatchTypeV2=${matchTypeUiToV2(declaredUi)} \u4E0E\u8BCD\u9762\u4E0D\u4E00\u81F4\uFF0C\u5DF2\u81EA\u52A8\u4FEE\u590D\u4E3A\uFF1A${formatted}`
110231
110354
  );
110232
110355
  return;
110233
110356
  }
110357
+ if (formatted !== trimmedRaw) {
110358
+ warnings.push(`${path22}.${fieldLabel} \u5DF2\u81EA\u52A8\u4FEE\u590D\u8BCD\u9762\uFF1A${trimmedRaw} \u2192 ${formatted}`);
110359
+ }
110360
+ }
110361
+ function normalizeKeywordTextList(texts, matchTypeRaw, path22, fieldLabel, errors, warnings, lengthViolations) {
110362
+ const declaredUi = matchTypeV2ToUi(matchTypeRaw);
110234
110363
  const normalized = [];
110235
110364
  const seen = /* @__PURE__ */ new Set();
110236
110365
  let resolvedUi = declaredUi;
110237
110366
  for (let k = 0; k < texts.length; k++) {
110238
110367
  const raw = texts[k];
110239
110368
  if (typeof raw !== "string" || !raw.trim()) {
110240
- errors.push(`${path22}.KeywordText[${k}] \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32`);
110369
+ errors.push(`${path22}.${fieldLabel}[${k}] \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32`);
110241
110370
  continue;
110242
110371
  }
110243
110372
  const trimmedRaw = collapseDuplicateSpacesInKeywordText(raw.trim());
110244
110373
  const beforeUi = inferMatchTypeFromDisplayText(trimmedRaw);
110245
- const { formatted, matchType, inferredMatchType } = normalizeKeywordSurface(trimmedRaw, block["MatchTypeV2"]);
110246
- if (inferredMatchType) {
110247
- warnings.push(
110248
- `${path22}.KeywordText[${k}] \u672A\u6307\u5B9A MatchTypeV2\uFF0C\u5DF2\u6309\u8BCD\u9762\u63A8\u65AD\u4E3A ${matchTypeUiToV2(matchType)}`
110249
- );
110250
- } else if (declaredUi && beforeUi !== declaredUi) {
110251
- warnings.push(
110252
- `${path22}.KeywordText[${k}] MatchTypeV2=${matchTypeUiToV2(declaredUi)} \u4E0E\u8BCD\u9762\u683C\u5F0F\u4E0D\u4E00\u81F4\uFF0C\u5DF2\u89C4\u8303\u4E3A\uFF1A${formatted}`
110253
- );
110254
- } else if (formatted !== trimmedRaw) {
110255
- warnings.push(`${path22}.KeywordText[${k}] \u8BCD\u9762\u5DF2\u89C4\u8303\u5316\uFF1A${trimmedRaw} \u2192 ${formatted}`);
110256
- }
110374
+ const { formatted, matchType, inferredMatchType } = normalizeKeywordSurface(trimmedRaw, matchTypeRaw);
110375
+ pushKeywordAutoFixWarning(
110376
+ warnings,
110377
+ path22,
110378
+ fieldLabel,
110379
+ trimmedRaw,
110380
+ formatted,
110381
+ declaredUi,
110382
+ beforeUi,
110383
+ matchType,
110384
+ inferredMatchType
110385
+ );
110257
110386
  const core = unwrapKeywordDisplayTextForEdit(formatted);
110258
- if (!validateKeywordCore(core, `${path22}.KeywordText[${k}]`, errors, lengthViolations))
110259
- continue;
110387
+ if (!validateKeywordCore(core, `${path22}.${fieldLabel}[${k}]`, errors, lengthViolations)) continue;
110260
110388
  const dedupeKey = `${matchTypeUiToV2(matchType)}:${core.toLowerCase()}`;
110261
110389
  if (seen.has(dedupeKey)) {
110262
- warnings.push(`${path22}.KeywordText[${k}] \u4E0E\u540C\u7EC4\u91CD\u590D\uFF0C\u5DF2\u8DF3\u8FC7\uFF1A${formatted}`);
110390
+ warnings.push(`${path22}.${fieldLabel}[${k}] \u4E0E\u540C\u7EC4\u91CD\u590D\uFF0C\u5DF2\u8DF3\u8FC7\uFF1A${formatted}`);
110263
110391
  continue;
110264
110392
  }
110265
110393
  seen.add(dedupeKey);
110266
110394
  normalized.push(formatted);
110267
110395
  if (!resolvedUi) resolvedUi = matchType;
110268
110396
  }
110269
- if (normalized.length === 0 && texts.length > 0) {
110270
- errors.push(`${path22}.KeywordText \u7ECF\u6821\u9A8C\u540E\u65E0\u6709\u6548\u5173\u952E\u8BCD\uFF0C\u8BF7\u4FEE\u6B63\u8BCD\u9762\u6216 MatchTypeV2`);
110397
+ return { normalized, resolvedUi };
110398
+ }
110399
+ function normalizeKeywordsForBatchJobBlock(block, path22, errors, warnings, lengthViolations) {
110400
+ const matchTypeRaw = readBlockMatchTypeRaw(block);
110401
+ const declaredUi = matchTypeV2ToUi(matchTypeRaw);
110402
+ if (matchTypeRaw !== void 0 && declaredUi === null) {
110403
+ errors.push(
110404
+ `${path22}.MatchTypeV2 \u65E0\u6548\uFF08${String(matchTypeRaw)}\uFF09\uFF0C\u5408\u6CD5\u503C\uFF1ABROAD | PHRASE | EXACT`
110405
+ );
110406
+ return;
110271
110407
  }
110272
- block["KeywordText"] = normalized;
110273
- if (resolvedUi) {
110274
- block["MatchTypeV2"] = matchTypeUiToV2(resolvedUi);
110408
+ const texts = readBlockKeywordTextArray(block);
110409
+ const items = readBlockItemsArray(block);
110410
+ const hasTexts = Array.isArray(texts) && texts.length > 0;
110411
+ const hasItems = Array.isArray(items) && items.length > 0;
110412
+ if (!hasTexts && !hasItems) return;
110413
+ let resolvedUi = declaredUi;
110414
+ let normalizedTexts;
110415
+ if (hasTexts && texts) {
110416
+ const result = normalizeKeywordTextList(
110417
+ texts,
110418
+ matchTypeRaw,
110419
+ path22,
110420
+ "KeywordText",
110421
+ errors,
110422
+ warnings,
110423
+ lengthViolations
110424
+ );
110425
+ normalizedTexts = result.normalized;
110426
+ if (result.resolvedUi) resolvedUi = result.resolvedUi;
110427
+ if (normalizedTexts.length === 0 && texts.length > 0) {
110428
+ errors.push(`${path22}.KeywordText \u7ECF\u6821\u9A8C\u540E\u65E0\u6709\u6548\u5173\u952E\u8BCD\uFF0C\u8BF7\u4FEE\u6B63\u8BCD\u9762\u6216 MatchTypeV2`);
110429
+ }
110275
110430
  }
110431
+ if (hasItems && items) {
110432
+ const seen = /* @__PURE__ */ new Set();
110433
+ for (let k = 0; k < items.length; k++) {
110434
+ const item = items[k];
110435
+ if (!item || typeof item !== "object") {
110436
+ errors.push(`${path22}.Items[${k}] \u5FC5\u987B\u662F\u5BF9\u8C61`);
110437
+ continue;
110438
+ }
110439
+ const raw = readItemText(item);
110440
+ if (raw === null || !raw.trim()) {
110441
+ errors.push(`${path22}.Items[${k}].Text \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32`);
110442
+ continue;
110443
+ }
110444
+ const trimmedRaw = collapseDuplicateSpacesInKeywordText(raw.trim());
110445
+ const beforeUi = inferMatchTypeFromDisplayText(trimmedRaw);
110446
+ const { formatted, matchType, inferredMatchType } = normalizeKeywordSurface(trimmedRaw, matchTypeRaw);
110447
+ pushKeywordAutoFixWarning(
110448
+ warnings,
110449
+ path22,
110450
+ `Items[${k}].Text`,
110451
+ trimmedRaw,
110452
+ formatted,
110453
+ declaredUi,
110454
+ beforeUi,
110455
+ matchType,
110456
+ inferredMatchType
110457
+ );
110458
+ const core = unwrapKeywordDisplayTextForEdit(formatted);
110459
+ if (!validateKeywordCore(core, `${path22}.Items[${k}].Text`, errors, lengthViolations)) continue;
110460
+ const dedupeKey = `${matchTypeUiToV2(matchType)}:${core.toLowerCase()}`;
110461
+ if (seen.has(dedupeKey)) {
110462
+ warnings.push(`${path22}.Items[${k}].Text \u4E0E\u540C\u7EC4\u91CD\u590D\uFF0C\u5DF2\u8DF3\u8FC7\uFF1A${formatted}`);
110463
+ continue;
110464
+ }
110465
+ seen.add(dedupeKey);
110466
+ writeItemText(item, formatted);
110467
+ if (!resolvedUi) resolvedUi = matchType;
110468
+ }
110469
+ }
110470
+ canonicalizeKeywordBatchBlock(
110471
+ block,
110472
+ resolvedUi ? matchTypeUiToV2(resolvedUi) : void 0,
110473
+ normalizedTexts,
110474
+ !!hasItems
110475
+ );
110276
110476
  }
110277
110477
  function normalizeCampaignKeywordTrees(campaign, errors, warnings, lengthViolations) {
110278
- const neg = campaign["NegativeKeywordsForBatchJob"];
110478
+ const neg = campaign["NegativeKeywordsForBatchJob"] ?? campaign["negativeKeywordsForBatchJob"];
110279
110479
  if (Array.isArray(neg)) {
110480
+ campaign["NegativeKeywordsForBatchJob"] = neg;
110481
+ delete campaign["negativeKeywordsForBatchJob"];
110280
110482
  for (let i = 0; i < neg.length; i++) {
110281
110483
  const block = neg[i];
110282
110484
  if (block && typeof block === "object") {
110283
- if (block["MatchTypeV2"] === void 0) block["MatchTypeV2"] = "BROAD";
110485
+ if (readBlockMatchTypeRaw(block) === void 0) block["MatchTypeV2"] = "BROAD";
110284
110486
  normalizeKeywordsForBatchJobBlock(
110285
110487
  block,
110286
110488
  `campaign.NegativeKeywordsForBatchJob[${i}]`,
@@ -110291,23 +110493,32 @@ function normalizeCampaignKeywordTrees(campaign, errors, warnings, lengthViolati
110291
110493
  }
110292
110494
  }
110293
110495
  }
110294
- const groups = campaign["AdGroupsForBatchJob"];
110496
+ const groups = campaign["AdGroupsForBatchJob"] ?? campaign["adGroupsForBatchJob"];
110295
110497
  if (!Array.isArray(groups)) return;
110498
+ campaign["AdGroupsForBatchJob"] = groups;
110499
+ delete campaign["adGroupsForBatchJob"];
110296
110500
  for (let i = 0; i < groups.length; i++) {
110297
110501
  const g = groups[i];
110298
- const kws = g?.["KeywordsForBatchJob"];
110502
+ const kws = g?.["KeywordsForBatchJob"] ?? g?.["keywordsForBatchJob"];
110299
110503
  if (!Array.isArray(kws)) continue;
110504
+ delete g["keywordsForBatchJob"];
110505
+ const expandedKws = [];
110300
110506
  for (let j = 0; j < kws.length; j++) {
110301
110507
  const block = kws[j];
110302
- if (block && typeof block === "object") {
110303
- normalizeKeywordsForBatchJobBlock(
110304
- block,
110305
- `campaign.AdGroupsForBatchJob[${i}].KeywordsForBatchJob[${j}]`,
110306
- errors,
110307
- warnings,
110308
- lengthViolations
110309
- );
110310
- }
110508
+ if (!block || typeof block !== "object") continue;
110509
+ const basePath = `campaign.AdGroupsForBatchJob[${i}].KeywordsForBatchJob[${j}]`;
110510
+ const splitBlocks = splitKeywordsForBatchJobBlockIfMixed(block, basePath, warnings);
110511
+ expandedKws.push(...splitBlocks);
110512
+ }
110513
+ g["KeywordsForBatchJob"] = expandedKws;
110514
+ for (let j = 0; j < expandedKws.length; j++) {
110515
+ normalizeKeywordsForBatchJobBlock(
110516
+ expandedKws[j],
110517
+ `campaign.AdGroupsForBatchJob[${i}].KeywordsForBatchJob[${j}]`,
110518
+ errors,
110519
+ warnings,
110520
+ lengthViolations
110521
+ );
110311
110522
  }
110312
110523
  }
110313
110524
  }
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "slug": "siluzan-tso",
3
- "version": "1.1.21-beta.1",
4
- "publishedAt": 1779419975724
3
+ "version": "1.1.21-beta.2",
4
+ "publishedAt": 1779679832469
5
5
  }
@@ -0,0 +1,218 @@
1
+ {
2
+ "account": "3206866200",
3
+ "customerName": "成功易人民币广告搭建测试账户",
4
+ "name": "Searh_核心中东",
5
+ "url": "https://www.hy-steelpipe.com/",
6
+ "locations": [
7
+ "United States"
8
+ ],
9
+ "productWords": [
10
+ "test",
11
+ "keyword"
12
+ ],
13
+ "googleDataRecordId": null,
14
+ "draft": false,
15
+ "campaign": {
16
+ "Name": "测试修复后的创建配置",
17
+ "StatusV2": "Enabled",
18
+ "ChannelTypeV2": "SEARCH",
19
+ "BiddingStrategyTypeV2": "TARGET_SPEND",
20
+ "Budget": 50,
21
+ "BudgetShared": false,
22
+ "BudgetId": 0,
23
+ "BudgetBudgetDeliveryMethodV2": "STANDARD",
24
+ "TargetSpend_BidCeilingAmount": 2,
25
+ "ManualCpc_EnhancedCpcEnabled": false,
26
+ "TargetGoogleSearch": true,
27
+ "TargetSearchNetwork": false,
28
+ "TargetContentNetwork": false,
29
+ "TargetPartnerSearchNetwork": false,
30
+ "PositiveGeoTargetType": 0,
31
+ "NegativeGeoTargetType": 0,
32
+ "DSADomainName": "",
33
+ "DSALanguageCode": "en",
34
+ "StartTime": "2026-05-25",
35
+ "EndTime": "2037-12-30",
36
+ "targetedLocations": [
37
+ {
38
+ "id": "2840",
39
+ "bidModifier": 0,
40
+ "bidModifierSpecified": false
41
+ }
42
+ ],
43
+ "excludedLocations": [],
44
+ "excludedIpAddresses": [],
45
+ "targetedLanguages": [
46
+ {
47
+ "id": 1000
48
+ }
49
+ ],
50
+ "targetedPlatforms": [
51
+ {
52
+ "id": 30001,
53
+ "bidModifier": 0
54
+ },
55
+ {
56
+ "id": 30002,
57
+ "bidModifier": 0
58
+ },
59
+ {
60
+ "id": 30000,
61
+ "bidModifier": 0
62
+ }
63
+ ],
64
+ "adSchedules": [
65
+ {
66
+ "DayOfWeekV2": 2,
67
+ "startHour": 0,
68
+ "StartMinuteV2": 2,
69
+ "endHour": 24,
70
+ "EndMinuteV2": 2
71
+ },
72
+ {
73
+ "DayOfWeekV2": 3,
74
+ "startHour": 0,
75
+ "StartMinuteV2": 2,
76
+ "endHour": 24,
77
+ "EndMinuteV2": 2
78
+ },
79
+ {
80
+ "DayOfWeekV2": 4,
81
+ "startHour": 0,
82
+ "StartMinuteV2": 2,
83
+ "endHour": 24,
84
+ "EndMinuteV2": 2
85
+ },
86
+ {
87
+ "DayOfWeekV2": 5,
88
+ "startHour": 0,
89
+ "StartMinuteV2": 2,
90
+ "endHour": 24,
91
+ "EndMinuteV2": 2
92
+ },
93
+ {
94
+ "DayOfWeekV2": 6,
95
+ "startHour": 0,
96
+ "StartMinuteV2": 2,
97
+ "endHour": 24,
98
+ "EndMinuteV2": 2
99
+ },
100
+ {
101
+ "DayOfWeekV2": 7,
102
+ "startHour": 0,
103
+ "StartMinuteV2": 2,
104
+ "endHour": 24,
105
+ "EndMinuteV2": 2
106
+ },
107
+ {
108
+ "DayOfWeekV2": 8,
109
+ "startHour": 0,
110
+ "StartMinuteV2": 2,
111
+ "endHour": 24,
112
+ "EndMinuteV2": 2
113
+ }
114
+ ],
115
+ "NegativeKeywordsForBatchJob": [
116
+ {
117
+ "KeywordText": [
118
+ "free sample",
119
+ "cheap"
120
+ ],
121
+ "MatchTypeV2": "BROAD",
122
+ "FinalURL": ""
123
+ }
124
+ ],
125
+ "ExtensionsForBatchJob": [],
126
+ "AdGroupsForBatchJob": [
127
+ {
128
+ "Name": "AG_Keyword_Normalize_Test",
129
+ "StatusV2": "Enabled",
130
+ "TypeV2": "SEARCH_STANDARD",
131
+ "RotationModeV2": "Unspecified",
132
+ "MaxCPCAmount": 2,
133
+ "KeywordsForBatchJob": [
134
+ {
135
+ "FinalURL": "https://www.example.com/test-a",
136
+ "MatchTypeV2": "BROAD",
137
+ "KeywordText": [
138
+ "broad match keyword"
139
+ ]
140
+ },
141
+ {
142
+ "FinalURL": "https://www.example.com/test-a",
143
+ "MatchTypeV2": "PHRASE",
144
+ "KeywordText": [
145
+ "\"phrase match keyword\""
146
+ ]
147
+ },
148
+ {
149
+ "FinalURL": "https://www.example.com/test-a",
150
+ "MatchTypeV2": "EXACT",
151
+ "KeywordText": [
152
+ "[exact match keyword]"
153
+ ]
154
+ },
155
+ {
156
+ "FinalURL": "https://www.example.com/test-b",
157
+ "MatchTypeV2": "BROAD",
158
+ "KeywordText": [
159
+ "running shoes broad"
160
+ ]
161
+ },
162
+ {
163
+ "FinalURL": "https://www.example.com/test-b",
164
+ "MatchTypeV2": "EXACT",
165
+ "KeywordText": [
166
+ "[steel pipe exact]"
167
+ ]
168
+ },
169
+ {
170
+ "MatchTypeV2": "EXACT",
171
+ "KeywordText": [
172
+ "[exact keyword one]",
173
+ "[exact keyword two]"
174
+ ],
175
+ "FinalURL": "https://www.example.com/test-c"
176
+ },
177
+ {
178
+ "MatchTypeV2": "PHRASE",
179
+ "KeywordText": [
180
+ "\"phrase keyword only\""
181
+ ],
182
+ "FinalURL": "https://www.example.com/test-d"
183
+ }
184
+ ],
185
+ "AdsForBatchJob": [
186
+ {
187
+ "TypeV2": "RESPONSIVE_SEARCH_AD",
188
+ "DestinationUrl": "https://www.example.com/test",
189
+ "Finalurl": "https://www.example.com/test",
190
+ "AdTitle": null,
191
+ "Path1": "test",
192
+ "Path2": "kw",
193
+ "headlinePart1": "Keyword Normalize Test H1",
194
+ "headlinePart2": "Validate Auto Fix Split",
195
+ "headlinePart3": "Do Not Run Create Yet",
196
+ "AddtionalHeadlines": [
197
+ "Headline Four For RSA",
198
+ "Headline Five For RSA",
199
+ "Headline Six For RSA",
200
+ "Headline Seven For RSA",
201
+ "Headline Eight For RSA",
202
+ "Headline Nine For RSA",
203
+ "Headline Ten For RSA",
204
+ "Headline Eleven RSA",
205
+ "Headline Twelve RSA"
206
+ ],
207
+ "adDescription": "Test ad for keyword validate only. Replace account before create.",
208
+ "adDescription2": "Second description line for RSA minimum fields.",
209
+ "AddtionalAdDescriptions": [
210
+ "Additional description one for responsive search ad.",
211
+ "Additional description two for responsive search ad."
212
+ ]
213
+ }
214
+ ]
215
+ }
216
+ ]
217
+ }
218
+ }
@@ -0,0 +1,146 @@
1
+ {
2
+ "_meta": {
3
+ "schema": "campaign-create/v2",
4
+ "purpose": "测试 campaign-validate 关键词自动修复与自动拆块",
5
+ "howToTest": [
6
+ "1. 将 account / customerName 替换为 list-accounts 中的真实 Google 账户",
7
+ "2. cd tso-cli && npm run build",
8
+ "3. node dist/index.js ad campaign-validate --config-file assets/siluzan-ads/assets/campaign-create-keyword-test.json --write-normalized ./campaign-create-keyword-test.fixed.json",
9
+ "4. 查看 warnings:应出现「自动拆分」「自动修复」;fixed.json 中 KeywordsForBatchJob 应为多块且词面带 []/\"\""
10
+ ],
11
+ "casesInThisFile": {
12
+ "mixedBlock": "AdGroups[0].Keywords[0]:一块内混 BROAD 裸词 + 词组引号 + 完全方括号 → 应拆成 3 块",
13
+ "broadWithExactSymbol": "Keywords[1]:MatchTypeV2=BROAD 但含 [exact kw] → 应拆成 BROAD + EXACT 两块",
14
+ "exactBareOnly": "Keywords[2]:MatchTypeV2=EXACT 全裸词 → 不拆,词面改为 [kw]",
15
+ "phraseBareOnly": "Keywords[3]:MatchTypeV2=PHRASE 裸词 → 不拆,词面改为 \"kw\""
16
+ }
17
+ },
18
+
19
+ "account": "4256317784",
20
+ "customerName": "REPLACE_请改为_list_accounts_返回的_mediaAccountName",
21
+ "name": "CLI_KeywordFixSplit_Test_2026",
22
+ "url": "https://www.example.com",
23
+ "locations": ["United States"],
24
+ "productWords": ["test", "keyword"],
25
+ "googleDataRecordId": null,
26
+ "draft": false,
27
+
28
+ "campaign": {
29
+ "Name": "CLI_KeywordFixSplit_Test_2026",
30
+ "StatusV2": "Enabled",
31
+ "ChannelTypeV2": "SEARCH",
32
+ "BiddingStrategyTypeV2": "TARGET_SPEND",
33
+ "Budget": 50,
34
+ "BudgetShared": false,
35
+ "BudgetId": 0,
36
+ "BudgetBudgetDeliveryMethodV2": "STANDARD",
37
+ "TargetSpend_BidCeilingAmount": 2,
38
+ "ManualCpc_EnhancedCpcEnabled": false,
39
+ "TargetGoogleSearch": true,
40
+ "TargetSearchNetwork": false,
41
+ "TargetContentNetwork": false,
42
+ "TargetPartnerSearchNetwork": false,
43
+ "PositiveGeoTargetType": 0,
44
+ "NegativeGeoTargetType": 0,
45
+ "DSADomainName": "",
46
+ "DSALanguageCode": "en",
47
+ "StartTime": "2026-05-25",
48
+ "EndTime": "2037-12-30",
49
+ "targetedLocations": [{ "id": "2840", "bidModifier": 0, "bidModifierSpecified": false }],
50
+ "excludedLocations": [],
51
+ "excludedIpAddresses": [],
52
+ "targetedLanguages": [{ "id": 1000 }],
53
+ "targetedPlatforms": [
54
+ { "id": 30001, "bidModifier": 0 },
55
+ { "id": 30002, "bidModifier": 0 },
56
+ { "id": 30000, "bidModifier": 0 }
57
+ ],
58
+
59
+ "adSchedules": [
60
+ { "DayOfWeekV2": 2, "startHour": 0, "StartMinuteV2": 2, "endHour": 24, "EndMinuteV2": 2 },
61
+ { "DayOfWeekV2": 3, "startHour": 0, "StartMinuteV2": 2, "endHour": 24, "EndMinuteV2": 2 },
62
+ { "DayOfWeekV2": 4, "startHour": 0, "StartMinuteV2": 2, "endHour": 24, "EndMinuteV2": 2 },
63
+ { "DayOfWeekV2": 5, "startHour": 0, "StartMinuteV2": 2, "endHour": 24, "EndMinuteV2": 2 },
64
+ { "DayOfWeekV2": 6, "startHour": 0, "StartMinuteV2": 2, "endHour": 24, "EndMinuteV2": 2 },
65
+ { "DayOfWeekV2": 7, "startHour": 0, "StartMinuteV2": 2, "endHour": 24, "EndMinuteV2": 2 },
66
+ { "DayOfWeekV2": 8, "startHour": 0, "StartMinuteV2": 2, "endHour": 24, "EndMinuteV2": 2 }
67
+ ],
68
+
69
+ "NegativeKeywordsForBatchJob": [
70
+ { "KeywordText": ["free sample", "cheap"], "MatchTypeV2": "BROAD", "FinalURL": "" }
71
+ ],
72
+
73
+ "ExtensionsForBatchJob": [],
74
+
75
+ "AdGroupsForBatchJob": [
76
+ {
77
+ "Name": "AG_Keyword_Normalize_Test",
78
+ "StatusV2": "Enabled",
79
+ "TypeV2": "SEARCH_STANDARD",
80
+ "RotationModeV2": "Unspecified",
81
+ "MaxCPCAmount": 2,
82
+
83
+ "KeywordsForBatchJob": [
84
+ {
85
+ "_comment": "用例 A:未写 MatchTypeV2,三种词面混在一块 → 应自动拆成 3 块",
86
+ "KeywordText": [
87
+ "broad match keyword",
88
+ "\"phrase match keyword\"",
89
+ "[exact match keyword]"
90
+ ],
91
+ "FinalURL": "https://www.example.com/test-a"
92
+ },
93
+ {
94
+ "_comment": "用例 B:块级 BROAD,但含完全匹配方括号词 → 应拆成 2 块",
95
+ "MatchTypeV2": "BROAD",
96
+ "KeywordText": ["running shoes broad", "[steel pipe exact]"],
97
+ "FinalURL": "https://www.example.com/test-b"
98
+ },
99
+ {
100
+ "_comment": "用例 C:块级 EXACT,全裸词 → 不拆,词面应变为 [kw]",
101
+ "MatchTypeV2": "EXACT",
102
+ "KeywordText": ["exact keyword one", "exact keyword two"],
103
+ "FinalURL": "https://www.example.com/test-c"
104
+ },
105
+ {
106
+ "_comment": "用例 D:块级 PHRASE,裸词 → 不拆,词面应变为 \"kw\"",
107
+ "MatchTypeV2": "PHRASE",
108
+ "KeywordText": ["phrase keyword only"],
109
+ "FinalURL": "https://www.example.com/test-d"
110
+ }
111
+ ],
112
+
113
+ "AdsForBatchJob": [
114
+ {
115
+ "TypeV2": "RESPONSIVE_SEARCH_AD",
116
+ "DestinationUrl": "https://www.example.com/test",
117
+ "Finalurl": "https://www.example.com/test",
118
+ "AdTitle": null,
119
+ "Path1": "test",
120
+ "Path2": "kw",
121
+ "headlinePart1": "Keyword Normalize Test H1",
122
+ "headlinePart2": "Validate Auto Fix Split",
123
+ "headlinePart3": "Do Not Run Create Yet",
124
+ "AddtionalHeadlines": [
125
+ "Headline Four For RSA",
126
+ "Headline Five For RSA",
127
+ "Headline Six For RSA",
128
+ "Headline Seven For RSA",
129
+ "Headline Eight For RSA",
130
+ "Headline Nine For RSA",
131
+ "Headline Ten For RSA",
132
+ "Headline Eleven RSA",
133
+ "Headline Twelve RSA"
134
+ ],
135
+ "adDescription": "Test ad for keyword validate only. Replace account before create.",
136
+ "adDescription2": "Second description line for RSA minimum fields.",
137
+ "AddtionalAdDescriptions": [
138
+ "Additional description one for responsive search ad.",
139
+ "Additional description two for responsive search ad."
140
+ ]
141
+ }
142
+ ]
143
+ }
144
+ ]
145
+ }
146
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  `siluzan-tso ad campaign-create` **仅**接受 `--config-file` 指向的 JSON 文件
4
4
 
5
- **JSON 字段名保持 PascalCase**,与后端 `Campaign` / `CampaignCreationRecord` 契约一致;`ad campaign-validate` 阶段**不**改词面与结构。
5
+ **JSON 字段名保持 PascalCase**,与后端 `Campaign` / `CampaignCreationRecord` 契约一致;`ad campaign-validate` 会**就地自动修复**关键词词面(按 `MatchTypeV2` 补全 `"` / `[]`,兼容 `matchTypeV2` / `keywordText` / `Items[]`),可用 `--write-normalized` 落盘修复后的 JSON。
6
6
 
7
7
  **`ad campaign-create` 提交前**,CLI 在 JSON 原文之外额外处理(不影响 validate 读到的「元」口径):
8
8
 
@@ -202,12 +202,12 @@ siluzan-tso ad batch diff --batch-id <taskId> --config-file ./campaign.json --js
202
202
 
203
203
  ### 关键词块(`KeywordsForBatchJob[j]`)
204
204
 
205
- 每个块描述一组**同匹配类型**的关键词:
205
+ 每个块描述一组**同匹配类型**的关键词。若同一块内混用裸词 / `"词组"` / `[完全]`(或与块级 `MatchTypeV2` 冲突的符号),`campaign-validate` 会**自动拆成多个块**(顺序:BROAD → PHRASE → EXACT,与 Web 智投一致),再按块级类型修复词面。
206
206
 
207
207
  | 字段 | 类型 | 说明 |
208
208
  | ------------- | --------------------------------- | --------------------------------------------------------------------- |
209
- | `KeywordText` | string[] | 关键词词面数组;PHRASE 应写 `"keyword"`,EXACT 应写 `[keyword]` |
210
- | `MatchTypeV2` | "BROAD" \| "PHRASE" \| "EXACT" | 与词面格式对应 |
209
+ | `KeywordText` | string[] | 关键词词面数组;PHRASE 应写 `"keyword"`,EXACT 应写 `[keyword]`(裸词亦可,`campaign-validate` 会按 `MatchTypeV2` **自动修复**) |
210
+ | `MatchTypeV2` | "BROAD" \| "PHRASE" \| "EXACT" | 与词面格式对应;Google 网关以词面符号为准,CLI 校验时会改写 `KeywordText` 与之对齐 |
211
211
  | `FinalURL` | string | 关键词级落地页 |
212
212
 
213
213
  ### 创意块(`AdsForBatchJob[j]`,RSA)
@@ -82,7 +82,7 @@ Google Keyword Planner 的搜索量/CPC 与**目标国家/地区**相关。CLI
82
82
 
83
83
  常见 ID(与 `ad campaign-create --location-ids` 同源):美国 `2840`,中国 `2826`。
84
84
 
85
- ### ⚠️ 多地区 `--geo`:返回汇总数据,无法按地区拆分
85
+ ### 多地区 `--geo`:返回汇总数据,无法按地区拆分
86
86
 
87
87
  `--geo` 可传多个 ID(逗号分隔,如 `--geo 2840,2826`),网关会把它们一并传给 `keywordidea/google?geoTargetConstantIds=...`。此时 Google Keyword Planner 返回的 `montlySearch`、CPC、竞争度等是**跨所传地区的汇总/合并口径**,**不是**「每个国家各一行」或 JSON 里带 `geoTargetConstantId` 分字段。
88
88
 
@@ -150,7 +150,7 @@ siluzan-tso keyword -k "structural adhesive,SG-200,curtain wall bonding" --url "
150
150
  2. ```bash
151
151
  siluzan-tso keyword -k "<种子>" --url "<竞品或落地页 URL>" --json-out ./snap-kw
152
152
  ```
153
- 3. 合并去重、按搜索量/CPC/竞争度排序、截断 Top N、写「词包说明」:**由 AI 对落盘 JSON 处理**(CLI 不内置「相关度 Top 200」参数)。
153
+ 3. 合并去重、按搜索量/CPC/竞争度排序、截断 Top N、写「词包说明」(注意通过 google-analysis --sections keywords 获取的账号关键词数据禁止参与合并,因为包含同关键词多个广告系列的表现,一旦合并会导致数据错乱)
154
154
 
155
155
  ### 2)核心词长尾裂变 + 自定义过滤
156
156
 
@@ -17,7 +17,7 @@
17
17
  | 4 | 广告系列表现(预算/出价策略/各系列消耗与效果) | `google-analysis --sections campaigns` |
18
18
  | 5 | 设备分布(PC/移动/平板 消耗/点击/转化) | `google-analysis --sections devices` |
19
19
  | 6 | 地域分布(国家/地区 消耗占比) | `google-analysis --sections geographic` |
20
- | 7 | 关键词表现(词/消耗/CTR/CPC 排行) | `google-analysis --sections keywords` |
20
+ | 7 | 关键词表现(词/消耗/CTR/CPC 排行) | `google-analysis --sections keywords`(这个命令获取的关键词数据会来自多个系列,不能合并去重)|
21
21
  | 8 | 优化建议(根据以上数据给出可执行改进建议) | 不额外拉数,基于已有数据撰写 |
22
22
 
23
23
  **在执行任何数据拉取之前**,先向用户展示以下可选维度,询问是否需要追加:
@@ -9,7 +9,7 @@ $ErrorActionPreference = 'Stop'
9
9
  # -- Package info (injected at build time) ------------------------------------
10
10
  $PKG_NAME = 'siluzan-tso-cli'
11
11
  # PKG_VERSION 锁定到与本脚本同批构建产物一致的版本,避免与 dist/skill 错位
12
- $PKG_VERSION = '1.1.21-beta.1'
12
+ $PKG_VERSION = '1.1.21-beta.2'
13
13
  $CLI_BIN = 'siluzan-tso'
14
14
  $SKILL_LABEL = 'Siluzan TSO'
15
15
  $INSTALL_CMD = 'npm install -g siluzan-tso-cli@beta'
@@ -9,7 +9,7 @@ set -euo pipefail
9
9
  # -- Package info (injected at build time) ------------------------------------
10
10
  readonly PKG_NAME="siluzan-tso-cli"
11
11
  # PKG_VERSION 锁定到与本脚本同批构建产物一致的版本,避免与 dist/skill 错位
12
- readonly PKG_VERSION="1.1.21-beta.1"
12
+ readonly PKG_VERSION="1.1.21-beta.2"
13
13
  readonly CLI_BIN="siluzan-tso"
14
14
  readonly SKILL_LABEL="Siluzan TSO"
15
15
  readonly INSTALL_CMD="npm install -g siluzan-tso-cli@beta"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "siluzan-tso-cli",
3
- "version": "1.1.21-beta.1",
3
+ "version": "1.1.21-beta.2",
4
4
  "description": "Siluzan 广告账户管理 CLI — 查询账户、余额、消耗数据,管理绑定关系与充值。",
5
5
  "keywords": [
6
6
  "ad-account",