siluzan-tso-cli 1.1.20-beta.21 → 1.1.20-beta.22

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.20-beta.21),供内部测试使用。正式发布后安装命令将改为 `npm install -g siluzan-tso-cli`。
54
+ > **注意**:当前为测试版(1.1.20-beta.22),供内部测试使用。正式发布后安装命令将改为 `npm install -g siluzan-tso-cli`。
55
55
 
56
56
  | 助手 | 建议 `--ai` |
57
57
  | ----------------------- | ------------------------------------ |
package/dist/index.js CHANGED
@@ -2418,12 +2418,12 @@ function createVersionNotifier(opts) {
2418
2418
  const HOURS_24 = 24 * 60 * 60 * 1e3;
2419
2419
  const TTL_MAIN_TAG_MS = 60 * 60 * 1e3;
2420
2420
  const TTL_MIN_REQUIRED_MS = HOURS_24;
2421
- async function fetchVersionByTag(tag, cacheKey2, fetchAtKey, cfg, maxAgeMs) {
2421
+ async function fetchVersionByTag(tag, cacheKey, fetchAtKey, cfg, maxAgeMs) {
2422
2422
  const lastAt = cfg[fetchAtKey];
2423
- if (typeof lastAt === "string" && cacheKey2 in cfg) {
2423
+ if (typeof lastAt === "string" && cacheKey in cfg) {
2424
2424
  const lastMs = new Date(lastAt).getTime();
2425
2425
  if (Date.now() - lastMs < maxAgeMs) {
2426
- const v = cfg[cacheKey2];
2426
+ const v = cfg[cacheKey];
2427
2427
  const sv = typeof v === "string" && v ? v : null;
2428
2428
  return { version: sv, hitNetwork: false };
2429
2429
  }
@@ -109480,6 +109480,31 @@ init_cli_json_snapshot();
109480
109480
  init_strip_legacy_google_fields();
109481
109481
  init_cli_table();
109482
109482
 
109483
+ // src/commands/ad/campaign-length-violations.ts
109484
+ function pushLengthViolation(violations, item) {
109485
+ violations.push({ ...item, excess: item.actual - item.limit });
109486
+ }
109487
+ function formatLengthViolationsReport(violations) {
109488
+ if (violations.length === 0) return "";
109489
+ const lines = [
109490
+ "",
109491
+ `\u{1F4CF} \u8D85\u957F\u5185\u5BB9\u6E05\u5355\uFF08\u5171 ${violations.length} \u9879\uFF09`,
109492
+ " \u8BF7\u52FF\u5728 JSON \u4E2D\u81EA\u52A8\u622A\u65AD\uFF1B\u8BF7\u5C06\u5168\u90E8\u6761\u76EE\u4E0E\u4FEE\u6539\u65B9\u6848\u5217\u7ED9\u7528\u6237\uFF0C\u786E\u8BA4\u540E\u518D\u6539 JSON \u5E76\u91CD\u65B0 campaign-validate\u3002",
109493
+ ""
109494
+ ];
109495
+ for (let i = 0; i < violations.length; i++) {
109496
+ const v = violations[i];
109497
+ const mode = v.countMode === "google" ? "Google \u5B57\u7B26\u5BBD\uFF08CJK\xD72\uFF09" : "\u5B57\u7B26\u6570";
109498
+ lines.push(
109499
+ ` ${i + 1}. ${v.path}`,
109500
+ ` \u5B57\u6BB5 ${v.field} \xB7 \u4E0A\u9650 ${v.limit} \xB7 \u5F53\u524D ${v.actual}\uFF08${mode}\uFF0C\u8D85\u51FA ${v.excess}\uFF09`,
109501
+ ` \u539F\u6587\uFF1A${JSON.stringify(v.text)}`
109502
+ );
109503
+ }
109504
+ lines.push("");
109505
+ return lines.join("\n");
109506
+ }
109507
+
109483
109508
  // src/commands/ad/campaign-extensions.ts
109484
109509
  var SITELINK_DESCRIPTION_MAX_LEN = 25;
109485
109510
  function pushErr(errors, msg) {
@@ -109506,12 +109531,11 @@ function coercePropertiesFromRaw(raw) {
109506
109531
  return props;
109507
109532
  }
109508
109533
  function normalizeSitelinkProperties(props) {
109509
- const trimSitelinkDesc = (s) => s.length <= SITELINK_DESCRIPTION_MAX_LEN ? s : `${s.slice(0, SITELINK_DESCRIPTION_MAX_LEN - 1)}\u2026`;
109510
109534
  if (props.LinkText && !props.Text) props.Text = props.LinkText;
109511
109535
  const d1 = props.Description1 ?? props.Line2 ?? props.Text ?? props.LinkText;
109512
109536
  const d2 = props.Description2 ?? props.Line3;
109513
- props.Line2 = trimSitelinkDesc((d1 ?? "").trim() || props.Text || " ");
109514
- props.Line3 = trimSitelinkDesc((d2 ?? "").trim() || props.Line2);
109537
+ props.Line2 = (d1 ?? "").trim() || props.Text || " ";
109538
+ props.Line3 = (d2 ?? "").trim() || props.Line2;
109515
109539
  delete props.LinkText;
109516
109540
  delete props.Description1;
109517
109541
  delete props.Description2;
@@ -109530,7 +109554,7 @@ function normalizeExtensionsForBatchJob(extensions) {
109530
109554
  return out;
109531
109555
  });
109532
109556
  }
109533
- function validateSitelinkProperties(prefix, raw, errors, warnings) {
109557
+ function validateSitelinkProperties(prefix, raw, errors, warnings, lengthViolations) {
109534
109558
  if (Array.isArray(raw["FinalUrls"])) {
109535
109559
  pushErr(
109536
109560
  errors,
@@ -109559,15 +109583,33 @@ function validateSitelinkProperties(prefix, raw, errors, warnings) {
109559
109583
  const line2Raw = props.Description2 ?? props.Line3;
109560
109584
  const line2 = typeof line2Raw === "string" ? line2Raw.trim() : "";
109561
109585
  if (line1.length > SITELINK_DESCRIPTION_MAX_LEN) {
109586
+ pushLengthViolation(lengthViolations, {
109587
+ path: `${prefix}.Properties`,
109588
+ field: "Description1/Line2",
109589
+ kind: "sitelink_description",
109590
+ limit: SITELINK_DESCRIPTION_MAX_LEN,
109591
+ actual: line1.length,
109592
+ countMode: "ascii",
109593
+ text: line1
109594
+ });
109562
109595
  pushErr(
109563
109596
  errors,
109564
- `${prefix}.Properties \u63CF\u8FF0\u884C 1 \u8D85\u8FC7 ${SITELINK_DESCRIPTION_MAX_LEN} \u5B57\u7B26\uFF08\u5F53\u524D ${line1.length}\uFF09\uFF1A"${line1.slice(0, 40)}${line1.length > 40 ? "\u2026" : ""}"`
109597
+ `${prefix}.Properties \u63CF\u8FF0\u884C 1 \u8D85\u8FC7 ${SITELINK_DESCRIPTION_MAX_LEN} \u5B57\u7B26\uFF08\u5F53\u524D ${line1.length}\uFF09\uFF1A"${line1}"`
109565
109598
  );
109566
109599
  }
109567
109600
  if (line2.length > SITELINK_DESCRIPTION_MAX_LEN) {
109601
+ pushLengthViolation(lengthViolations, {
109602
+ path: `${prefix}.Properties`,
109603
+ field: "Description2/Line3",
109604
+ kind: "sitelink_description",
109605
+ limit: SITELINK_DESCRIPTION_MAX_LEN,
109606
+ actual: line2.length,
109607
+ countMode: "ascii",
109608
+ text: line2
109609
+ });
109568
109610
  pushErr(
109569
109611
  errors,
109570
- `${prefix}.Properties \u63CF\u8FF0\u884C 2 \u8D85\u8FC7 ${SITELINK_DESCRIPTION_MAX_LEN} \u5B57\u7B26\uFF08\u5F53\u524D ${line2.length}\uFF09\uFF1A"${line2.slice(0, 40)}${line2.length > 40 ? "\u2026" : ""}"`
109612
+ `${prefix}.Properties \u63CF\u8FF0\u884C 2 \u8D85\u8FC7 ${SITELINK_DESCRIPTION_MAX_LEN} \u5B57\u7B26\uFF08\u5F53\u524D ${line2.length}\uFF09\uFF1A"${line2}"`
109571
109613
  );
109572
109614
  }
109573
109615
  const rawDesc2 = raw["Description2"];
@@ -109581,7 +109623,7 @@ function validateSitelinkProperties(prefix, raw, errors, warnings) {
109581
109623
  );
109582
109624
  }
109583
109625
  }
109584
- function validateCampaignExtensionsForBatchJob(campaign, errors, warnings) {
109626
+ function validateCampaignExtensionsForBatchJob(campaign, errors, warnings, lengthViolations = []) {
109585
109627
  const extensions = campaign["ExtensionsForBatchJob"];
109586
109628
  if (extensions === void 0) return;
109587
109629
  if (!Array.isArray(extensions)) {
@@ -109603,7 +109645,13 @@ function validateCampaignExtensionsForBatchJob(campaign, errors, warnings) {
109603
109645
  continue;
109604
109646
  }
109605
109647
  if (type === "SITELINK" && props && typeof props === "object") {
109606
- validateSitelinkProperties(prefix, props, errors, warnings);
109648
+ validateSitelinkProperties(
109649
+ prefix,
109650
+ props,
109651
+ errors,
109652
+ warnings,
109653
+ lengthViolations
109654
+ );
109607
109655
  } else if (type === "SITELINK") {
109608
109656
  pushErr(errors, `${prefix}\uFF08SITELINK\uFF09\u7F3A\u5C11 Properties`);
109609
109657
  }
@@ -109651,6 +109699,11 @@ function firstKeywordTextFromRecord(k) {
109651
109699
  }
109652
109700
  return "";
109653
109701
  }
109702
+ function keywordRowToYuan(item) {
109703
+ const { maxCPC: rawMaxCpc, ...rest } = item;
109704
+ if (rawMaxCpc == null) return item;
109705
+ return { ...rest, maxCPCYuan: toDisplayMoney(rawMaxCpc) };
109706
+ }
109654
109707
  async function runAdKeywords(opts) {
109655
109708
  const config = loadConfig(opts.token);
109656
109709
  const googleApiUrl = requireGoogleApi(config);
@@ -109678,8 +109731,9 @@ async function runAdKeywords(opts) {
109678
109731
  });
109679
109732
  const label = opts.negative ? "\u5426\u5B9A\u5173\u952E\u8BCD" : "\u5173\u952E\u8BCD";
109680
109733
  const n = items.length;
109734
+ const displayItems = opts.negative ? items : items.map((row) => keywordRowToYuan(row));
109681
109735
  const kwPayload = stripLegacyGoogleFieldsIfV2Present(
109682
- wrapListJson({ page: 1, pageSize: Math.max(n, 1), total: n, items })
109736
+ wrapListJson({ page: 1, pageSize: Math.max(n, 1), total: n, items: displayItems })
109683
109737
  );
109684
109738
  if (await emitCliJsonOrSnapshot(opts, {
109685
109739
  section: `ad-keywords-${opts.account}-${opts.negative ? "negative" : "positive"}`,
@@ -109870,7 +109924,7 @@ async function runAdKeywordEdit(opts) {
109870
109924
  } else if (opts.text !== void 0) {
109871
109925
  body["keywordText"] = [opts.text];
109872
109926
  }
109873
- if (opts.maxCpc !== void 0) body["maxCPC"] = opts.maxCpc;
109927
+ if (opts.maxCpc !== void 0) body["maxCPC"] = toCentAmount(opts.maxCpc);
109874
109928
  if (opts.finalUrl !== void 0) body["finalURL"] = opts.finalUrl;
109875
109929
  if (opts.status !== void 0) body["userStatusV2"] = opts.status;
109876
109930
  const url = `${googleApiUrl}/keywordmanagement/Keyword/${opts.account}/batch`;
@@ -110040,15 +110094,26 @@ function inferMatchTypeFromDisplayText(text) {
110040
110094
  if (t.length >= 2 && t.startsWith("[") && t.endsWith("]")) return "Exact";
110041
110095
  return "Broad";
110042
110096
  }
110043
- function validateKeywordCore(core, path22, errors) {
110097
+ function validateKeywordCore(core, path22, errors, lengthViolations) {
110044
110098
  const trimmed = core.trim();
110045
110099
  if (!trimmed) {
110046
110100
  errors.push(`${path22} \u5173\u952E\u8BCD\u8BCD\u5E72\u4E0D\u80FD\u4E3A\u7A7A`);
110047
110101
  return false;
110048
110102
  }
110049
110103
  if (trimmed.length > GOOGLE_KEYWORD_MAX_CORE_LENGTH) {
110104
+ if (lengthViolations) {
110105
+ pushLengthViolation(lengthViolations, {
110106
+ path: path22,
110107
+ field: "KeywordText",
110108
+ kind: "keyword_core",
110109
+ limit: GOOGLE_KEYWORD_MAX_CORE_LENGTH,
110110
+ actual: trimmed.length,
110111
+ countMode: "ascii",
110112
+ text: trimmed
110113
+ });
110114
+ }
110050
110115
  errors.push(
110051
- `${path22} \u5173\u952E\u8BCD\u8BCD\u5E72\u8D85\u8FC7 ${GOOGLE_KEYWORD_MAX_CORE_LENGTH} \u5B57\u7B26\uFF08\u5F53\u524D ${trimmed.length}\uFF09\uFF1A"${trimmed.slice(0, 40)}${trimmed.length > 40 ? "\u2026" : ""}"`
110116
+ `${path22} \u5173\u952E\u8BCD\u8BCD\u5E72\u8D85\u8FC7 ${GOOGLE_KEYWORD_MAX_CORE_LENGTH} \u5B57\u7B26\uFF08\u5F53\u524D ${trimmed.length}\uFF09\uFF1A"${trimmed}"`
110052
110117
  );
110053
110118
  return false;
110054
110119
  }
@@ -110066,7 +110131,7 @@ function normalizeKeywordSurface(raw, matchTypeV2) {
110066
110131
  const formatted = formatKeywordTextForMatchType(raw, matchType);
110067
110132
  return { formatted, matchType, inferredMatchType: fromField === null };
110068
110133
  }
110069
- function normalizeKeywordsForBatchJobBlock(block, path22, errors, warnings) {
110134
+ function normalizeKeywordsForBatchJobBlock(block, path22, errors, warnings, lengthViolations) {
110070
110135
  const texts = block["KeywordText"];
110071
110136
  if (!Array.isArray(texts)) return;
110072
110137
  const declaredUi = matchTypeV2ToUi(block["MatchTypeV2"]);
@@ -110100,7 +110165,8 @@ function normalizeKeywordsForBatchJobBlock(block, path22, errors, warnings) {
110100
110165
  warnings.push(`${path22}.KeywordText[${k}] \u8BCD\u9762\u5DF2\u89C4\u8303\u5316\uFF1A${trimmedRaw} \u2192 ${formatted}`);
110101
110166
  }
110102
110167
  const core = unwrapKeywordDisplayTextForEdit(formatted);
110103
- if (!validateKeywordCore(core, `${path22}.KeywordText[${k}]`, errors)) continue;
110168
+ if (!validateKeywordCore(core, `${path22}.KeywordText[${k}]`, errors, lengthViolations))
110169
+ continue;
110104
110170
  const dedupeKey = `${matchTypeUiToV2(matchType)}:${core.toLowerCase()}`;
110105
110171
  if (seen.has(dedupeKey)) {
110106
110172
  warnings.push(`${path22}.KeywordText[${k}] \u4E0E\u540C\u7EC4\u91CD\u590D\uFF0C\u5DF2\u8DF3\u8FC7\uFF1A${formatted}`);
@@ -110118,7 +110184,7 @@ function normalizeKeywordsForBatchJobBlock(block, path22, errors, warnings) {
110118
110184
  block["MatchTypeV2"] = matchTypeUiToV2(resolvedUi);
110119
110185
  }
110120
110186
  }
110121
- function normalizeCampaignKeywordTrees(campaign, errors, warnings) {
110187
+ function normalizeCampaignKeywordTrees(campaign, errors, warnings, lengthViolations) {
110122
110188
  const neg = campaign["NegativeKeywordsForBatchJob"];
110123
110189
  if (Array.isArray(neg)) {
110124
110190
  for (let i = 0; i < neg.length; i++) {
@@ -110129,7 +110195,8 @@ function normalizeCampaignKeywordTrees(campaign, errors, warnings) {
110129
110195
  block,
110130
110196
  `campaign.NegativeKeywordsForBatchJob[${i}]`,
110131
110197
  errors,
110132
- warnings
110198
+ warnings,
110199
+ lengthViolations
110133
110200
  );
110134
110201
  }
110135
110202
  }
@@ -110147,7 +110214,8 @@ function normalizeCampaignKeywordTrees(campaign, errors, warnings) {
110147
110214
  block,
110148
110215
  `campaign.AdGroupsForBatchJob[${i}].KeywordsForBatchJob[${j}]`,
110149
110216
  errors,
110150
- warnings
110217
+ warnings,
110218
+ lengthViolations
110151
110219
  );
110152
110220
  }
110153
110221
  }
@@ -110265,7 +110333,7 @@ function calcGoogleCharLength(text) {
110265
110333
  }
110266
110334
  return len;
110267
110335
  }
110268
- function validateRsaDisplayPath(prefix, ad, errors) {
110336
+ function validateRsaDisplayPath(prefix, ad, errors, lengthViolations) {
110269
110337
  const jsonHint = `\u5728 ${prefix} \u589E\u52A0 "Path1"\u3001"Path2"\uFF08PascalCase\uFF0C\u52FF\u7528 path1/path2\uFF1B\u6A21\u677F\u89C1 assets/campaign-create-template.json\uFF09`;
110270
110338
  if (ad["path1"] !== void 0 && ad["Path1"] === void 0) {
110271
110339
  pushErr2(
@@ -110307,6 +110375,15 @@ function validateRsaDisplayPath(prefix, ad, errors) {
110307
110375
  }
110308
110376
  const len = calcGoogleCharLength(raw);
110309
110377
  if (len > 15) {
110378
+ pushLengthViolation(lengthViolations, {
110379
+ path: `${prefix}.${field}`,
110380
+ field,
110381
+ kind: "rsa_path",
110382
+ limit: 15,
110383
+ actual: len,
110384
+ countMode: "google",
110385
+ text: raw
110386
+ });
110310
110387
  pushErr2(
110311
110388
  errors,
110312
110389
  `${prefix}.${field} \u8D85\u8FC7 15 \u5B57\u7B26\uFF08\u5F53\u524D ${len}\uFF0CCJK \u6309 2 \u8BA1\uFF09\uFF1A"${raw}"`
@@ -110320,7 +110397,7 @@ function validateRsaDisplayPath(prefix, ad, errors) {
110320
110397
  }
110321
110398
  }
110322
110399
  }
110323
- function validateRsaAd(prefix, ad, errors, warnings) {
110400
+ function validateRsaAd(prefix, ad, errors, warnings, lengthViolations) {
110324
110401
  const h1 = ad["headlinePart1"];
110325
110402
  const h2 = ad["headlinePart2"];
110326
110403
  const h3 = ad["headlinePart3"];
@@ -110341,12 +110418,28 @@ function validateRsaAd(prefix, ad, errors, warnings) {
110341
110418
  } else if (headlines.length < 12) {
110342
110419
  pushWarn2(warnings, `${prefix} \u6807\u9898\u63A8\u8350 12\u201315 \u6761\uFF08\u5F53\u524D ${headlines.length} \u6761\uFF09\uFF0C\u66F4\u591A\u7EC4\u5408\u53EF\u63D0\u5347\u6295\u653E\u8868\u73B0`);
110343
110420
  }
110421
+ const headlineFields = [
110422
+ "headlinePart1",
110423
+ "headlinePart2",
110424
+ "headlinePart3",
110425
+ ...moreH.map((_, idx) => `AddtionalHeadlines[${idx}]`)
110426
+ ];
110344
110427
  for (let i = 0; i < headlines.length; i++) {
110345
110428
  const len = calcGoogleCharLength(headlines[i]);
110346
110429
  if (len > 30) {
110430
+ const field = headlineFields[i] ?? `headline[${i}]`;
110431
+ pushLengthViolation(lengthViolations, {
110432
+ path: `${prefix}.${field}`,
110433
+ field,
110434
+ kind: "rsa_headline",
110435
+ limit: 30,
110436
+ actual: len,
110437
+ countMode: "google",
110438
+ text: headlines[i]
110439
+ });
110347
110440
  pushErr2(
110348
110441
  errors,
110349
- `${prefix} \u6807\u9898[${i}] \u8D85\u8FC7 30 \u5B57\u7B26\uFF08\u5F53\u524D ${len}\uFF0CCJK \u8BA1 2\uFF09\uFF1A"${headlines[i].slice(0, 30)}"`
110442
+ `${prefix} \u6807\u9898[${i}] \u8D85\u8FC7 30 \u5B57\u7B26\uFF08\u5F53\u524D ${len}\uFF0CCJK \u8BA1 2\uFF09\uFF1A"${headlines[i]}"`
110350
110443
  );
110351
110444
  }
110352
110445
  }
@@ -110360,13 +110453,28 @@ function validateRsaAd(prefix, ad, errors, warnings) {
110360
110453
  } else if (descriptions.length < 4) {
110361
110454
  pushWarn2(warnings, `${prefix} \u63CF\u8FF0\u63A8\u8350 4 \u6761\uFF08\u5F53\u524D ${descriptions.length} \u6761\uFF09`);
110362
110455
  }
110456
+ const descriptionFields = [
110457
+ "adDescription",
110458
+ "adDescription2",
110459
+ ...moreD.map((_, idx) => `AddtionalAdDescriptions[${idx}]`)
110460
+ ];
110363
110461
  for (let i = 0; i < descriptions.length; i++) {
110364
110462
  const len = calcGoogleCharLength(descriptions[i]);
110365
110463
  if (len > 90) {
110366
- pushErr2(errors, `${prefix} \u63CF\u8FF0[${i}] \u8D85\u8FC7 90 \u5B57\u7B26\uFF08\u5F53\u524D ${len}\uFF09\uFF1A"${descriptions[i].slice(0, 40)}"`);
110464
+ const field = descriptionFields[i] ?? `description[${i}]`;
110465
+ pushLengthViolation(lengthViolations, {
110466
+ path: `${prefix}.${field}`,
110467
+ field,
110468
+ kind: "rsa_description",
110469
+ limit: 90,
110470
+ actual: len,
110471
+ countMode: "google",
110472
+ text: descriptions[i]
110473
+ });
110474
+ pushErr2(errors, `${prefix} \u63CF\u8FF0[${i}] \u8D85\u8FC7 90 \u5B57\u7B26\uFF08\u5F53\u524D ${len}\uFF09\uFF1A"${descriptions[i]}"`);
110367
110475
  }
110368
110476
  }
110369
- validateRsaDisplayPath(prefix, ad, errors);
110477
+ validateRsaDisplayPath(prefix, ad, errors, lengthViolations);
110370
110478
  const finalUrl = ad["Finalurl"] ?? ad["DestinationUrl"];
110371
110479
  if (typeof finalUrl === "string" && finalUrl.length > 0 && !URL_REGEX.test(finalUrl)) {
110372
110480
  pushErr2(errors, `${prefix} Finalurl \u683C\u5F0F\u4E0D\u6B63\u786E\uFF08\u5E94\u4EE5 http(s):// \u5F00\u5934\uFF09\uFF1A${finalUrl}`);
@@ -110376,8 +110484,14 @@ function validateRsaAd(prefix, ad, errors, warnings) {
110376
110484
  function validateCampaignCreateConfigCore(cfg) {
110377
110485
  const errors = [];
110378
110486
  const warnings = [];
110487
+ const lengthViolations = [];
110379
110488
  if (cfg.campaign && typeof cfg.campaign === "object" && !Array.isArray(cfg.campaign)) {
110380
- normalizeCampaignKeywordTrees(cfg.campaign, errors, warnings);
110489
+ normalizeCampaignKeywordTrees(
110490
+ cfg.campaign,
110491
+ errors,
110492
+ warnings,
110493
+ lengthViolations
110494
+ );
110381
110495
  }
110382
110496
  if (!cfg.account?.toString().trim()) {
110383
110497
  pushErr2(errors, "account\uFF08\u9876\u5C42 customerId\uFF09\u4E0D\u80FD\u4E3A\u7A7A");
@@ -110391,7 +110505,7 @@ function validateCampaignCreateConfigCore(cfg) {
110391
110505
  }
110392
110506
  if (!cfg.campaign || typeof cfg.campaign !== "object" || Array.isArray(cfg.campaign)) {
110393
110507
  pushErr2(errors, "campaign \u5BF9\u8C61\u4E0D\u80FD\u4E3A\u7A7A\uFF08\u540E\u7AEF\uFF1Acampaign is null\uFF09");
110394
- return { errors, warnings };
110508
+ return { errors, warnings, lengthViolations };
110395
110509
  }
110396
110510
  const campaign = cfg.campaign;
110397
110511
  if (campaign["TargetPartnerSearchNetwork"] === true) {
@@ -110554,7 +110668,7 @@ function validateCampaignCreateConfigCore(cfg) {
110554
110668
  for (let j = 0; j < ads.length; j++) {
110555
110669
  const ad = ads[j];
110556
110670
  if (ad && typeof ad === "object") {
110557
- validateRsaAd(`${gPrefix}.AdsForBatchJob[${j}]`, ad, errors, warnings);
110671
+ validateRsaAd(`${gPrefix}.AdsForBatchJob[${j}]`, ad, errors, warnings, lengthViolations);
110558
110672
  }
110559
110673
  }
110560
110674
  }
@@ -110571,8 +110685,8 @@ function validateCampaignCreateConfigCore(cfg) {
110571
110685
  }
110572
110686
  validateCampaignRsaCrossGroupHeadlines(campaign, errors);
110573
110687
  }
110574
- validateCampaignExtensionsForBatchJob(campaign, errors, warnings);
110575
- return { errors, warnings };
110688
+ validateCampaignExtensionsForBatchJob(campaign, errors, warnings, lengthViolations);
110689
+ return { errors, warnings, lengthViolations };
110576
110690
  }
110577
110691
  function runCampaignCreateValidation(cfg) {
110578
110692
  return validateCampaignCreateConfigCore(cfg);
@@ -113146,46 +113260,53 @@ async function runAiCreationUpdate(opts) {
113146
113260
 
113147
113261
  // src/commands/ad/campaign-validate.ts
113148
113262
  import { writeFileSync as writeFileSync3 } from "fs";
113263
+ init_cli_json_snapshot();
113264
+ var LENGTH_VIOLATION_AGENT_HINT = "\u52FF\u81EA\u52A8\u622A\u65AD JSON\u3002\u8BFB\u53D6\u843D\u76D8 JSON \u7684 lengthViolations\uFF0C\u5C06\u5168\u90E8\u6761\u76EE\u4E0E\u4FEE\u6539\u65B9\u6848\u5217\u7ED9\u7528\u6237\uFF0C\u786E\u8BA4\u540E\u5199\u5165 campaign.json \u5E76\u91CD\u65B0 campaign-validate\u3002";
113149
113265
  async function runAdCampaignValidate(opts) {
113150
113266
  const cfg = loadCampaignCreateConfig(opts.configFile);
113151
- const { errors, warnings } = runCampaignCreateValidation(cfg);
113267
+ const { errors, warnings, lengthViolations } = runCampaignCreateValidation(cfg);
113152
113268
  if (opts.writeNormalized) {
113153
113269
  const toWrite = stripMetaKeysForExport(cfg);
113154
113270
  writeFileSync3(opts.writeNormalized, `${JSON.stringify(toWrite, null, 2)}
113155
113271
  `, "utf8");
113156
113272
  }
113157
- if (opts.json) {
113158
- console.log(
113159
- JSON.stringify(
113160
- {
113161
- ok: errors.length === 0,
113162
- errors,
113163
- warnings
113164
- },
113165
- null,
113166
- 2
113167
- )
113168
- );
113169
- } else {
113170
- if (warnings.length > 0) {
113171
- console.warn("\n\u26A0\uFE0F \u6295\u653E\u914D\u7F6E\u8B66\u544A\uFF1A");
113172
- for (const w of warnings) console.warn(` \u2022 ${w}`);
113173
- }
113174
- if (errors.length > 0) {
113175
- console.error("\n\u274C \u6295\u653E\u914D\u7F6E\u6821\u9A8C\u5931\u8D25\uFF1A");
113176
- for (const e of errors) console.error(` \u2022 ${e}`);
113177
- console.error();
113178
- process.exit(1);
113179
- }
113180
- console.log("\n\u2705 \u6295\u653E\u914D\u7F6E\u6821\u9A8C\u901A\u8FC7");
113181
- if (opts.writeNormalized) {
113182
- console.log(` \u5DF2\u5199\u5165\u89C4\u8303\u5316 JSON\uFF1A${opts.writeNormalized}`);
113183
- }
113184
- console.log();
113273
+ const payload = {
113274
+ ok: errors.length === 0,
113275
+ configFile: opts.configFile,
113276
+ account: cfg.account?.toString().trim() || void 0,
113277
+ errors,
113278
+ warnings,
113279
+ lengthViolations,
113280
+ agentHint: lengthViolations.length > 0 ? LENGTH_VIOLATION_AGENT_HINT : void 0
113281
+ };
113282
+ const accountSuffix = payload.account || void 0;
113283
+ if (await emitCliJsonOrSnapshot(opts, {
113284
+ section: "ad-campaign-validate",
113285
+ commandLabel: "ad campaign-validate",
113286
+ commandHint: opts.configFile,
113287
+ payload,
113288
+ idSuffix: accountSuffix
113289
+ })) {
113290
+ if (!payload.ok) process.exit(1);
113291
+ return;
113292
+ }
113293
+ if (warnings.length > 0) {
113294
+ console.warn("\n\u26A0\uFE0F \u6295\u653E\u914D\u7F6E\u8B66\u544A\uFF1A");
113295
+ for (const w of warnings) console.warn(` \u2022 ${w}`);
113185
113296
  }
113186
113297
  if (errors.length > 0) {
113298
+ console.error("\n\u274C \u6295\u653E\u914D\u7F6E\u6821\u9A8C\u5931\u8D25\uFF1A");
113299
+ for (const e of errors) console.error(` \u2022 ${e}`);
113300
+ const lengthReport = formatLengthViolationsReport(lengthViolations);
113301
+ if (lengthReport) console.error(lengthReport);
113302
+ console.error();
113187
113303
  process.exit(1);
113188
113304
  }
113305
+ console.log("\n\u2705 \u6295\u653E\u914D\u7F6E\u6821\u9A8C\u901A\u8FC7");
113306
+ if (opts.writeNormalized) {
113307
+ console.log(` \u5DF2\u5199\u5165\u89C4\u8303\u5316 JSON\uFF1A${opts.writeNormalized}`);
113308
+ }
113309
+ console.log();
113189
113310
  }
113190
113311
 
113191
113312
  // src/commands/ad/_register.ts
@@ -113535,16 +113656,20 @@ function register20(program2) {
113535
113656
  }
113536
113657
  );
113537
113658
  adCmd.command("campaign-validate").description(
113538
- "\u6821\u9A8C campaign-create JSON\uFF08\u8BCD\u9762\u89C4\u8303\u5316 + \u540E\u7AEF\u786C\u7EA6\u675F\uFF1B\u4E0D\u8C03\u7528 API\uFF09\n\n \u7528\u6CD5\uFF1A\n siluzan-tso ad campaign-validate --config-file ./campaign.json\n siluzan-tso ad campaign-validate --config-file ./campaign.json --write-normalized ./campaign.normalized.json"
113659
+ "\u6821\u9A8C campaign-create JSON\uFF08\u8BCD\u9762\u89C4\u8303\u5316 + \u540E\u7AEF\u786C\u7EA6\u675F\uFF1B\u4E0D\u8C03\u7528 API\uFF09\n\n \u7528\u6CD5\uFF1A\n siluzan-tso ad campaign-validate --config-file ./campaign.json --json-out ./snap-campaign\n siluzan-tso ad campaign-validate --config-file ./campaign.json --write-normalized ./campaign.normalized.json"
113539
113660
  ).requiredOption("--config-file <path>", "campaign-create JSON \u8DEF\u5F84").option(
113540
113661
  "--write-normalized <path>",
113541
113662
  "\u5C06\u89C4\u8303\u5316\u540E\u7684 JSON \u5199\u5165\u8BE5\u8DEF\u5F84\uFF08\u5173\u952E\u8BCD\u8BCD\u9762\u5DF2\u4FEE\u6B63\uFF09"
113542
- ).option("--json", "\u8F93\u51FA { ok, errors, warnings }", false).option("--verbose", "\u8BE6\u7EC6\u9519\u8BEF\u4FE1\u606F", false).action(
113663
+ ).option("--json", "\u8F93\u51FA { ok, errors, warnings, lengthViolations }\uFF08\u4E0E --json-out \u4E92\u65A5\uFF09", false).option(
113664
+ "--json-out <path>",
113665
+ "\u843D\u76D8\u6821\u9A8C\u7ED3\u679C\uFF08\u542B lengthViolations\uFF09\u5E76\u66F4\u65B0 cli-manifest\uFF1B\u4E0E --json \u4E92\u65A5"
113666
+ ).option("--verbose", "\u8BE6\u7EC6\u9519\u8BEF\u4FE1\u606F", false).action(
113543
113667
  async (opts) => {
113544
113668
  await runAdCampaignValidate({
113545
113669
  configFile: opts.configFile,
113546
113670
  writeNormalized: opts.writeNormalized,
113547
113671
  json: opts.json,
113672
+ jsonOut: opts.jsonOut,
113548
113673
  verbose: opts.verbose
113549
113674
  });
113550
113675
  }
@@ -113705,7 +113830,7 @@ function register20(program2) {
113705
113830
  '\u65B0\u5339\u914D\u7C7B\u578B\uFF1ABroad | Phrase | Exact\uFF08\u5199 matchTypeV2\uFF0C\u5E76\u9ED8\u8BA4\u540C\u6B65\u6539\u5199 keywordText \u4E3A\u8BCD\u5E72/"\u8BCD"/[\u8BCD] \u4EE5\u7B26\u5408\u7F51\u5173\u63A8\u65AD\uFF09'
113706
113831
  ).option(
113707
113832
  "--max-cpc <n>",
113708
- "\u6700\u9AD8\u6BCF\u6B21\u70B9\u51FB\u8D39\u7528 maxCPC\uFF0C\u4E3B\u5E01\u79CD\u91D1\u989D\uFF08\u5982 5 \u8868\u793A \xA55\uFF1B\u26A0\uFE0F \u8FD9\u4E2A\u5B57\u6BB5\u540E\u7AEF\u5355\u4F4D\u5C31\u662F\u300C\u4E3B\u5E01\u79CD\u5143\u300D\uFF0CCLI \u76F4\u63A5\u900F\u4F20\u4E0D\u505A \xD7100\uFF0C\u4E0E budget / \u7EC4 maxCPCAmount \u4E0D\u540C\uFF1B0 \u8868\u793A\u6309\u5E73\u53F0/\u8BA1\u5212\u9ED8\u8BA4\uFF09"
113833
+ "\u6700\u9AD8\u6BCF\u6B21\u70B9\u51FB\u8D39\u7528 maxCPC\uFF0C\u4E3B\u5E01\u79CD\u91D1\u989D\uFF08\u5982 10 \u8868\u793A \xA510\uFF1BCLI \u5185\u90E8 \xD7100 \u5199\u5165\u300C\u5206\u300D\u5B57\u6BB5\uFF0C\u4E0E adgroup-edit --max-cpc \u540C\u53E3\u5F84\uFF1B0 \u8868\u793A\u6309\u5E73\u53F0/\u8BA1\u5212\u9ED8\u8BA4\uFF09"
113709
113834
  ).option("--final-url <url>", "\u5173\u952E\u8BCD\u7EA7\u6700\u7EC8\u5230\u8FBE\u7F51\u5740 finalURL").option("--status <Enabled|Paused>", "\u7528\u6237\u72B6\u6001\uFF08\u5199\u5165 userStatusV2\uFF0C\u4E0E Web \u5173\u952E\u8BCD\u5F00\u5173\u4E00\u81F4\uFF09").option("--start <date>", "\u5217\u8868\u67E5\u8BE2\u8D77\u59CB\u65E5\u671F YYYY-MM-DD").option("--end <date>", "\u5217\u8868\u67E5\u8BE2\u7ED3\u675F\u65E5\u671F YYYY-MM-DD").option("-t, --token <token>", "Auth Token").option("--verbose", "\u8BE6\u7EC6\u9519\u8BEF\u4FE1\u606F", false).action(
113710
113835
  async (opts) => {
113711
113836
  if (opts.matchType && !["Broad", "Phrase", "Exact"].includes(opts.matchType)) {
@@ -114022,59 +114147,6 @@ function register20(program2) {
114022
114147
 
114023
114148
  // src/commands/keyword.ts
114024
114149
  init_auth();
114025
-
114026
- // src/utils/usd-cny-rate.ts
114027
- init_auth();
114028
- var RATE_CACHE_TTL_MS = 10 * 60 * 1e3;
114029
- var rateCache = null;
114030
- function cacheKey(config) {
114031
- return `${config.apiBaseUrl.replace(/\/$/, "")}|USD|CNY`;
114032
- }
114033
- function parseExrate(data) {
114034
- if (data == null || typeof data !== "object") return null;
114035
- const raw = data.exrate;
114036
- const n = typeof raw === "string" ? Number(raw) : Number(raw);
114037
- if (!Number.isFinite(n) || n <= 0) return null;
114038
- return n;
114039
- }
114040
- async function getUsdToCnyExchangeRate(config, verbose) {
114041
- const key = cacheKey(config);
114042
- const now = Date.now();
114043
- if (rateCache && rateCache.key === key && now - rateCache.fetchedAt < RATE_CACHE_TTL_MS) {
114044
- if (verbose) {
114045
- console.error(` [\u6C47\u7387] \u4F7F\u7528\u7F13\u5B58 USD\u2192CNY=${rateCache.rate}\uFF08${Math.round((now - rateCache.fetchedAt) / 1e3)}s \u524D\uFF09`);
114046
- }
114047
- return rateCache.rate;
114048
- }
114049
- const base = config.apiBaseUrl.replace(/\/$/, "");
114050
- const url = `${base}/allinpay/GetRate?srcccy=USD&dstccy=CNY`;
114051
- try {
114052
- const data = await apiFetch2(url, config, { method: "GET" }, verbose);
114053
- const rate = parseExrate(data);
114054
- if (rate == null) {
114055
- console.error("\n\u26A0 \u83B7\u53D6\u6C47\u7387\u5931\u8D25\uFF1AGetRate \u672A\u8FD4\u56DE\u6709\u6548 exrate\uFF0C\u51FA\u4EF7 CNY \u5B57\u6BB5\u5C06\u7701\u7565\u3002\n");
114056
- return null;
114057
- }
114058
- rateCache = { key, rate, fetchedAt: now };
114059
- if (verbose) {
114060
- console.error(` [\u6C47\u7387] USD\u2192CNY=${rate}\uFF08\u5DF2\u7F13\u5B58 ${RATE_CACHE_TTL_MS / 6e4} \u5206\u949F\uFF09`);
114061
- }
114062
- return rate;
114063
- } catch (err) {
114064
- const msg = err instanceof Error ? err.message : String(err);
114065
- console.error(`
114066
- \u26A0 \u83B7\u53D6 USD\u2192CNY \u6C47\u7387\u5931\u8D25\uFF08\u51FA\u4EF7 CNY \u5B57\u6BB5\u5C06\u7701\u7565\uFF09\uFF1A${msg}
114067
- `);
114068
- return null;
114069
- }
114070
- }
114071
- function convertUsdToCny(amountUsd, rate) {
114072
- if (amountUsd == null || !Number.isFinite(amountUsd)) return null;
114073
- if (!Number.isFinite(rate) || rate <= 0) return null;
114074
- return Math.round(amountUsd * rate * 100) / 100;
114075
- }
114076
-
114077
- // src/commands/keyword.ts
114078
114150
  init_cli_json_snapshot();
114079
114151
  init_cli_table();
114080
114152
 
@@ -114145,40 +114217,16 @@ function requireGoogleApi2(config) {
114145
114217
  return config.googleApiUrl;
114146
114218
  }
114147
114219
  var KEYWORD_SUGGEST_BID_AMOUNT_CURRENCY = "USD";
114148
- function applyUsdBidAmountsInCny(item, usdToCnyRate) {
114149
- return {
114150
- ...item,
114151
- averageCpcCNY: convertUsdToCny(item.averageCpc, usdToCnyRate),
114152
- lowTopOfPageBidCNY: convertUsdToCny(item.lowTopOfPageBid, usdToCnyRate),
114153
- highTopOfPageBidCNY: convertUsdToCny(item.highTopOfPageBid, usdToCnyRate)
114154
- };
114155
- }
114156
- function withUsdFieldAliases(item) {
114157
- return {
114158
- ...item,
114159
- averageCpcUSD: item.averageCpc,
114160
- lowTopOfPageBidUSD: item.lowTopOfPageBid,
114161
- highTopOfPageBidUSD: item.highTopOfPageBid
114162
- };
114163
- }
114164
114220
  function buildKeywordSuggestJsonPayload(items, opts) {
114165
114221
  const currency = opts.bidAmountCurrency.toUpperCase();
114166
- let normalized = items.map((row) => ({
114222
+ const normalized = items.map((row) => ({
114167
114223
  ...microsItemToBidAmounts(row),
114168
114224
  bidAmountCurrency: currency
114169
114225
  }));
114170
- const rate = opts.usdToCnyRate;
114171
- const isUsdPlanner = currency === KEYWORD_SUGGEST_BID_AMOUNT_CURRENCY;
114172
- if (isUsdPlanner && rate != null && Number.isFinite(rate) && rate > 0) {
114173
- normalized = normalized.map((row) => withUsdFieldAliases(applyUsdBidAmountsInCny(row, rate)));
114174
- } else if (isUsdPlanner) {
114175
- normalized = normalized.map(withUsdFieldAliases);
114176
- }
114177
114226
  const n = normalized.length;
114178
114227
  return {
114179
114228
  ...wrapListJson({ page: 1, pageSize: Math.max(n, 1), total: n, items: normalized }),
114180
- bidAmountCurrency: currency,
114181
- ...isUsdPlanner && rate != null && Number.isFinite(rate) && rate > 0 ? { usdToCnyExchangeRate: rate } : {}
114229
+ bidAmountCurrency: currency
114182
114230
  };
114183
114231
  }
114184
114232
  async function fetchUrlKeywords(url, keywords, verbose) {
@@ -114367,12 +114415,7 @@ async function runKeywordSuggest(opts) {
114367
114415
  });
114368
114416
  }
114369
114417
  const n = items.length;
114370
- const isUsdPlanner = bidAmountCurrency === KEYWORD_SUGGEST_BID_AMOUNT_CURRENCY;
114371
- const usdToCnyRate = isUsdPlanner ? await getUsdToCnyExchangeRate(config, opts.verbose) : null;
114372
- const kwPayload = buildKeywordSuggestJsonPayload(items, {
114373
- bidAmountCurrency,
114374
- usdToCnyRate
114375
- });
114418
+ const kwPayload = buildKeywordSuggestJsonPayload(items, { bidAmountCurrency });
114376
114419
  const displayItems = kwPayload.items;
114377
114420
  if (await emitCliJsonOrSnapshot(opts, {
114378
114421
  section: "keyword-suggest",
@@ -114390,16 +114433,7 @@ async function runKeywordSuggest(opts) {
114390
114433
  }
114391
114434
  const fmtAmt = (v) => v != null && Number.isFinite(v) ? v.toFixed(2) : "\u2014";
114392
114435
  const cur = bidAmountCurrency;
114393
- const hasCnyRef = isUsdPlanner && usdToCnyRate != null && usdToCnyRate > 0;
114394
- const cols = hasCnyRef ? [
114395
- { key: "keyword", header: "\u5173\u952E\u8BCD" },
114396
- { key: "montlySearch", header: "\u6708\u5747\u641C\u7D22" },
114397
- { key: "cpcMain", header: `\u5E73\u5747CPC(${cur})` },
114398
- { key: "cpcCny", header: "\u5E73\u5747CPC(CNY\u53C2\u8003)" },
114399
- { key: "bidRangeMain", header: `\u9875\u9996\u51FA\u4EF7(${cur})` },
114400
- { key: "bidRangeCny", header: "\u9875\u9996\u51FA\u4EF7(CNY\u53C2\u8003)" },
114401
- { key: "competition", header: "\u7ADE\u4E89\u5EA6" }
114402
- ] : [
114436
+ const cols = [
114403
114437
  { key: "keyword", header: "\u5173\u952E\u8BCD" },
114404
114438
  { key: "montlySearch", header: "\u6708\u5747\u641C\u7D22" },
114405
114439
  { key: "cpcMain", header: `\u5E73\u5747CPC(${cur})` },
@@ -114412,33 +114446,21 @@ async function runKeywordSuggest(opts) {
114412
114446
  const highMain = fmtAmt(item.highTopOfPageBid);
114413
114447
  const bidRangeMain = lowMain === "\u2014" && highMain === "\u2014" ? "\u2014" : `${lowMain} ~ ${highMain}`;
114414
114448
  const competitionDisplay = item.competitionV2 ?? (item.competition != null ? item.competition.toFixed(2) : "\u2014");
114415
- const row = {
114449
+ return {
114416
114450
  keyword: item.keyword ?? "",
114417
114451
  montlySearch: String(item.montlySearch ?? "\u2014"),
114418
114452
  cpcMain,
114419
114453
  bidRangeMain,
114420
114454
  competition: competitionDisplay
114421
114455
  };
114422
- if (hasCnyRef) {
114423
- const cpcCny = fmtAmt(item.averageCpcCNY);
114424
- const lowCny = fmtAmt(item.lowTopOfPageBidCNY);
114425
- const highCny = fmtAmt(item.highTopOfPageBidCNY);
114426
- row.cpcCny = cpcCny;
114427
- row.bidRangeCny = lowCny === "\u2014" && highCny === "\u2014" ? "\u2014" : `${lowCny} ~ ${highCny}`;
114428
- }
114429
- return row;
114430
114456
  });
114431
114457
  printCliTable(rows, cols);
114432
- if (hasCnyRef) {
114433
- console.log(
114434
- ` \uFF08\u51FA\u4EF7\u5E01\u79CD ${cur}\uFF1BCNY \u4E3A\u6C47\u7387 ${usdToCnyRate} \u6362\u7B97\u53C2\u8003\uFF0C\u975E Google \u8D26\u6237\u5E01\u79CD\u53E3\u5F84\uFF09
114435
- `
114436
- );
114437
- } else if (accountId) {
114458
+ if (accountId) {
114438
114459
  console.log(` \uFF08\u51FA\u4EF7\u5E01\u79CD\uFF1A${cur}\uFF0C\u4E0E\u8D26\u6237 currencyCode \u4E00\u81F4\uFF09
114439
114460
  `);
114440
114461
  } else {
114441
- console.log();
114462
+ console.log(` \uFF08\u51FA\u4EF7\u5E01\u79CD\uFF1A${cur}\uFF09
114463
+ `);
114442
114464
  }
114443
114465
  }
114444
114466
  function microsToAmount(v) {
@@ -115686,8 +115708,8 @@ async function runAccountWithdrawSubmit(opts) {
115686
115708
  });
115687
115709
  const feeRateCache = /* @__PURE__ */ new Map();
115688
115710
  for (const { currency, availableAmount } of accountDetails) {
115689
- const cacheKey2 = `${currency}-${availableAmount.toFixed(2)}`;
115690
- if (feeRateCache.has(cacheKey2)) continue;
115711
+ const cacheKey = `${currency}-${availableAmount.toFixed(2)}`;
115712
+ if (feeRateCache.has(cacheKey)) continue;
115691
115713
  try {
115692
115714
  const feeParams = new URLSearchParams({
115693
115715
  mediaType: "Google",
@@ -115700,16 +115722,16 @@ async function runAccountWithdrawSubmit(opts) {
115700
115722
  {},
115701
115723
  opts.verbose
115702
115724
  );
115703
- feeRateCache.set(cacheKey2, feeData?.feeRate ?? 0);
115725
+ feeRateCache.set(cacheKey, feeData?.feeRate ?? 0);
115704
115726
  } catch {
115705
115727
  console.warn(` \u26A0\uFE0F ${currency} \u7BA1\u7406\u8D39\u67E5\u8BE2\u5931\u8D25\uFF0C\u5C06\u4EE5 0 \u8D39\u7387\u63D0\u4EA4\u3002`);
115706
- feeRateCache.set(cacheKey2, 0);
115728
+ feeRateCache.set(cacheKey, 0);
115707
115729
  }
115708
115730
  }
115709
115731
  const body = accountDetails.map(
115710
115732
  ({ ma, mai, currency, balance, adjustments, availableAmount }) => {
115711
- const cacheKey2 = `${currency}-${availableAmount.toFixed(2)}`;
115712
- const feeRate = feeRateCache.get(cacheKey2) ?? 0;
115733
+ const cacheKey = `${currency}-${availableAmount.toFixed(2)}`;
115734
+ const feeRate = feeRateCache.get(cacheKey) ?? 0;
115713
115735
  const taxRate = currency === "CNY" ? 0.06 : 0;
115714
115736
  const totalAmounts = availableAmount * (1 + feeRate) * (1 + taxRate);
115715
115737
  return {
@@ -152,7 +152,7 @@ Windows 注意:部分 Agent 客户端通过 PowerShell / cmd 代执行命令
152
152
  - `ad campaigns --json/--json-out` → `budget`(元,与 `campaign-edit --budget` 同口径)
153
153
  - `ad groups --json` → `maxCPCAmountYuan` / `targetCpaAmountYuan`(元)
154
154
  - `google-analysis campaigns-*.json` → `budgetAmountYuan` / `campaignTargetCpaYuan` / `maximizeConversionsTargetCpaYuan` / `spend` / `averageCpc` / `costPerConversion`(均元)
155
- - `keyword --json` → `averageCpc` / `lowTopOfPageBid` / `highTopOfPageBid` + 每条 `bidAmountCurrency`(无 `-a` 为 USD,可选 `averageCpcCNY` 汇率参考;有 `-a` 为账户 `currencyCode`,勿用汇率换算);`-a <mediaCustomerId>` 走账户级推荐接口;限定市场用 `keyword geo-list` + `--geo <id>`(**多 id = 汇总指标**;分市场须多次调用、每次一个 `--geo`,见 `references/keyword-planner-workflows.md`)
155
+ - `keyword --json` → `averageCpc` / `lowTopOfPageBid` / `highTopOfPageBid` + 根级与每条 `bidAmountCurrency`(无 `-a` 为 USD;有 `-a` 为账户 `currencyCode`);`-a <mediaCustomerId>` 走账户级推荐接口;限定市场用 `keyword geo-list` + `--geo <id>`(**多 id = 汇总指标**;分市场须多次调用、每次一个 `--geo`,见 `references/keyword-planner-workflows.md`)
156
156
  - **品牌名优先级**:(1) 用户明确提供 → (2) `list-accounts.mag.advertiserName` → (3) 用户提供网址 → 域名占位并标注 `[待确认品牌名]`。**严禁**把英文域名翻译为虚构中文品牌。
157
157
  - 完整字段表见 `references/currency.md`。
158
158
 
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "slug": "siluzan-tso",
3
- "version": "1.1.20-beta.21",
4
- "publishedAt": 1779348843065
3
+ "version": "1.1.20-beta.22",
4
+ "publishedAt": 1779355357943
5
5
  }
@@ -23,11 +23,11 @@ JSON 模板:同目录 [`campaign-create-template.json`](campaign-create-templa
23
23
 
24
24
  | 命令 | 支持 `--json-out`? | 推荐用法 |
25
25
  | ---- | :-----------------: | -------- |
26
- | `ad campaign-validate` | **否** | `siluzan-tso ad campaign-validate --config-file ./campaign.json` 或加 `--json`;规范化输出用 `--write-normalized ./campaign.normalized.json` |
27
- | `ad campaign-create` | **是**(较新版本) | 落盘任务响应:`--json-out ./snap-campaign`;与 `--json` 互斥。若报 `unknown option --json-out`,升级 `siluzan-tso` 或暂用 `--json` |
26
+ | `ad campaign-validate` | **是** | **推荐** `--json-out ./snap-campaign`(落盘 `ad-campaign-validate-<account>.json`,含 `lengthViolations`);与 `--json` 互斥。人读:`--config-file` 即可;词面规范化:`--write-normalized`。超长勿自动截断,见 `google-ads-campaign-plan.md` § 超长人工确认 |
27
+ | `ad campaign-create` | **是** | 落盘任务响应:`--json-out ./snap-campaign`;与 `--json` 互斥 |
28
28
  | `ad batch get` / `ad batch diff` | **是** | 轮询与 diff 结果落盘,见 `references/tips.md` |
29
29
 
30
- **禁止**:对 `campaign-validate` `--json-out`(会报参数不支持,浪费多轮重试)。
30
+ **Agent**:校验与 create/batch 共用同一 `--json-out` 目录时,按 stdout 摘要里的 `outlineFile` → 再读对应 JSON(勿把 outline 当数据)。
31
31
 
32
32
  ### RSA 落地页:`Finalurl` 必填
33
33
 
@@ -83,7 +83,7 @@ CLI 出口的所有 JSON / 表格金额已统一为**元**,关键字段:
83
83
  | `ad campaigns --json` | `budget`(元,与 `--budget` 写参同口径) |
84
84
  | `ad groups --json` | `maxCPCAmountYuan`、`targetCpaAmountYuan` |
85
85
  | `google-analysis campaigns` 落盘 `campaigns-*.json` | `budgetAmountYuan`、`campaignTargetCpaYuan`、`maximizeConversionsTargetCpaYuan`;同行 `spend` / `averageCpc` / `costPerConversion` 也是元 |
86
- | `keyword suggest --json` | `averageCpc`、`lowTopOfPageBid`、`highTopOfPageBid`;`bidAmountCurrency`(有 `-a` 为账户币;无 `-a` 为 USD,可选 `averageCpcCNY` 参考) |
86
+ | `keyword suggest --json` | `averageCpc`、`lowTopOfPageBid`、`highTopOfPageBid`;根级与每条 `bidAmountCurrency`(有 `-a` 为账户币;无 `-a` 为 USD |
87
87
  | `balance` 等账户余额接口 | `remainingAccountBudget`(元) |
88
88
 
89
89
  旧字段 `budgetAmount`(分)、`maxCPCAmountDisplay`、`*Micros`(微元)**已不再落盘**,下游脚本无需做单位换算。金额保留 2 位小数,带货币代码(如 `¥50.00 CNY`、`$50.00 USD`),`currencyCode` 从响应读取,跨币种账户分表;细则见 `references/currency.md`。
@@ -46,7 +46,7 @@
46
46
 
47
47
  ## 金额单位约定
48
48
 
49
- - **CLI 出口的大多数 JSON / 表格金额以「元」为单位**:`budget`、`*Yuan` 后缀(`budgetAmountYuan`、`maxCPCAmountYuan` 等)、`spend` / `averageCpc` / `costPerConversion` 等。**`keyword suggest`**:`averageCpc` / `lowTopOfPageBid` / `highTopOfPageBid` 的币种见根级与每条 **`bidAmountCurrency`**(传 `-a` 时为账户 `currencyCode`;无 `-a` 时为 **USD**,可选 `averageCpcCNY` 为汇率参考、非 Google 账户口径)。
49
+ - **CLI 出口的大多数 JSON / 表格金额以「元」为单位**:`budget`、`*Yuan` 后缀(`budgetAmountYuan`、`maxCPCAmountYuan` 等)、`spend` / `averageCpc` / `costPerConversion` 等。**`keyword suggest`**:`averageCpc` / `lowTopOfPageBid` / `highTopOfPageBid` 的币种见根级与每条 **`bidAmountCurrency`**(传 `-a` 时为账户 `currencyCode`;无 `-a` 时为 **USD**)。
50
50
  - **写 CLI 参数**(`--budget`、`--max-cpc`、`--target-cpa`、`--amount` 等):同样传**主币种元**,与账户 `currencyCode` 一致;CLI 内部按需 ×100 / ×1_000_000 写后端。
51
51
  - 旧版网关字段(`budgetAmount` 分、`*Micros` 微元、`maxCPCAmountDisplay` 等)**已不再落盘到 CLI 输出**,下游脚本无需做单位换算。
52
52
 
@@ -26,7 +26,7 @@
26
26
  | 2 | 可选 `rag query`;`keyword` / `keyword geo-list` 拓词 | `references/keyword-planner-workflows.md` |
27
27
  | 3 | 按分层规则写入 `KeywordsForBatchJob`(Exact/Phrase/Broad) | `google-ads-rules/google-ads-keyword-taxonomy.md`(参考,非 CLI 强制) |
28
28
  | 4 | 填 `campaign`(预算/出价/地域/否词≥20/RSA/附加信息) | `assets/campaign-create-template.md` |
29
- | 5 | **`ad campaign-validate --config-file <json>`**(失败只改 JSON | 下文「校验」 |
29
+ | 5 | **`ad campaign-validate --config-file <json>`**(失败只改 JSON;超长见下文「超长人工确认」) | 下文「校验」 |
30
30
  | 6 | 输出:**JSON 代码块** → **Markdown**(`google-ads-launch-plan-template.md` 正文)→ 待确认 | — |
31
31
  | 7 | 用户确认后 **`ad campaign-create`** | `google-ads.md`|
32
32
  | 8 | 每隔5s 获取创建结果| `ad batch get --id <taskId> --config-file ./campaign.json` |
@@ -72,7 +72,8 @@
72
72
  ## 校验与创建(命令速查)
73
73
 
74
74
  ```bash
75
- siluzan-tso ad campaign-validate --config-file ./campaign.json [--json] [--write-normalized <path>] --commit "xxx"
75
+ siluzan-tso ad campaign-validate --config-file ./campaign.json --json-out ./snap-campaign
76
+ siluzan-tso ad campaign-validate --config-file ./campaign.json [--json] [--write-normalized <path>]
76
77
  siluzan-tso ad campaign-create --config-file ./campaign.json
77
78
  siluzan-tso ad batch get --id <taskId> --config-file ./campaign.json
78
79
  siluzan-tso ad batch diff --batch-id <taskId> --config-file ./campaign.json
@@ -81,6 +82,17 @@ siluzan-tso ad geo search
81
82
 
82
83
  validate 与 create **共用** `runCampaignCreateValidation`:词面规范化 + 后端/Google 硬约束(预算、RSA、匹配符号与 `MatchTypeV2` 对齐、搜索网络等)。**不含**关键词分层数量、匹配占比、否词条数下限。
83
84
 
85
+ ### 超长内容:禁止 Agent 自动截断
86
+
87
+ 标题/描述/Path/关键词/Sitelink 超限时 CLI **报错阻断**,不会在 JSON 里静默改短。
88
+
89
+ 1. 使用 **`ad campaign-validate --config-file <json> --json-out <dir>`**(与 create/batch 同一落盘目录),读落盘文件中的 `lengthViolations`(每项含 `path`、`limit`、`actual`、**完整** `text`)。小文件可用 `--json` 代替。
90
+ 2. Agent 将 **全部** 超长条目整理成表(路径、原文、上限、超出量),并为每条给出 **1–2 个改写方案**(保留卖点、符合字符计数;CJK 按 2 计见 `google-ads-compliance.md` §3.2.1)。
91
+ 3. **用户确认**选用方案后,Agent **只改 JSON 对应字段**,再执行 `campaign-validate`;通过后再 `campaign-create`。
92
+ 4. **禁止**:未确认前 `slice`/省略号截断、仅改 `--write-normalized` 而不经用户确认。
93
+
94
+ 人读模式失败时 CLI 会额外打印「📏 超长内容清单」;`--json-out` / `--json` 时见 `lengthViolations` + `agentHint`。
95
+
84
96
  ---
85
97
 
86
98
  ## 已上线后的修改
@@ -54,7 +54,7 @@
54
54
  | 技术词 | api/sdk/integration | payment sdk integration |
55
55
  | 问题词 | how to + 问题 | how to reduce failed payments |
56
56
 
57
- 拓词编排见 `references/keyword-planner-workflows.md`;Planner 出价用 `*USD` `*CNY`,根级 `bidAmountCurrency` / `usdToCnyExchangeRate`。
57
+ 拓词编排见 `references/keyword-planner-workflows.md`;Planner 出价见 `averageCpc` / `lowTopOfPageBid` / `highTopOfPageBid` 与根级、每条 `bidAmountCurrency`。
58
58
 
59
59
  ---
60
60
 
@@ -6,8 +6,7 @@
6
6
 
7
7
  ## 金额单位(全局重要)
8
8
 
9
- > **所有 CLI 金额参数均按「主币种金额」传入**(如 `1.5` = ¥1.50 / $1.50
10
- > **唯一例外**:`ad keyword-edit --max-cpc` 单位为「主币种元」(直接透传,不做 ×100 转换)。
9
+ > **所有 CLI 金额参数均按「主币种金额」传入**(如 `1.5` = ¥1.50 / $1.50);CLI 写入网关前对「分」字段 ×100(含 `ad keyword-edit --max-cpc` → `maxCPC`)。
11
10
  > **禁止** 按 Google micros(×1,000,000)填写任何金额参数。
12
11
 
13
12
  ---
@@ -277,6 +276,8 @@ siluzan-tso keyword geo-list [--country-code <US,CN,...>] [--name-contains <text
277
276
 
278
277
  不提交 API;创建系列前**建议**跑。命令、选项、与 create 共用校验逻辑见 **`references/google-ads-campaign-plan.md`** § 校验与创建(后端/Google 硬约束,不含关键词分层占比)。
279
278
 
279
+ **超长内容**:加 **`--json-out <dir>`**(推荐,与 create/batch 共用目录)或 `--json` 时响应含 `lengthViolations`(完整 `text` + JSON `path`)。Agent **勿自动截断**;须列出全部超长项与改写方案,用户确认后再改 JSON 并重跑 validate(流程见 `google-ads-campaign-plan.md` § 超长人工确认)。
280
+
280
281
  ---
281
282
 
282
283
  ## ad campaign-create — 广告系列创建
@@ -442,7 +443,7 @@ siluzan-tso ad keyword-edit \
442
443
  [--max-cpc <n>] [--final-url <url>] [--status Enabled|Paused]
443
444
  ```
444
445
 
445
- 传 `--match-type` 时 CLI 自动规范 `keywordText` 括号/引号格式。至少传一项。`--max-cpc` 为「主币种元」(见文首金额单位说明)。`--status` 写入 `userStatusV2`(与 Web 关键词开关一致,非系列的 `statusV2`)。
446
+ 传 `--match-type` 时 CLI 自动规范 `keywordText` 括号/引号格式。至少传一项。`--max-cpc` 为主币种元(CLI ×100 写入 `maxCPC`「分」字段,与 `adgroup-edit --max-cpc` 同口径)。`ad keywords --json` 出价见 `maxCPCYuan`。`--status` 写入 `userStatusV2`(与 Web 关键词开关一致,非系列的 `statusV2`)。
446
447
 
447
448
  ---
448
449
 
@@ -205,6 +205,6 @@ siluzan-tso keyword geo-list [--country-code <codes>] [--name-contains <text>] [
205
205
 
206
206
  `--google-only`:只调 Google 推荐主接口(有 `-a` 为账户接口,无 `-a` 为 `keywordidea/google`),不叠加 `--url` 的网址拓词;**分支 B(仅 Google)必加**。
207
207
 
208
- **返回字段**(与后端 `Samm.Core.Service.KeywordRecommendation` 对齐):根级与每条 **`bidAmountCurrency`**(无账户=`USD`;有账户=`list-accounts` 的 `currencyCode`);`averageCpc` / `lowTopOfPageBid` / `highTopOfPageBid`(微元 ÷1,000,000,**与 `bidAmountCurrency` 一致**)。无账户 USD 时可选 `usdToCnyExchangeRate` 与 `averageCpcCNY` 等**参考**字段(非 Google 账户币种口径)。另有 `keyword` / `montlySearch` / `competition` / `competitionV2` / `source` 等。
208
+ **返回字段**(与后端 `Samm.Core.Service.KeywordRecommendation` 对齐):根级与每条 **`bidAmountCurrency`**(无账户=`USD`;有账户=`list-accounts` 的 `currencyCode`);`averageCpc` / `lowTopOfPageBid` / `highTopOfPageBid`(微元 ÷1,000,000,**与 `bidAmountCurrency` 一致**)。另有 `keyword` / `montlySearch` / `competition` / `competitionV2` / `source` 等。
209
209
 
210
210
  与只读账户关键词列表、否词 CRUD 的对照仍归 **`references/google-ads.md`** 中 `ad keywords` / `ad keyword-*` 各节。
@@ -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.20-beta.21'
12
+ $PKG_VERSION = '1.1.20-beta.22'
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.20-beta.21"
12
+ readonly PKG_VERSION="1.1.20-beta.22"
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.20-beta.21",
3
+ "version": "1.1.20-beta.22",
4
4
  "description": "Siluzan 广告账户管理 CLI — 查询账户、余额、消耗数据,管理绑定关系与充值。",
5
5
  "keywords": [
6
6
  "ad-account",