siluzan-tso-cli 1.1.37-beta.2 → 1.1.37-beta.4

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.37-beta.2),供内部测试使用。正式发布后安装命令将改为 `npm install -g siluzan-tso-cli`。
54
+ > **注意**:当前为测试版(1.1.37-beta.4),供内部测试使用。正式发布后安装命令将改为 `npm install -g siluzan-tso-cli`。
55
55
 
56
56
  | 助手 | 建议 `--ai` |
57
57
  | ----------------------- | ------------------------------------ |
package/dist/index.js CHANGED
@@ -110511,6 +110511,7 @@ function validateCampaignExtensionsForBatchJob(campaign, errors, warnings, lengt
110511
110511
 
110512
110512
  // src/commands/ad/keyword.ts
110513
110513
  init_auth();
110514
+ import { readFileSync as readFileSync4 } from "fs";
110514
110515
  init_cli_json_snapshot();
110515
110516
  init_strip_legacy_google_fields();
110516
110517
  init_cli_table();
@@ -110539,6 +110540,68 @@ function formatKeywordTextForMatchType(rawCoreOrDisplay, matchType) {
110539
110540
  }
110540
110541
  }
110541
110542
  }
110543
+ function parseKeywordsCsv(raw) {
110544
+ const out = [];
110545
+ let cur = "";
110546
+ let inDouble = false;
110547
+ let inBracket = false;
110548
+ for (let i = 0; i < raw.length; i++) {
110549
+ const ch = raw[i];
110550
+ if (ch === '"' && !inBracket) {
110551
+ inDouble = !inDouble;
110552
+ cur += ch;
110553
+ continue;
110554
+ }
110555
+ if (ch === "[" && !inDouble && !inBracket) {
110556
+ inBracket = true;
110557
+ cur += ch;
110558
+ continue;
110559
+ }
110560
+ if (ch === "]" && inBracket) {
110561
+ inBracket = false;
110562
+ cur += ch;
110563
+ continue;
110564
+ }
110565
+ if (ch === "," && !inDouble && !inBracket) {
110566
+ const t = cur.trim();
110567
+ if (t) out.push(t);
110568
+ cur = "";
110569
+ continue;
110570
+ }
110571
+ cur += ch;
110572
+ }
110573
+ const last = cur.trim();
110574
+ if (last) out.push(last);
110575
+ return out;
110576
+ }
110577
+ function loadKeywordsFromFile(filePath) {
110578
+ let raw;
110579
+ try {
110580
+ raw = JSON.parse(readFileSync4(filePath, "utf8"));
110581
+ } catch (err) {
110582
+ throw new Error(
110583
+ `\u8BFB\u53D6\u5173\u952E\u8BCD\u6587\u4EF6\u5931\u8D25\uFF08${filePath}\uFF09\uFF1A${err instanceof Error ? err.message : String(err)}`
110584
+ );
110585
+ }
110586
+ if (!Array.isArray(raw) || raw.length === 0) {
110587
+ throw new Error(`\u5173\u952E\u8BCD\u6587\u4EF6\u987B\u4E3A\u975E\u7A7A JSON \u5B57\u7B26\u4E32\u6570\u7EC4\uFF1A${filePath}`);
110588
+ }
110589
+ const list = raw.map((x) => typeof x === "string" ? x.trim() : "").filter((s) => s.length > 0);
110590
+ if (list.length === 0) {
110591
+ throw new Error(`\u5173\u952E\u8BCD\u6587\u4EF6\u65E0\u6709\u6548\u5B57\u7B26\u4E32\uFF1A${filePath}`);
110592
+ }
110593
+ return list;
110594
+ }
110595
+ function resolveKeywordTexts(opts) {
110596
+ const fromCsv = opts.keywords?.length ? opts.keywords : [];
110597
+ const fromFile = opts.keywordsFile ? loadKeywordsFromFile(opts.keywordsFile) : [];
110598
+ const merged = [...fromCsv, ...fromFile];
110599
+ if (merged.length === 0) {
110600
+ throw new Error("\u8BF7\u4F20 --keywords \u548C/\u6216 --keywords-file\uFF08\u591A\u8BCD\u77ED\u8BED\u76F4\u63A5\u5199\u7A7A\u683C\u5373\u53EF\uFF0C\u5982 how to make\uFF09");
110601
+ }
110602
+ if (!opts.matchType) return merged;
110603
+ return merged.map((k) => formatKeywordTextForMatchType(k, opts.matchType));
110604
+ }
110542
110605
  function firstKeywordTextFromRecord(k) {
110543
110606
  const kt = k["keywordText"];
110544
110607
  if (Array.isArray(kt) && kt.length > 0 && typeof kt[0] === "string") {
@@ -110661,8 +110724,21 @@ ${label}\uFF08\u8D26\u6237\uFF1A${opts.account}\uFF0C\u7B2C 1 \u9875\uFF0C\u672C
110661
110724
  async function runAdKeywordCreate(opts) {
110662
110725
  const config = loadConfig(opts.token);
110663
110726
  const googleApiUrl = requireGoogleApi(config);
110727
+ let keywords;
110728
+ try {
110729
+ keywords = resolveKeywordTexts({
110730
+ keywords: opts.keywords,
110731
+ keywordsFile: opts.keywordsFile,
110732
+ matchType: opts.matchType
110733
+ });
110734
+ } catch (err) {
110735
+ console.error(`
110736
+ \u274C ${err instanceof Error ? err.message : String(err)}
110737
+ `);
110738
+ process.exit(1);
110739
+ }
110664
110740
  const body = {
110665
- KeywordText: opts.keywords,
110741
+ KeywordText: keywords,
110666
110742
  FinalUrl: opts.finalUrl ?? "",
110667
110743
  AdGroup: opts.adgroupName,
110668
110744
  AdGroupId: opts.adgroupId,
@@ -110688,20 +110764,33 @@ async function runAdKeywordCreate(opts) {
110688
110764
  section: `ad-keyword-create-${opts.account}-${opts.adgroupId}`,
110689
110765
  commandLabel: "ad keyword-create",
110690
110766
  commandHint: `${opts.account}:${opts.adgroupId}`,
110691
- payload: { request: { adgroupId: opts.adgroupId, count: opts.keywords.length }, response },
110767
+ payload: { request: { adgroupId: opts.adgroupId, count: keywords.length }, response },
110692
110768
  idSuffix: `${opts.account}-${opts.adgroupId}`
110693
110769
  })) {
110694
110770
  return;
110695
110771
  }
110696
110772
  console.log(`
110697
- \u2705 \u5DF2\u6DFB\u52A0 ${opts.keywords.length} \u4E2A\u5173\u952E\u8BCD\u5230\u5E7F\u544A\u7EC4\u300C${opts.adgroupName}\u300D
110773
+ \u2705 \u5DF2\u6DFB\u52A0 ${keywords.length} \u4E2A\u5173\u952E\u8BCD\u5230\u5E7F\u544A\u7EC4\u300C${opts.adgroupName}\u300D
110698
110774
  `);
110699
110775
  }
110700
110776
  async function runAdNegativeKeywordCreate(opts) {
110701
110777
  const config = loadConfig(opts.token);
110702
110778
  const googleApiUrl = requireGoogleApi(config);
110779
+ let keywords;
110780
+ try {
110781
+ keywords = resolveKeywordTexts({
110782
+ keywords: opts.keywords,
110783
+ keywordsFile: opts.keywordsFile,
110784
+ matchType: opts.matchType
110785
+ });
110786
+ } catch (err) {
110787
+ console.error(`
110788
+ \u274C ${err instanceof Error ? err.message : String(err)}
110789
+ `);
110790
+ process.exit(1);
110791
+ }
110703
110792
  const body = {
110704
- KeywordText: opts.keywords,
110793
+ KeywordText: keywords,
110705
110794
  CampaignId: opts.campaignId,
110706
110795
  Campaign: opts.campaignName
110707
110796
  };
@@ -110720,7 +110809,7 @@ async function runAdNegativeKeywordCreate(opts) {
110720
110809
  process.exit(1);
110721
110810
  }
110722
110811
  console.log(`
110723
- \u2705 \u5DF2\u5728${level}\u5C42\u7EA7\u6DFB\u52A0 ${opts.keywords.length} \u4E2A\u5426\u5B9A\u5173\u952E\u8BCD
110812
+ \u2705 \u5DF2\u5728${level}\u5C42\u7EA7\u6DFB\u52A0 ${keywords.length} \u4E2A\u5426\u5B9A\u5173\u952E\u8BCD
110724
110813
  `);
110725
110814
  }
110726
110815
  async function runAdKeywordDelete(opts) {
@@ -111957,7 +112046,7 @@ function runCampaignCreateValidation(cfg) {
111957
112046
  }
111958
112047
 
111959
112048
  // src/commands/ad/campaign-load.ts
111960
- import { readFileSync as readFileSync4 } from "fs";
112049
+ import { readFileSync as readFileSync5 } from "fs";
111961
112050
  function stripMetaKeys(value) {
111962
112051
  if (Array.isArray(value)) {
111963
112052
  return value.map((item) => stripMetaKeys(item));
@@ -111988,7 +112077,7 @@ function loadCampaignCreateConfig(configFile) {
111988
112077
  function tryLoadCampaignCreateConfig(configFile) {
111989
112078
  let raw;
111990
112079
  try {
111991
- raw = JSON.parse(readFileSync4(configFile, "utf8"));
112080
+ raw = JSON.parse(readFileSync5(configFile, "utf8"));
111992
112081
  } catch {
111993
112082
  return null;
111994
112083
  }
@@ -113017,12 +113106,12 @@ async function runAdEdit(opts) {
113017
113106
 
113018
113107
  // src/commands/ad/extension.ts
113019
113108
  init_auth();
113020
- import { readFileSync as readFileSync7 } from "fs";
113109
+ import { readFileSync as readFileSync8 } from "fs";
113021
113110
  init_cli_json_snapshot();
113022
113111
  init_strip_legacy_google_fields();
113023
113112
 
113024
113113
  // src/commands/ad/extension-lead-form.ts
113025
- import { readFileSync as readFileSync5 } from "fs";
113114
+ import { readFileSync as readFileSync6 } from "fs";
113026
113115
  var VALID_LEAD_FORM_INPUT_TYPES = [
113027
113116
  "FULL_NAME",
113028
113117
  "FIRST_NAME",
@@ -113039,10 +113128,14 @@ var VALID_LEAD_FORM_INPUT_TYPES = [
113039
113128
  "POSTAL_CODE",
113040
113129
  "STREET_ADDRESS"
113041
113130
  ];
113131
+ var LEAD_FORM_HEADLINE_MAX_LEN = 30;
113132
+ var LEAD_FORM_DESCRIPTION_MAX_LEN = 200;
113133
+ var LEAD_FORM_BUSINESS_NAME_MAX_LEN = 25;
113134
+ var LEAD_FORM_CTA_DESCRIPTION_MAX_LEN = 30;
113042
113135
  function loadLeadFormExtensionConfig(configFile) {
113043
113136
  let raw;
113044
113137
  try {
113045
- raw = JSON.parse(readFileSync5(configFile, "utf8"));
113138
+ raw = JSON.parse(readFileSync6(configFile, "utf8"));
113046
113139
  } catch {
113047
113140
  console.error(`
113048
113141
  \u274C \u8BFB\u53D6\u914D\u7F6E\u6587\u4EF6\u5931\u8D25\uFF08${configFile}\uFF09
@@ -113066,10 +113159,30 @@ function validateLeadFormPayload(lf, prefix = "leadForm") {
113066
113159
  return errors;
113067
113160
  }
113068
113161
  if (!lf.businessName?.trim()) errors.push(`${prefix}.businessName \u4E0D\u80FD\u4E3A\u7A7A`);
113162
+ else if (lf.businessName.trim().length > LEAD_FORM_BUSINESS_NAME_MAX_LEN) {
113163
+ errors.push(
113164
+ `${prefix}.businessName \u8D85\u957F\uFF08${lf.businessName.trim().length}/${LEAD_FORM_BUSINESS_NAME_MAX_LEN}\uFF09\uFF1A\u300C${lf.businessName.trim()}\u300D`
113165
+ );
113166
+ }
113069
113167
  if (!lf.headline?.trim()) errors.push(`${prefix}.headline \u4E0D\u80FD\u4E3A\u7A7A`);
113168
+ else if (lf.headline.trim().length > LEAD_FORM_HEADLINE_MAX_LEN) {
113169
+ errors.push(
113170
+ `${prefix}.headline \u8D85\u957F\uFF08${lf.headline.trim().length}/${LEAD_FORM_HEADLINE_MAX_LEN}\uFF09\uFF1A\u300C${lf.headline.trim()}\u300D`
113171
+ );
113172
+ }
113070
113173
  if (!lf.description?.trim()) errors.push(`${prefix}.description \u4E0D\u80FD\u4E3A\u7A7A`);
113174
+ else if (lf.description.trim().length > LEAD_FORM_DESCRIPTION_MAX_LEN) {
113175
+ errors.push(
113176
+ `${prefix}.description \u8D85\u957F\uFF08${lf.description.trim().length}/${LEAD_FORM_DESCRIPTION_MAX_LEN}\uFF09`
113177
+ );
113178
+ }
113071
113179
  if (!lf.privacyPolicyUrl?.trim()) errors.push(`${prefix}.privacyPolicyUrl \u4E0D\u80FD\u4E3A\u7A7A`);
113072
113180
  if (!lf.finalUrl?.trim()) errors.push(`${prefix}.finalUrl \u4E0D\u80FD\u4E3A\u7A7A`);
113181
+ if (lf.callToActionDescription?.trim() && lf.callToActionDescription.trim().length > LEAD_FORM_CTA_DESCRIPTION_MAX_LEN) {
113182
+ errors.push(
113183
+ `${prefix}.callToActionDescription \u8D85\u957F\uFF08${lf.callToActionDescription.trim().length}/${LEAD_FORM_CTA_DESCRIPTION_MAX_LEN}\uFF09`
113184
+ );
113185
+ }
113073
113186
  if (!Array.isArray(lf.fields) || lf.fields.length === 0) {
113074
113187
  errors.push(`${prefix}.fields \u81F3\u5C11 1 \u9879`);
113075
113188
  } else {
@@ -113134,7 +113247,7 @@ function buildLeadFormExtensionBody(cfg, extensionId) {
113134
113247
  }
113135
113248
 
113136
113249
  // src/commands/ad/extension-whatsapp.ts
113137
- import { readFileSync as readFileSync6 } from "fs";
113250
+ import { readFileSync as readFileSync7 } from "fs";
113138
113251
  var VALID_WHATSAPP_CTA_SELECTIONS = [
113139
113252
  "CONTACT_US",
113140
113253
  "LEARN_MORE",
@@ -113146,7 +113259,7 @@ var VALID_WHATSAPP_CTA_SELECTIONS = [
113146
113259
  function loadWhatsappExtensionConfig(configFile) {
113147
113260
  let raw;
113148
113261
  try {
113149
- raw = JSON.parse(readFileSync6(configFile, "utf8"));
113262
+ raw = JSON.parse(readFileSync7(configFile, "utf8"));
113150
113263
  } catch {
113151
113264
  console.error(`
113152
113265
  \u274C \u8BFB\u53D6\u914D\u7F6E\u6587\u4EF6\u5931\u8D25\uFF08${configFile}\uFF09
@@ -113608,7 +113721,7 @@ async function runAdExtensionUpdate(opts) {
113608
113721
  const googleApiUrl = requireGoogleApi(config);
113609
113722
  let raw;
113610
113723
  try {
113611
- raw = stripMetaKeys(JSON.parse(readFileSync7(opts.configFile, "utf8")));
113724
+ raw = stripMetaKeys(JSON.parse(readFileSync8(opts.configFile, "utf8")));
113612
113725
  } catch {
113613
113726
  console.error(`
113614
113727
  \u274C \u8BFB\u53D6\u914D\u7F6E\u6587\u4EF6\u5931\u8D25\uFF08${opts.configFile}\uFF09
@@ -115802,7 +115915,7 @@ async function runPmaxImageValidation(configFile, cfg) {
115802
115915
  // src/commands/ad/pmax-image-resolve.ts
115803
115916
  init_auth();
115804
115917
  import { basename as basename4, dirname as dirname8, isAbsolute as isAbsolute3, resolve as resolve7 } from "path";
115805
- import { readFileSync as readFileSync8 } from "fs";
115918
+ import { readFileSync as readFileSync9 } from "fs";
115806
115919
 
115807
115920
  // src/commands/ad/pmax-urls.ts
115808
115921
  function pmaxChannelTypesUrl(googleApiUrl) {
@@ -115965,7 +116078,7 @@ async function uploadPmaxImageFileBase64(config, googleApiUrl, accountId, absIma
115965
116078
  async function uploadPmaxImageFile(config, googleApiUrl, accountId, absImagePath, name2, verbose) {
115966
116079
  const fileName = basename4(absImagePath);
115967
116080
  const assetName = (name2 ?? fileName).trim() || fileName;
115968
- const fileBuffer = readFileSync8(absImagePath);
116081
+ const fileBuffer = readFileSync9(absImagePath);
115969
116082
  if (fileBuffer.length <= MAX_BASE64_UPLOAD_BYTES) {
115970
116083
  return uploadPmaxImageFileBase64(
115971
116084
  config,
@@ -116051,7 +116164,7 @@ function assertPmaxImageSlotsResolved(slots, requiredKinds = ["marketing", "squa
116051
116164
  }
116052
116165
 
116053
116166
  // src/commands/ad/pmax-load.ts
116054
- import { readFileSync as readFileSync9 } from "fs";
116167
+ import { readFileSync as readFileSync10 } from "fs";
116055
116168
 
116056
116169
  // src/commands/ad/pmax-campaign-extensions.ts
116057
116170
  init_auth();
@@ -116555,7 +116668,10 @@ function classifyExtensionAttachError(error) {
116555
116668
  return `${raw}\uFF08\u591A\u4E3A header \u4E0D\u5728\u5408\u6CD5\u679A\u4E3E\uFF1B\u8FD0\u884C ad extension snippet-headers \u6838\u5BF9\uFF09`;
116556
116669
  }
116557
116670
  if (/lead form|lead_form|LEAD_FORM/i.test(raw)) {
116558
- return `${raw}\uFF08\u68C0\u67E5 Google Ads UI \u662F\u5426\u5DF2\u63A5\u53D7 Lead Form ToS\u3001fields \u662F\u5426\u542B FULL_NAME/EMAIL/PHONE_NUMBER\uFF09`;
116671
+ return `${raw}\uFF08\u68C0\u67E5\uFF1Aheadline\u226430\u3001description\u2264200\u3001businessName\u226425\uFF1Bfields \u52FF\u7528 MESSAGE\uFF1BGoogle Ads UI \u662F\u5426\u5DF2\u63A5\u53D7 Lead Form ToS\uFF09`;
116672
+ }
116673
+ if (/^HTTP 400\b/i.test(raw)) {
116674
+ return `${raw}\uFF08\u6269\u5C55\u5B57\u6BB5\u8FDD\u89C4\u5E38\u89C1\uFF1Asnippet header \u975E\u679A\u4E3E\u3001Lead Form \u6587\u6848\u8D85\u957F\u3001URL \u65E0\u6548\uFF1B\u52A0 --verbose \u770B\u7F51\u5173\u539F\u6587\uFF09`;
116559
116675
  }
116560
116676
  return raw;
116561
116677
  }
@@ -116678,7 +116794,7 @@ function detectDeprecatedPmaxVideoKeys(raw) {
116678
116794
  function tryLoadPmaxCreateConfig(configFile) {
116679
116795
  let raw;
116680
116796
  try {
116681
- raw = JSON.parse(readFileSync9(configFile, "utf8"));
116797
+ raw = JSON.parse(readFileSync10(configFile, "utf8"));
116682
116798
  } catch {
116683
116799
  return null;
116684
116800
  }
@@ -116686,7 +116802,7 @@ function tryLoadPmaxCreateConfig(configFile) {
116686
116802
  }
116687
116803
  function loadPmaxCreateConfigRaw(configFile) {
116688
116804
  try {
116689
- const raw = JSON.parse(readFileSync9(configFile, "utf8"));
116805
+ const raw = JSON.parse(readFileSync10(configFile, "utf8"));
116690
116806
  return stripMetaKeys(raw);
116691
116807
  } catch {
116692
116808
  return null;
@@ -116737,19 +116853,19 @@ function buildPmaxCreateUrl(googleApiUrl, accountId) {
116737
116853
  }
116738
116854
 
116739
116855
  // src/commands/ad/pmax-video-upload.ts
116740
- import { existsSync as existsSync3, readFileSync as readFileSync11 } from "fs";
116856
+ import { existsSync as existsSync3, readFileSync as readFileSync12 } from "fs";
116741
116857
  import { basename as basename5, dirname as dirname10, isAbsolute as isAbsolute5, resolve as resolve9 } from "path";
116742
116858
 
116743
116859
  // src/commands/ad/pmax-shared.ts
116744
116860
  init_auth();
116745
116861
  init_cli_json_snapshot();
116746
- import { readFileSync as readFileSync10 } from "fs";
116862
+ import { readFileSync as readFileSync11 } from "fs";
116747
116863
  import { dirname as dirname9, isAbsolute as isAbsolute4, resolve as resolve8 } from "path";
116748
116864
  var PMAX_MONEY_KEYS = /* @__PURE__ */ new Set(["budget", "targetCpa_BidingAmount"]);
116749
116865
  function loadPmaxJsonFile(configFile) {
116750
116866
  let raw;
116751
116867
  try {
116752
- raw = JSON.parse(readFileSync10(configFile, "utf8"));
116868
+ raw = JSON.parse(readFileSync11(configFile, "utf8"));
116753
116869
  } catch (e) {
116754
116870
  const msg = e instanceof Error ? e.message : String(e);
116755
116871
  console.error(`
@@ -116949,7 +117065,7 @@ function runPmaxVideoValidation(configFile, cfg) {
116949
117065
  return { errors, warnings };
116950
117066
  }
116951
117067
  try {
116952
- const buf = readFileSync11(abs);
117068
+ const buf = readFileSync12(abs);
116953
117069
  if (buf.length === 0) errors.push(`videoPath \u4E3A\u7A7A\u6587\u4EF6\uFF1A${abs}`);
116954
117070
  } catch (e) {
116955
117071
  const msg = e instanceof Error ? e.message : String(e);
@@ -117012,7 +117128,7 @@ async function uploadPmaxLocalVideo(opts) {
117012
117128
  const fileName = basename5(opts.absVideoPath);
117013
117129
  const title = (opts.title ?? fileName).trim() || fileName;
117014
117130
  const description = (opts.description ?? "Uploaded via siluzan-tso PMax video upload").trim() || "Uploaded via siluzan-tso";
117015
- const fileBuffer = readFileSync11(opts.absVideoPath);
117131
+ const fileBuffer = readFileSync12(opts.absVideoPath);
117016
117132
  const form = new FormData();
117017
117133
  form.append("media_account_id", opts.accountId.replace(/-/g, ""));
117018
117134
  form.append("title", title);
@@ -117070,7 +117186,7 @@ function validatePmaxVideoPathQuick(absPath) {
117070
117186
  return `\u89C6\u9891\u6587\u4EF6\u4E0D\u5B58\u5728\uFF1A${absPath}`;
117071
117187
  }
117072
117188
  try {
117073
- if (readFileSync11(absPath).length === 0) return `\u89C6\u9891\u6587\u4EF6\u4E3A\u7A7A\uFF1A${absPath}`;
117189
+ if (readFileSync12(absPath).length === 0) return `\u89C6\u9891\u6587\u4EF6\u4E3A\u7A7A\uFF1A${absPath}`;
117074
117190
  } catch (e) {
117075
117191
  const msg = e instanceof Error ? e.message : String(e);
117076
117192
  return `\u65E0\u6CD5\u8BFB\u53D6\u89C6\u9891\u6587\u4EF6\uFF08${absPath}\uFF09\uFF1A${msg}`;
@@ -118473,7 +118589,7 @@ async function runAdCampaignValidate(opts) {
118473
118589
  }
118474
118590
 
118475
118591
  // src/commands/ad/pmax-image-convert.ts
118476
- import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync5, readFileSync as readFileSync12, existsSync as existsSync4 } from "fs";
118592
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync5, readFileSync as readFileSync13, existsSync as existsSync4 } from "fs";
118477
118593
  import { resolve as resolve10, dirname as dirname11, basename as basename7, extname } from "path";
118478
118594
  var SPECS = [
118479
118595
  { kind: "marketing", width: 1200, height: 628, fit: "cover", suffix: "_marketing" },
@@ -118562,7 +118678,7 @@ async function runAdPmaxImageConvert(opts) {
118562
118678
  if (opts.updateConfig) {
118563
118679
  const configPath = resolve10(opts.updateConfig);
118564
118680
  try {
118565
- const raw = JSON.parse(readFileSync12(configPath, "utf8"));
118681
+ const raw = JSON.parse(readFileSync13(configPath, "utf8"));
118566
118682
  const imgPaths = raw["imagePaths"] ?? {};
118567
118683
  if (outputPaths.marketing) imgPaths["marketing"] = outputPaths.marketing;
118568
118684
  if (outputPaths.square) imgPaths["square"] = outputPaths.square;
@@ -118718,6 +118834,17 @@ async function runAdPmaxBrandGuidelinesEnable(opts) {
118718
118834
  }
118719
118835
 
118720
118836
  // src/commands/ad/_register.ts
118837
+ function parseOptionalMatchType(raw) {
118838
+ if (!raw?.trim()) return void 0;
118839
+ const t = raw.trim().toLowerCase();
118840
+ if (t === "broad") return "Broad";
118841
+ if (t === "phrase") return "Phrase";
118842
+ if (t === "exact") return "Exact";
118843
+ console.error(`
118844
+ \u274C --match-type \u65E0\u6548\uFF1A${raw}\uFF1B\u5408\u6CD5\u503C Broad | Phrase | Exact
118845
+ `);
118846
+ process.exit(1);
118847
+ }
118721
118848
  function register20(program2) {
118722
118849
  const adCmd = program2.command("ad").description(
118723
118850
  "Google \u5E7F\u544A\u7BA1\u7406\uFF08Google API \u5730\u5740\u4ECE TSO API \u81EA\u52A8\u63A8\u5BFC\uFF0C\u53EF\u901A\u8FC7 SILUZAN_GOOGLE_API \u73AF\u5883\u53D8\u91CF\u8986\u76D6\uFF09\n \u542B\uFF1A\u7CFB\u5217/\u7EC4/\u521B\u610F/\u5173\u952E\u8BCD\u7B49\u5E38\u89C4\u5E7F\u544A\u6295\u653E\u4E0E\u7BA1\u7406\u80FD\u529B"
@@ -119002,10 +119129,16 @@ function register20(program2) {
119002
119129
  });
119003
119130
  }
119004
119131
  );
119005
- adCmd.command("keyword-create").description("\u5411\u5E7F\u544A\u7EC4\u6DFB\u52A0\u641C\u7D22\u5173\u952E\u8BCD").requiredOption("-a, --account <id>", "Google \u8D26\u6237 mediaCustomerId").requiredOption("--adgroup-id <id>", "\u76EE\u6807\u5E7F\u544A\u7EC4 ID").requiredOption("--adgroup-name <name>", "\u76EE\u6807\u5E7F\u544A\u7EC4\u540D\u79F0").requiredOption("--campaign-id <id>", "\u6240\u5C5E\u5E7F\u544A\u7CFB\u5217 ID").requiredOption("--campaign-name <name>", "\u6240\u5C5E\u5E7F\u544A\u7CFB\u5217\u540D\u79F0").requiredOption(
119132
+ adCmd.command("keyword-create").description("\u5411\u5E7F\u544A\u7EC4\u6DFB\u52A0\u641C\u7D22\u5173\u952E\u8BCD\uFF08\u652F\u6301\u7A7A\u683C\u591A\u8BCD\uFF1B\u53EF\u7528 --keywords-file\uFF09").requiredOption("-a, --account <id>", "Google \u8D26\u6237 mediaCustomerId").requiredOption("--adgroup-id <id>", "\u76EE\u6807\u5E7F\u544A\u7EC4 ID").requiredOption("--adgroup-name <name>", "\u76EE\u6807\u5E7F\u544A\u7EC4\u540D\u79F0").requiredOption("--campaign-id <id>", "\u6240\u5C5E\u5E7F\u544A\u7CFB\u5217 ID").requiredOption("--campaign-name <name>", "\u6240\u5C5E\u5E7F\u544A\u7CFB\u5217\u540D\u79F0").option(
119006
119133
  "--keywords <kw1,kw2,...>",
119007
- "\u5173\u952E\u8BCD\u5217\u8868\uFF0C\u9017\u53F7\u5206\u9694",
119008
- (v) => v.split(",").map((s) => s.trim())
119134
+ "\u5173\u952E\u8BCD\u5217\u8868\uFF0C\u9017\u53F7\u5206\u9694\uFF08\u7A7A\u683C\u591A\u8BCD\u5408\u6CD5\uFF0C\u5982 how to make\uFF09\uFF1B\u53EF\u4E0E --keywords-file \u5E76\u7528",
119135
+ (v) => parseKeywordsCsv(v)
119136
+ ).option(
119137
+ "--keywords-file <path>",
119138
+ 'JSON \u5B57\u7B26\u4E32\u6570\u7EC4\u6587\u4EF6\uFF08\u63A8\u8350\u591A\u8BCD/\u5E26\u5F15\u53F7\u573A\u666F\uFF09\uFF0C\u5982 ["how to make","home cooking"]'
119139
+ ).option(
119140
+ "--match-type <Broad|Phrase|Exact>",
119141
+ '\u7EDF\u4E00\u5339\u914D\u7C7B\u578B\uFF08\u5199\u5165\u65F6\u7528\u6587\u6848 "\u2026" / [\u2026] \u5305\u88C5\uFF1B\u7701\u7565\u5219\u6309\u8BCD\u9762\u539F\u6837\u63D0\u4EA4\uFF09'
119009
119142
  ).option("--final-url <url>", "\u5173\u952E\u8BCD\u6700\u7EC8\u5230\u8FBE\u7F51\u5740\uFF08\u53EF\u9009\uFF09").option("-t, --token <token>", "Auth Token").option(
119010
119143
  "--json-out <path>",
119011
119144
  "\u843D\u76D8\uFF08\u76EE\u5F55\u6216 *.json \u6587\u4EF6\u8DEF\u5F84\uFF09\u5E76\u66F4\u65B0 cli-manifest[-<\u67E5\u8BE2id>].json",
@@ -119020,16 +119153,26 @@ function register20(program2) {
119020
119153
  campaignId: opts.campaignId,
119021
119154
  campaignName: opts.campaignName,
119022
119155
  keywords: opts.keywords,
119156
+ keywordsFile: opts.keywordsFile,
119157
+ matchType: parseOptionalMatchType(opts.matchType),
119023
119158
  finalUrl: opts.finalUrl,
119024
119159
  jsonOut: opts.jsonOut,
119025
119160
  verbose: opts.verbose
119026
119161
  });
119027
119162
  }
119028
119163
  );
119029
- adCmd.command("keyword-negative-create").description("\u6DFB\u52A0\u5426\u5B9A\u5173\u952E\u8BCD\uFF08\u5E7F\u544A\u7CFB\u5217\u5C42\u7EA7\uFF1B\u4F20 --adgroup-id \u5219\u4E3A\u5E7F\u544A\u7EC4\u5C42\u7EA7\uFF09").requiredOption("-a, --account <id>", "Google \u8D26\u6237 mediaCustomerId").requiredOption("--campaign-id <id>", "\u5E7F\u544A\u7CFB\u5217 ID").requiredOption("--campaign-name <name>", "\u5E7F\u544A\u7CFB\u5217\u540D\u79F0").requiredOption(
119164
+ adCmd.command("keyword-negative-create").description(
119165
+ "\u6DFB\u52A0\u5426\u5B9A\u5173\u952E\u8BCD\uFF08\u7CFB\u5217/\u7EC4\u7EA7\uFF1B\u652F\u6301\u7A7A\u683C\u591A\u8BCD\u77ED\u8BED\uFF0C\u5982 how to make\uFF1B\u53EF\u7528 --keywords-file\uFF09"
119166
+ ).requiredOption("-a, --account <id>", "Google \u8D26\u6237 mediaCustomerId").requiredOption("--campaign-id <id>", "\u5E7F\u544A\u7CFB\u5217 ID").requiredOption("--campaign-name <name>", "\u5E7F\u544A\u7CFB\u5217\u540D\u79F0").option(
119030
119167
  "--keywords <kw1,kw2,...>",
119031
- "\u5426\u5B9A\u5173\u952E\u8BCD\u5217\u8868\uFF0C\u9017\u53F7\u5206\u9694",
119032
- (v) => v.split(",").map((s) => s.trim())
119168
+ "\u5426\u5B9A\u8BCD\u5217\u8868\uFF0C\u9017\u53F7\u5206\u9694\uFF08\u7A7A\u683C\u591A\u8BCD\u5408\u6CD5\uFF09\uFF1B\u53EF\u4E0E --keywords-file \u5E76\u7528",
119169
+ (v) => parseKeywordsCsv(v)
119170
+ ).option(
119171
+ "--keywords-file <path>",
119172
+ 'JSON \u5B57\u7B26\u4E32\u6570\u7EC4\u6587\u4EF6\uFF08\u63A8\u8350\u591A\u8BCD\u77ED\u8BED\uFF09\uFF0C\u5982 ["how to make","home cooking","second hand"]'
119173
+ ).option(
119174
+ "--match-type <Broad|Phrase|Exact>",
119175
+ "\u7EDF\u4E00\u5339\u914D\u7C7B\u578B\uFF08Broad \u65E0\u5305\u88C5 / Phrase \u52A0\u53CC\u5F15\u53F7 / Exact \u52A0\u65B9\u62EC\u53F7\uFF09"
119033
119176
  ).option("--adgroup-id <id>", "\u5E7F\u544A\u7EC4 ID\uFF08\u6307\u5B9A\u540E\u4E3A\u5E7F\u544A\u7EC4\u5C42\u7EA7\u5426\u5B9A\uFF09").option("--adgroup-name <name>", "\u5E7F\u544A\u7EC4\u540D\u79F0").option("-t, --token <token>", "Auth Token").option("--verbose", "\u8BE6\u7EC6\u9519\u8BEF\u4FE1\u606F", false).action(
119034
119177
  async (opts) => {
119035
119178
  await runAdNegativeKeywordCreate({
@@ -119040,6 +119183,8 @@ function register20(program2) {
119040
119183
  adgroupId: opts.adgroupId,
119041
119184
  adgroupName: opts.adgroupName,
119042
119185
  keywords: opts.keywords,
119186
+ keywordsFile: opts.keywordsFile,
119187
+ matchType: parseOptionalMatchType(opts.matchType),
119043
119188
  verbose: opts.verbose
119044
119189
  });
119045
119190
  }
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "slug": "siluzan-tso",
3
- "version": "1.1.37-beta.2",
4
- "publishedAt": 1783912835109
3
+ "version": "1.1.37-beta.4",
4
+ "publishedAt": 1783934085806
5
5
  }
@@ -7,6 +7,7 @@
7
7
  "agentPitfalls": [
8
8
  "campaign-validate 推荐 --json-out ",
9
9
  "否词只写 campaign.NegativeKeywordsForBatchJob,禁止把 free/jobs/tutorial 等否词抄进 AdGroupsForBatchJob[].KeywordsForBatchJob",
10
+ "KeywordText 是 JSON 字符串数组,空格多词合法(如 how to make);禁止因多词省略否词或让用户去后台手补",
10
11
  "RSA 须同时写 DestinationUrl 与 Finalurl(后端必填 Finalurl;仅有 DestinationUrl 时 validate 可能仍通过)",
11
12
  "SITELINK 须写 AssetFieldType: SITELINK(与 typeV2 一致)",
12
13
  "提交前:ad campaign-validate → 用户确认 → ad campaign-create(勿对 validate 加 --json-out)"
@@ -65,7 +66,24 @@
65
66
  ],
66
67
 
67
68
  "NegativeKeywordsForBatchJob": [
68
- { "KeywordText": ["free", "cheap", "download"], "MatchTypeV2": "BROAD", "FinalURL": "" }
69
+ {
70
+ "KeywordText": [
71
+ "free",
72
+ "cheap",
73
+ "download",
74
+ "how to make",
75
+ "home cooking",
76
+ "second hand",
77
+ "near me"
78
+ ],
79
+ "MatchTypeV2": "BROAD",
80
+ "FinalURL": ""
81
+ },
82
+ {
83
+ "KeywordText": ["\"vegan recipe\"", "\"stock price\""],
84
+ "MatchTypeV2": "PHRASE",
85
+ "FinalURL": ""
86
+ }
69
87
  ],
70
88
 
71
89
  "ExtensionsForBatchJob": [
@@ -31,6 +31,8 @@ JSON 模板:同目录 [`campaign-create-template.json`](campaign-create-templa
31
31
  **禁止**:把方案 Markdown「否定词表」里的 `free` / `jobs` / `tutorial` 等抄进 `KeywordsForBatchJob`——那会**主动购买**这些流量。
32
32
  **正确**:否词只写 `NegativeKeywordsForBatchJob`;`ad campaign-validate` 会对误填给出 **warnings**(常见否词词根 / 与否词块重复)。
33
33
 
34
+ **多词短语(与 `keyword-negative-create` 同类误判)**:`KeywordText` 是 **JSON 字符串数组**,**空格多词完全合法**(如 `"how to make"`、`"home cooking"`)。PHRASE 写 `"\"vegan recipe\""` 或裸词 + `MatchTypeV2: "PHRASE"`(validate 会补引号)。**禁止**因「多词」就省略否词或让用户去 Google Ads 后台手补——那是 Agent 误判,不是 `campaign-create` 限制。
35
+
34
36
  投放方案 Markdown 中 §3.3 关键词矩阵 → `KeywordsForBatchJob`;§3.3 系列级否定词 / §3.4 账户级否定词 → `NegativeKeywordsForBatchJob`(账户级列表仍须在 Google 后台单独应用)。
35
37
 
36
38
  ### CLI 参数(勿混用)
@@ -184,7 +186,7 @@ siluzan-tso ad batch diff --batch-id <taskId> --config-file ./campaign.json --js
184
186
  | 字段 | 类型 | 说明 |
185
187
  | ----------------------------- | -------- | --------------------------------------------------------------------------------- |
186
188
  | `AdGroupsForBatchJob` | object[] | **至少 1 组**;见下 |
187
- | `NegativeKeywordsForBatchJob` | object[] | 系列级否词;元素:`{ KeywordText: string[], MatchTypeV2: "BROAD", FinalURL: "" }` |
189
+ | `NegativeKeywordsForBatchJob` | object[] | 系列级否词;元素:`{ KeywordText: string[], MatchTypeV2: "BROAD"\|"PHRASE"\|"EXACT", FinalURL: "" }`;**KeywordText 可含空格多词**(见模板示例) |
188
190
  | `ExtensionsForBatchJob` | object[] | 附加信息;`Properties` 须 **string→string**(勿用数组值)。SITELINK 见下表 |
189
191
 
190
192
  #### SITELINK(`ExtensionsForBatchJob[i]`,`typeV2` / `AssetFieldType` = `SITELINK`)
@@ -40,14 +40,14 @@ siluzan-tso ad extension update -a <accountId> --id <extensionId> --config-file
40
40
  | ---------------------------------- | -------- | :--: | -------------------------------------------------------------- |
41
41
  | `account` | string | ✅ | Google 媒体客户 ID |
42
42
  | `campaignId` | string | ✅ | PMax 活动 ID(`channelTypeV2=PERFORMANCE_MAX`) |
43
- | `leadForm.businessName` | string | ✅ | 商家名称 |
44
- | `leadForm.headline` | string | ✅ | 表单标题 |
45
- | `leadForm.description` | string | ✅ | 表单描述 |
43
+ | `leadForm.businessName` | string | ✅ | 商家名称(≤25 字符) |
44
+ | `leadForm.headline` | string | ✅ | 表单标题(**≤30 字符**,超长网关常直接 HTTP 400) |
45
+ | `leadForm.description` | string | ✅ | 表单描述(≤200 字符) |
46
46
  | `leadForm.privacyPolicyUrl` | string | ✅ | 隐私政策 URL |
47
47
  | `leadForm.finalUrl` | string | ✅ | 落地页 URL |
48
48
  | `leadForm.callToActionType` | string | | 默认 `LEARN_MORE` |
49
- | `leadForm.callToActionDescription` | string | | 默认 `Contact us` |
50
- | `leadForm.fields` | object[] | ✅ | 至少 1 项;`inputType` 如 `FULL_NAME`、`EMAIL`、`PHONE_NUMBER` |
49
+ | `leadForm.callToActionDescription` | string | | 默认 `Contact us`(≤30 字符) |
50
+ | `leadForm.fields` | object[] | ✅ | 至少 1 项;`inputType` 如 `FULL_NAME`、`EMAIL`、`PHONE_NUMBER`(**无** `MESSAGE`) |
51
51
  | `leadForm.webhook` | object | | 写入 Google 的 Webhook(非平台接收端) |
52
52
 
53
53
  ---
@@ -203,7 +203,7 @@ siluzan-tso accounts-digest -m Google -a id1,id2 --min-spend 10 \
203
203
  --start 2026-04-01 --end 2026-04-15 --json-out ./snap-p3
204
204
  ```
205
205
 
206
- **`data.items[]` 主要字段**:`mediaCustomerId`、`name`、`advertiserName`、`currencyCode`、`balance`、`spend`、`impressions`、`clicks`、`conversions`、`ctr`(%)、`cpc`、`cpa`。跨币种汇总见 `references/accounts/currency.md`(**禁止**对 `meta.totals` 跨币种直接当最终结论)。
206
+ **`data.items[]` 主要字段**:`mediaCustomerId`、`name`、`advertiserName`、`currencyCode`、`balance`、`spend`(**区间合计**,同 `stats`)、`impressions`、`clicks`、`conversions`、`ctr`(%)、`cpc`、`cpa`。跨币种汇总见 `references/accounts/currency.md`(**禁止**对 `meta.totals` 跨币种直接当最终结论)。
207
207
 
208
208
  ---
209
209
 
@@ -230,6 +230,8 @@ siluzan-tso stats -m <媒体类型> [选项]
230
230
  | `--start-date` / `--end-date` | 与 `--start` / `--end` 同义(CLI 别名,与 SKILL Playbook 一致) | — |
231
231
  | `--json-out` | 输出原始 JSON;**失败时 stdout 仍为 JSON**(`{"ok":false,"error":"..."}`) | — |
232
232
 
233
+ **口径(易错)**:返回的 **`spend` = `--start`~`--end` 闭区间合计消耗**,默认近 7 天即约 **7 日之和**,**不是**「一天的消耗」。要日消耗须 `start=end`;要日均用 `spend/天数` 或 `balance-scan` 的 `dailySpend`。**禁止**把默认窗口的 `spend` 当成日消耗去比日预算或写「今天花了 X」。
234
+
233
235
  **示例:**
234
236
 
235
237
  ```bash
@@ -194,10 +194,13 @@
194
194
 
195
195
  | 任务 | 推荐命令 | 禁止 |
196
196
  | --------------------------- | -------------------------------------------------------------------------------------- | -------------------------------- |
197
- | 多账户余额 / 预算不足预警 | `balance-scan -m <媒体> --threshold-days 7` | 逐账户 `balance --accounts ...` |
198
- | 多账户投放画像 | `accounts-digest -m <媒体> [-a ...] --start --end --json-out` | 逐账户 `stats` |
197
+ | 多账户余额 / 预算不足预警 | `balance-scan -m <媒体> --threshold-days 7`(用 **`dailySpend`** / `remainingDays`) | 逐账户 `balance --accounts ...`;把 `stats` 多日 **`spend` 当「日消耗」** |
198
+ | 多账户投放画像 | `accounts-digest -m <媒体> [-a ...] --start --end --json-out`(**`spend`=区间合计**) | 逐账户 `stats`;把合计 `spend` 说成「每天花了」 |
199
199
  | 多账户 × 多维度 Google 数据 | 全量:`google-analysis-batch run`(省略 `-a`);2~10:`google-analysis -a id1,id2,...` | 外层 for-loop;先 list 再拼 `-a` |
200
200
  | 多系列诊断 | `ad campaigns --json-out` + node 读文件过滤 | 逐系列 `ad campaign-get` |
201
+ | 单日预算熔断 / 当日花费比日预算 | `ad campaigns --start <当日> --end <当日>`(**start=end**)后再比 `spend` 与 `budget` | 用默认近 7 天窗口的 `spend` 对比日预算 |
202
+
203
+ **消耗口径(自动化必读)**:凡 `--start`/`--end` 读命令的 `spend` = **闭区间合计**,默认近 7 天 ≠ 一天。日均用 `balance-scan.dailySpend` 或 `合计÷天数`;详见 `references/operations/hosted-automation-self-control.md`「检查项常用 JSON 字段」。
201
204
 
202
205
  **`google-analysis-batch` 纪律**(详见 `references/analytics/google-analysis-batch.md`):拉全量时省略 `-a`;中断后**必须** `resume --run-id <id>`,**禁止**重新 `run`;stdout 始终单行 JSON(`kind=siluzan-tso-batch-summary`);退出码 `0` 全成功 / `2` 部分成功 / `3` 全失败或 Token 失效 / `4` 用法错误;401 → 整批终止,按 `references/core/setup.md` 重登录后 `resume`。
203
206
 
@@ -93,16 +93,17 @@ low.forEach((a) => console.log(' ', a.mediaCustomerId, a.name, '余额:', a.rema
93
93
 
94
94
  ### 3. 汇总消耗数据
95
95
 
96
- `stats` 的 `AccountOverviewItem` 使用 **`spend`**(非 `cost`):
96
+ `stats` 的 `AccountOverviewItem` 使用 **`spend`**(非 `cost`)。**`spend` = `--start`~`--end` 区间合计**(默认近 7 天 ≈ 7 日之和,**不是**「一天的消耗」):
97
97
 
98
98
  ```bash
99
- siluzan-tso stats -m Google --days 7 --json-out ./snap-cli
99
+ siluzan-tso stats -m Google -a <id> --start 2026-07-06 --end 2026-07-12 --json-out ./snap-cli
100
100
  node -e "
101
101
  const d = require('./snap-cli/stats-google.json');
102
102
  const list = d.items || [];
103
+ // 「总消耗」= 区间合计;日均请 total/天数,或 balance-scan.dailySpend;勿说成每天花了 total
103
104
  const total = list.reduce((s, a) => s + Number(a.spend ?? 0), 0);
104
105
  const clicks = list.reduce((s, a) => s + Number(a.clicks ?? 0), 0);
105
- console.log('总消耗:', total.toFixed(2), ' 总点击:', clicks);
106
+ console.log('区间总消耗:', total.toFixed(2), ' 总点击:', clicks);
106
107
  list
107
108
  .slice()
108
109
  .sort((a, b) => Number(b.spend) - Number(a.spend))
@@ -27,7 +27,8 @@
27
27
  2. **结论先行**:第一句给出可操作的判断(正常 / 有问题 / 原因是什么),不要让用户从表格里自己猜。
28
28
  3. **数据支撑**:引用**当次 CLI/脚本**的真实数字(消耗、CPA、余额、状态等);禁止编造、禁止示例 ID。
29
29
  4. **行动建议**:1~3 条可执行项,点名对象(系列名、账户、关键词)并引用数据。
30
- 5. **下一步**:一句预测或邀请(「要我帮您调整预算吗?」「还想看关键词明细吗?」)。
30
+ 5. **下一步**:一句预测或邀请(「要我帮您调整预算吗?」「还想看关键词明细吗?」)。
31
+ **注意**:客户端会另行展示可点击的 suggestion chips(完整用户话术)。本文件与各任务 reference **只要求回复里出现一句邀请**,**不要**在 skill 各任务文档里维护「可点 chips 全文清单」——那份清单由产品侧 chips 规则维护,skill 更新不应与之逐条对齐。
31
32
 
32
33
  长任务(预计 >30s):中间用**进度式**短句(「正在拉取系列数据…」「正在检查余额…」),不要长时间沉默后突然扔一大段表。
33
34
 
@@ -59,7 +60,8 @@
59
60
  | 帮我开个户 / 建广告 | W2 / W3 | 「没问题,我先告诉您需要准备哪些资料。」 |
60
61
  | 网站行不行 | P8 | 「我来给这个网站做个体检,看是否符合投放要求。」 |
61
62
 
62
- 完整话术矩阵见产品文档第十章;Skill 内记住**结构**即可,勿背诵全文。
63
+ 完整话术矩阵见产品文档第十章;Skill 内记住**结构**即可,勿背诵全文。
64
+ 可点击的「下一步」完整句子由客户端 suggestion chips 提供,**禁止**在本 skill 的 playbooks/workflows 里复制维护 chips 文案表(避免 skill 更新后与产品侧脱节)。
63
65
 
64
66
  ---
65
67
 
@@ -106,7 +108,8 @@
106
108
  | ---- | ---- |
107
109
  | `agent-conventions.md` | 怎么拉数、怎么读 outline、怎么自检、硬规范 |
108
110
  | `intent-routing.md` | 报告/分析消歧、选哪个 P/W |
109
- | `playbooks.md` / `workflows.md` | 步骤清单 |
110
- | **本文件** | **对用户怎么说** |
111
+ | `playbooks.md` / `workflows.md` | 步骤清单(执行),不维护 chips 全文 |
112
+ | **本文件** | **对用户怎么说**(结构 + 一句下一步邀请) |
113
+ | 客户端 suggestion chips | **可点的下一步完整话术**(产品侧维护,与本 skill 解耦) |
111
114
 
112
115
  数值纪律不变:所有金额、状态、ID 仍只来自当次 CLI/`--json-out` 脚本;本文件只约束**表述方式**,不放宽数据真实性要求。
@@ -97,7 +97,9 @@
97
97
  siluzan-tso ad campaigns -a <accountId> [--start <YYYY-MM-DD>] [--end <YYYY-MM-DD>] [--json-out ./snap]
98
98
  ```
99
99
 
100
- 落盘 JSON 中 `budget` 为日预算(主币种「元」,CLI 已 ÷100),另有 `statusDisplay`(状态文案)。
100
+ 落盘 JSON 中 **`budget` 为日预算**(主币种「元」,CLI 已 ÷100),另有 `statusDisplay`(状态文案)。
101
+ 带 `--start` / `--end` 时,同行的 **`spend` / `impressions` / `clicks` / `conversions` 为该闭区间合计**(**不是**日消耗)。
102
+ 与日预算比较「是否超预算」时,**必须** `start=end=统计日`;多日窗口须先 ÷ 天数或改用 `balance-scan.dailySpend` / `overview.averageDailyCost` 再谈「日均」。
101
103
 
102
104
  ### 启停
103
105
 
@@ -266,26 +268,38 @@ siluzan-tso ad keyword-create \
266
268
  -a <accountId> \
267
269
  --adgroup-id <adGroupId> --adgroup-name <adGroupName> \
268
270
  --campaign-id <campaignId> --campaign-name <campaignName> \
269
- --keywords "词1,词2,词3" [--final-url <url>] \
271
+ --keywords "词1,词2,多词短语" [--final-url <url>] \
272
+ [--match-type Broad|Phrase|Exact] [--keywords-file ./kws.json] \
270
273
  [--json-out <path>]
271
274
  ```
272
275
 
273
- `--json-out` 落盘 `{ request: { adgroupId, count }, response: ... }`,批量脚本可直接据此核对成功量。
276
+ `--keywords` **支持空格多词**(逗号才是分隔符)。`--json-out` 落盘 `{ request: { adgroupId, count }, response: ... }`,批量脚本可直接据此核对成功量。
274
277
 
275
278
  ### 否定关键词
276
279
 
280
+ **支持空格多词短语**(如 `how to make`、`home cooking`)。匹配类型与 Web 一致:裸词=广泛,`"短语"`=词组,`[词]`=完全;也可用 `--match-type`。多词/带引号时推荐 `--keywords-file`,避免 shell 转义。
281
+
277
282
  ```bash
278
283
  # 添加(默认系列层级;传 --adgroup-id 则为组层级)
284
+ # 多词短语直接写空格,逗号分隔多条——CLI 支持,无需改后台手工补
279
285
  siluzan-tso ad keyword-negative-create \
280
286
  -a <accountId> \
281
287
  --campaign-id <campaignId> --campaign-name <campaignName> \
282
- --keywords "词1,词2" \
283
- [--adgroup-id <id> --adgroup-name <name>]
288
+ --keywords "how to make,home cooking,second hand,near me"
289
+
290
+ # 统一词组匹配 + JSON 文件(推荐)
291
+ # negatives.json → ["how to make","home cooking","vegan recipe"]
292
+ siluzan-tso ad keyword-negative-create \
293
+ -a <accountId> \
294
+ --campaign-id <campaignId> --campaign-name <campaignName> \
295
+ --keywords-file ./negatives.json --match-type Phrase
284
296
 
285
297
  # 删除(先用 ad keywords --negative --json-out ./snap 获取 id)
286
298
  siluzan-tso ad keyword-negative-delete -a <accountId> --id <negativeKeywordId>
287
299
  ```
288
300
 
301
+ > **禁止**因「多词」就让用户去 Google Ads 后台手工补否词——那是 Agent 误判;用上面命令即可。
302
+
289
303
  ---
290
304
 
291
305
  ## ad batch — 异步批量创建记录
@@ -403,6 +417,8 @@ BatchJob 中 `ExtensionsForBatchJob`(尤其 **Sitelink**)失败率较高,
403
417
 
404
418
  **关键词匹配:** 写在 `KeywordsForBatchJob` 块内;同一块同匹配类型,`MatchTypeV2` 与 `KeywordText` 词面格式必须对齐(`PHRASE` 用 `"词"`、`EXACT` 用 `[词]`、`BROAD` 直写)。
405
419
 
420
+ **多词短语:** `KeywordText` / 否词 `NegativeKeywordsForBatchJob[].KeywordText` 是 **JSON 字符串数组**,**空格多词合法**(如 `"how to make"`)。与 `keyword-negative-create` 不同,这里**没有**逗号 CLI 解析问题——**禁止**因「多词」省略否词或让用户去后台手补。
421
+
406
422
  广告组/关键词/创意的分步创建仍用 `adgroup-create`、`keyword-create`、`ad-create`。
407
423
 
408
424
  ---
@@ -409,8 +409,8 @@ Broad Match + 受众信号 = 伪装成广泛定向的精准投放。
409
409
  | ------------ | ------------------------------------------------------------ | ----------------------------------------------------------------------- |
410
410
  | 关键词调研 | `keyword -k "词" [--url]` | 结果按意图分层后再添加 |
411
411
  | 添加关键词 | `ad keyword-create --keywords "词1,词2"` | 格式:`词→BROAD`、`"词"→PHRASE`、`[词]→EXACT` |
412
- | 添加否定词 | `ad keyword-negative-create` | 每周从 `ad search-terms` 挖掘 |
413
- | 创建广告系列 | `ad campaign-create --config-file`(先 `campaign-validate`) | 按本文第三章架构设计系列结构;匹配块见 `google-ads-keyword-taxonomy.md` |
412
+ | 添加否定词 | `ad keyword-negative-create` | **支持空格多词**(`how to make`);多词用 `--keywords` 或 `--keywords-file`,**禁止**因多词改让用户后台手补 |
413
+ | 创建广告系列 | `ad campaign-create --config-file`(先 `campaign-validate`) | `KeywordText` 为 JSON 数组,**空格多词合法**;否词写 `NegativeKeywordsForBatchJob`,勿因多词省略 |
414
414
  | 搜索词分析 | `ad search-terms` | 每周导出,按第八章流程处理 |
415
415
  | 竞品监控 | `ad auction-insights`(如有) | 按第九章分析竞争态势 |
416
416
  | 出价调整 | 广告系列级 Smart Bidding 设置 | 新账户用 Manual/ECPC → 有数据后切 tCPA/tROAS |
@@ -26,11 +26,15 @@
26
26
 
27
27
  | 检查意图 | 可关注的 JSON 字段(键名以实际 stdout 为准) |
28
28
  | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
29
- | 周期内费用 | 系列/组/创意列表中的 **`spend`**;报表类子命令中带费用的指标字段 |
30
- | 日预算与费用比 | **`ad campaigns --json-out ./snap`**:`**budget`**(CLI 已为元)与 **`spend`**;比较时须**统一货币与单位\*\*(见 `SKILL.md` 金额硬规范) |
31
- | 转化次数 | **`conversions`**(或报表里等价字段名) |
32
- | 实际 CPA | 宿主侧 **`spend / conversions`**(`conversions` 为 0 时不做 CPA 判断) |
29
+ | **区间合计费用** | 系列/组/创意列表中的 **`spend`** = **`--start`~`--end` 整段合计**(**不是**「一天的消耗」;窗口 7 天则约为 7 日之和) |
30
+ | **日预算** | **`ad campaigns --json-out ./snap`** 的 **`budget`**(CLI 已为元)= **每日预算上限**,与 `spend` **量纲不同** |
31
+ | 日预算与「当日」费用比 | **仅当** `--start` 与 `--end` 为**同一统计日**时,才可用 `spend` 对比 `budget`;**禁止**用多日 `spend` 去除以或对比日预算(会把周/月合计误当成「一天花了这么多」) |
32
+ | 转化次数 | **`conversions`**(窗口内合计) |
33
+ | 实际 CPA | 宿主侧 **`spend / conversions`**(同窗口合计;`conversions` 为 0 时不做 CPA 判断) |
33
34
  | 目标 CPA(系列 / 组) | 系列列表 JSON 中的 **`targetCpa_BidingAmount`** 等;组列表 **`ad groups --json-out ./snap`** 中的 **`targetCpaAmount`**(写入口径见 `references/google-ads/google-ads.md`「广告组编辑」) |
35
+ | 日均消耗(续航用) | 用 `balance-scan` 的 **`dailySpend`**(近 7 日合计/7),或自算 `spend / 活跃天数`;**禁止**把多日 `spend` 直接当「日消耗」 |
36
+
37
+ > **硬约束(自动化 / 预警必读)**:凡带 `--start` / `--end` 的读命令,返回的 `spend` / `impressions` / `clicks` / `conversions` 均为**该闭区间合计**。要「一天的消耗」必须 `start=end=该日`,或显式 `合计 ÷ 天数`;要「日均」用 `balance-scan.dailySpend` / `overview.averageDailyCost`。
34
38
 
35
39
  若某条检查所需字段在**当前**落盘 JSON 中不存在,**禁止猜测**:先换用 `references/analytics/account-analytics.md` 中其它子命令拉数,或与维护方确认是否需扩展 `siluzan-tso`;宿主侧不得编造字段。
36
40
 
@@ -38,21 +42,22 @@
38
42
 
39
43
  ## 场景 1:单日预算熔断(Campaign)
40
44
 
41
- **业务目标**:当周期内费用相对日预算达到设定比例(如 **≥ 110%**,建议 **110%–120%** 缓冲 API 延迟)时,**将系列状态置为 Paused**;恢复由人工加预算后在网页或 CLI 再启用。
45
+ **业务目标**:当**当日**系列花费相对**日预算**达到设定比例(如 **≥ 110%**,建议 **110%–120%** 缓冲 API 延迟)时,**将系列状态置为 Paused**;恢复由人工加预算后在网页或 CLI 再启用。
42
46
 
43
47
  ### 检查项(宿主每轮执行)
44
48
 
45
49
  1. 解析 `mediaCustomerId`(见上文前置)。
46
- 2. 拉系列列表(**统计日 = 当日**,起止同为该日或业务日定义):
50
+ 2. 拉系列列表(**必须** `--start` = `--end` = **当日**统计日;**禁止**用默认近 7 天窗口做本场景):
47
51
 
48
52
  ```bash
49
- siluzan-tso ad campaigns -a <mediaCustomerId> --start <YYYY-MM-DD> --end <YYYY-MM-DD> --json-out ./snap
53
+ siluzan-tso ad campaigns -a <mediaCustomerId> --start <当日YYYY-MM-DD> --end <当日YYYY-MM-DD> --json-out ./snap
50
54
  ```
51
55
 
52
56
  3. 对 `items[]` 中每条系列(`id` 为系列 ID):
53
- - 读取 **`spend`**(或文档约定的费用字段)与 **`budget`**(`ad campaigns` JSON 下**已为元**,与 `spend` 同量级口径,见 `SKILL.md` 硬规范)。
54
- - 计算阈值:`threshold = budget_in_same_unit * (coefficient / 100)`,系数 `coefficient` 建议 **110–120**(配置项)。
57
+ - 读取 **`spend`**(= **仅当日**合计费用)与 **`budget`**(**日预算**,元)。
58
+ - 计算阈值:`threshold = budget * (coefficient / 100)`,系数 `coefficient` 建议 **110–120**(配置项)。
55
59
  - **IF** `spend >= threshold` **且**宿主策略允许对该系列熔断(如排除白名单),则进入写操作。
60
+ - **禁止**:`--start`≠`--end` 时仍用 `spend` 对比 `budget`(会把多日合计误判成单日超预算)。
56
61
 
57
62
  ### 最终操作
58
63
 
@@ -14,6 +14,8 @@
14
14
 
15
15
  **总索引**:[`references/operations/hosted-automation-scenarios.md`](references/operations/hosted-automation-scenarios.md) · **自控全文**:[`references/operations/hosted-automation-self-control.md`](references/operations/hosted-automation-self-control.md) · **金额硬规范**(展示用 `*Display` 等):[`SKILL.md`](../SKILL.md)「金额与货币单位硬约束」。
16
16
 
17
+ > **消耗 ≠ 日消耗**:`ad campaigns` / `stats` / `accounts-digest` 的 **`spend` 是 `--start`~`--end` 合计**。单日熔断必须 `start=end=当日`;续航日均用 **`balance-scan.dailySpend`**,禁止把默认近 7 天 `spend` 当成「一天的花费」去比日预算。
18
+
17
19
  ## 异常监控与报警系统
18
20
 
19
21
  编排(定时、HTTP 探活、通知)在宿主;**读哪些 JSON 字段、用什么命令** 以 **[`references/operations/hosted-automation-monitoring-json.md`](references/operations/hosted-automation-monitoring-json.md)** 为准(键名以当次 `--json-out` 落盘 JSON 为准,勿套用 Google Ads API 官方字段名解析本 CLI 输出)。
@@ -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.37-beta.2'
12
+ $PKG_VERSION = '1.1.37-beta.4'
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.37-beta.2"
12
+ readonly PKG_VERSION="1.1.37-beta.4"
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.37-beta.2",
3
+ "version": "1.1.37-beta.4",
4
4
  "description": "Siluzan 广告账户管理 CLI — 查询账户、余额、消耗数据,管理绑定关系与充值。",
5
5
  "keywords": [
6
6
  "ad-account",