siluzan-tso-cli 1.1.33-beta.1 → 1.1.33-beta.3

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.33-beta.1),供内部测试使用。正式发布后安装命令将改为 `npm install -g siluzan-tso-cli`。
54
+ > **注意**:当前为测试版(1.1.33-beta.3),供内部测试使用。正式发布后安装命令将改为 `npm install -g siluzan-tso-cli`。
55
55
 
56
56
  | 助手 | 建议 `--ai` |
57
57
  | ----------------------- | ------------------------------------ |
package/dist/index.js CHANGED
@@ -4329,6 +4329,44 @@ function groupAccountIdsByOverviewRoute(cliMedia, accountIds, accountMediaTypes)
4329
4329
  }
4330
4330
  return groups;
4331
4331
  }
4332
+ function routeToBalanceMedia(route) {
4333
+ if (route === "FacebookAds") return "MetaAd";
4334
+ if (BALANCE_SUPPORTED_MEDIA.includes(route)) {
4335
+ return route;
4336
+ }
4337
+ return null;
4338
+ }
4339
+ function applyRealtimeBalanceIfNeeded(item, bal) {
4340
+ if (bal == null || typeof bal.remainingAccountBudget !== "number") return;
4341
+ if (bal.remainingAccountBudget > 0) {
4342
+ item.remainingAccountBudget = bal.remainingAccountBudget;
4343
+ }
4344
+ if (bal.currencyCode) item.currencyCode = bal.currencyCode;
4345
+ if (bal.status) item.status = bal.status;
4346
+ }
4347
+ async function overlayRealtimeBalanceOnOverviewMap(route, overviewMap, config, verbose) {
4348
+ const media = routeToBalanceMedia(route);
4349
+ if (!media || overviewMap.size === 0) return;
4350
+ const ids = [...overviewMap.keys()];
4351
+ const balanceMap = await fetchBalanceMap(media, ids, config, void 0, void 0, verbose);
4352
+ for (const [id, item] of overviewMap) {
4353
+ applyRealtimeBalanceIfNeeded(item, balanceMap.get(id));
4354
+ }
4355
+ }
4356
+ async function enrichOverviewRecordWithRealtimeBalance(media, accountId, config, record, verbose) {
4357
+ if (!BALANCE_SUPPORTED_MEDIA.includes(media)) {
4358
+ return record;
4359
+ }
4360
+ const balanceMap = await fetchBalanceMap(media, [accountId], config, void 0, void 0, verbose);
4361
+ const bal = balanceMap.get(accountId);
4362
+ if (bal == null) return record;
4363
+ const out = { ...record };
4364
+ applyRealtimeBalanceIfNeeded(out, bal);
4365
+ if (bal.name && out["accountName"] == null && out["mediaCustomerName"] == null) {
4366
+ out["mediaCustomerName"] = bal.name;
4367
+ }
4368
+ return out;
4369
+ }
4332
4370
  async function fetchBalanceMap(media, accountIds, config, startDate, endDate, verbose) {
4333
4371
  const result = /* @__PURE__ */ new Map();
4334
4372
  if (accountIds.length === 0) return result;
@@ -4462,70 +4500,73 @@ async function fetchOverviewMap(route, accountIds, config, startDate, endDate, v
4462
4500
  for (const row of rows) {
4463
4501
  result.set(row.mediaAccountId, row);
4464
4502
  }
4465
- return result;
4466
- }
4467
- const fetchLegacyBatch = async (ids) => {
4468
- const batch = /* @__PURE__ */ new Map();
4469
- const params = new URLSearchParams({
4470
- period: "true",
4471
- startDate: start,
4472
- endDate: end,
4473
- mediaCustomerIds: ids.join(",")
4474
- });
4475
- const url = `${config.apiBaseUrl}/report/media-account/${route}/accountsoverview?${params}`;
4476
- const raw = await apiFetch2(url, config, {}, verbose);
4477
- const items = Array.isArray(raw) ? raw : [];
4478
- for (const item of items) {
4479
- if (item.mediaAccountId != null) {
4480
- batch.set(String(item.mediaAccountId), item);
4503
+ } else {
4504
+ const fetchLegacyBatch = async (ids) => {
4505
+ const batch = /* @__PURE__ */ new Map();
4506
+ const params = new URLSearchParams({
4507
+ period: "true",
4508
+ startDate: start,
4509
+ endDate: end,
4510
+ mediaCustomerIds: ids.join(",")
4511
+ });
4512
+ const url = `${config.apiBaseUrl}/report/media-account/${route}/accountsoverview?${params}`;
4513
+ const raw = await apiFetch2(url, config, {}, verbose);
4514
+ const items = Array.isArray(raw) ? raw : [];
4515
+ for (const item of items) {
4516
+ if (item.mediaAccountId != null) {
4517
+ batch.set(String(item.mediaAccountId), item);
4518
+ }
4481
4519
  }
4482
- }
4483
- return batch;
4484
- };
4485
- const mergeOverview = (batch) => {
4486
- for (const [k, v] of batch) result.set(k, v);
4487
- };
4488
- if (accountIds.length === 1) {
4489
- mergeOverview(await fetchLegacyBatch(accountIds));
4490
- return result;
4491
- }
4492
- try {
4493
- const batch = await fetchLegacyBatch(accountIds);
4494
- mergeOverview(batch);
4495
- if (batch.size === 0) {
4496
- for (const id of accountIds) {
4497
- try {
4498
- mergeOverview(await fetchLegacyBatch([id]));
4499
- } catch (singleErr) {
4500
- if (verbose) {
4501
- process.stderr.write(
4502
- `[fetchOverviewMap] \u9010\u6237\u91CD\u8BD5\u5931\u8D25 ${id}\uFF1A${singleErr instanceof Error ? singleErr.message : String(singleErr)}
4520
+ return batch;
4521
+ };
4522
+ const mergeOverview = (batch) => {
4523
+ for (const [k, v] of batch) result.set(k, v);
4524
+ };
4525
+ if (accountIds.length === 1) {
4526
+ mergeOverview(await fetchLegacyBatch(accountIds));
4527
+ } else {
4528
+ try {
4529
+ const batch = await fetchLegacyBatch(accountIds);
4530
+ mergeOverview(batch);
4531
+ if (batch.size === 0) {
4532
+ for (const id of accountIds) {
4533
+ try {
4534
+ mergeOverview(await fetchLegacyBatch([id]));
4535
+ } catch (singleErr) {
4536
+ if (verbose) {
4537
+ process.stderr.write(
4538
+ `[fetchOverviewMap] \u9010\u6237\u91CD\u8BD5\u5931\u8D25 ${id}\uFF1A${singleErr instanceof Error ? singleErr.message : String(singleErr)}
4503
4539
  `
4504
- );
4540
+ );
4541
+ }
4542
+ }
4505
4543
  }
4506
4544
  }
4507
- }
4508
- }
4509
- } catch (err) {
4510
- if (verbose) {
4511
- process.stderr.write(
4512
- `[fetchOverviewMap] \u6279\u91CF\u5F02\u5E38\uFF0C\u9010\u6237\u91CD\u8BD5\uFF1A${err instanceof Error ? err.message : String(err)}
4513
- `
4514
- );
4515
- }
4516
- for (const id of accountIds) {
4517
- try {
4518
- mergeOverview(await fetchLegacyBatch([id]));
4519
- } catch (singleErr) {
4545
+ } catch (err) {
4520
4546
  if (verbose) {
4521
4547
  process.stderr.write(
4522
- `[fetchOverviewMap] \u9010\u6237\u91CD\u8BD5\u5931\u8D25 ${id}\uFF1A${singleErr instanceof Error ? singleErr.message : String(singleErr)}
4548
+ `[fetchOverviewMap] \u6279\u91CF\u5F02\u5E38\uFF0C\u9010\u6237\u91CD\u8BD5\uFF1A${err instanceof Error ? err.message : String(err)}
4523
4549
  `
4524
4550
  );
4525
4551
  }
4552
+ for (const id of accountIds) {
4553
+ try {
4554
+ mergeOverview(await fetchLegacyBatch([id]));
4555
+ } catch (singleErr) {
4556
+ if (verbose) {
4557
+ process.stderr.write(
4558
+ `[fetchOverviewMap] \u9010\u6237\u91CD\u8BD5\u5931\u8D25 ${id}\uFF1A${singleErr instanceof Error ? singleErr.message : String(singleErr)}
4559
+ `
4560
+ );
4561
+ }
4562
+ }
4563
+ }
4526
4564
  }
4527
4565
  }
4528
4566
  }
4567
+ if (result.size > 0) {
4568
+ await overlayRealtimeBalanceOnOverviewMap(route, result, config, verbose);
4569
+ }
4529
4570
  } catch (err) {
4530
4571
  if (verbose) {
4531
4572
  process.stderr.write(
@@ -106210,11 +106251,15 @@ async function runStats(opts) {
106210
106251
  }
106211
106252
  }
106212
106253
  } else {
106213
- const params = new URLSearchParams({ startDate, endDate, period: "true" });
106214
- params.set("mediaCustomerIds", ids.join(","));
106215
- const url = `${config.apiBaseUrl}/report/media-account/${opts.media}/accountsoverview?${params.toString()}`;
106216
- const raw = await apiFetch2(url, config, {}, opts.verbose);
106217
- items = Array.isArray(raw) ? raw : raw.items ?? [];
106254
+ const map = await fetchOverviewMap(
106255
+ opts.media,
106256
+ ids,
106257
+ config,
106258
+ startDate,
106259
+ endDate,
106260
+ opts.verbose
106261
+ );
106262
+ items = [...map.values()];
106218
106263
  }
106219
106264
  } catch (err) {
106220
106265
  const message = err instanceof Error ? err.message : String(err);
@@ -107748,6 +107793,7 @@ async function fetchBingJson(config, url, verbose) {
107748
107793
 
107749
107794
  // src/commands/report-bing-analysis/run.ts
107750
107795
  init_auth();
107796
+ init_balance();
107751
107797
  async function runReportBingOverview(opts) {
107752
107798
  const id = assertBingAccountId(opts.account);
107753
107799
  const { startDate, endDate } = resolveBingDateRange(opts.start, opts.end);
@@ -107755,7 +107801,14 @@ async function runReportBingOverview(opts) {
107755
107801
  const params = new URLSearchParams({ startDate, endDate });
107756
107802
  const url = reportingUrl2(config, id, "OverviewSectionData", params.toString());
107757
107803
  try {
107758
- const data = await fetchBingJson(config, url, !!opts.verbose);
107804
+ const raw = await fetchBingJson(config, url, !!opts.verbose);
107805
+ const data = raw && typeof raw === "object" && !Array.isArray(raw) ? await enrichOverviewRecordWithRealtimeBalance(
107806
+ "BingV2",
107807
+ id,
107808
+ config,
107809
+ raw,
107810
+ opts.verbose
107811
+ ) : raw;
107759
107812
  if (opts.jsonOut) {
107760
107813
  await emitBingSnapshot(opts, "bing-overview", id, startDate, endDate, data);
107761
107814
  return;
@@ -121000,6 +121053,27 @@ async function runAccountAuth(opts) {
121000
121053
  tryOpenBrowser: tryOpenHttpsUrlInBrowser
121001
121054
  });
121002
121055
  }
121056
+ async function runAccountReauth(opts) {
121057
+ if (!opts.id && (!opts.ids || opts.ids.length === 0)) {
121058
+ console.error(
121059
+ "\n\u274C \u91CD\u65B0\u6388\u6743\u987B\u6307\u5B9A\u8981\u89E3\u7ED1\u7684\u8D26\u6237 entityId\uFF1A--id <entityId> \u6216 --ids id1,id2\n entityId \u6765\u81EA list-accounts --json-out \u7684 ma.entityId\uFF08\u4E0D\u662F mediaCustomerId\uFF09\n"
121060
+ );
121061
+ process.exit(1);
121062
+ }
121063
+ console.log("\n\u6B65\u9AA4 1/2\uFF1A\u65AD\u5F00\u5173\u8054\uFF08\u91CD\u65B0\u6388\u6743\u524D\u5FC5\u987B\u5148\u89E3\u7ED1\uFF0C\u5BF9\u9F50\u7F51\u9875\u300C\u91CD\u65B0\u6388\u6743\u300D\uFF09\u2026\n");
121064
+ await runAccountDelink({
121065
+ token: opts.token,
121066
+ id: opts.id,
121067
+ ids: opts.ids,
121068
+ verbose: opts.verbose
121069
+ });
121070
+ console.log("\u6B65\u9AA4 2/2\uFF1A\u53D1\u8D77 OAuth \u6388\u6743\u2026\n");
121071
+ await runAccountAuth({
121072
+ token: opts.token,
121073
+ media: opts.media,
121074
+ verbose: opts.verbose
121075
+ });
121076
+ }
121003
121077
 
121004
121078
  // src/commands/account-manage/google-mcc.ts
121005
121079
  init_auth();
@@ -121959,12 +122033,30 @@ function register23(program2) {
121959
122033
  });
121960
122034
  }
121961
122035
  );
121962
- accountCmd.command("auth").description("\u6DFB\u52A0\u6388\u6743\uFF1A\u53D1\u8D77\u5A92\u4F53 OAuth\uFF0C\u6D4F\u89C8\u5668\u6253\u5F00\u6388\u6743\u9875\uFF08\u7F51\u9875 manageAccounts\u300C\u6DFB\u52A0\u6388\u6743\u300D\uFF09").requiredOption(
122036
+ accountCmd.command("auth").description(
122037
+ "\u6DFB\u52A0\u6388\u6743\uFF1A\u9996\u6B21\u7ED1\u5B9A\u5A92\u4F53\u8D26\u6237 OAuth\uFF08\u7F51\u9875 manageAccounts\u300C\u6DFB\u52A0\u6388\u6743\u300D\uFF09\uFF1BOAuth \u5931\u6548\u8BF7\u7528 reauth"
122038
+ ).requiredOption(
121963
122039
  "-m, --media <type>",
121964
122040
  "\u5A92\u4F53\u7C7B\u578B\uFF1AGoogle | TikTok | Meta | Yandex | BingV2 | Kwai"
121965
122041
  ).option("-t, --token <token>", "Auth Token").option("--verbose", "\u8BE6\u7EC6\u9519\u8BEF\u4FE1\u606F", false).action(async (opts) => {
121966
122042
  await runAccountAuth({ token: opts.token, media: opts.media, verbose: opts.verbose });
121967
122043
  });
122044
+ accountCmd.command("reauth").description(
122045
+ "\u91CD\u65B0\u6388\u6743\uFF1A\u5148\u89E3\u7ED1\u518D OAuth\uFF08\u7F51\u9875 manageAccounts\u300C\u91CD\u65B0\u6388\u6743\u300D\uFF1BinvalidOAuthToken \u5931\u6548\u65F6\u5FC5\u8D70\u6B64\u547D\u4EE4\uFF09"
122046
+ ).requiredOption(
122047
+ "-m, --media <type>",
122048
+ "\u5A92\u4F53\u7C7B\u578B\uFF1AGoogle | TikTok | Meta | Yandex | BingV2 | Kwai"
122049
+ ).option("--id <entityId>", "\u5355\u4E2A\u8D26\u6237 entityId\uFF08\u6765\u81EA list-accounts \u7684 ma.entityId\uFF09").option("--ids <entityIds>", "\u6279\u91CF entityId\uFF0C\u9017\u53F7\u5206\u9694\uFF08\u4E0E --id \u4E8C\u9009\u4E00\uFF0C\u53EF\u540C\u65F6\u4F20\uFF09").option("-t, --token <token>", "Auth Token").option("--verbose", "\u8BE6\u7EC6\u9519\u8BEF\u4FE1\u606F", false).action(
122050
+ async (opts) => {
122051
+ await runAccountReauth({
122052
+ token: opts.token,
122053
+ media: opts.media,
122054
+ id: opts.id,
122055
+ ids: opts.ids ? opts.ids.split(",").map((s) => s.trim()).filter(Boolean) : void 0,
122056
+ verbose: opts.verbose
122057
+ });
122058
+ }
122059
+ );
121968
122060
  accountCmd.command("mcc-bind").description("Google\uFF1A\u5C06\u5B50\u8D26\u6237\u7ED1\u5B9A\u5230\u7ECF\u7406\u8D26\u6237\uFF08MCC\uFF09\uFF0C\u9700\u5DF2\u914D\u7F6E googleApiUrl").requiredOption(
121969
122061
  "--customers <ids>",
121970
122062
  "\u5B50\u8D26\u6237 mediaCustomerId\uFF0C\u591A\u4E2A\u9017\u53F7\u5206\u9694\uFF08\u6765\u81EA list-accounts \u7684 ma.mediaCustomerId\uFF09"
@@ -124937,8 +125029,8 @@ function buildGoogleAdsDiagnosisCollectAgentHint(snapDir) {
124937
125029
  "Google Ads \u8BCA\u65AD collect \u5DF2\u5B8C\u6210\uFF08\u4EC5\u4E8B\u5B9E\u6570\u636E\uFF0C\u4E0D\u542B\u5EFA\u8BAE\u6027\u6587\u6848\uFF09\u3002",
124938
125030
  `1) Read ${dir}/${GOOGLE_ADS_DIAGNOSIS_COLLECT_BASENAME}\uFF08reportData + agentBrief\uFF09\uFF1B`,
124939
125031
  "2) Read references/report-templates/google-ads-diagnosis.md\uFF0C\u7531 Agent \u64B0\u5199\u5168\u90E8 analysis/suggestions\u3001diagnosisOverview\u3001summary\u3001accountInfo.businessModel/industry\u3001budgetCompetitiveness[].strategy\u3001newFeatures[].optimizerRecommendation\u3001adCreativeOptimization.items[].suggestion \u7B49\uFF1B",
124940
- `3) \u5C06 reportData \u4E0E\u53D9\u4E8B\u5B57\u6BB5\u5408\u5E76\u5199\u5165 ${dir}/${GOOGLE_ADS_DIAGNOSIS_REPORT_BASENAME}\uFF1B`,
124941
- `4) siluzan-tso google-ads-diagnosis render --data ${dir}/${GOOGLE_ADS_DIAGNOSIS_REPORT_BASENAME} --out ${dir}/google-ads-diagnosis-report.html\u3002`,
125032
+ `3) \u5C06 reportData \u4E0E\u53D9\u4E8B\u5B57\u6BB5\u5408\u5E76\u5199\u5165 ${dir}/${GOOGLE_ADS_DIAGNOSIS_REPORT_BASENAME}\uFF08**\u7981\u6B62\u6539\u5199** campaigns/geographic/keywords \u7684 items \u884C\uFF0C\u53EA\u586B analysis/suggestions\uFF09\uFF1B`,
125033
+ `4) siluzan-tso google-ads-diagnosis render --data ${dir}/${GOOGLE_ADS_DIAGNOSIS_REPORT_BASENAME} --out ${dir}/google-ads-diagnosis-report.html\uFF08\u9ED8\u8BA4\u540C\u76EE\u5F55 collect \u81EA\u52A8\u6062\u590D\u88AB\u6539\u574F\u7684 items\uFF09\u3002`,
124942
125034
  "\u7981\u6B62\u5728\u672A\u7ECF\u8FC7 Agent \u64B0\u5199\u7684\u60C5\u51B5\u4E0B\u76F4\u63A5 render collect \u4EA7\u7269\uFF1B\u7F3A\u4EFB\u4E00\u5EFA\u8BAE\u5B57\u6BB5 render \u4F1A\u5931\u8D25\u3002"
124943
125035
  ].join("\n");
124944
125036
  }
@@ -126063,13 +126155,90 @@ function formatGoogleAdsDiagnosisContentErrors(check) {
126063
126155
  return parts.join("\n");
126064
126156
  }
126065
126157
 
126066
- // src/commands/google-ads-diagnosis/render-report.ts
126158
+ // src/commands/google-ads-diagnosis/merge-collect-facts.ts
126159
+ var GOOGLE_ADS_DIAGNOSIS_COMPARISON_MODULES = [
126160
+ "campaigns",
126161
+ "geographic",
126162
+ "keywords"
126163
+ ];
126067
126164
  function asRecord6(value) {
126165
+ if (value && typeof value === "object" && !Array.isArray(value)) {
126166
+ return value;
126167
+ }
126168
+ return null;
126169
+ }
126170
+ function isValidRateChange(value) {
126171
+ if (value === null || value === void 0) return true;
126172
+ return typeof value === "number" && Number.isFinite(value);
126173
+ }
126174
+ function isComparisonTableRowValid(row) {
126175
+ const rec = asRecord6(row);
126176
+ if (!rec) return false;
126177
+ if (!String(rec.title ?? "").trim()) return false;
126178
+ if (!Number.isFinite(Number(rec.currentCost))) return false;
126179
+ if (!Number.isFinite(Number(rec.currentClicks))) return false;
126180
+ if (!Number.isFinite(Number(rec.currentCvr))) return false;
126181
+ if (!isValidRateChange(rec.costRateChange)) return false;
126182
+ if (!isValidRateChange(rec.clicksRateChange)) return false;
126183
+ if (!isValidRateChange(rec.cvrRateChange)) return false;
126184
+ return true;
126185
+ }
126186
+ function moduleItemsNeedRestore(items) {
126187
+ if (!Array.isArray(items) || items.length === 0) return false;
126188
+ return items.some((row) => !isComparisonTableRowValid(row));
126189
+ }
126190
+ function getComparisonTableErrors(data) {
126191
+ const errors = [];
126192
+ for (const mod of GOOGLE_ADS_DIAGNOSIS_COMPARISON_MODULES) {
126193
+ const block = asRecord6(data[mod]);
126194
+ const items = block?.items;
126195
+ if (!Array.isArray(items) || items.length === 0) continue;
126196
+ for (let i = 0; i < items.length; i++) {
126197
+ if (!isComparisonTableRowValid(items[i])) {
126198
+ errors.push(
126199
+ `${mod}.items[${i}] \u7F3A\u5C11 title/currentCost/currentClicks \u6216\u73AF\u6BD4\u5B57\u6BB5\u88AB\u5199\u6210\u5B57\u7B26\u4E32\uFF08\u987B\u4FDD\u7559 collect \u4E8B\u5B9E\u884C\uFF0C\u4EC5\u7531 Agent \u586B\u5199 analysis/suggestions\uFF09`
126200
+ );
126201
+ }
126202
+ }
126203
+ }
126204
+ return errors;
126205
+ }
126206
+ function mergeComparisonItemsFromCollect(finalData, collectReportData) {
126207
+ const data = { ...finalData };
126208
+ const restoredModules = [];
126209
+ for (const mod of GOOGLE_ADS_DIAGNOSIS_COMPARISON_MODULES) {
126210
+ const finalBlock = asRecord6(data[mod]);
126211
+ const collectBlock = asRecord6(collectReportData[mod]);
126212
+ const finalItems = finalBlock?.items;
126213
+ const collectItems = collectBlock?.items;
126214
+ if (!moduleItemsNeedRestore(finalItems)) continue;
126215
+ if (!Array.isArray(collectItems) || collectItems.length === 0) continue;
126216
+ data[mod] = {
126217
+ ...finalBlock ?? {},
126218
+ items: collectItems
126219
+ };
126220
+ restoredModules.push(mod);
126221
+ }
126222
+ return { data, restoredModules };
126223
+ }
126224
+
126225
+ // src/commands/google-ads-diagnosis/render-report.ts
126226
+ function asRecord7(value) {
126068
126227
  if (value && typeof value === "object" && !Array.isArray(value)) {
126069
126228
  return value;
126070
126229
  }
126071
126230
  throw new Error("\u8BCA\u65AD\u6570\u636E\u987B\u4E3A JSON \u5BF9\u8C61");
126072
126231
  }
126232
+ async function tryLoadCollectReportData(collectPath) {
126233
+ try {
126234
+ const raw = JSON.parse(await fs18.readFile(collectPath, "utf8"));
126235
+ const root = asRecord7(raw);
126236
+ const reportData = asRecord7(root?.reportData);
126237
+ return reportData ?? root;
126238
+ } catch {
126239
+ return null;
126240
+ }
126241
+ }
126073
126242
  async function runGoogleAdsDiagnosisRender(opts) {
126074
126243
  const dataPath = path23.resolve(opts.dataFile);
126075
126244
  let dataRaw;
@@ -126081,7 +126250,39 @@ async function runGoogleAdsDiagnosisRender(opts) {
126081
126250
  `);
126082
126251
  process.exit(1);
126083
126252
  }
126084
- const data = asRecord6(dataRaw);
126253
+ let data = asRecord7(dataRaw);
126254
+ if (!opts.noMergeCollect) {
126255
+ const collectPath = path23.resolve(
126256
+ opts.collectFile ?? path23.join(path23.dirname(dataPath), GOOGLE_ADS_DIAGNOSIS_COLLECT_BASENAME)
126257
+ );
126258
+ const collectReportData = await tryLoadCollectReportData(collectPath);
126259
+ if (collectReportData) {
126260
+ const { data: merged, restoredModules } = mergeComparisonItemsFromCollect(
126261
+ data,
126262
+ collectReportData
126263
+ );
126264
+ data = merged;
126265
+ if (restoredModules.length > 0) {
126266
+ console.log(
126267
+ `
126268
+ \u2139\uFE0F \u5DF2\u4ECE collect \u6062\u590D \xA73.5 \u5BF9\u6BD4\u8868 items\uFF08${restoredModules.join("\u3001")}\uFF09\uFF1BAgent \u53EA\u5E94\u586B\u5199 analysis/suggestions\uFF0C\u52FF\u6539\u5199 items \u884C\u6570\u636E\u3002
126269
+ \u6765\u6E90\uFF1A${collectPath}
126270
+ `
126271
+ );
126272
+ }
126273
+ }
126274
+ }
126275
+ const comparisonErrors = getComparisonTableErrors(data);
126276
+ if (comparisonErrors.length > 0) {
126277
+ console.error("\n\u274C \u5BF9\u6BD4\u8868 items \u4E0D\u5B8C\u6574\uFF0CHTML \u4E2D\u7CFB\u5217/\u5730\u57DF/\u5173\u952E\u8BCD\u8868\u4F1A\u663E\u793A\u4E3A\u7A7A\uFF1A\n");
126278
+ for (const err of comparisonErrors) {
126279
+ console.error(` \xB7 ${err}`);
126280
+ }
126281
+ console.error(
126282
+ "\n \u4FEE\u590D\uFF1A\u540C\u76EE\u5F55\u4FDD\u7559 google-ads-diagnosis-collect.json \u540E\u91CD\u65B0 render\uFF08\u9ED8\u8BA4\u81EA\u52A8\u5408\u5E76\uFF09\uFF1B\u6216\u624B\u52A8\u4ECE collect.reportData \u590D\u5236 campaigns/geographic/keywords.items\u3002\n"
126283
+ );
126284
+ process.exit(1);
126285
+ }
126085
126286
  const contentCheck = validateGoogleAdsDiagnosisContent(data);
126086
126287
  if (!contentCheck.ok) {
126087
126288
  console.error(`
@@ -126172,13 +126373,20 @@ function registerGoogleAdsDiagnosisCommands(program2) {
126172
126373
  );
126173
126374
  root.command("render").description(
126174
126375
  "\u5C06 google-ads-diagnosis.json \u6CE8\u5165 GoogleAdsDiagnosisReport.html\uFF0C\u8F93\u51FA\u4E0E MarkAI \u4E00\u81F4\u7684 HTML \u7EC8\u7A3F"
126175
- ).requiredOption("--data <file>", "Agent \u4EA7\u51FA\u7684\u8BCA\u65AD JSON\uFF08\u4E0E MarkAI sectionData \u540C\u7ED3\u6784\uFF09").option("--out <file>", "\u8F93\u51FA HTML \u8DEF\u5F84\uFF08\u9ED8\u8BA4\u540C --data \u76EE\u5F55\uFF09").option("--lenient", "\u7ED3\u6784\u8B66\u544A\uFF08\u5982\u7F3A analysis\uFF09\u65F6\u4E0D\u963B\u65AD\u6E32\u67D3", false).action(async (opts) => {
126176
- await runGoogleAdsDiagnosisRender({
126177
- dataFile: opts.data,
126178
- out: opts.out,
126179
- lenient: opts.lenient
126180
- });
126181
- });
126376
+ ).requiredOption("--data <file>", "Agent \u4EA7\u51FA\u7684\u8BCA\u65AD JSON\uFF08\u4E0E MarkAI sectionData \u540C\u7ED3\u6784\uFF09").option("--out <file>", "\u8F93\u51FA HTML \u8DEF\u5F84\uFF08\u9ED8\u8BA4\u540C --data \u76EE\u5F55\uFF09").option(
126377
+ "--collect <file>",
126378
+ "collect JSON \u8DEF\u5F84\uFF08\u9ED8\u8BA4\u540C --data \u76EE\u5F55\u7684 google-ads-diagnosis-collect.json\uFF0C\u7528\u4E8E\u6062\u590D\u5BF9\u6BD4\u8868 items\uFF09"
126379
+ ).option("--no-merge-collect", "\u4E0D\u4ECE collect \u81EA\u52A8\u6062\u590D campaigns/geographic/keywords.items", false).option("--lenient", "\u7ED3\u6784\u8B66\u544A\uFF08\u5982\u7F3A analysis\uFF09\u65F6\u4E0D\u963B\u65AD\u6E32\u67D3", false).action(
126380
+ async (opts) => {
126381
+ await runGoogleAdsDiagnosisRender({
126382
+ dataFile: opts.data,
126383
+ out: opts.out,
126384
+ collectFile: opts.collect,
126385
+ noMergeCollect: opts.noMergeCollect,
126386
+ lenient: opts.lenient
126387
+ });
126388
+ }
126389
+ );
126182
126390
  }
126183
126391
 
126184
126392
  // src/index.ts
@@ -126535,7 +126743,7 @@ function ensureScorecardFromSnapshot(healthDiagnosis, merged) {
126535
126743
  }
126536
126744
 
126537
126745
  // src/commands/facebook-analysis/merge-snapshot.ts
126538
- function asRecord7(value) {
126746
+ function asRecord8(value) {
126539
126747
  if (value && typeof value === "object" && !Array.isArray(value)) {
126540
126748
  return value;
126541
126749
  }
@@ -126584,7 +126792,7 @@ async function readManifest(snapshotDir) {
126584
126792
  async function loadSectionFile(snapshotDir, file) {
126585
126793
  try {
126586
126794
  const raw = await fs21.readFile(path28.join(snapshotDir, file), "utf8");
126587
- return asRecord7(JSON.parse(raw));
126795
+ return asRecord8(JSON.parse(raw));
126588
126796
  } catch {
126589
126797
  return null;
126590
126798
  }
@@ -126632,7 +126840,7 @@ async function mergeFacebookSnapshotIntoReport(payload, snapshotDir) {
126632
126840
  if (data) sectionMap.set(art.section, data);
126633
126841
  }
126634
126842
  const overview = sectionMap.get("overview");
126635
- const current = asRecord7(overview?.currentPeriod);
126843
+ const current = asRecord8(overview?.currentPeriod);
126636
126844
  const accountName = typeof overview?.accountName === "string" ? overview.accountName : void 0;
126637
126845
  const accountId = manifest.accountId ?? (typeof overview?.accountId === "string" ? overview.accountId : void 0);
126638
126846
  const kpis = { ...payload.kpis ?? {} };
@@ -126754,7 +126962,7 @@ function isFiniteNumber(value) {
126754
126962
  function asArray2(value) {
126755
126963
  return Array.isArray(value) ? value : [];
126756
126964
  }
126757
- function asRecord8(value) {
126965
+ function asRecord9(value) {
126758
126966
  if (value && typeof value === "object" && !Array.isArray(value)) {
126759
126967
  return value;
126760
126968
  }
@@ -126766,26 +126974,26 @@ function pushMissing(missing, id, chapter, dimension, hint) {
126766
126974
  function countAdSetsWithSpend(tables) {
126767
126975
  const rows = asArray2(tables?.adSets);
126768
126976
  return rows.filter((row) => {
126769
- const r = asRecord8(row);
126977
+ const r = asRecord9(row);
126770
126978
  const spend = r?.spend;
126771
126979
  return isFiniteNumber(spend) && spend > 0;
126772
126980
  }).length;
126773
126981
  }
126774
126982
  function chartLabels(chart) {
126775
- const c = asRecord8(chart);
126983
+ const c = asRecord9(chart);
126776
126984
  return asArray2(c?.labels).length;
126777
126985
  }
126778
126986
  function validateMetaPeriodReportContent(data) {
126779
126987
  const missing = [];
126780
- const meta = asRecord8(data.meta) ?? {};
126781
- const kpis = asRecord8(data.kpis) ?? {};
126782
- const narrative = asRecord8(data.narrative) ?? {};
126783
- const tables = asRecord8(data.tables);
126784
- const charts = asRecord8(data.charts);
126785
- const sections = asRecord8(data.sections);
126786
- const health = asRecord8(data.healthDiagnosis);
126787
- const priorityPlan = asRecord8(data.priorityPlan);
126788
- const actionChecklist = asRecord8(data.actionChecklist);
126988
+ const meta = asRecord9(data.meta) ?? {};
126989
+ const kpis = asRecord9(data.kpis) ?? {};
126990
+ const narrative = asRecord9(data.narrative) ?? {};
126991
+ const tables = asRecord9(data.tables);
126992
+ const charts = asRecord9(data.charts);
126993
+ const sections = asRecord9(data.sections);
126994
+ const health = asRecord9(data.healthDiagnosis);
126995
+ const priorityPlan = asRecord9(data.priorityPlan);
126996
+ const actionChecklist = asRecord9(data.actionChecklist);
126789
126997
  const adSetsWithSpend2 = countAdSetsWithSpend(tables ?? void 0);
126790
126998
  const regionalNarratives = asArray2(narrative.regional).length;
126791
126999
  const recommendations = asArray2(narrative.recommendations);
@@ -126857,7 +127065,7 @@ function validateMetaPeriodReportContent(data) {
126857
127065
  "charts.audience.labels \u81F3\u5C11 1 \u9879"
126858
127066
  );
126859
127067
  }
126860
- const funnel = asRecord8(charts?.funnel);
127068
+ const funnel = asRecord9(charts?.funnel);
126861
127069
  if (!isFiniteNumber(funnel?.reach)) {
126862
127070
  pushMissing(
126863
127071
  missing,
@@ -126921,7 +127129,7 @@ function validateMetaPeriodReportContent(data) {
126921
127129
  );
126922
127130
  } else {
126923
127131
  for (let i = 0; i < regionalNarratives; i++) {
126924
- const row = asRecord8(asArray2(narrative.regional)[i]);
127132
+ const row = asRecord9(asArray2(narrative.regional)[i]);
126925
127133
  if (textLen2(row?.text) < 80) {
126926
127134
  pushMissing(
126927
127135
  missing,
@@ -126951,7 +127159,7 @@ function validateMetaPeriodReportContent(data) {
126951
127159
  "\u5FC5\u987B\u6070\u597D 4 \u6761\uFF08\u7B80\u5316\u8868\u5355/\u533A\u57DF\u8C03\u6574/\u9884\u7B97\u91CD\u6784/\u7D20\u6750\u5EFA\u8BAE\uFF09"
126952
127160
  );
126953
127161
  } else {
126954
- const titles = recommendations.map((r) => asRecord8(r)?.title);
127162
+ const titles = recommendations.map((r) => asRecord9(r)?.title);
126955
127163
  for (const title of RECOMMENDATION_TITLES) {
126956
127164
  if (!titles.includes(title)) {
126957
127165
  pushMissing(
@@ -126964,7 +127172,7 @@ function validateMetaPeriodReportContent(data) {
126964
127172
  }
126965
127173
  }
126966
127174
  for (let i = 0; i < recommendations.length; i++) {
126967
- const rec = asRecord8(recommendations[i]);
127175
+ const rec = asRecord9(recommendations[i]);
126968
127176
  if (textLen2(rec?.content) < 150) {
126969
127177
  pushMissing(
126970
127178
  missing,
@@ -126986,7 +127194,7 @@ function validateMetaPeriodReportContent(data) {
126986
127194
  );
126987
127195
  } else {
126988
127196
  for (let i = 0; i < supplementary.length; i++) {
126989
- const row = asRecord8(supplementary[i]);
127197
+ const row = asRecord9(supplementary[i]);
126990
127198
  if (!textLen2(row?.dimension) || !textLen2(row?.issue)) {
126991
127199
  pushMissing(
126992
127200
  missing,
@@ -127068,7 +127276,7 @@ function validateMetaPeriodReportContent(data) {
127068
127276
  );
127069
127277
  } else {
127070
127278
  for (let i = 0; i < fourQuestions.length; i++) {
127071
- const q = asRecord8(fourQuestions[i]);
127279
+ const q = asRecord9(fourQuestions[i]);
127072
127280
  const evidence = asArray2(q?.evidence).filter((e) => textLen2(e) > 0);
127073
127281
  if (evidence.length < 2) {
127074
127282
  pushMissing(
@@ -127092,7 +127300,7 @@ function validateMetaPeriodReportContent(data) {
127092
127300
  }
127093
127301
  let scorecardValidRows = 0;
127094
127302
  for (let i = 0; i < scorecard.length; i++) {
127095
- const row = asRecord8(scorecard[i]);
127303
+ const row = asRecord9(scorecard[i]);
127096
127304
  const itemOk = textLen2(row?.item) > 0;
127097
127305
  const dataOk = textLen2(row?.data) > 0;
127098
127306
  const signalOk = SCORECARD_SIGNALS.includes(row?.signal);
@@ -127134,7 +127342,7 @@ function validateMetaPeriodReportContent(data) {
127134
127342
  );
127135
127343
  }
127136
127344
  for (const key of ["platform", "country", "adSets"]) {
127137
- const insight = textLen2(asRecord8(sections?.[key])?.insight);
127345
+ const insight = textLen2(asRecord9(sections?.[key])?.insight);
127138
127346
  if (insight < 200) {
127139
127347
  pushMissing(
127140
127348
  missing,
@@ -127145,7 +127353,7 @@ function validateMetaPeriodReportContent(data) {
127145
127353
  );
127146
127354
  }
127147
127355
  }
127148
- const audience = asRecord8(sections?.audience);
127356
+ const audience = asRecord9(sections?.audience);
127149
127357
  if (asArray2(audience?.goldenProfile).filter((s) => textLen2(s) > 0).length < 3) {
127150
127358
  pushMissing(
127151
127359
  missing,
@@ -127173,7 +127381,7 @@ function validateMetaPeriodReportContent(data) {
127173
127381
  "\u2265150 \u5B57"
127174
127382
  );
127175
127383
  }
127176
- const landingRows = asArray2(asRecord8(sections?.landingPage)?.rows);
127384
+ const landingRows = asArray2(asRecord9(sections?.landingPage)?.rows);
127177
127385
  if (landingRows.length < 3) {
127178
127386
  pushMissing(
127179
127387
  missing,
@@ -127258,7 +127466,7 @@ async function readJsonFile2(filePath) {
127258
127466
  throw new Error(`\u65E0\u6CD5\u89E3\u6790 JSON\uFF1A${filePath}`);
127259
127467
  }
127260
127468
  }
127261
- function asRecord9(value) {
127469
+ function asRecord10(value) {
127262
127470
  if (value && typeof value === "object" && !Array.isArray(value)) {
127263
127471
  return value;
127264
127472
  }
@@ -127273,7 +127481,7 @@ function injectReportData2(html, payload) {
127273
127481
  }
127274
127482
  async function runFacebookAnalysisRender(opts) {
127275
127483
  const dataPath = path29.resolve(opts.dataFile);
127276
- let data = asRecord9(await readJsonFile2(dataPath));
127484
+ let data = asRecord10(await readJsonFile2(dataPath));
127277
127485
  if (opts.snapshotDir?.trim()) {
127278
127486
  data = await mergeFacebookSnapshotIntoReport(data, path29.resolve(opts.snapshotDir));
127279
127487
  }
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "slug": "siluzan-tso",
3
- "version": "1.1.33-beta.1",
4
- "publishedAt": 1782810997774
3
+ "version": "1.1.33-beta.3",
4
+ "publishedAt": 1782887114389
5
5
  }
@@ -307,9 +307,11 @@ siluzan-tso account me --check-phone 15130150466 --json-out ./snap-me
307
307
 
308
308
  ---
309
309
 
310
- ### auth — 添加媒体平台 OAuth 授权
310
+ ### auth — 添加媒体平台 OAuth 授权(首次绑定)
311
311
 
312
- 在浏览器中打开对应媒体的 OAuth 授权页面,授权后账户自动绑定到丝路赞。
312
+ 在浏览器中打开对应媒体的 OAuth 授权页面,授权后账户自动绑定到丝路赞。对应网页 **「添加授权」**。
313
+
314
+ > **OAuth 已失效**(`invalidOAuthToken=true`)时**不要**用本命令,须走下方 **`reauth`**(重新授权必须先解绑)。
313
315
 
314
316
  ```bash
315
317
  siluzan-tso account auth -m <媒体类型>
@@ -322,13 +324,13 @@ siluzan-tso account auth -m <媒体类型>
322
324
  **示例:**
323
325
 
324
326
  ```bash
325
- # 授权 Google Ads 账户
327
+ # 首次授权 Google Ads 账户
326
328
  siluzan-tso account auth -m Google
327
329
 
328
- # 授权 TikTok Ads 账户
330
+ # 首次授权 TikTok Ads 账户
329
331
  siluzan-tso account auth -m TikTok
330
332
 
331
- # 授权 Meta(Facebook)Ads 账户
333
+ # 首次授权 Meta(Facebook)Ads 账户
332
334
  siluzan-tso account auth -m Meta
333
335
  ```
334
336
 
@@ -336,6 +338,38 @@ siluzan-tso account auth -m Meta
336
338
 
337
339
  ---
338
340
 
341
+ ### reauth — 重新授权(先解绑再 OAuth)
342
+
343
+ OAuth 失效时恢复授权。对齐 TSO 网页 **「重新授权」**:程序会先 **delink** 断开关联,再跳转媒体 OAuth。**禁止**对失效账户跳过解绑直接用 `account auth`。
344
+
345
+ ```bash
346
+ siluzan-tso account reauth -m <媒体类型> --id <entityId>
347
+ siluzan-tso account reauth -m Google --ids <id1,id2>
348
+ ```
349
+
350
+ | 选项 | 说明 |
351
+ | -------------------- | ------------------------------------------------------------------------ |
352
+ | `-m, --media <type>` | 媒体类型(必填) |
353
+ | `--id <entityId>` | 单个账户 `entityId`(来自 `list-accounts` 的 `ma.entityId`) |
354
+ | `--ids <id1,id2>` | 批量 `entityId`,逗号分隔(与 `--id` 二选一) |
355
+
356
+ **示例:**
357
+
358
+ ```bash
359
+ # 1. 查失效账户的 entityId
360
+ siluzan-tso list-accounts -m Google --json-out ./snap
361
+
362
+ # 2. 重新授权(内置 delink → OAuth)
363
+ siluzan-tso account reauth -m Google --id abc123def456
364
+
365
+ # 3. 验证
366
+ siluzan-tso list-accounts -m Google
367
+ ```
368
+
369
+ > 手动两步等价于 `reauth`:`account delink --id …` → `account auth -m …`(须用户确认解绑风险)。
370
+
371
+ ---
372
+
339
373
  ### delink — 解除授权 / 断开账户关联
340
374
 
341
375
  从当前丝路赞账号下移除媒体账户绑定。
@@ -219,7 +219,7 @@
219
219
  ## 十一、常见 HTTP 状态码
220
220
 
221
221
  - **400**:参数错误,查看对应 reference 或 `-h`
222
- - **401**:平台方返回则需用户重新授权;**我方凭据失效**则优先 `send-login-code` + `login --phone --code`,见 `references/core/setup.md`
222
+ - **401**:媒体 OAuth 失效 → `account reauth -m <媒体> --id <entityId>`(须先 delink,见 W9);**丝路赞登录凭据失效** → `send-login-code` + `login --phone --code`,见 `references/core/setup.md`
223
223
  - **500**:服务可能正在部署/升级,建议反馈 Siluzan 相关人员
224
224
 
225
225
  ---
@@ -152,7 +152,8 @@
152
152
  - **步骤(按场景)**:
153
153
  - **分享**:`list-accounts --json-out` 取 `entityId` → `account share --id <entityId> --phone <手机号>`;查 `account share-detail --customer-id <mediaCustomerId>`;取消 `account unshare --id <entityId> --account-id <userId>`。
154
154
  - **解绑**:`account delink --id <entityId>` / `--ids id1,id2`。
155
- - **OAuth 重授权**:`invalidOAuthToken=true` → `account auth -m <媒体>`(浏览器授权)→ `list-accounts` 验证。
155
+ - **OAuth 重授权**:`invalidOAuthToken=true` → `list-accounts --json-out` 取 `ma.entityId` → **`account reauth -m <媒体> --id <entityId>`**(内置先 delink 再 OAuth,对齐网页「重新授权」)→ `list-accounts` 验证。**禁止**对失效账户直接用 `account auth`(首次「添加授权」专用)。
156
+ - **首次 OAuth 添加授权**:`account auth -m <媒体>`(无需先 delink)。
156
157
  - **MCC**:`account mcc-bind --customers <mediaCustomerId,...> --mcc <MCC客户ID>` / `mcc-unbind`(走 `googleApiUrl`,先 `config show`)。
157
158
  - **BC(TikTok)**:`account bc-bind --customers <id> --bc-ids <id>` / `bc-unbind --bc-id <id>`(解绑一次一个)。
158
159
  - **BM(Meta)**:`account bm-bind --account-id <mediaCustomerId> --bm-id <bmId>`。
@@ -18,6 +18,7 @@
18
18
  ## 1. 执行摘要(总览)
19
19
 
20
20
  - **CLI**:`siluzan-tso report bing-overview -a <mediaCustomerId> [--start … --end …] --json-out <dir>`
21
+ - **余额**:若 `OverviewSectionData` 误返 `remainingAccountBudget: 0`,CLI 会自动用 `GetMediaAccountInfo` 实时余额回填(与 Web 账户列表一致);**仍建议**单查余额用 `balance -m BingV2`。
21
22
 
22
23
  ## 2. 设备与地域
23
24
 
@@ -33,6 +33,9 @@ siluzan-tso google-ads-diagnosis render \
33
33
  | `collect` | 拉 google-analysis + 可选 Lighthouse;输出 `google-ads-diagnosis-collect.json` |
34
34
  | `render` | 读取 Agent 产出的 `google-ads-diagnosis.json`,注入 `GoogleAdsDiagnosisReport.html` |
35
35
 
36
+ - **render 自动合并(默认开启)**:若同目录存在 `google-ads-diagnosis-collect.json`,且 Agent 改坏了 `campaigns` / `geographic` / `keywords` 的 `items`(缺 `title`/`currentCost` 或环比写成字符串),CLI **自动从 collect.reportData 恢复 items**,保留 Agent 的 `analysis`/`suggestions`。可用 `--no-merge-collect` 关闭;`--collect <file>` 指定 collect 路径。
37
+ - **render 硬校验**:合并后对比表仍不完整则 **exit 1**(避免 HTML 表体全空)。
38
+
36
39
  - **禁止**在 `collect` 阶段生成或写入 `analysis` / `suggestions` / `diagnosisOverview` / `summary` 等建议性文案。
37
40
  - **禁止**跳过 Agent 直接 `render` collect 产物(`render` 会校验全部叙事 / 建议字段;调试可加 `--lenient`)。
38
41
  - JSON 顶层结构须与 MarkAI `sectionData.js` / `fetchData` 输出一致(见下文各 `section-*` 数据对象)。
@@ -204,6 +207,7 @@ siluzan-tso google-ads-diagnosis render \
204
207
  - 指标列:花费(上期/本期/环比)、点击(上期/本期/环比)、转化率(上期/本期/环比)
205
208
  - **数据**:`campaigns.items`(`title`、`previousCost`、`currentCost`、`costRateChange`、`previousClicks`、`currentClicks`、`clicksRateChange`、`previousCvr`、`currentCvr`、`cvrRateChange`)
206
209
  - **分析 / 建议**:`campaigns.analysis`、`campaigns.suggestions`
210
+ - **Agent 禁止改写 `items` 行**(须原样保留 collect `reportData`);`render` 会从同目录 collect 自动恢复被改坏的 items
207
211
 
208
212
  #### 国家地区分析
209
213
 
@@ -4296,6 +4296,75 @@
4296
4296
  const labels = items.map((item, index) =>
4297
4297
  formatDateLabel(item?.date, index),
4298
4298
  );
4299
+ const totalConversions = items.reduce(
4300
+ (sum, item) => sum + (Number(item?.conversions) || 0),
4301
+ 0,
4302
+ );
4303
+ // 零转化账户:CPA/转化折线全为 0 视觉上像空图,改展示消耗 + 点击按日趋势
4304
+ const useCostClickTrend = totalConversions === 0;
4305
+
4306
+ if (useCostClickTrend) {
4307
+ const costData = items.map((item) => Number(item?.cost) || 0);
4308
+ const clicksData = items.map((item) => Number(item?.clicks) || 0);
4309
+ return {
4310
+ type: "line",
4311
+ data: {
4312
+ labels,
4313
+ datasets: [
4314
+ {
4315
+ label: "消耗",
4316
+ data: costData,
4317
+ borderColor: "#3b82f6",
4318
+ backgroundColor: "rgba(59, 130, 246, 0.12)",
4319
+ tension: 0.3,
4320
+ yAxisID: "y1",
4321
+ pointRadius: 3,
4322
+ },
4323
+ {
4324
+ label: "点击次数",
4325
+ data: clicksData,
4326
+ borderColor: "#10b981",
4327
+ backgroundColor: "rgba(16, 185, 129, 0.12)",
4328
+ tension: 0.3,
4329
+ yAxisID: "y2",
4330
+ pointRadius: 3,
4331
+ },
4332
+ ],
4333
+ },
4334
+ options: {
4335
+ responsive: true,
4336
+ maintainAspectRatio: false,
4337
+ interaction: { mode: "index", intersect: false },
4338
+ stacked: false,
4339
+ plugins: {
4340
+ legend: { labels: { font: { size: 12 } } },
4341
+ },
4342
+ scales: {
4343
+ y1: {
4344
+ type: "linear",
4345
+ position: "left",
4346
+ beginAtZero: true,
4347
+ ticks: { font: { size: 11 } },
4348
+ title: { display: true, text: "消耗", font: { size: 12 } },
4349
+ },
4350
+ y2: {
4351
+ type: "linear",
4352
+ position: "right",
4353
+ beginAtZero: true,
4354
+ ticks: { font: { size: 11 } },
4355
+ grid: { drawOnChartArea: false },
4356
+ title: {
4357
+ display: true,
4358
+ text: "点击次数",
4359
+ font: { size: 12 },
4360
+ },
4361
+ },
4362
+ x: { ticks: { font: { size: 11 } } },
4363
+ },
4364
+ },
4365
+ };
4366
+ }
4367
+
4299
4368
  const cpaData = items.map((item) => Number(item?.cpa) || 0);
4300
4369
  const conversionsData = items.map((item) =>
4301
4370
  Number(item?.conversions ?? 0),
@@ -4401,6 +4470,40 @@
4401
4470
  };
4402
4471
  };
4403
4472
 
4473
+ const buildConversionCostDailyTableHtml = (items) => {
4474
+ const rows = Array.isArray(items) ? items : [];
4475
+ if (!rows.length) {
4476
+ return '<tr><td colspan="5" class="empty-row">暂无按日趋势数据</td></tr>';
4477
+ }
4478
+ return rows
4479
+ .map((item) => {
4480
+ const rawDate = item?.date;
4481
+ let dateLabel = rawDate;
4482
+ const parsed = new Date(rawDate);
4483
+ if (!Number.isNaN(parsed.getTime())) {
4484
+ dateLabel = parsed.toISOString().slice(0, 10);
4485
+ }
4486
+ const cost = Number(item?.cost);
4487
+ const clicks = Number(item?.clicks);
4488
+ const conversions = Number(item?.conversions ?? 0);
4489
+ const cpa = Number(item?.cpa);
4490
+ const cpaDisplay =
4491
+ conversions > 0 && Number.isFinite(cpa)
4492
+ ? formatNumber(cpa, 2)
4493
+ : "—";
4494
+ return `
4495
+ <tr>
4496
+ <td data-label="日期">${escapeHtml(String(dateLabel || "—"))}</td>
4497
+ <td data-label="消耗">${escapeHtml(formatNumber(cost, 2))}</td>
4498
+ <td data-label="点击">${escapeHtml(formatNumber(clicks, 0))}</td>
4499
+ <td data-label="转化">${escapeHtml(formatNumber(conversions, 0))}</td>
4500
+ <td data-label="CPA">${escapeHtml(cpaDisplay)}</td>
4501
+ </tr>
4502
+ `;
4503
+ })
4504
+ .join("");
4505
+ };
4506
+
4404
4507
  const createAccountInfoSection = () => {
4405
4508
  const reportMain = document.getElementById("report-main");
4406
4509
  if (!reportMain) return null;
@@ -4883,17 +4986,47 @@
4883
4986
  const conversionTrendSuggestionsList = toArray(
4884
4987
  conversionCost.suggestions,
4885
4988
  ).map((item) => `<li class="insight-item">${escapeHtml(item)}</li>`);
4989
+ const conversionCostDailyItems = Array.isArray(conversionCost.items)
4990
+ ? conversionCost.items
4991
+ : [];
4992
+ const conversionDailyTableHtml = buildConversionCostDailyTableHtml(
4993
+ conversionCostDailyItems,
4994
+ );
4886
4995
  const conversionTrendContent =
4887
4996
  (chartConfig ||
4997
+ conversionCostDailyItems.length ||
4888
4998
  conversionTrendAnalysisList.length ||
4889
4999
  conversionTrendSuggestionsList.length) &&
4890
5000
  `
4891
5001
  <div class="analysis-dimension conversion-trend-section">
4892
5002
  <h4 class="dimension-title">转化成本与转化趋势优化</h4>
4893
5003
  <div class="dimension-content">
5004
+ <div class="table-block" data-table-key="conversion-cost-daily">
5005
+ <table class="data-table">
5006
+ <thead>
5007
+ <tr>
5008
+ <th>日期</th>
5009
+ <th>消耗</th>
5010
+ <th>点击</th>
5011
+ <th>转化</th>
5012
+ <th>CPA</th>
5013
+ </tr>
5014
+ </thead>
5015
+ <tbody>
5016
+ ${conversionDailyTableHtml}
5017
+ </tbody>
5018
+ </table>
5019
+ </div>
4894
5020
  ${
4895
5021
  chartConfig
4896
5022
  ? `
5023
+ <p class="conversion-trend-chart-note" style="margin:12px 0 8px;font-size:13px;color:#64748b;">${
5024
+ conversionCostDailyItems.every(
5025
+ (item) => !(Number(item?.conversions) > 0),
5026
+ ) && conversionCostDailyItems.length
5027
+ ? "本期无转化记录,下图展示消耗与点击按日趋势。"
5028
+ : "下图展示 CPA 与转化次数按日趋势。"
5029
+ }</p>
4897
5030
  <div class="chart-card conversion-trend-chart">
4898
5031
  <div style="height: 280px;">
4899
5032
  <canvas id="${chartId}" height="260"></canvas>
@@ -18,6 +18,7 @@
18
18
  ## 1. 执行摘要(总览)
19
19
 
20
20
  - **CLI**:`siluzan-tso report bing-overview -a <mediaCustomerId> [--start … --end …] --json-out <dir>`
21
+ - **余额**:若 `OverviewSectionData` 误返 `remainingAccountBudget: 0`,CLI 会自动用 `GetMediaAccountInfo` 实时余额回填(与 Web 账户列表一致);**仍建议**单查余额用 `balance -m BingV2`。
21
22
 
22
23
  ## 2. 设备与地域
23
24
 
@@ -33,6 +33,9 @@ siluzan-tso google-ads-diagnosis render \
33
33
  | `collect` | 拉 google-analysis + 可选 Lighthouse;输出 `google-ads-diagnosis-collect.json` |
34
34
  | `render` | 读取 Agent 产出的 `google-ads-diagnosis.json`,注入 `GoogleAdsDiagnosisReport.html` |
35
35
 
36
+ - **render 自动合并(默认开启)**:若同目录存在 `google-ads-diagnosis-collect.json`,且 Agent 改坏了 `campaigns` / `geographic` / `keywords` 的 `items`(缺 `title`/`currentCost` 或环比写成字符串),CLI **自动从 collect.reportData 恢复 items**,保留 Agent 的 `analysis`/`suggestions`。可用 `--no-merge-collect` 关闭;`--collect <file>` 指定 collect 路径。
37
+ - **render 硬校验**:合并后对比表仍不完整则 **exit 1**(避免 HTML 表体全空)。
38
+
36
39
  - **禁止**在 `collect` 阶段生成或写入 `analysis` / `suggestions` / `diagnosisOverview` / `summary` 等建议性文案。
37
40
  - **禁止**跳过 Agent 直接 `render` collect 产物(`render` 会校验全部叙事 / 建议字段;调试可加 `--lenient`)。
38
41
  - JSON 顶层结构须与 MarkAI `sectionData.js` / `fetchData` 输出一致(见下文各 `section-*` 数据对象)。
@@ -204,6 +207,7 @@ siluzan-tso google-ads-diagnosis render \
204
207
  - 指标列:花费(上期/本期/环比)、点击(上期/本期/环比)、转化率(上期/本期/环比)
205
208
  - **数据**:`campaigns.items`(`title`、`previousCost`、`currentCost`、`costRateChange`、`previousClicks`、`currentClicks`、`clicksRateChange`、`previousCvr`、`currentCvr`、`cvrRateChange`)
206
209
  - **分析 / 建议**:`campaigns.analysis`、`campaigns.suggestions`
210
+ - **Agent 禁止改写 `items` 行**(须原样保留 collect `reportData`);`render` 会从同目录 collect 自动恢复被改坏的 items
207
211
 
208
212
  #### 国家地区分析
209
213
 
@@ -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.33-beta.1'
12
+ $PKG_VERSION = '1.1.33-beta.3'
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.33-beta.1"
12
+ readonly PKG_VERSION="1.1.33-beta.3"
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.33-beta.1",
3
+ "version": "1.1.33-beta.3",
4
4
  "description": "Siluzan 广告账户管理 CLI — 查询账户、余额、消耗数据,管理绑定关系与充值。",
5
5
  "keywords": [
6
6
  "ad-account",