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

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.4),供内部测试使用。正式发布后安装命令将改为 `npm install -g siluzan-tso-cli`。
54
+ > **注意**:当前为测试版(1.1.37-beta.5),供内部测试使用。正式发布后安装命令将改为 `npm install -g siluzan-tso-cli`。
55
55
 
56
56
  | 助手 | 建议 `--ai` |
57
57
  | ----------------------- | ------------------------------------ |
package/dist/index.js CHANGED
@@ -111582,6 +111582,52 @@ function pushErr2(errors, msg) {
111582
111582
  function pushWarn2(warnings, msg) {
111583
111583
  warnings.push(msg);
111584
111584
  }
111585
+ function readLocationId(loc) {
111586
+ return loc["id"] !== void 0 ? loc["id"] : loc["Id"];
111587
+ }
111588
+ function validateLocationsFidelity(cfg, campaign, errors) {
111589
+ const targetedLocations = campaign["targetedLocations"];
111590
+ if (!Array.isArray(targetedLocations) || targetedLocations.length === 0) {
111591
+ pushErr2(
111592
+ errors,
111593
+ "campaign.targetedLocations \u4E0D\u80FD\u4E3A\u7A7A\uFF0C\u8BF7\u5148\u6267\u884C\uFF1Asiluzan-tso ad geo search -a <accountId> -q <\u5730\u533A\u540D>"
111594
+ );
111595
+ return;
111596
+ }
111597
+ for (let i = 0; i < targetedLocations.length; i++) {
111598
+ const loc = targetedLocations[i];
111599
+ if (!loc || typeof loc !== "object") {
111600
+ pushErr2(errors, `campaign.targetedLocations[${i}] \u5FC5\u987B\u662F\u5BF9\u8C61`);
111601
+ continue;
111602
+ }
111603
+ const id = readLocationId(loc);
111604
+ if (id === void 0 || id === null || String(id).trim() === "") {
111605
+ pushErr2(errors, `campaign.targetedLocations[${i}] \u7F3A\u5C11 id \u5B57\u6BB5`);
111606
+ continue;
111607
+ }
111608
+ const idStr = String(id).trim();
111609
+ if (!/^\d{3,10}$/.test(idStr)) {
111610
+ pushErr2(
111611
+ errors,
111612
+ `campaign.targetedLocations[${i}].id="${idStr}" \u4E0D\u662F\u5408\u6CD5\u7684 Google \u5730\u7406\u4F4D\u7F6E\u6570\u5B57 ID\uFF1B\u8BF7\u7528 siluzan-tso ad geo search -a <accountId> -q <\u5730\u533A\u540D> \u83B7\u53D6\uFF0C\u7981\u6B62\u7F16\u9020/ISO \u5FC3\u7B97`
111613
+ );
111614
+ }
111615
+ }
111616
+ const locations = cfg.locations;
111617
+ if (Array.isArray(locations) && locations.length > 0) {
111618
+ if (locations.length !== targetedLocations.length) {
111619
+ pushErr2(
111620
+ errors,
111621
+ `\u5916\u5C42 locations\uFF08${locations.length}\uFF09\u4E0E campaign.targetedLocations\uFF08${targetedLocations.length}\uFF09\u6570\u91CF\u4E0D\u4E00\u81F4\uFF1B\u987B\u540C\u5E8F\u540C\u91CF\uFF08\u6BCF\u4E2A\u5C55\u793A\u540D\u5BF9\u5E94\u4E00\u6B21 ad geo search \u53D6\u5230\u7684 id\uFF09\u3002\u89C1 google-ads-plan-source-fidelity.md`
111622
+ );
111623
+ }
111624
+ for (let i = 0; i < locations.length; i++) {
111625
+ if (typeof locations[i] !== "string" || !locations[i].trim()) {
111626
+ pushErr2(errors, `locations[${i}] \u4E0D\u80FD\u4E3A\u7A7A\u5B57\u7B26\u4E32`);
111627
+ }
111628
+ }
111629
+ }
111630
+ }
111585
111631
  function calcGoogleCharLength(text) {
111586
111632
  let len = 0;
111587
111633
  for (const ch of text) {
@@ -111867,20 +111913,7 @@ function validateCampaignCreateConfigCore(cfg) {
111867
111913
  }
111868
111914
  }
111869
111915
  }
111870
- const targetedLocations = campaign["targetedLocations"];
111871
- if (!Array.isArray(targetedLocations) || targetedLocations.length === 0) {
111872
- pushErr2(
111873
- errors,
111874
- "campaign.targetedLocations \u4E0D\u80FD\u4E3A\u7A7A\uFF0C\u8BF7\u5148\u6267\u884C\uFF1Asiluzan-tso ad geo search -a <accountId> -q <\u5730\u533A\u540D>"
111875
- );
111876
- } else {
111877
- for (let i = 0; i < targetedLocations.length; i++) {
111878
- const loc = targetedLocations[i];
111879
- if (!loc || loc["id"] === void 0 && loc["Id"] === void 0) {
111880
- pushErr2(errors, `campaign.targetedLocations[${i}] \u7F3A\u5C11 id \u5B57\u6BB5`);
111881
- }
111882
- }
111883
- }
111916
+ validateLocationsFidelity(cfg, campaign, errors);
111884
111917
  const targetedLanguages = campaign["targetedLanguages"];
111885
111918
  if (!Array.isArray(targetedLanguages) || targetedLanguages.length === 0) {
111886
111919
  pushErr2(errors, "campaign.targetedLanguages \u4E0D\u80FD\u4E3A\u7A7A\uFF08\u82F1\u8BED id=1000\uFF0C\u4E2D\u6587 id=1017\uFF09");
@@ -113872,6 +113905,7 @@ async function runAdSearchTerms(opts) {
113872
113905
 
113873
113906
  // src/commands/ad/geo.ts
113874
113907
  init_auth();
113908
+ import { readFileSync as readFileSync9 } from "fs";
113875
113909
  init_cli_json_snapshot();
113876
113910
  init_strip_legacy_google_fields();
113877
113911
 
@@ -114119,20 +114153,48 @@ async function runAdDeviceBidSet(opts) {
114119
114153
  }
114120
114154
 
114121
114155
  // src/commands/ad/geo.ts
114156
+ async function fetchGeoLocationCandidates(googleApiUrl, account, query, config, verbose) {
114157
+ const url = `${googleApiUrl}/campaignmanagement/geolocations/${account}/${encodeURIComponent(query)}`;
114158
+ const data = await apiFetch2(url, config, {}, verbose);
114159
+ return Array.isArray(data) ? data : data.data ?? [];
114160
+ }
114161
+ function geoItemDisplayName(item) {
114162
+ return String(item["locationName"] ?? item["canonicalName"] ?? item["name"] ?? "");
114163
+ }
114164
+ function geoItemType(item) {
114165
+ return String(item["targetType"] ?? item["typeV2"] ?? "");
114166
+ }
114167
+ function pickGeoResolveCandidate(query, items2) {
114168
+ if (items2.length === 0) return null;
114169
+ const q = query.trim().toLowerCase();
114170
+ const isCountry = (it) => /country/i.test(geoItemType(it)) || geoItemType(it).toUpperCase() === "COUNTRY";
114171
+ const nameEq = (it) => geoItemDisplayName(it).trim().toLowerCase() === q;
114172
+ const exactCountry = items2.find((it) => isCountry(it) && nameEq(it));
114173
+ if (exactCountry) return exactCountry;
114174
+ const anyCountry = items2.find((it) => isCountry(it));
114175
+ if (anyCountry) return anyCountry;
114176
+ const exactAny = items2.find((it) => nameEq(it));
114177
+ if (exactAny) return exactAny;
114178
+ return items2[0] ?? null;
114179
+ }
114122
114180
  async function runAdGeoSearch(opts) {
114123
114181
  const config = loadConfig(opts.token);
114124
114182
  const googleApiUrl = requireGoogleApi(config);
114125
- const url = `${googleApiUrl}/campaignmanagement/geolocations/${opts.account}/${encodeURIComponent(opts.query)}`;
114126
- let data;
114183
+ let items2;
114127
114184
  try {
114128
- data = await apiFetch2(url, config, {}, opts.verbose);
114185
+ items2 = await fetchGeoLocationCandidates(
114186
+ googleApiUrl,
114187
+ opts.account,
114188
+ opts.query,
114189
+ config,
114190
+ opts.verbose
114191
+ );
114129
114192
  } catch (err) {
114130
114193
  console.error(`
114131
114194
  \u274C \u641C\u7D22\u5730\u7406\u4F4D\u7F6E\u5931\u8D25\uFF1A${err instanceof Error ? err.message : String(err)}
114132
114195
  `);
114133
114196
  process.exit(1);
114134
114197
  }
114135
- const items2 = Array.isArray(data) ? data : data.data ?? [];
114136
114198
  console.log(`
114137
114199
  \u5730\u7406\u4F4D\u7F6E\u641C\u7D22\u7ED3\u679C\u300C${opts.query}\u300D\uFF08\u5171 ${items2.length} \u6761\uFF09
114138
114200
  `);
@@ -114142,12 +114204,162 @@ async function runAdGeoSearch(opts) {
114142
114204
  }
114143
114205
  for (const item of items2) {
114144
114206
  const id = String(item["id"] ?? "");
114145
- const name2 = String(item["locationName"] ?? item["canonicalName"] ?? item["name"] ?? "");
114146
- const type = String(item["targetType"] ?? item["typeV2"] ?? "");
114207
+ const name2 = geoItemDisplayName(item);
114208
+ const type = geoItemType(item);
114147
114209
  console.log(` id:${id} ${name2} [${type}]`);
114148
114210
  }
114149
114211
  console.log();
114150
114212
  }
114213
+ function parseGeoResolveQueries(opts) {
114214
+ const out = [];
114215
+ if (opts.fromFile) {
114216
+ const raw = readFileSync9(opts.fromFile, "utf8").trim();
114217
+ if (raw.startsWith("[")) {
114218
+ const arr = JSON.parse(raw);
114219
+ if (!Array.isArray(arr)) {
114220
+ throw new Error(`--from-file JSON \u5FC5\u987B\u662F\u5B57\u7B26\u4E32\u6570\u7EC4\uFF1A${opts.fromFile}`);
114221
+ }
114222
+ for (const x of arr) {
114223
+ if (typeof x === "string" && x.trim()) out.push(x.trim());
114224
+ else if (x && typeof x === "object" && typeof x.name === "string") {
114225
+ const n = x.name.trim();
114226
+ if (n) out.push(n);
114227
+ }
114228
+ }
114229
+ } else {
114230
+ for (const line of raw.split(/\r?\n/)) {
114231
+ const t = line.trim();
114232
+ if (t && !t.startsWith("#")) out.push(t);
114233
+ }
114234
+ }
114235
+ }
114236
+ if (opts.queries) {
114237
+ for (const part of opts.queries.split(",")) {
114238
+ const t = part.trim();
114239
+ if (t) out.push(t);
114240
+ }
114241
+ }
114242
+ const seen = /* @__PURE__ */ new Set();
114243
+ const unique = [];
114244
+ for (const q of out) {
114245
+ const key = q.toLowerCase();
114246
+ if (seen.has(key)) continue;
114247
+ seen.add(key);
114248
+ unique.push(q);
114249
+ }
114250
+ return unique;
114251
+ }
114252
+ async function runAdGeoResolve(opts) {
114253
+ let queries;
114254
+ try {
114255
+ queries = parseGeoResolveQueries(opts);
114256
+ } catch (err) {
114257
+ console.error(`
114258
+ \u274C ${err instanceof Error ? err.message : String(err)}
114259
+ `);
114260
+ process.exit(1);
114261
+ }
114262
+ if (queries.length === 0) {
114263
+ console.error(
114264
+ '\n\u274C \u8BF7\u63D0\u4F9B --queries "Peru,Chile,..." \u6216 --from-file <names.txt|names.json>\n'
114265
+ );
114266
+ process.exit(1);
114267
+ }
114268
+ const config = loadConfig(opts.token);
114269
+ const googleApiUrl = requireGoogleApi(config);
114270
+ const rows = [];
114271
+ for (const query of queries) {
114272
+ let items2 = [];
114273
+ try {
114274
+ items2 = await fetchGeoLocationCandidates(
114275
+ googleApiUrl,
114276
+ opts.account,
114277
+ query,
114278
+ config,
114279
+ opts.verbose
114280
+ );
114281
+ } catch (err) {
114282
+ console.error(
114283
+ `
114284
+ \u274C \u89E3\u6790\u300C${query}\u300D\u5931\u8D25\uFF1A${err instanceof Error ? err.message : String(err)}
114285
+ `
114286
+ );
114287
+ process.exit(1);
114288
+ }
114289
+ const picked = pickGeoResolveCandidate(query, items2);
114290
+ if (!picked) {
114291
+ rows.push({
114292
+ query,
114293
+ id: null,
114294
+ name: null,
114295
+ targetType: null,
114296
+ status: "not_found",
114297
+ candidates: 0
114298
+ });
114299
+ continue;
114300
+ }
114301
+ rows.push({
114302
+ query,
114303
+ id: String(picked["id"] ?? "") || null,
114304
+ name: geoItemDisplayName(picked) || null,
114305
+ targetType: geoItemType(picked) || null,
114306
+ status: items2.length > 1 ? "ambiguous_ok" : "ok",
114307
+ candidates: items2.length
114308
+ });
114309
+ }
114310
+ const failed = rows.filter((r) => !r.id);
114311
+ const locations = rows.filter((r) => r.id).map((r) => r.query);
114312
+ const targetedLocations = rows.filter((r) => r.id).map((r) => ({ id: r.id }));
114313
+ const payload = {
114314
+ account: opts.account,
114315
+ resolved: rows.filter((r) => r.id).length,
114316
+ failed: failed.length,
114317
+ rows,
114318
+ /** 可直接写入 campaign-create JSON */
114319
+ locations,
114320
+ targetedLocations,
114321
+ nameToId: Object.fromEntries(
114322
+ rows.filter((r) => r.id).map((r) => [r.query, r.id])
114323
+ )
114324
+ };
114325
+ if (await emitCliJsonOrSnapshot(
114326
+ { jsonOut: opts.jsonOut },
114327
+ {
114328
+ section: `ad-geo-resolve-${opts.account}`,
114329
+ commandLabel: "ad geo resolve",
114330
+ commandHint: opts.account,
114331
+ payload,
114332
+ idSuffix: opts.account
114333
+ }
114334
+ )) {
114335
+ if (failed.length > 0 && !opts.allowPartial) process.exit(1);
114336
+ return;
114337
+ }
114338
+ console.log(`
114339
+ \u5730\u7406\u4F4D\u7F6E\u6279\u91CF\u89E3\u6790\uFF08${opts.account}\uFF09\uFF1A\u6210\u529F ${payload.resolved} / \u5931\u8D25 ${failed.length}
114340
+ `);
114341
+ for (const r of rows) {
114342
+ if (r.id) {
114343
+ console.log(` \u2705 ${r.query} \u2192 id:${r.id} ${r.name ?? ""} [${r.targetType ?? ""}]`);
114344
+ } else {
114345
+ console.log(` \u274C ${r.query} \u2192 \u672A\u627E\u5230`);
114346
+ }
114347
+ }
114348
+ if (failed.length > 0) {
114349
+ console.error(
114350
+ `
114351
+ \u274C ${failed.length} \u4E2A\u5730\u540D\u672A\u89E3\u6790\uFF1B\u8BF7\u6539\u540D\u540E\u91CD\u8BD5\uFF0C\u6216\u68C0\u67E5\u62FC\u5199\uFF08\u52FF\u7F16\u9020 id\uFF09\u3002
114352
+ `
114353
+ );
114354
+ if (!opts.allowPartial) process.exit(1);
114355
+ } else {
114356
+ console.log(
114357
+ `
114358
+ \u63D0\u793A\uFF1A--json-out \u843D\u76D8\u540E\u53EF\u8BFB locations / targetedLocations / nameToId\uFF0C\u76F4\u63A5\u5199\u5165 campaign-create JSON\u3002
114359
+ `
114360
+ );
114361
+ }
114362
+ }
114151
114363
  async function runAdGeoList(opts) {
114152
114364
  const config = loadConfig(opts.token);
114153
114365
  const googleApiUrl = requireGoogleApi(config);
@@ -115915,7 +116127,7 @@ async function runPmaxImageValidation(configFile, cfg) {
115915
116127
  // src/commands/ad/pmax-image-resolve.ts
115916
116128
  init_auth();
115917
116129
  import { basename as basename4, dirname as dirname8, isAbsolute as isAbsolute3, resolve as resolve7 } from "path";
115918
- import { readFileSync as readFileSync9 } from "fs";
116130
+ import { readFileSync as readFileSync10 } from "fs";
115919
116131
 
115920
116132
  // src/commands/ad/pmax-urls.ts
115921
116133
  function pmaxChannelTypesUrl(googleApiUrl) {
@@ -116078,7 +116290,7 @@ async function uploadPmaxImageFileBase64(config, googleApiUrl, accountId, absIma
116078
116290
  async function uploadPmaxImageFile(config, googleApiUrl, accountId, absImagePath, name2, verbose) {
116079
116291
  const fileName = basename4(absImagePath);
116080
116292
  const assetName = (name2 ?? fileName).trim() || fileName;
116081
- const fileBuffer = readFileSync9(absImagePath);
116293
+ const fileBuffer = readFileSync10(absImagePath);
116082
116294
  if (fileBuffer.length <= MAX_BASE64_UPLOAD_BYTES) {
116083
116295
  return uploadPmaxImageFileBase64(
116084
116296
  config,
@@ -116164,7 +116376,7 @@ function assertPmaxImageSlotsResolved(slots, requiredKinds = ["marketing", "squa
116164
116376
  }
116165
116377
 
116166
116378
  // src/commands/ad/pmax-load.ts
116167
- import { readFileSync as readFileSync10 } from "fs";
116379
+ import { readFileSync as readFileSync11 } from "fs";
116168
116380
 
116169
116381
  // src/commands/ad/pmax-campaign-extensions.ts
116170
116382
  init_auth();
@@ -116794,7 +117006,7 @@ function detectDeprecatedPmaxVideoKeys(raw) {
116794
117006
  function tryLoadPmaxCreateConfig(configFile) {
116795
117007
  let raw;
116796
117008
  try {
116797
- raw = JSON.parse(readFileSync10(configFile, "utf8"));
117009
+ raw = JSON.parse(readFileSync11(configFile, "utf8"));
116798
117010
  } catch {
116799
117011
  return null;
116800
117012
  }
@@ -116802,7 +117014,7 @@ function tryLoadPmaxCreateConfig(configFile) {
116802
117014
  }
116803
117015
  function loadPmaxCreateConfigRaw(configFile) {
116804
117016
  try {
116805
- const raw = JSON.parse(readFileSync10(configFile, "utf8"));
117017
+ const raw = JSON.parse(readFileSync11(configFile, "utf8"));
116806
117018
  return stripMetaKeys(raw);
116807
117019
  } catch {
116808
117020
  return null;
@@ -116853,19 +117065,19 @@ function buildPmaxCreateUrl(googleApiUrl, accountId) {
116853
117065
  }
116854
117066
 
116855
117067
  // src/commands/ad/pmax-video-upload.ts
116856
- import { existsSync as existsSync3, readFileSync as readFileSync12 } from "fs";
117068
+ import { existsSync as existsSync3, readFileSync as readFileSync13 } from "fs";
116857
117069
  import { basename as basename5, dirname as dirname10, isAbsolute as isAbsolute5, resolve as resolve9 } from "path";
116858
117070
 
116859
117071
  // src/commands/ad/pmax-shared.ts
116860
117072
  init_auth();
116861
117073
  init_cli_json_snapshot();
116862
- import { readFileSync as readFileSync11 } from "fs";
117074
+ import { readFileSync as readFileSync12 } from "fs";
116863
117075
  import { dirname as dirname9, isAbsolute as isAbsolute4, resolve as resolve8 } from "path";
116864
117076
  var PMAX_MONEY_KEYS = /* @__PURE__ */ new Set(["budget", "targetCpa_BidingAmount"]);
116865
117077
  function loadPmaxJsonFile(configFile) {
116866
117078
  let raw;
116867
117079
  try {
116868
- raw = JSON.parse(readFileSync11(configFile, "utf8"));
117080
+ raw = JSON.parse(readFileSync12(configFile, "utf8"));
116869
117081
  } catch (e) {
116870
117082
  const msg = e instanceof Error ? e.message : String(e);
116871
117083
  console.error(`
@@ -117065,7 +117277,7 @@ function runPmaxVideoValidation(configFile, cfg) {
117065
117277
  return { errors, warnings };
117066
117278
  }
117067
117279
  try {
117068
- const buf = readFileSync12(abs);
117280
+ const buf = readFileSync13(abs);
117069
117281
  if (buf.length === 0) errors.push(`videoPath \u4E3A\u7A7A\u6587\u4EF6\uFF1A${abs}`);
117070
117282
  } catch (e) {
117071
117283
  const msg = e instanceof Error ? e.message : String(e);
@@ -117128,7 +117340,7 @@ async function uploadPmaxLocalVideo(opts) {
117128
117340
  const fileName = basename5(opts.absVideoPath);
117129
117341
  const title = (opts.title ?? fileName).trim() || fileName;
117130
117342
  const description = (opts.description ?? "Uploaded via siluzan-tso PMax video upload").trim() || "Uploaded via siluzan-tso";
117131
- const fileBuffer = readFileSync12(opts.absVideoPath);
117343
+ const fileBuffer = readFileSync13(opts.absVideoPath);
117132
117344
  const form = new FormData();
117133
117345
  form.append("media_account_id", opts.accountId.replace(/-/g, ""));
117134
117346
  form.append("title", title);
@@ -117186,7 +117398,7 @@ function validatePmaxVideoPathQuick(absPath) {
117186
117398
  return `\u89C6\u9891\u6587\u4EF6\u4E0D\u5B58\u5728\uFF1A${absPath}`;
117187
117399
  }
117188
117400
  try {
117189
- if (readFileSync12(absPath).length === 0) return `\u89C6\u9891\u6587\u4EF6\u4E3A\u7A7A\uFF1A${absPath}`;
117401
+ if (readFileSync13(absPath).length === 0) return `\u89C6\u9891\u6587\u4EF6\u4E3A\u7A7A\uFF1A${absPath}`;
117190
117402
  } catch (e) {
117191
117403
  const msg = e instanceof Error ? e.message : String(e);
117192
117404
  return `\u65E0\u6CD5\u8BFB\u53D6\u89C6\u9891\u6587\u4EF6\uFF08${absPath}\uFF09\uFF1A${msg}`;
@@ -118589,7 +118801,7 @@ async function runAdCampaignValidate(opts) {
118589
118801
  }
118590
118802
 
118591
118803
  // src/commands/ad/pmax-image-convert.ts
118592
- import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync5, readFileSync as readFileSync13, existsSync as existsSync4 } from "fs";
118804
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync5, readFileSync as readFileSync14, existsSync as existsSync4 } from "fs";
118593
118805
  import { resolve as resolve10, dirname as dirname11, basename as basename7, extname } from "path";
118594
118806
  var SPECS = [
118595
118807
  { kind: "marketing", width: 1200, height: 628, fit: "cover", suffix: "_marketing" },
@@ -118678,7 +118890,7 @@ async function runAdPmaxImageConvert(opts) {
118678
118890
  if (opts.updateConfig) {
118679
118891
  const configPath = resolve10(opts.updateConfig);
118680
118892
  try {
118681
- const raw = JSON.parse(readFileSync13(configPath, "utf8"));
118893
+ const raw = JSON.parse(readFileSync14(configPath, "utf8"));
118682
118894
  const imgPaths = raw["imagePaths"] ?? {};
118683
118895
  if (outputPaths.marketing) imgPaths["marketing"] = outputPaths.marketing;
118684
118896
  if (outputPaths.square) imgPaths["square"] = outputPaths.square;
@@ -119888,6 +120100,28 @@ function register20(program2) {
119888
120100
  verbose: opts.verbose
119889
120101
  });
119890
120102
  });
120103
+ geoCmd.command("resolve").description(
120104
+ "\u6279\u91CF\u89E3\u6790\u5730\u540D\u2192locationId\uFF08\u6295\u653E\u65B9\u6848\u56FD\u5BB6\u5217\u8868\uFF09\uFF1B\u8F93\u51FA locations / targetedLocations / nameToId\uFF0C\u4F9B campaign-create JSON \u76F4\u63A5\u5199\u5165"
120105
+ ).requiredOption("-a, --account <id>", "Google \u8D26\u6237 mediaCustomerId").option("-q, --queries <text>", "\u9017\u53F7\u5206\u9694\u5730\u540D\uFF0C\u5982 Peru,Chile,Kenya").option(
120106
+ "--from-file <path>",
120107
+ "\u5730\u540D\u6587\u4EF6\uFF1A\u6BCF\u884C\u4E00\u4E2A\uFF0C\u6216 JSON \u5B57\u7B26\u4E32\u6570\u7EC4\uFF08\u5982 plan-extract.json \u7684 locations\uFF09"
120108
+ ).option("--allow-partial", "\u5141\u8BB8\u90E8\u5206\u5931\u8D25\u4ECD exit 0\uFF08\u9ED8\u8BA4\u4EFB\u4E00\u672A\u89E3\u6790\u5219 exit 1\uFF09", false).option("-t, --token <token>", "Auth Token").option(
120109
+ "--json-out <path>",
120110
+ "\u843D\u76D8\uFF08\u76EE\u5F55\u6216 *.json\uFF09\uFF1Bstdout \u4E00\u884C\u6458\u8981\uFF1B\u8BFB payload.locations / targetedLocations / nameToId",
120111
+ void 0
120112
+ ).option("--verbose", "\u8BE6\u7EC6\u9519\u8BEF\u4FE1\u606F", false).action(
120113
+ async (opts) => {
120114
+ await runAdGeoResolve({
120115
+ token: opts.token,
120116
+ account: opts.account,
120117
+ queries: opts.queries,
120118
+ fromFile: opts.fromFile,
120119
+ allowPartial: opts.allowPartial,
120120
+ jsonOut: opts.jsonOut,
120121
+ verbose: opts.verbose
120122
+ });
120123
+ }
120124
+ );
119891
120125
  geoCmd.command("list").description(
119892
120126
  "\u67E5\u8BE2\u5730\u7406\u4F4D\u7F6E\u5B9A\u5411\u5217\u8868\uFF08targeted = \u5DF2\u5B9A\u4F4D\uFF1Bexcluded = \u5DF2\u6392\u9664\uFF1Breport = \u6D88\u8017\u62A5\u544A\uFF0C\u53EF\u9009\u6309\u5E7F\u544A\u7CFB\u5217\u8FC7\u6EE4\uFF09"
119893
120127
  ).requiredOption("-a, --account <id>", "Google \u8D26\u6237 mediaCustomerId").option("--mode <mode>", "\u5217\u8868\u7C7B\u578B\uFF1Atargeted | excluded | report\uFF08\u9ED8\u8BA4 targeted\uFF09", "targeted").option("--campaign-id <id>", "\u53EF\u9009\uFF1A\u6309\u5E7F\u544A\u7CFB\u5217 ID \u8FC7\u6EE4\u7ED3\u679C\uFF08\u6765\u81EA ad campaigns\uFF09").option("--start <date>", "\u5F00\u59CB\u65E5\u671F YYYY-MM-DD\uFF08\u9ED8\u8BA4 30 \u5929\u524D\uFF0C\u6240\u6709\u6A21\u5F0F\u751F\u6548\uFF09").option("--end <date>", "\u7ED3\u675F\u65E5\u671F YYYY-MM-DD\uFF08\u9ED8\u8BA4\u4ECA\u5929\uFF0C\u6240\u6709\u6A21\u5F0F\u751F\u6548\uFF09").option("-t, --token <token>", "Auth Token").option(
@@ -82,7 +82,7 @@ siluzan-tso -h # 查看帮助
82
82
 
83
83
  | 用户意图(关键词) | 工作流 | 必读文档 |
84
84
  | ------------------ | ------ | -------- |
85
- | 新建搜索系列 / 出投放方案 / **根据官网·网站·URL 生成 Google 搜索广告** / 搜索广告文案·关键词·**表格** / 写搜索广告计划 | **W3** | `references/google-ads/google-ads-campaign-plan.md` + **`assets/campaign-create-template.json`**(结构真相源,先 Read)+ `assets/campaign-create-template.md`(字段说明);表格交付读 `references/google-ads/rules/google-ads-launch-plan-template.md`。**≠ P8 网站诊断 / ≠ P9 市场分析 / ≠ W5 仅拓词** |
85
+ | 新建搜索系列 / 出投放方案 / **按 Excel·xlsx·表格投放方案创建广告** / **根据官网·网站·URL 生成 Google 搜索广告** / 搜索广告文案·关键词·**表格** / 写搜索广告计划 | **W3** | `references/google-ads/google-ads-campaign-plan.md` + **`assets/campaign-create-template.json`**(先 Read)+ `assets/campaign-create-template.md`;**有方案文件时必读** `references/google-ads/rules/google-ads-plan-source-fidelity.md`(`plan-extract` → **`ad geo resolve`** → assemble 脚本,禁止对话手填 JSON);表格交付读 `references/google-ads/rules/google-ads-launch-plan-template.md`。**≠ P8 / ≠ P9 / ≠ W5** |
86
86
  | 广告系列/组/广告/关键词 CRUD / 拒审 | W3 | `references/google-ads/google-ads.md` |
87
87
  | PMax 系列 | W3 | **`assets/pmax-create-template.json`**(先 Read)+ `assets/pmax-create-template.md` + `references/google-ads/pmax-api.md` |
88
88
  | AI 智投草稿 list/get/update/publish | W4 | `references/google-ads/google-ads.md`(§ ad batch) |
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "slug": "siluzan-tso",
3
- "version": "1.1.37-beta.4",
4
- "publishedAt": 1783934085806
3
+ "version": "1.1.37-beta.5",
4
+ "publishedAt": 1784079896188
5
5
  }
@@ -8,6 +8,8 @@
8
8
  "campaign-validate 推荐 --json-out ",
9
9
  "否词只写 campaign.NegativeKeywordsForBatchJob,禁止把 free/jobs/tutorial 等否词抄进 AdGroupsForBatchJob[].KeywordsForBatchJob",
10
10
  "KeywordText 是 JSON 字符串数组,空格多词合法(如 how to make);禁止因多词省略否词或让用户去后台手补",
11
+ "Excel/表格方案:plan-extract.json → ad geo resolve --json-out → assemble-campaign-from-plan.mjs;禁止对话手填完整 JSON、禁止编造 geo id",
12
+ "Excel/表格方案:中间件 keyword 带 matchType;组装脚本分 EXACT/PHRASE/BROAD 块;有方案源时勿压成一律 BROAD",
11
13
  "RSA 须同时写 DestinationUrl 与 Finalurl(后端必填 Finalurl;仅有 DestinationUrl 时 validate 可能仍通过)",
12
14
  "SITELINK 须写 AssetFieldType: SITELINK(与 typeV2 一致)",
13
15
  "提交前:ad campaign-validate → 用户确认 → ad campaign-create(勿对 validate 加 --json-out)"
@@ -31,6 +31,16 @@ 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
+ ### Excel / 表格方案保真(地域 + 匹配类型)
35
+
36
+ 用户给了投放方案文件时,**必读** `references/google-ads/rules/google-ads-plan-source-fidelity.md`,并遵守:
37
+
38
+ | 坑 | 正确做法 |
39
+ | -- | -------- |
40
+ | 编造 `targetedLocations[].id` 或对 30 国逐条 `geo search` | **一次** `ad geo resolve --from-file … --json-out`;见 `google-ads-plan-source-fidelity.md` |
41
+ | 对话里手写完整 campaign JSON | 抽取 `plan-extract.json` → geo resolve → `assemble-campaign-from-plan.mjs` |
42
+ | 方案有 Exact/Phrase,JSON 却一律 `BROAD` | 中间件 keyword 带 `matchType`;组装脚本自动分块 |
43
+
34
44
  **多词短语(与 `keyword-negative-create` 同类误判)**:`KeywordText` 是 **JSON 字符串数组**,**空格多词完全合法**(如 `"how to make"`、`"home cooking"`)。PHRASE 写 `"\"vegan recipe\""` 或裸词 + `MatchTypeV2: "PHRASE"`(validate 会补引号)。**禁止**因「多词」就省略否词或让用户去 Google Ads 后台手补——那是 Agent 误判,不是 `campaign-create` 限制。
35
45
 
36
46
  投放方案 Markdown 中 §3.3 关键词矩阵 → `KeywordsForBatchJob`;§3.3 系列级否定词 / §3.4 账户级否定词 → `NegativeKeywordsForBatchJob`(账户级列表仍须在 Google 后台单独应用)。
@@ -113,7 +123,7 @@ siluzan-tso ad batch diff --batch-id <taskId> --config-file ./campaign.json --js
113
123
  | `customerName` | string | | 展示/智投用客户名;**可省略**——`campaign-create` / `batch publish` 会按 `account` 调 `list-accounts` 自动填入 `mediaCustomerName`。若填写则须与之一致,否则提交时 CLI 自动更正 |
114
124
  | `name` | string | | 智投 `campaignName`;缺省取 `campaign.Name`;账户内不得与已有在投/暂停系列重名,否则 BatchJob 系列创建失败 |
115
125
  | `url` | string | | 智投展示用 URL;后端只读,用于回显 |
116
- | `locations` | string[] | | 展示用地区名(后端只读,可空数组) |
126
+ | `locations` | string[] | | 展示用地区名(后端只读,可空数组);**若填写非空**则长度须与 `campaign.targetedLocations` 一致(validate 硬校验) |
117
127
  | `productWords` | string[] | | 智投/推荐用产品核心词 |
118
128
  | `googleDataRecordId` | string \| null | | 智投记录 ID;省略时提交 `""`(CLI 默认) |
119
129
  | `draft` | boolean | | `false`(默认)立即发布到 Google;`true` 仅保存草稿,需后续 `ad batch publish` |
@@ -163,7 +173,7 @@ siluzan-tso ad batch diff --batch-id <taskId> --config-file ./campaign.json --js
163
173
 
164
174
  | 字段 | 类型 | 必填 | 说明 |
165
175
  | ----------------------- | ---------------------------------------- | :--: | --------------------------------------------------------------- |
166
- | `targetedLocations` | `{ id: string, bidModifier?: number }[]` | ✅ | 至少 1 个;先 `siluzan-tso ad geo search -a <acct> -q <地区名>` |
176
+ | `targetedLocations` | `{ id: string, bidModifier?: number }[]` | ✅ | 至少 1 个;**id 必须来自** `siluzan-tso ad geo search -a <acct> -q <地区名>`(禁止编造) |
167
177
  | `excludedLocations` | 同上 | | 排除地区 |
168
178
  | `targetedLanguages` | `{ id: number }[]` | ✅ | 英语 1000,中文 1017 |
169
179
  | `targetedPlatforms` | `{ id: number, bidModifier?: number }[]` | | 30001 桌面 / 30002 平板 / 30000 移动 |
@@ -247,6 +257,7 @@ siluzan-tso ad batch diff --batch-id <taskId> --config-file ./campaign.json --js
247
257
  | 规则 | 说明 |
248
258
  | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
249
259
  | 基础 | `customerName` / `campaign` 非空;`Budget > 0`;地理/语言至少 1 项 |
260
+ | 地域一致性 | 外层 `locations` 非空时,长度须等于 `targetedLocations`;每个 location `id` 须为数字字符串 |
250
261
  | 网络 | `TargetPartnerSearchNetwork` 须 false;不可同时关闭 Google 搜索与搜索网络 |
251
262
  | RSA / 关键词 / 附加信息 | 标题 **15** 条、描述 **4** 条(须写满);字符上限、词面非空;RSA `Path1`/`Path2` 必填;SITELINK 行长度限制 |
252
263
  | 否词误填 | `KeywordsForBatchJob` 含常见否词词根或与 `NegativeKeywordsForBatchJob` 重复 → **warnings**(不阻断,须 Agent 修正后再 create) |
@@ -0,0 +1,74 @@
1
+ {
2
+ "_comment": "Excel/表格抽取的中间件(非 campaign-create 契约)。Agent 抽取脚本输出此形状;再用 ad geo resolve + assemble-campaign-from-plan.mjs 生成最终 JSON。",
3
+ "account": "REPLACE_mediaCustomerId",
4
+ "customerName": "REPLACE_from_list_accounts",
5
+ "name": "Search_Brand_US_2026",
6
+ "url": "https://www.example.com",
7
+ "budget": 100,
8
+ "biddingStrategyTypeV2": "MANUAL_CPC",
9
+ "manualCpcEnhancedCpcEnabled": false,
10
+ "maxCpcDefault": 5,
11
+ "languageId": 1000,
12
+ "startTime": "2026-07-15",
13
+ "endTime": "2037-12-30",
14
+ "locations": ["United States", "Canada"],
15
+ "productWords": ["brand", "product"],
16
+ "adGroups": [
17
+ {
18
+ "name": "Core_Brand_Keywords",
19
+ "maxCpc": 5,
20
+ "finalUrl": "https://www.example.com/products",
21
+ "keywords": [
22
+ { "text": "brand name product", "matchType": "EXACT" },
23
+ { "text": "brand name product", "matchType": "PHRASE" },
24
+ { "text": "buy brand product online", "matchType": "BROAD" }
25
+ ],
26
+ "rsa": {
27
+ "path1": "products",
28
+ "path2": "brand",
29
+ "headlines": [
30
+ "Brand Name Official Store",
31
+ "Quality Products You Trust",
32
+ "Shop Direct From Brand",
33
+ "Headline 4",
34
+ "Headline 5",
35
+ "Headline 6",
36
+ "Headline 7",
37
+ "Headline 8",
38
+ "Headline 9",
39
+ "Headline 10",
40
+ "Headline 11",
41
+ "Headline 12",
42
+ "Headline 13",
43
+ "Headline 14",
44
+ "Headline 15"
45
+ ],
46
+ "descriptions": [
47
+ "Description line one for the brand.",
48
+ "Description line two for the brand.",
49
+ "Description line three for the brand.",
50
+ "Description line four for the brand."
51
+ ]
52
+ }
53
+ }
54
+ ],
55
+ "negativeKeywords": [
56
+ { "text": "free", "matchType": "BROAD" },
57
+ { "text": "cheap", "matchType": "BROAD" }
58
+ ],
59
+ "extensions": {
60
+ "sitelinks": [
61
+ {
62
+ "text": "About Us",
63
+ "line2": "Company Info",
64
+ "line3": "Learn More",
65
+ "url": "https://www.example.com/about"
66
+ }
67
+ ],
68
+ "callouts": ["Free Shipping", "Official Store"],
69
+ "structuredSnippet": {
70
+ "header": "Types",
71
+ "values": ["Product A", "Product B", "Product C", "Product D"]
72
+ }
73
+ }
74
+ }
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 将 plan-extract.json + ad geo resolve 落盘 JSON → campaign-create 配置。
4
+ *
5
+ * 用法:
6
+ * node assemble-campaign-from-plan.mjs \
7
+ * --plan ./plan-extract.json \
8
+ * --geo ./snap-geo/ad-geo-resolve-123.json \
9
+ * --template ./campaign-create-template.json \
10
+ * --out ./campaign.json
11
+ *
12
+ * 匹配类型与 geo id 只来自输入文件,不在此脚本内猜测。
13
+ */
14
+ import { readFileSync, writeFileSync } from "node:fs";
15
+ import { resolve } from "node:path";
16
+
17
+ function arg(name, fallback) {
18
+ const i = process.argv.indexOf(name);
19
+ if (i === -1) return fallback;
20
+ return process.argv[i + 1];
21
+ }
22
+
23
+ function die(msg) {
24
+ console.error(`❌ ${msg}`);
25
+ process.exit(1);
26
+ }
27
+
28
+ function stripMeta(value) {
29
+ if (Array.isArray(value)) return value.map(stripMeta);
30
+ if (value && typeof value === "object") {
31
+ const out = {};
32
+ for (const [k, v] of Object.entries(value)) {
33
+ if (k.startsWith("_")) continue;
34
+ out[k] = stripMeta(v);
35
+ }
36
+ return out;
37
+ }
38
+ return value;
39
+ }
40
+
41
+ function normalizeMatchType(raw) {
42
+ const u = String(raw ?? "BROAD").trim().toUpperCase();
43
+ if (u === "EXACT" || u === "完全匹配" || u === "精确匹配") return "EXACT";
44
+ if (u === "PHRASE" || u === "词组匹配" || u === "短语匹配") return "PHRASE";
45
+ if (u === "BROAD" || u === "广泛匹配") return "BROAD";
46
+ die(`未知 matchType: ${raw}`);
47
+ }
48
+
49
+ function formatKeyword(text, matchType) {
50
+ const core = String(text ?? "")
51
+ .trim()
52
+ .replace(/^\[|\]$/g, "")
53
+ .replace(/^"|"$/g, "");
54
+ if (!core) die("关键词 text 为空");
55
+ if (matchType === "EXACT") return `[${core}]`;
56
+ if (matchType === "PHRASE") return `"${core}"`;
57
+ return core;
58
+ }
59
+
60
+ function readJsonFile(path) {
61
+ const raw = readFileSync(path, "utf8").replace(/^\uFEFF/, "");
62
+ return JSON.parse(raw);
63
+ }
64
+
65
+ function loadGeoPayload(geoPath) {
66
+ const geo = readJsonFile(geoPath);
67
+ // 兼容 --json-out 包一层 / 直接 payload
68
+ const payload = geo.targetedLocations ? geo : (geo.data ?? geo.payload ?? geo);
69
+ if (!Array.isArray(payload.locations) || !Array.isArray(payload.targetedLocations)) {
70
+ die(`geo 文件缺少 locations / targetedLocations:${geoPath}`);
71
+ }
72
+ if (payload.locations.length !== payload.targetedLocations.length) {
73
+ die("geo 文件 locations 与 targetedLocations 数量不一致");
74
+ }
75
+ if (payload.failed > 0) {
76
+ die(`geo resolve 仍有 ${payload.failed} 个失败项,请先修好再组装`);
77
+ }
78
+ return payload;
79
+ }
80
+
81
+ function buildKeywordBlocks(keywords, finalUrl) {
82
+ const buckets = { EXACT: [], PHRASE: [], BROAD: [] };
83
+ for (const kw of keywords ?? []) {
84
+ const mt = normalizeMatchType(kw.matchType);
85
+ buckets[mt].push(formatKeyword(kw.text, mt));
86
+ }
87
+ const order = ["EXACT", "PHRASE", "BROAD"];
88
+ return order
89
+ .filter((mt) => buckets[mt].length > 0)
90
+ .map((mt) => ({
91
+ KeywordText: buckets[mt],
92
+ MatchTypeV2: mt,
93
+ FinalURL: finalUrl,
94
+ }));
95
+ }
96
+
97
+ function buildRsa(rsa, fallbackUrl) {
98
+ const headlines = rsa?.headlines ?? [];
99
+ const descriptions = rsa?.descriptions ?? [];
100
+ if (headlines.length !== 15) {
101
+ die(`RSA headlines 须为 15 条,当前 ${headlines.length}`);
102
+ }
103
+ if (descriptions.length !== 4) {
104
+ die(`RSA descriptions 须为 4 条,当前 ${descriptions.length}`);
105
+ }
106
+ const url = rsa.finalUrl ?? fallbackUrl;
107
+ return {
108
+ TypeV2: "RESPONSIVE_SEARCH_AD",
109
+ DestinationUrl: url,
110
+ Finalurl: url,
111
+ AdTitle: null,
112
+ Path1: rsa.path1 ?? "",
113
+ Path2: rsa.path2 ?? "",
114
+ headlinePart1: headlines[0],
115
+ headlinePart2: headlines[1],
116
+ headlinePart3: headlines[2],
117
+ AddtionalHeadlines: headlines.slice(3),
118
+ adDescription: descriptions[0],
119
+ adDescription2: descriptions[1],
120
+ AddtionalAdDescriptions: descriptions.slice(2),
121
+ };
122
+ }
123
+
124
+ function buildExtensions(ext, defaultUrl) {
125
+ const out = [];
126
+ for (const s of ext?.sitelinks ?? []) {
127
+ out.push({
128
+ level: "Campaign",
129
+ typeV2: "SITELINK",
130
+ AssetFieldType: "SITELINK",
131
+ Properties: {
132
+ Text: s.text,
133
+ Line2: s.line2 ?? s.text,
134
+ Line3: s.line3 ?? s.line2 ?? s.text,
135
+ DestinationUrl: s.url ?? defaultUrl,
136
+ },
137
+ });
138
+ }
139
+ for (const c of ext?.callouts ?? []) {
140
+ out.push({
141
+ level: "Campaign",
142
+ typeV2: "CALLOUT",
143
+ AssetFieldType: "CALLOUT",
144
+ Properties: { Text: c },
145
+ });
146
+ }
147
+ const sn = ext?.structuredSnippet;
148
+ if (sn?.header && Array.isArray(sn.values) && sn.values.length > 0) {
149
+ out.push({
150
+ level: "Campaign",
151
+ typeV2: "STRUCTURED_SNIPPET",
152
+ AssetFieldType: "STRUCTURED_SNIPPET",
153
+ StructuredSnippetHeaderValue: { Key: sn.header, Value: sn.values },
154
+ });
155
+ }
156
+ return out;
157
+ }
158
+
159
+ const planPath = arg("--plan");
160
+ const geoPath = arg("--geo");
161
+ const templatePath = arg("--template");
162
+ const outPath = arg("--out", "./campaign.json");
163
+
164
+ if (!planPath || !geoPath || !templatePath) {
165
+ die(
166
+ "用法: node assemble-campaign-from-plan.mjs --plan <plan-extract.json> --geo <geo-resolve.json> --template <campaign-create-template.json> [--out campaign.json]",
167
+ );
168
+ }
169
+
170
+ const plan = stripMeta(readJsonFile(resolve(planPath)));
171
+ const geo = loadGeoPayload(resolve(geoPath));
172
+ const template = stripMeta(readJsonFile(resolve(templatePath)));
173
+
174
+ const url = plan.url ?? template.url ?? "";
175
+ const name = plan.name ?? template.name;
176
+ const defaultMaxCpc = Number(plan.maxCpcDefault ?? 5);
177
+
178
+ const negBlocks = (() => {
179
+ const buckets = { EXACT: [], PHRASE: [], BROAD: [] };
180
+ for (const kw of plan.negativeKeywords ?? []) {
181
+ const mt = normalizeMatchType(kw.matchType ?? "BROAD");
182
+ buckets[mt].push(formatKeyword(kw.text, mt));
183
+ }
184
+ return ["BROAD", "PHRASE", "EXACT"]
185
+ .filter((mt) => buckets[mt].length > 0)
186
+ .map((mt) => ({ KeywordText: buckets[mt], MatchTypeV2: mt, FinalURL: "" }));
187
+ })();
188
+
189
+ const adGroups = (plan.adGroups ?? []).map((g) => {
190
+ const finalUrl = g.finalUrl ?? url;
191
+ return {
192
+ Name: g.name,
193
+ StatusV2: "Enabled",
194
+ TypeV2: "SEARCH_STANDARD",
195
+ RotationModeV2: "Unspecified",
196
+ MaxCPCAmount: Number(g.maxCpc ?? defaultMaxCpc),
197
+ KeywordsForBatchJob: buildKeywordBlocks(g.keywords, finalUrl),
198
+ AdsForBatchJob: [buildRsa(g.rsa ?? {}, finalUrl)],
199
+ };
200
+ });
201
+
202
+ if (adGroups.length === 0) die("plan.adGroups 不能为空");
203
+
204
+ let exact = 0;
205
+ let phrase = 0;
206
+ let broad = 0;
207
+ for (const g of plan.adGroups ?? []) {
208
+ for (const kw of g.keywords ?? []) {
209
+ const mt = normalizeMatchType(kw.matchType);
210
+ if (mt === "EXACT") exact++;
211
+ else if (mt === "PHRASE") phrase++;
212
+ else broad++;
213
+ }
214
+ }
215
+
216
+ const campaign = {
217
+ ...(template.campaign ?? {}),
218
+ Name: name,
219
+ StatusV2: "Enabled",
220
+ ChannelTypeV2: "SEARCH",
221
+ BiddingStrategyTypeV2: plan.biddingStrategyTypeV2 ?? "MANUAL_CPC",
222
+ Budget: Number(plan.budget ?? template.campaign?.Budget ?? 0),
223
+ BudgetShared: false,
224
+ BudgetId: 0,
225
+ BudgetBudgetDeliveryMethodV2: "STANDARD",
226
+ ManualCpc_EnhancedCpcEnabled: Boolean(plan.manualCpcEnhancedCpcEnabled ?? false),
227
+ TargetGoogleSearch: true,
228
+ TargetSearchNetwork: false,
229
+ TargetContentNetwork: false,
230
+ TargetPartnerSearchNetwork: false,
231
+ StartTime: plan.startTime ?? template.campaign?.StartTime,
232
+ EndTime: plan.endTime ?? template.campaign?.EndTime,
233
+ targetedLocations: geo.targetedLocations,
234
+ excludedLocations: [],
235
+ excludedIpAddresses: [],
236
+ targetedLanguages: [{ id: Number(plan.languageId ?? 1000) }],
237
+ NegativeKeywordsForBatchJob: negBlocks,
238
+ ExtensionsForBatchJob: buildExtensions(plan.extensions, url),
239
+ AdGroupsForBatchJob: adGroups,
240
+ };
241
+
242
+ // MANUAL_CPC 不需要 TargetSpend 字段
243
+ if (campaign.BiddingStrategyTypeV2 === "MANUAL_CPC") {
244
+ delete campaign.TargetSpend_BidCeilingAmount;
245
+ } else if (
246
+ campaign.BiddingStrategyTypeV2 === "TARGET_SPEND" &&
247
+ campaign.TargetSpend_BidCeilingAmount == null
248
+ ) {
249
+ campaign.TargetSpend_BidCeilingAmount = defaultMaxCpc;
250
+ }
251
+
252
+ const out = {
253
+ account: String(plan.account ?? template.account),
254
+ customerName: plan.customerName ?? template.customerName ?? "",
255
+ name,
256
+ url,
257
+ locations: geo.locations,
258
+ productWords: plan.productWords ?? [],
259
+ googleDataRecordId: null,
260
+ draft: false,
261
+ campaign,
262
+ };
263
+
264
+ writeFileSync(resolve(outPath), JSON.stringify(out, null, 2) + "\n", "utf8");
265
+ console.log(
266
+ JSON.stringify(
267
+ {
268
+ out: resolve(outPath),
269
+ locations: geo.locations.length,
270
+ adGroups: adGroups.length,
271
+ keywords: { exact, phrase, broad, total: exact + phrase + broad },
272
+ next: `siluzan-tso ad campaign-validate --config-file ${outPath}`,
273
+ },
274
+ null,
275
+ 2,
276
+ ),
277
+ );
@@ -122,6 +122,7 @@
122
122
  - 当前凭据未返回手机号且用户指定了手机号 → 视同未校验通过,引导重新用手机号登录。
123
123
  - **Google 新建搜索系列**:流程在 `references/google-ads/google-ads-campaign-plan.md`;填 JSON 前**必须先 Read** `assets/campaign-create-template.json`,再 Read `assets/campaign-create-template.md`。**禁止**只读 `.md` 手写 JSON。
124
124
  - **「根据官网生成 Google 搜索广告 / 表格格式」**:仍属新建搜索系列 → **W3 + 本文件上条**;用户要的「表格」是 `google-ads-launch-plan-template.md` 对 JSON 的投影,**不是**可跳过 JSON/`campaign-validate` 的独立交付物。**禁止**与 P8 网站诊断、P9 市场分析、W5 仅拓词混用。
125
+ - **Excel/表格投放方案 → 创建广告**:必读 `references/google-ads/rules/google-ads-plan-source-fidelity.md`。最短路径:`plan-extract.json` → **`ad geo resolve`** → `assemble-campaign-from-plan.mjs` → validate。**禁止**对话手填完整 campaign JSON、**禁止**编造 geo id、**禁止**一律 BROAD。
125
126
  - **「行业分析 / 行业分析报告 / 生成 XX 行业报告」**(例:「帮我生成一份电商行业的行业分析报告」)→ **P9 战略市场分析**。**必须**先 `siluzan-tso market-analysis collect … --json-out`,再 WebSearch 补数据、写 `market-report.json`,最后 `market-analysis render` 出 HTML。**禁止**不调用 CLI、仅在对话里用 WebSearch 写 Markdown 充当终稿。**不是** `google-analysis`、**不是** P8 网站诊断。
126
127
  - **开户首次响应**:对话内首次进入开户话题时,**必须先**按 `references/accounts/open-account-by-media.md` §「首次响应硬规范」输出**完整必填清单**(未指明媒体则列全平台六表),再收集资料;**禁止**未列清单就执行 `open-account` 或零散追问。
127
128
  - **Google 开户**:`open-account google-wizard` 仅限真实 TTY;Agent/自动化用非交互 `open-account google ...`,审核进度用 `account-history`。
@@ -55,23 +55,29 @@
55
55
 
56
56
  ## W3 · Google 广告创建与精细管理
57
57
 
58
- - **触发**:新建搜索系列、出投放方案、**根据官网/网站/URL 生成 Google 搜索广告(含「表格格式」)**、搜索广告文案/关键词/计划表、系列/组/广告/关键词 CRUD、PMax、拒审处理、日常调价/启停。
58
+ - **触发**:新建搜索系列、出投放方案、**按 Excel/表格投放方案创建**、**根据官网/网站/URL 生成 Google 搜索广告(含「表格格式」)**、搜索广告文案/关键词/计划表、系列/组/广告/关键词 CRUD、PMax、拒审处理、日常调价/启停。
59
59
  - **勿误判**:仅给官网 URL 且目标是「写/生成搜索广告」→ **本卡片(W3)**,不是 P8 网站诊断、不是 P9 市场分析;若用户只要拓词无系列结构 → **W5**。
60
- - **必读**:方案与门禁 `references/google-ads/google-ads-campaign-plan.md` + **`assets/campaign-create-template.json`**(先 Read)+ `assets/campaign-create-template.md`;命令参数 `references/google-ads/google-ads.md`;PMax 加 **`assets/pmax-create-template.json`** + `assets/pmax-create-template.md` + `references/google-ads/pmax-api.md`。
60
+ - **必读**:方案与门禁 `references/google-ads/google-ads-campaign-plan.md` + **`assets/campaign-create-template.json`**(先 Read)+ `assets/campaign-create-template.md`;**有 Excel/表格方案时另读** `references/google-ads/rules/google-ads-plan-source-fidelity.md`;命令参数 `references/google-ads/google-ads.md`;PMax 加 **`assets/pmax-create-template.json`** + `assets/pmax-create-template.md` + `references/google-ads/pmax-api.md`。
61
61
  - **创建路径选择**:
62
62
  - 已有 AI 智投草稿 → 走 **W4**。
63
63
  - **PMax 出方案/创建** → **`assets/pmax-create-template.json`**(先 Read)+ `pmax-create-template.md` + `pmax-api.md`:`pmax-validate` → 用户确认 → `pmax-create`(**勿**用 Search `campaign-create`)。
64
- - 搜索系列出方案 → `google-ads-campaign-plan.md`:JSON → `campaign-validate` → 用户确认`campaign-create`。
64
+ - **用户给了 Excel/表格方案** **方案源轨(三步)**:见 `google-ads-plan-source-fidelity.md`:`plan-extract.json`**`ad geo resolve`** → `assemble-campaign-from-plan.mjs` → validate确认 → create(**禁止**对话手填完整 campaign JSON)。
65
+ - 搜索系列从零出方案 → `google-ads-campaign-plan.md`:JSON → `campaign-validate` → 用户确认 → `campaign-create`。
65
66
  - 已有完整结构化 JSON → 对应 validate → create。
66
67
  - **步骤(PMax 方案 → 创建)**:
67
68
  1. 账户:`list-accounts -m Google -k <id>`;落地页与品牌从官网/RAG 归纳。
68
- 2. 地域/语言:`ad geo search` location id;语言 id 写入 JSON。
69
+ 2. 地域/语言:多国用 `ad geo resolve`,单国用 `ad geo search`;语言 id 写入 JSON。
69
70
  3. 复制 `pmax-create-template.json` 填文案/预算/图片;**必须**含 `campaignExtensions`(至少 callouts + structuredSnippets);**Lead Gen/B2B 默认** `campaignExtensions.leadForm`(方案 Markdown 须单列表单节)。
70
71
  4. 门禁:`ad pmax-validate --config-file ./pmax.json --json-out ./snap-pmax`。
71
72
  5. 输出 JSON + Markdown 方案 → 用户确认 → `ad pmax-create --commit "…"`。
72
73
  6. 复核:`ad campaigns` / `ad pmax-get`;缺表单时 `ad extension lead-form` 补挂。
73
- - **步骤(Search 一体化创建)**:
74
- 1. 地域 ID:`ad geo search -a <id> -q "United States"` 写入 `campaign.targetedLocations[].id`。
74
+ - **步骤(Search · 方案文件 → 创建,最短稳妥)**:
75
+ 1. 抽取脚本 `plan-extract.json`(形状见 `assets/plan-extract.example.json`;关键词带 `matchType`)。
76
+ 2. `ad geo resolve -a <id> --from-file ./locations.json --json-out ./snap-geo`(**一次**批量取 id)。
77
+ 3. `node …/assemble-campaign-from-plan.mjs --plan … --geo … --template … --out ./campaign.json`。
78
+ 4. `ad campaign-validate` → 确认摘要(国家↔id + Exact/Phrase/Broad 计数)→ `campaign-create` → `batch get` / `batch diff` / 自动补扩展。
79
+ - **步骤(Search 一体化创建,无现成方案文件)**:
80
+ 1. 地域:多国 `ad geo resolve`;单国 `ad geo search -a <id> -q "United States"`(仍禁止编造)。
75
81
  2. 门禁:`ad campaign-validate --config-file ./campaign.json`(必跑)。
76
82
  3. 用户确认后创建:`ad campaign-create --config-file ./campaign.json`,记录返回 taskId。
77
83
  4. 轮询:`ad batch get --id <taskId>` 直至非 Creating。
@@ -9,6 +9,7 @@
9
9
  | 用户说法(示例) | 正确工作流 | **禁止** |
10
10
  | ------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------- |
11
11
  | 「根据 www.example.com 官网生成 Google 搜索广告」 | **W3 · 本文件标准流水线** | 只输出一张手写关键词/RSA 表;走 P8 网站诊断 |
12
+ | 「按这份 Excel / 投放方案创建广告」 | **方案源轨**(下表)+ **必读** `rules/google-ads-plan-source-fidelity.md` | 猜 geo id;关键词一律 BROAD;跳过 validate |
12
13
  | 「要表格格式 / 表格给我」 | 先 JSON → `campaign-validate` → 再按 `google-ads-launch-plan-template.md` **投影 Markdown 表格** | 跳过 JSON 直接填表;把表格当唯一交付物 |
13
14
  | 「帮我写搜索广告文案/关键词」且未指定已有系列 | **W3**(含官网/RAG 归纳背景) | 与 W5 纯拓词混淆(W5 无系列/组/RSA 结构) |
14
15
  | 「分析这个网站能不能投广告」 | **P8** 网站诊断 | 本文件建户方案 |
@@ -17,18 +18,22 @@
17
18
 
18
19
  ---
19
20
 
20
- | 轨 | 条件 | 动作 |
21
- | ------------ | ---------------------------------------- | ------------------------------------------------------------------------ |
22
- | **直读直写** | 用户已给账户/预算/组/词/RSA 等结构化数据 | 通过代码转换为 campaign-create直接可用的 JSON → validate → 确认 → create |
23
- | **方案先行** | 无完整结构,或要求「先出方案」 | 读本文件 + 必读规则 生成 JSON → validate → Markdown → 确认 → create |
21
+ | 轨 | 条件 | 动作 |
22
+ | -------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
23
+ | **方案源轨** | 用户已给 Excel/表格/结构化投放方案(含地域、词、匹配类型等) | **先 Read** `rules/google-ads-plan-source-fidelity.md` 抽取 `plan-extract.json` **`ad geo resolve`** `assemble-campaign-from-plan.mjs` → validate → 确认 → create |
24
+ | **直读直写** | 用户已给账户/预算/组/词/RSA 等结构化数据(非文件,或已是 JSON) | 通过代码转换为 campaign-create 直接可用的 JSON → validate → 确认 → create |
25
+ | **方案先行** | 无完整结构,或要求「先出方案」 | 读本文件 + 必读规则 → 生成 JSON → validate → Markdown → 确认 → create |
24
26
 
25
27
  **硬约束**
26
28
 
27
29
  - 可执行真相只有 **JSON**(`assets/campaign-create-template.json` 同构);Markdown 只读投影。
28
30
  - **Agent Read 顺序(建系列前必做)**:① `assets/campaign-create-template.json`(复制/改写的结构真相源)→ ② `assets/campaign-create-template.md`(字段说明与踩坑)。**禁止**只读 `.md` 凭印象拼 JSON。
29
- - 改需求 **只改 JSON**,再 `campaign-validate`,再刷新 Markdown。
31
+ - **方案文件(Excel 等)额外必读**:`references/google-ads/rules/google-ads-plan-source-fidelity.md`(**三步**:plan-extract `ad geo resolve` → assemble 脚本;禁止对话手填完整 campaign JSON)。
32
+ - 改需求 **只改中间件/脚本再组装**,再 `campaign-validate`,再刷新 Markdown。
30
33
  - **PMax 系列创建**走独立流水线(勿用本文件 JSON 模板):**先 Read `assets/pmax-create-template.json`** + `assets/pmax-create-template.md` + `ad pmax-validate` / `ad pmax-create`;**Lead Gen/B2B 方案默认含 `campaignExtensions.leadForm`**(方案 Markdown 须单列表单);运营诊断见 `references/google-ads/rules/google-ads-pmax-guide.md`。
31
34
  - 搜索网络:仅 Google 搜索(`TargetSearchNetwork`/`TargetContentNetwork`/`TargetPartnerSearchNetwork` 均为 false)。
35
+ - **地域 id**:多国用 **`ad geo resolve`**(单国可用 `ad geo search`);**禁止**编造 / ISO 心算。外层 `locations` 与 `targetedLocations` 数量必须一致(validate 硬校验)。
36
+ - **匹配类型**:在 `plan-extract.json` 的每条 keyword 上带 `matchType`,由组装脚本分块写入 EXACT/PHRASE/BROAD(有方案源时勿压成一律 BROAD)。
32
37
 
33
38
  ---
34
39
 
@@ -36,16 +41,18 @@
36
41
 
37
42
  | 步 | 动作 | 文档/命令 |
38
43
  | --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
44
+ | 0 | **方案源轨**:抽取脚本 → `plan-extract.json` → **`ad geo resolve --json-out`** → `assemble-campaign-from-plan.mjs` → `campaign.json`(**勿**对话手填) | **`rules/google-ads-plan-source-fidelity.md`** + `assets/plan-extract.example.json` |
39
45
  | 1 | `list-accounts` 锁定 `account` / `customerName` / 币种 | `references/accounts/currency.md` |
40
- | 2 | 可选 `rag query`;`keyword` / `keyword geo-list` 拓词 | `references/analytics/keyword-planner-workflows.md` |
41
- | 3 | 按分层规则写入 `KeywordsForBatchJob`(Exact/Phrase/Broad);**否词单独写** `NegativeKeywordsForBatchJob`(勿与正向词混放) | `references/google-ads/rules/google-ads-keyword-taxonomy.md`(参考,非 CLI 强制) |
42
- | 4 | 复制 `campaign-create-template.json` 并填 `campaign`(预算/出价/地域/否词≥20/RSA/附加信息) | **`assets/campaign-create-template.json`** + `assets/campaign-create-template.md` |
43
- | 5 | **`ad campaign-validate --config-file <json>`**(失败只改 JSON;超长见下文「超长人工确认」) | 下文「校验」 |
44
- | 6 | 输出:**JSON 代码块** → **Markdown**(`google-ads-launch-plan-template.md` 正文)→ 待确认 | |
45
- | 7 | 用户确认后 **`ad campaign-create`** | `references/google-ads/google-ads.md` |
46
- | 8 | 每隔5s 获取创建结果 | `ad batch get --id <taskId> --config-file ./campaign.json` |
47
- | 9 | 成功或部分成功后 **`ad batch diff`** 对照 JSON 与账户实况 | |
48
- | 10 | **自动补建缺失项**(见下文「batch diff 后自动补建」):附加信息(Sitelink/Callout 等)**直接**执行 `ad extension *`;组/词/RSA 用对应 create 命令。**勿**仅汇报失败并反问用户是否补建 | `references/google-ads/google-ads.md` § batch diff 后自动补建 |
46
+ | 2 | 可选 `rag query`;无现成词表时再 `keyword` / `keyword geo-list` 拓词 | `references/analytics/keyword-planner-workflows.md` |
47
+ | 3 | 无方案文件时:多国 **`ad geo resolve`**(或单国 `ad geo search`)写入 `locations` + `targetedLocations` | **禁止编造 id** |
48
+ | 4 | 无方案文件时:按分层写入 `KeywordsForBatchJob`(EXACT/PHRASE/BROAD);否词进 `NegativeKeywordsForBatchJob` | 参考 `google-ads-keyword-taxonomy.md`;有方案源则走步骤 0 组装脚本 |
49
+ | 5 | 复制/组装得到 `campaign-create` JSON | **`assets/campaign-create-template.json`** + assemble 脚本 |
50
+ | 6 | **`ad campaign-validate --config-file <json>`**(失败改中间件/脚本再组装;超长见下文) | 下文「校验」 |
51
+ | 7 | 给人看:**国家↔id** + **匹配类型条数** + 可选 Markdown 投影;**勿**贴整份 JSON 当主交付 | `google-ads-launch-plan-template.md` |
52
+ | 8 | 用户确认后 **`ad campaign-create`** | `references/google-ads/google-ads.md` |
53
+ | 9 | 每隔5s 获取创建结果 | `ad batch get --id <taskId> --config-file ./campaign.json` |
54
+ | 10 | 成功或部分成功后 **`ad batch diff`** 对照 JSON 与账户实况 | |
55
+ | 11 | **自动补建缺失项**(见下文「batch diff 后自动补建」):附加信息(Sitelink/Callout 等)**直接**执行 `ad extension *`;组/词/RSA 用对应 create 命令。**勿**仅汇报失败并反问用户是否补建 | `references/google-ads/google-ads.md` § batch diff 后自动补建 |
49
56
 
50
57
  多系列:每系列一个 JSON;可选 `campaign-manifest.json`(`role: brand|competitor|generic`)仅作文件组织参考。
51
58
 
@@ -57,7 +64,8 @@
57
64
 
58
65
  | 文档 | 用途 |
59
66
  | --------------------------------------------------------------------------------- | --------------------------------------------------------------- |
60
- | `references/google-ads/rules/google-ads-keyword-taxonomy.md` | 核心/长尾与匹配块**建议**(Agent 参考,CLI 不强制) |
67
+ | `references/google-ads/rules/google-ads-plan-source-fidelity.md` | **有 Excel/表格方案时必读**:地域 geo search、匹配类型分块保真 |
68
+ | `references/google-ads/rules/google-ads-keyword-taxonomy.md` | 无方案源时的核心/长尾与匹配块**建议** |
61
69
  | `references/google-ads/rules/google-ads-compliance.md` | 词与文案合规 |
62
70
  | `references/google-ads/rules/sensitive-industries.md` | 敏感行业(若相关) |
63
71
  | `references/google-ads/rules/google-ads-launch-plan-template.md` | 用户可见 Markdown 结构与 RSA/否词表 |
@@ -90,10 +98,11 @@ siluzan-tso ad campaign-validate --config-file ./campaign.json [--json-out ./sna
90
98
  siluzan-tso ad campaign-create --config-file ./campaign.json
91
99
  siluzan-tso ad batch get --id <taskId> --config-file ./campaign.json
92
100
  siluzan-tso ad batch diff --batch-id <taskId> --config-file ./campaign.json
93
- siluzan-tso ad geo search
101
+ siluzan-tso ad geo resolve -a <accountId> --from-file ./locations.json --json-out ./snap-geo
102
+ siluzan-tso ad geo search -a <accountId> -q "United States"
94
103
  ```
95
104
 
96
- validate 与 create **共用** `runCampaignCreateValidation`:词面规范化 + 后端/Google 硬约束(预算、RSA、匹配符号与 `MatchTypeV2` 对齐、搜索网络等)。**不含**关键词分层数量、匹配占比、否词条数下限。
105
+ validate 与 create **共用** `runCampaignCreateValidation`:词面规范化 + 后端/Google 硬约束(预算、RSA、匹配符号与 `MatchTypeV2` 对齐、搜索网络、`locations`/`targetedLocations` 数量一致等)。**不含**关键词分层数量建议、否词条数下限(策略表仍见 taxonomy)。
97
106
 
98
107
  ### 超长内容:禁止 Agent 自动截断
99
108
 
@@ -37,7 +37,7 @@
37
37
  | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
38
38
  | 模板 | 复制 `assets/pmax-create-template.json`,字段见 `assets/pmax-create-template.md` |
39
39
  | **素材转换** | `siluzan-tso ad pmax-image-convert --input ./banner.jpg --output-dir ./assets --prefix <name> [--update-config ./pmax.json]` |
40
- | 地理 ID | `siluzan-tso ad geo search -a <accountId> -q "<地区名>"` |
40
+ | 地理 ID | 多国:`ad geo resolve -a <accountId> --from-file ./locations.json --json-out ./snap-geo`;单国:`ad geo search -a <accountId> -q "<地区名>"` |
41
41
  | 校验 | `siluzan-tso ad pmax-validate --config-file ./pmax.json [--json-out ./snap-pmax]`(图片规格 + 文案超长 `lengthViolations`;超长勿自动截断,见 `references/google-ads/google-ads-campaign-plan.md` § 超长人工确认) |
42
42
  | 创建 | `siluzan-tso ad pmax-create --config-file ./pmax.json [--json-out ./snap-pmax]` |
43
43
  | 复核 | `siluzan-tso ad campaigns -a <accountId> --json-out ./snap` → `channelTypeV2` 为 `PERFORMANCE_MAX` |
@@ -606,9 +606,13 @@ siluzan-tso ad search-terms -a <accountId> [--start/--end YYYY-MM-DD] [--json-ou
606
606
  ## ad geo — 地理位置定向管理
607
607
 
608
608
  ```bash
609
- # 搜索 locationId
609
+ # 搜索 locationId(单国)
610
610
  siluzan-tso ad geo search -a <accountId> -q <地名>
611
611
 
612
+ # 批量解析地名→id(投放方案多国;落盘含 locations / targetedLocations / nameToId)
613
+ siluzan-tso ad geo resolve -a <accountId> --from-file ./locations.json --json-out ./snap-geo
614
+ siluzan-tso ad geo resolve -a <accountId> -q "Peru,Chile,Kenya" --json-out ./snap-geo
615
+
612
616
  # 查询已定向
613
617
  siluzan-tso ad geo list -a <accountId> --mode targeted|excluded|report [--start/--end <date>]
614
618
 
@@ -9,6 +9,7 @@
9
9
  | `google-ads-keyword-strategy.md` | 关键词策略规划 |
10
10
  | `google-ads-keyword-optimization.md` | 关键词出价/否定/扩词优化 |
11
11
  | `google-ads-keyword-taxonomy.md` | 词类分层与命名 |
12
+ | `google-ads-plan-source-fidelity.md` | **Excel/表格方案 → JSON**:地域 geo search、匹配类型保真(有方案文件时必读) |
12
13
  | `google-ads-creative-optimization.md` | 创意/文案优化 |
13
14
  | `google-ads-audience-strategy.md` | 受众策略 |
14
15
  | `google-ads-pmax-guide.md` | PMax 运营指南(配合 `assets/pmax-create-template.md`) |
@@ -40,7 +40,7 @@
40
40
  | 系列名称 | `name`(外层展示用) / `campaign.Name`(内层) |
41
41
  | 日预算 | `campaign.Budget`(**元**,CLI 内部 ×100 转分) |
42
42
  | 出价策略 / 上限 / tCPA | `campaign.BiddingStrategyTypeV2`、`campaign.TargetSpend_BidCeilingAmount`、`campaign.TargetCpa_BidingAmount`、`campaign.TargetRoas` |
43
- | 投放地域 | `campaign.targetedLocations: [{ id: "<geoId>" }]`(先 `ad geo search` 取 id)、外层 `locations`(展示名,可选) |
43
+ | 投放地域 | `campaign.targetedLocations: [{ id: "<geoId>" }]`(**必须**先 `ad geo search` 取 id,禁止编造)、外层 `locations`(与 targetedLocations 同序同量) |
44
44
  | 语言 | `campaign.targetedLanguages: [{ id: 1000 }]`(英语 1000 / 中文 1017) |
45
45
  | 起止日期 | `campaign.StartTime`、`campaign.EndTime`(YYYY-MM-DD) |
46
46
  | 落地页 | 外层 `url`;广告组级 `KeywordsForBatchJob[].FinalURL`、创意级 `AdsForBatchJob[].Finalurl` |
@@ -51,7 +51,8 @@
51
51
  | 仅保存草稿 | `draft: true` → 确认后 `ad batch publish` |
52
52
  | 立即创建 | `draft: false`(默认) |
53
53
 
54
- 关键词匹配(在 `KeywordsForBatchJob` 块中以 `MatchTypeV2` 区分;同一块同匹配类型):`词`→广泛、`"词"`→词组、`[词]`→完全。
54
+ 关键词匹配(在 `KeywordsForBatchJob` 块中以 `MatchTypeV2` 区分;同一块同匹配类型):`词`→广泛、`"词"`→词组、`[词]`→完全。
55
+ 从 Excel/表格方案导入时须保真匹配类型与地域 id,见 `google-ads-plan-source-fidelity.md`。
55
56
 
56
57
  ---
57
58
 
@@ -0,0 +1,131 @@
1
+ # 方案源 → campaign-create:Agent 最短稳妥路径
2
+
3
+ > **设计原则(按智能体真实能力)**:少靠对话纪律,多靠 **落盘中间件 + CLI + 脚本**。LLM 只做「认列」和「写抽取脚本」;**匹配类型与 geo id 一律由代码/CLI 决定**,禁止在对话里手填。
4
+ >
5
+ > Excel 版式不固定 → **不**做通用 Excel 解析器;固定的是中间清单 `plan-extract.json` 的形状。
6
+
7
+ ---
8
+
9
+ ## 为什么不能「读 Excel → 直接写 campaign.json」
10
+
11
+ | 易错点 | 对话里手填时会发生什么 | 稳妥做法 |
12
+ | ------ | ---------------------- | -------- |
13
+ | 30 国 geo id | 编造 / 记错 / CriterionError | **一次** `ad geo resolve` |
14
+ | Exact/Phrase | 图省事全写成 BROAD | 抽取时带上 `matchType`,组装脚本分块 |
15
+ | 大 JSON | 上下文截断、漏字段 | 脚本写文件,Agent 只读 validate 摘要 |
16
+ | 版式差异 | 猜 Sheet 名失败就瞎补 | 只写「本文件专用」抽取脚本,输出固定中间件 |
17
+
18
+ ---
19
+
20
+ ## 三步闭环(推荐,最快且最稳)
21
+
22
+ ```text
23
+ Excel/表格 ──① 抽取脚本──► plan-extract.json
24
+
25
+ ├─ locations[] ──② ad geo resolve --json-out──► geo-map.json
26
+
27
+ └─ + geo-map + 账户 ──③ 组装脚本──► campaign.json
28
+
29
+ ad campaign-validate
30
+
31
+ 用户确认摘要 → campaign-create
32
+ ```
33
+
34
+ Agent **对话内只做**:认列写①③脚本、跑 CLI、把 validate 错误修回脚本/中间件、给用户看「国家↔id + 匹配类型计数」。
35
+
36
+ ---
37
+
38
+ ### ① 抽取 → `plan-extract.json`(唯一允许「认版式」的一步)
39
+
40
+ 复制形状:`assets/plan-extract.example.json`。
41
+
42
+ 用 **Python openpyxl / Node** 读**全部 Sheet**,只输出中间件,**不要**在这一步写 `targetedLocations` id,**不要**写最终 campaign JSON。
43
+
44
+ 关键词行必须带匹配类型(列优先,否则看词面 `[ ]` / `" "`):
45
+
46
+ ```json
47
+ { "text": "compactor garbage truck for sale", "matchType": "EXACT" }
48
+ ```
49
+
50
+ `matchType` ∈ `EXACT` | `PHRASE` | `BROAD`。组装脚本再加 `[]` / `""`。
51
+
52
+ ### ② 批量地域 → `geo-map.json`(禁止逐国 search、禁止编造)
53
+
54
+ ```bash
55
+ # 从中间件抽出 locations 数组文件,或直接 --queries
56
+ siluzan-tso ad geo resolve -a <mediaCustomerId> \
57
+ --from-file ./locations.json \
58
+ --json-out ./snap-geo
59
+ ```
60
+
61
+ 落盘 payload 已含可直写字段:
62
+
63
+ - `locations` / `targetedLocations` / `nameToId`
64
+
65
+ 任一地名失败 → CLI **exit 1**(改拼写重跑;**禁止**跳过或心算 id)。
66
+
67
+ ### ③ 组装 → `campaign.json`(纯代码,零 LLM 填 id/匹配类型)
68
+
69
+ 推荐直接跑 Skill 自带脚手架(按中间件 + geo-map 填模板):
70
+
71
+ ```bash
72
+ node <skill>/assets/scripts/assemble-campaign-from-plan.mjs \
73
+ --plan ./plan-extract.json \
74
+ --geo ./snap-geo/ad-geo-resolve-<account>.json \
75
+ --template <skill>/assets/campaign-create-template.json \
76
+ --out ./campaign.json
77
+ ```
78
+
79
+ 脚本职责(Agent 勿手改输出里的这两块):
80
+
81
+ - 拷贝 `locations` + `targetedLocations`(来自 geo-map,同序)
82
+ - 按广告组把 keywords **分块**为 EXACT / PHRASE / BROAD `KeywordsForBatchJob`
83
+ - 否词 → `NegativeKeywordsForBatchJob`;RSA / Extensions 从中间件映射
84
+
85
+ 然后:
86
+
87
+ ```bash
88
+ siluzan-tso ad campaign-validate --config-file ./campaign.json --json-out ./snap-campaign
89
+ ```
90
+
91
+ 确认摘要(给用户,勿贴整份 JSON):
92
+
93
+ 1. 国家名 → id(来自 geo-map `rows`)
94
+ 2. Exact / Phrase / Broad **条数**(来自 plan-extract 统计,须与 validate 后一致)
95
+ 3. 预算 / 组数 / 否词数
96
+
97
+ 用户确认 → `ad campaign-create` → `batch get` / `batch diff`。
98
+
99
+ ---
100
+
101
+ ## CLI 硬门禁(第二道保险)
102
+
103
+ | 条件 | `campaign-validate` |
104
+ | ---- | ------------------- |
105
+ | `locations.length ≠ targetedLocations.length` | error |
106
+ | location id 非数字 | error |
107
+
108
+ 匹配类型保真靠中间件 `matchType` + 组装脚本,不靠 CLI 禁止「全 BROAD」。
109
+
110
+ ---
111
+
112
+ ## Agent 禁令(高优先级)
113
+
114
+ 1. **禁止**在对话里手写 / 粘贴完整 `campaign.json`(用脚本 `--out`)。
115
+ 2. **禁止**对 30 国循环口述 `ad geo search`;用 **`ad geo resolve`**。
116
+ 3. **禁止**编造 geo id(含 ISO、`2000+ISO` 心算)。
117
+ 4. **禁止**把方案里的 Exact/Phrase 压成一律 BROAD。
118
+ 5. **禁止**跳过 `plan-extract.json` 直接从 Excel「脑补」进 create。
119
+
120
+ ---
121
+
122
+ ## 与其它文档的关系
123
+
124
+ | 文档 | 何时 |
125
+ | ---- | ---- |
126
+ | 本文件 | 有 Excel/表格方案 → 创建 |
127
+ | `google-ads-campaign-plan.md` | W3 总流程;方案源轨指向本文件 |
128
+ | `assets/campaign-create-template.json` | 组装目标结构(先 Read) |
129
+ | `google-ads-launch-plan-template.md` | 可选:validate 后投影 Markdown 给人看 |
130
+
131
+ 无方案文件、从零出方案时:仍走 campaign-plan「方案先行」;地域仍优先 `ad geo resolve`(多国)或单次 `ad geo search`(单国)。
@@ -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.4'
12
+ $PKG_VERSION = '1.1.37-beta.5'
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.4"
12
+ readonly PKG_VERSION="1.1.37-beta.5"
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.4",
3
+ "version": "1.1.37-beta.5",
4
4
  "description": "Siluzan 广告账户管理 CLI — 查询账户、余额、消耗数据,管理绑定关系与充值。",
5
5
  "keywords": [
6
6
  "ad-account",