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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -51,7 +51,7 @@ siluzan-tso init -d /path/to/skills # 写入自定义目录
51
51
  siluzan-tso init --force # 强制覆盖已存在文件
52
52
  ```
53
53
 
54
- > **注意**:当前为测试版(1.1.45-beta.2),供内部测试使用。正式发布后安装命令将改为 `npm install -g siluzan-tso-cli`。
54
+ > **注意**:当前为测试版(1.1.45-beta.4),供内部测试使用。正式发布后安装命令将改为 `npm install -g siluzan-tso-cli`。
55
55
 
56
56
  | 助手 | 建议 `--ai` |
57
57
  | ----------------------- | ------------------------------------ |
package/dist/index.js CHANGED
@@ -4503,17 +4503,57 @@ var init_currency_display = __esm({
4503
4503
  }
4504
4504
  });
4505
4505
 
4506
+ // src/commands/google-analysis/daily-metrics-range.ts
4507
+ function pad2(n) {
4508
+ return String(n).padStart(2, "0");
4509
+ }
4510
+ function formatChinaOffsetDateTime(d) {
4511
+ const sh = new Date(d.getTime() + CHINA_OFFSET_MS);
4512
+ return `${sh.getUTCFullYear()}-${pad2(sh.getUTCMonth() + 1)}-${pad2(sh.getUTCDate())}T${pad2(sh.getUTCHours())}:${pad2(sh.getUTCMinutes())}:${pad2(sh.getUTCSeconds())}+08:00`;
4513
+ }
4514
+ function defaultChinaDateRange(now = /* @__PURE__ */ new Date()) {
4515
+ const chinaNow = new Date(now.getTime() + CHINA_OFFSET_MS);
4516
+ chinaNow.setUTCDate(chinaNow.getUTCDate() - 1);
4517
+ const endY = chinaNow.getUTCFullYear();
4518
+ const endM = chinaNow.getUTCMonth();
4519
+ const endD = chinaNow.getUTCDate();
4520
+ const startChina = new Date(Date.UTC(endY, endM, endD - 6));
4521
+ const endChina = new Date(Date.UTC(endY, endM, endD));
4522
+ const fmt = (utcCal) => `${utcCal.getUTCFullYear()}-${pad2(utcCal.getUTCMonth() + 1)}-${pad2(utcCal.getUTCDate())}`;
4523
+ return { startDate: fmt(startChina), endDate: fmt(endChina) };
4524
+ }
4525
+ function resolveDailyMetricsQueryTimes(startDate, endDate, now = /* @__PURE__ */ new Date()) {
4526
+ const startInstant = `${startDate}T00:00:00+08:00`;
4527
+ const endOfDay = /* @__PURE__ */ new Date(`${endDate}T23:59:59+08:00`);
4528
+ const endInstant = endOfDay.getTime() > now.getTime() ? formatChinaOffsetDateTime(now) : formatChinaOffsetDateTime(endOfDay);
4529
+ return { startInstant, endInstant };
4530
+ }
4531
+ function resolveChinaQueryTimes(startDate, endDate, _now = /* @__PURE__ */ new Date()) {
4532
+ return { startInstant: startDate, endInstant: endDate };
4533
+ }
4534
+ var CHINA_OFFSET_MS;
4535
+ var init_daily_metrics_range = __esm({
4536
+ "src/commands/google-analysis/daily-metrics-range.ts"() {
4537
+ "use strict";
4538
+ CHINA_OFFSET_MS = 8 * 36e5;
4539
+ }
4540
+ });
4541
+
4506
4542
  // src/utils/balance.ts
4507
- function fmt(d) {
4508
- return d.toISOString().slice(0, 10);
4543
+ function pad22(n) {
4544
+ return String(n).padStart(2, "0");
4509
4545
  }
4510
- function dateRangeLastNDays(n) {
4546
+ function dateRangeLastNDays(n, now = /* @__PURE__ */ new Date()) {
4511
4547
  const days = Math.max(1, Math.floor(Number(n) || 1));
4512
- const end = /* @__PURE__ */ new Date();
4513
- end.setDate(end.getDate() - 1);
4514
- const start = new Date(end);
4515
- start.setDate(start.getDate() - (days - 1));
4516
- return { startDate: fmt(start), endDate: fmt(end) };
4548
+ const chinaNow = new Date(now.getTime() + CHINA_OFFSET_MS2);
4549
+ chinaNow.setUTCDate(chinaNow.getUTCDate() - 1);
4550
+ const endY = chinaNow.getUTCFullYear();
4551
+ const endM = chinaNow.getUTCMonth();
4552
+ const endD = chinaNow.getUTCDate();
4553
+ const startChina = new Date(Date.UTC(endY, endM, endD - (days - 1)));
4554
+ const endChina = new Date(Date.UTC(endY, endM, endD));
4555
+ const fmtChina = (utcCal) => `${utcCal.getUTCFullYear()}-${pad22(utcCal.getUTCMonth() + 1)}-${pad22(utcCal.getUTCDate())}`;
4556
+ return { startDate: fmtChina(startChina), endDate: fmtChina(endChina) };
4517
4557
  }
4518
4558
  function defaultDateRange() {
4519
4559
  return dateRangeLastNDays(7);
@@ -4709,9 +4749,10 @@ async function fetchOverviewMap(route, accountIds, config, startDate, endDate, v
4709
4749
  const end = endDate ?? range.endDate;
4710
4750
  try {
4711
4751
  if (route === "Google") {
4752
+ const { startInstant, endInstant } = resolveDailyMetricsQueryTimes(start, end);
4712
4753
  const params = new URLSearchParams({
4713
- startDate: start,
4714
- endDate: end,
4754
+ startDate: startInstant,
4755
+ endDate: endInstant,
4715
4756
  mediaCustomerIds: accountIds.join("|")
4716
4757
  });
4717
4758
  const url = `${config.apiBaseUrl}/report/media-account/google/account-spend-overview?${params}`;
@@ -4797,10 +4838,11 @@ async function fetchOverviewMap(route, accountIds, config, startDate, endDate, v
4797
4838
  }
4798
4839
  return result;
4799
4840
  }
4800
- var BALANCE_SUPPORTED_MEDIA;
4841
+ var BALANCE_SUPPORTED_MEDIA, CHINA_OFFSET_MS2;
4801
4842
  var init_balance = __esm({
4802
4843
  "src/utils/balance.ts"() {
4803
4844
  "use strict";
4845
+ init_daily_metrics_range();
4804
4846
  init_auth();
4805
4847
  BALANCE_SUPPORTED_MEDIA = [
4806
4848
  "Google",
@@ -4810,6 +4852,7 @@ var init_balance = __esm({
4810
4852
  "Kwai",
4811
4853
  "MetaAd"
4812
4854
  ];
4855
+ CHINA_OFFSET_MS2 = 8 * 36e5;
4813
4856
  }
4814
4857
  });
4815
4858
 
@@ -5525,42 +5568,6 @@ var init_accounts_digest2 = __esm({
5525
5568
  }
5526
5569
  });
5527
5570
 
5528
- // src/commands/google-analysis/daily-metrics-range.ts
5529
- function pad2(n) {
5530
- return String(n).padStart(2, "0");
5531
- }
5532
- function formatChinaOffsetDateTime(d) {
5533
- const sh = new Date(d.getTime() + CHINA_OFFSET_MS);
5534
- return `${sh.getUTCFullYear()}-${pad2(sh.getUTCMonth() + 1)}-${pad2(sh.getUTCDate())}T${pad2(sh.getUTCHours())}:${pad2(sh.getUTCMinutes())}:${pad2(sh.getUTCSeconds())}+08:00`;
5535
- }
5536
- function defaultChinaDateRange(now = /* @__PURE__ */ new Date()) {
5537
- const chinaNow = new Date(now.getTime() + CHINA_OFFSET_MS);
5538
- chinaNow.setUTCDate(chinaNow.getUTCDate() - 1);
5539
- const endY = chinaNow.getUTCFullYear();
5540
- const endM = chinaNow.getUTCMonth();
5541
- const endD = chinaNow.getUTCDate();
5542
- const startChina = new Date(Date.UTC(endY, endM, endD - 6));
5543
- const endChina = new Date(Date.UTC(endY, endM, endD));
5544
- const fmt2 = (utcCal) => `${utcCal.getUTCFullYear()}-${pad2(utcCal.getUTCMonth() + 1)}-${pad2(utcCal.getUTCDate())}`;
5545
- return { startDate: fmt2(startChina), endDate: fmt2(endChina) };
5546
- }
5547
- function resolveDailyMetricsQueryTimes(startDate, endDate, now = /* @__PURE__ */ new Date()) {
5548
- const startInstant = `${startDate}T00:00:00+08:00`;
5549
- const endOfDay = /* @__PURE__ */ new Date(`${endDate}T23:59:59+08:00`);
5550
- const endInstant = endOfDay.getTime() > now.getTime() ? formatChinaOffsetDateTime(now) : formatChinaOffsetDateTime(endOfDay);
5551
- return { startInstant, endInstant };
5552
- }
5553
- function resolveChinaQueryTimes(startDate, endDate, _now = /* @__PURE__ */ new Date()) {
5554
- return { startInstant: startDate, endInstant: endDate };
5555
- }
5556
- var CHINA_OFFSET_MS;
5557
- var init_daily_metrics_range = __esm({
5558
- "src/commands/google-analysis/daily-metrics-range.ts"() {
5559
- "use strict";
5560
- CHINA_OFFSET_MS = 8 * 36e5;
5561
- }
5562
- });
5563
-
5564
5571
  // src/utils/snapshot/async-lock.ts
5565
5572
  async function withAsyncLock(key, fn) {
5566
5573
  const prev = chains.get(key) ?? Promise.resolve();
@@ -108630,8 +108637,8 @@ function resolveFacebookDateRange(start, end) {
108630
108637
  endD.setDate(endD.getDate() - 1);
108631
108638
  const startD = new Date(endD);
108632
108639
  startD.setDate(startD.getDate() - 6);
108633
- const fmt2 = (d) => d.toISOString().slice(0, 10);
108634
- return { startDate: fmt2(startD), endDate: fmt2(endD) };
108640
+ const fmt = (d) => d.toISOString().slice(0, 10);
108641
+ return { startDate: fmt(startD), endDate: fmt(endD) };
108635
108642
  }
108636
108643
  console.error("\n\u274C --start \u4E0E --end \u987B\u540C\u65F6\u4F20\u5165\uFF0C\u6216\u540C\u65F6\u7701\u7565\u4EE5\u4F7F\u7528\u9ED8\u8BA4\u8FD1 7 \u5929\uFF08\u622A\u81F3\u6628\u5929\uFF09\u3002\n");
108637
108644
  process.exit(1);
@@ -108729,8 +108736,8 @@ function resolveMetaOverviewDateRange(start, end) {
108729
108736
  endD.setDate(endD.getDate() - 1);
108730
108737
  const startD = new Date(endD);
108731
108738
  startD.setDate(startD.getDate() - 6);
108732
- const fmt2 = (d) => d.toISOString().slice(0, 10);
108733
- return { startDate: fmt2(startD), endDate: fmt2(endD) };
108739
+ const fmt = (d) => d.toISOString().slice(0, 10);
108740
+ return { startDate: fmt(startD), endDate: fmt(endD) };
108734
108741
  }
108735
108742
  console.error("\n\u274C --start \u4E0E --end \u987B\u540C\u65F6\u4F20\u5165\uFF0C\u6216\u540C\u65F6\u7701\u7565\u4EE5\u4F7F\u7528\u9ED8\u8BA4\u8FD1 7 \u5929\uFF08\u622A\u81F3\u6628\u5929\uFF09\u3002\n");
108736
108743
  process.exit(1);
@@ -133252,8 +133259,8 @@ function resolveDateRange2(start, end) {
133252
133259
  endD.setDate(endD.getDate() - 1);
133253
133260
  const startD = new Date(endD);
133254
133261
  startD.setDate(startD.getDate() - 6);
133255
- const fmt2 = (d) => d.toISOString().slice(0, 10);
133256
- return { startDate: fmt2(startD), endDate: fmt2(endD) };
133262
+ const fmt = (d) => d.toISOString().slice(0, 10);
133263
+ return { startDate: fmt(startD), endDate: fmt(endD) };
133257
133264
  }
133258
133265
  console.error("\n\u274C --start \u4E0E --end \u987B\u540C\u65F6\u4F20\u5165\uFF0C\u6216\u540C\u65F6\u7701\u7565\u4EE5\u4F7F\u7528\u9ED8\u8BA4\u8FD1 7 \u5929\uFF08\u622A\u81F3\u6628\u5929\uFF09\u3002\n");
133259
133266
  process.exit(1);
@@ -134320,6 +134327,7 @@ function asRecord26(value) {
134320
134327
  return null;
134321
134328
  }
134322
134329
  function num10(value) {
134330
+ if (value === null || value === void 0 || value === "") return void 0;
134323
134331
  const n = Number(value);
134324
134332
  return Number.isFinite(n) ? n : void 0;
134325
134333
  }
@@ -134334,15 +134342,19 @@ function asItems(payload) {
134334
134342
  return items2.filter((r) => asRecord26(r) !== null);
134335
134343
  }
134336
134344
  function pickMetrics2(row, extras = []) {
134345
+ const spend = num10(row.spend) ?? 0;
134346
+ const impressions = num10(row.impressions) ?? 0;
134347
+ const clicks = num10(row.clicks) ?? 0;
134348
+ const conversions = num10(row.conversions) ?? 0;
134337
134349
  const out = {
134338
- spend: num10(row.spend) ?? 0,
134339
- impressions: num10(row.impressions) ?? 0,
134340
- clicks: num10(row.clicks) ?? 0,
134341
- ctr: num10(row.ctr),
134342
- averageCpc: num10(row.averageCpc),
134343
- conversions: num10(row.conversions) ?? 0,
134344
- conversionRate: num10(row.conversionRate),
134345
- costPerConversion: num10(row.costPerConversion),
134350
+ spend,
134351
+ impressions,
134352
+ clicks,
134353
+ conversions,
134354
+ ctr: num10(row.ctr) ?? (impressions > 0 ? clicks / impressions : void 0),
134355
+ averageCpc: num10(row.averageCpc) ?? (clicks > 0 ? round25(spend / clicks) : void 0),
134356
+ conversionRate: num10(row.conversionRate) ?? (clicks > 0 ? conversions / clicks : void 0),
134357
+ costPerConversion: num10(row.costPerConversion) ?? (conversions > 0 ? round25(spend / conversions) : void 0),
134346
134358
  roi: num10(row.roi)
134347
134359
  };
134348
134360
  for (const f of extras) {
@@ -134619,6 +134631,39 @@ function asRecord27(value) {
134619
134631
  function pushMissing6(missing, id, chapter, dimension, hint) {
134620
134632
  missing.push({ id, chapter, dimension, hint });
134621
134633
  }
134634
+ function normKey(value) {
134635
+ return String(value ?? "").trim().toLowerCase();
134636
+ }
134637
+ function insightMatchRecord(item) {
134638
+ const rec = asRecord27(item);
134639
+ if (!rec) return {};
134640
+ return asRecord27(rec.match) ?? rec;
134641
+ }
134642
+ function findRowInsight(list, primaryField, primaryValue, row) {
134643
+ const target = normKey(primaryValue);
134644
+ if (!target) return null;
134645
+ const candidates = list.filter((it) => normKey(insightMatchRecord(it)[primaryField]) === target);
134646
+ if (candidates.length <= 1) return asRecord27(candidates[0]);
134647
+ const campaign = normKey(row.campaignName);
134648
+ const narrowed = candidates.find(
134649
+ (it) => normKey(insightMatchRecord(it).campaignName) === campaign
134650
+ );
134651
+ return asRecord27(narrowed ?? candidates[0]);
134652
+ }
134653
+ function topRowsBySpend(rows, limit) {
134654
+ return rows.map((r) => asRecord27(r)).filter((r) => r != null).sort((a, b) => (Number(b.spend) || 0) - (Number(a.spend) || 0)).slice(0, limit);
134655
+ }
134656
+ var ROW_INSIGHT_SPECS = [
134657
+ {
134658
+ tableKey: "keywords",
134659
+ insightKey: "keywords",
134660
+ matchField: "keyword",
134661
+ requiredField: "translation",
134662
+ limit: 40,
134663
+ chapter: "\u2463\u5173\u952E\u8BCD",
134664
+ label: "\u4E2D\u6587\u7FFB\u8BD1"
134665
+ }
134666
+ ];
134622
134667
  function validateYandexPeriodReportContent(data) {
134623
134668
  const missing = [];
134624
134669
  const meta = asRecord27(data.meta) ?? {};
@@ -134689,6 +134734,38 @@ function validateYandexPeriodReportContent(data) {
134689
134734
  "\u81F3\u5C11\u4E00\u9879\u6709\u6548\u6570\u5B57\uFF08\u8BF7\u4F20 --snapshot-dir \u7531 CLI \u4ECE overview \u5408\u5E76\uFF09"
134690
134735
  );
134691
134736
  }
134737
+ const tables = asRecord27(data.tables) ?? {};
134738
+ const rowInsights = asRecord27(narrative.rowInsights) ?? {};
134739
+ const rowInsightCoverage = {};
134740
+ for (const spec of ROW_INSIGHT_SPECS) {
134741
+ const shown = topRowsBySpend(asArray7(tables[spec.tableKey]), spec.limit).filter(
134742
+ (row) => textLen7(row[spec.matchField])
134743
+ );
134744
+ const insights = asArray7(rowInsights[spec.insightKey]);
134745
+ let covered = 0;
134746
+ const uncovered = [];
134747
+ for (const row of shown) {
134748
+ const insight = findRowInsight(insights, spec.matchField, row[spec.matchField], row);
134749
+ if (insight && textLen7(insight[spec.requiredField]) > 0) {
134750
+ covered += 1;
134751
+ } else {
134752
+ uncovered.push(String(row[spec.matchField]));
134753
+ }
134754
+ }
134755
+ rowInsightCoverage[spec.insightKey] = { shown: shown.length, covered };
134756
+ if (shown.length === 0) continue;
134757
+ if (covered < shown.length) {
134758
+ const sample = uncovered.slice(0, 3).join("\u3001");
134759
+ const more = uncovered.length > 3 ? ` \u7B49 ${uncovered.length} \u884C` : "";
134760
+ pushMissing6(
134761
+ missing,
134762
+ `row-insights-${spec.insightKey}`,
134763
+ spec.chapter,
134764
+ `narrative.rowInsights.${spec.insightKey}.${spec.requiredField}`,
134765
+ `HTML \u5C06\u5C55\u793A ${shown.length} \u884C\uFF0C\u6BCF\u884C\u987B\u6709${spec.label}\uFF08\u5F53\u524D ${covered}/${shown.length}\uFF09\u3002\u672A\u8986\u76D6\u793A\u4F8B\uFF1A${sample}${more}`
134766
+ );
134767
+ }
134768
+ }
134692
134769
  return {
134693
134770
  ok: missing.length === 0,
134694
134771
  missing,
@@ -134696,7 +134773,8 @@ function validateYandexPeriodReportContent(data) {
134696
134773
  executiveSummaryCount: executiveSummary.length,
134697
134774
  recommendationCount: recommendations.length,
134698
134775
  sectionAnalysisCounts,
134699
- sectionSuggestionCounts
134776
+ sectionSuggestionCounts,
134777
+ rowInsightCoverage
134700
134778
  }
134701
134779
  };
134702
134780
  }
@@ -134707,7 +134785,7 @@ function formatYandexPeriodReportErrors(result) {
134707
134785
  `\u62A5\u544A JSON \u7F3A\u5C11 ${result.missing.length} \u9879\u5FC5\u542B\u5185\u5BB9\uFF08\u5BF9\u9F50 yandex-period-report.md\uFF09\uFF1A`,
134708
134786
  ...lines,
134709
134787
  "",
134710
- "\u8BF7\u8865\u5168 narrative.sections.<dimension>.{analysis,suggestions} \u4E0E narrative.recommendations \u540E\u91CD\u65B0\u6267\u884C render\u3002",
134788
+ "\u8BF7\u8865\u5168 narrative.sections.<dimension>.{analysis,suggestions}\u3001narrative.recommendations\uFF0C\u4EE5\u53CA\u8868\u683C\u884C\u5BF9\u5E94\u7684 narrative.rowInsights\uFF08\u5173\u952E\u8BCD translation\uFF09\u540E\u91CD\u65B0\u6267\u884C render\u3002",
134711
134789
  "\u6570\u503C\u7C7B\u5B57\u6BB5\uFF08kpis/tables\uFF09\u987B\u7531 --snapshot-dir \u4ECE\u843D\u76D8 JSON \u5408\u5E76\uFF1BAgent \u53EA\u64B0\u5199\u53D9\u4E8B\uFF0C\u7981\u6B62\u624B\u5199 HTML\u3002"
134712
134790
  ].join("\n");
134713
134791
  }
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "slug": "siluzan-tso",
3
- "version": "1.1.45-beta.2",
4
- "publishedAt": 1786612533558
3
+ "version": "1.1.45-beta.4",
4
+ "publishedAt": 1786689638774
5
5
  }
@@ -24,7 +24,7 @@ siluzan-tso balance-scan -m <媒体类型> [选项]
24
24
  | `-m, --media <type>` | 必填:`Google \| TikTok \| Yandex \| MetaAd \| BingV2 \| Kwai` | — |
25
25
  | `-a, --accounts <ids>` | 指定 `mediaCustomerId`(逗号分隔);**跳过清单翻页**,对这些 ID 拉数后全部输出 | — |
26
26
  | `--threshold-days <n>` | 剩余续航天数阈值 | `7` |
27
- | `--spend-days <n>` | 日均消耗回看自然日数(截至昨天不含今天;日均 = 窗口合计 / N) | `7` |
27
+ | `--spend-days <n>` | 日均消耗回看自然日数(截至昨天不含今天,**北京时间**;日均 = 窗口合计 / N) | `7` |
28
28
  | `--min-balance <n>` | 绝对余额阈值(与 threshold 取并集) | — |
29
29
  | `--min-daily-spend <n>` | 日均消耗低于此值视为僵尸账户,不做续航估算 | `0.01` |
30
30
  | `--target-days <n>` | 建议充值目标续航天数 | `30` |
@@ -47,7 +47,7 @@ siluzan-tso balance-scan -m BingV2 -a id1,id2,id3 --json-out ./snap-p2-subset
47
47
  siluzan-tso balance-scan -m MetaAd --json-out ./snap-p2-meta
48
48
  ```
49
49
 
50
- **读盘要点**:先看 `meta.dataIssue`(非空则失败)。`data.items` = **全部已检查账户**(OAuth 失效户不在 items,见 `meta.skippedInvalidOAuth`);`meta.hitCount` = 触阈值条数。预警筛 `hitReason !== "none"`;`hitReason="none"` 表示已检查但未触阈值。按 `remainingDays` 升序交付。用 **`dailySpend`** 做日均(口径见 `meta.spendDays` / `meta.spendWindow`),勿把 `stats` 区间合计 `spend` 当「每天消耗」。与 `balance` / `accounts-digest` 分工见 `references/core/agent-conventions.md` §八。
50
+ **读盘要点**:先看 `meta.dataIssue`(非空则失败)。`data.items` = **全部已检查账户**(OAuth 失效户不在 items,见 `meta.skippedInvalidOAuth`);`meta.hitCount` = 触阈值条数。预警筛 `hitReason !== "none"`;`hitReason="none"` 表示已检查但未触阈值。按 `remainingDays` 升序交付。用 **`dailySpend`** 做日均(口径见 `meta.spendDays` / `meta.spendWindow`),勿把 `stats` 区间合计 `spend` 当「每天消耗」。Google 消耗走 `account-spend-overview`,`startDate`/`endDate` 带东八区墙钟(`…T00:00:00+08:00` / `…T23:59:59+08:00`),与 `stats` 同口径。与 `balance` / `accounts-digest` 分工见 `references/core/agent-conventions.md` §八。
51
51
 
52
52
  ---
53
53
 
@@ -27,7 +27,7 @@
27
27
  | `google-analysis --sections overview`(Google 网关 `OverviewSectionData`) | **实时**;请求起止为 **YYYY-MM-DD**(不附加时区) | ✅ 可查当天 | 当天/今日消耗、当天高消耗账号排行 |
28
28
  | `google-analysis --sections campaign-types`(`types-summary`) | **实时** | ✅ 可查当天 | 当天系列类型分布 |
29
29
  | `google-analysis` 其他 range 维度(`campaigns` / `keywords` / `devices` / `geographic` / `daily-metrics` / …) | 实时(受 Google Ads API 同步延迟影响);**一律 YYYY-MM-DD**(不附加时区;与 `stats` 的 UTC+8 不同) | 可查当天,但当天可能尚未结算 | 周期分析、报告 |
30
- | `stats -m Google` / `balance-scan -m Google` / `accounts-digest -m Google` / `list-accounts -m Google` 合并消耗(TSO `account-spend-overview`,2026-05 起) | **后端自动分流**:窗口完全在历史 → `database`;含今天 → `googleCombined`。**`stats` 请求起止按 UTC+8 墙钟**(`…T00:00:00+08:00` ~ `…T23:59:59+08:00`) | ✅ 可查当天(含今天时切实时聚合) | 历史回溯、巡检、周报 KPI、余额续航估算 |
30
+ | `stats -m Google` / `balance-scan -m Google` / `accounts-digest -m Google` / `list-accounts -m Google` 合并消耗(TSO `account-spend-overview`,2026-05 起) | **后端自动分流**:窗口完全在历史 → `database`;含今天 → `googleCombined`。**凡走本接口的请求起止一律东八区墙钟**(`…T00:00:00+08:00` ~ `…T23:59:59+08:00`) | ✅ 可查当天(含今天时切实时聚合) | 历史回溯、巡检、周报 KPI、余额续航估算 |
31
31
  | `stats` / `balance-scan` / `accounts-digest` / `list-accounts` 的 **TikTok / Yandex / BingV2 / Kwai / MetaAd** 合并消耗(TSO `accountsoverview`;MetaAd OAuth 户走 `FacebookAds` 段) | **每日同步昨天** | ❌ 查今天会全为 0 | 历史回溯、巡检、余额续航估算(口径为"截至昨天") |
32
32
  | `balance`(`GetMediaAccountInfo`) | 实时 | — | 仅当前余额,不反映消耗 |
33
33
 
@@ -22,7 +22,7 @@ siluzan-tso balance-scan -m Google [--threshold-days <n>] [--min-balance <n>] [-
22
22
  | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
23
23
  | 账户 | `data.items[].mediaCustomerId`、`data.items[].name`、`data.items[].advertiserName` |
24
24
  | 余额(主币种金额,与平台余额接口一致) | **`data.items[].balance`**(由 `remainingAccountBudget` 计算而来) |
25
- | 近 7 日估算日均消耗 | **`data.items[].dailySpend`** = 近 7 日总消耗 / 7(窗口为 **[T-7, T-1]**,即截至昨天的 7 个自然日,**不含当天**,避免拉到当天未结算数据) |
25
+ | 近 7 日估算日均消耗 | **`data.items[].dailySpend`** = 近 7 日总消耗 / 7(窗口为 **[T-7, T-1]**,北京时间截至昨天的 7 个自然日,**不含当天**;Google 请求带 `+08:00`) |
26
26
  | 按余额÷日均估算的续航天数 | **`data.items[].remainingDays`** = `balance / dailySpend`(消耗过低 < `minDailySpend` 时为 `null`) |
27
27
  | 建议充值额(按 `meta.thresholds.targetDaysForTopup` 目标) | **`data.items[].recommendedTopup`** |
28
28
  | 命中原因(阈值逻辑) | **`data.items[].hitReason`**:`low-days` \| `low-balance` \| `both` \| `none`(已检查但未触阈值)。`data.items` 含全部已检查账户;预警筛 `hitReason !== "none"`。`meta.hitCount` 为触阈值条数 |
@@ -89,7 +89,7 @@ siluzan-tso yandex-analysis -a <porg-xxx> --start <S> --end <E> --json-out ./sna
89
89
  | ❌ 禁止 | ✅ 正确 |
90
90
  | ------------------------------------ | ---------------------------------------------------- |
91
91
  | 把 `ctr` 当已是百分数再 ÷100 | 落盘为 0~1;Excel 用百分比格式或 `*100` 后加 `%` |
92
- | 编造学习期 / 翻译字段 | API 无则叙事说明「数据不可用」 |
92
+ | 编造学习期、搜索词分类等无接口硬事实 | 无字段且算不出则不写该列;关键词翻译走 rowInsights |
93
93
  | `campaignId` 写成数字 | `String(id)` + 文本格式 |
94
94
  | search-terms 拉超过 180 天 | 缩短区间或排除该维 |
95
95
 
@@ -60,6 +60,43 @@ siluzan-tso yandex-analysis render \
60
60
  - `executiveSummary[]`:≥1 段执行摘要
61
61
  - `sections.{overview,search,campaigns,keywords,searchTerms,geo,devices,daily}`:每个维度各 `{analysis[](≥1,含数字依据), suggestions[](≥1)}`
62
62
  - `recommendations[]`:≥3 条跨维度优化建议(可用 `{priority,text,anchor}`,anchor 对齐样例锚点如 `#keyword` / `#query`)
63
+ - `rowInsights`:有关键词表格行时,**HTML 将展示的 Top N 行必须有中文翻译**;`render` 缺项会拒绝出 HTML。问题/建议可空。见下方「逐行洞察」
64
+
65
+ #### 逐行洞察 `narrative.rowInsights`
66
+
67
+ 开发样例的部分表格列(关键词"中文翻译"、各维度"问题/建议")不是原始接口字段,而是 **Agent 基于已拉取数据做的翻译/判断**:
68
+
69
+ - **必填(`render` 校验)**:`tables.keywords` 有行时,须为 HTML 将展示的每一行(按消耗降序,最多 40 行)提供匹配的 `rowInsights`,且有非空 `translation`。缺一项即拒绝生成 HTML。
70
+ - **可空**:各维 `issue` / `suggestion`(空表示该行无问题,模板显示「—」)。
71
+ - 搜索词「分类」Yandex 不提供,也无法由现有指标算出,报告不展示该列。
72
+
73
+ 示例:
74
+
75
+ ```jsonc
76
+ "narrative": {
77
+ "rowInsights": {
78
+ // 按 keyword 原文匹配(如同词跨系列,可加 campaignName 消歧)
79
+ "keywords": [
80
+ { "match": { "keyword": "металлопрокатное оборудование" }, "translation": "金属轧制设备", "issue": "", "suggestion": "可加大预算" }
81
+ ],
82
+ // 按 searchQuery 原文匹配
83
+ "searchTerms": [
84
+ { "match": { "searchQuery": "прокатный стан своими руками" }, "issue": "DIY 意图消耗", "suggestion": "添加否定短语 своими руками" }
85
+ ],
86
+ // 按 campaignName 匹配
87
+ "campaigns": [
88
+ { "match": { "campaignName": "Search - 自动定向" }, "issue": "自动定向 CTR 弱", "suggestion": "评估降价或暂停" }
89
+ ],
90
+ // 按 locationName(城市名)匹配
91
+ "geo": [{ "match": { "locationName": "Moscow" }, "issue": "", "suggestion": "" }],
92
+ // 按 device 匹配
93
+ "devices": [{ "match": { "device": "tablet" }, "issue": "样本不足", "suggestion": "暂不调整设备系数" }]
94
+ }
95
+ }
96
+ ```
97
+
98
+ - `translation` 是 Agent **对已知原文的翻译**,允许生成;不得编造原始接口没有、也无法由现有指标算出的硬事实。Campaign「学习期」、搜索词「分类」无数据源,报告不展示这两列。
99
+ - 每条洞察按原文匹配一行;HTML 将展示的关键词行若缺中文翻译,`render` 会拒绝出 HTML。
63
100
 
64
101
  ### 快照合并范围(`--snapshot-dir`)
65
102
 
@@ -93,7 +130,7 @@ siluzan-tso yandex-analysis -a <porg-xxx> --start <S> --end <E> --json-out ./sna
93
130
 
94
131
  - 写脚本前先读各 `yandex-*-<id>.outline.txt`(或 stdout 摘要里的 outline),再脚本读 `.json`。
95
132
  - `search` / `campaigns` / `keywords` / `geo` / `devices` 在 Direct 侧过滤 `AdNetworkType=SEARCH`。
96
- - **无**学习期 / 关键词翻译等专用接口——相关洞察仅能基于现有字段与叙事,勿编造后端字段。
133
+ - Campaign「学习期」、搜索词「分类」无接口字段、也无法由现有指标算出,报告不展示这两列。关键词中文翻译可由 Agent 通过 `narrative.rowInsights` 提供(见上方「逐行洞察」)。
97
134
 
98
135
  ---
99
136
 
@@ -89,7 +89,7 @@ siluzan-tso yandex-analysis -a <porg-xxx> --start <S> --end <E> --json-out ./sna
89
89
  | ❌ 禁止 | ✅ 正确 |
90
90
  | ------------------------------------ | ---------------------------------------------------- |
91
91
  | 把 `ctr` 当已是百分数再 ÷100 | 落盘为 0~1;Excel 用百分比格式或 `*100` 后加 `%` |
92
- | 编造学习期 / 翻译字段 | API 无则叙事说明「数据不可用」 |
92
+ | 编造学习期、搜索词分类等无接口硬事实 | 无字段且算不出则不写该列;关键词翻译走 rowInsights |
93
93
  | `campaignId` 写成数字 | `String(id)` + 文本格式 |
94
94
  | search-terms 拉超过 180 天 | 缩短区间或排除该维 |
95
95