siluzan-tso-cli 1.1.45-beta.3 → 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.3),供内部测试使用。正式发布后安装命令将改为 `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
@@ -134342,15 +134342,19 @@ function asItems(payload) {
134342
134342
  return items2.filter((r) => asRecord26(r) !== null);
134343
134343
  }
134344
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;
134345
134349
  const out = {
134346
- spend: num10(row.spend) ?? 0,
134347
- impressions: num10(row.impressions) ?? 0,
134348
- clicks: num10(row.clicks) ?? 0,
134349
- ctr: num10(row.ctr),
134350
- averageCpc: num10(row.averageCpc),
134351
- conversions: num10(row.conversions) ?? 0,
134352
- conversionRate: num10(row.conversionRate),
134353
- 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),
134354
134358
  roi: num10(row.roi)
134355
134359
  };
134356
134360
  for (const f of extras) {
@@ -134627,6 +134631,39 @@ function asRecord27(value) {
134627
134631
  function pushMissing6(missing, id, chapter, dimension, hint) {
134628
134632
  missing.push({ id, chapter, dimension, hint });
134629
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
+ ];
134630
134667
  function validateYandexPeriodReportContent(data) {
134631
134668
  const missing = [];
134632
134669
  const meta = asRecord27(data.meta) ?? {};
@@ -134697,6 +134734,38 @@ function validateYandexPeriodReportContent(data) {
134697
134734
  "\u81F3\u5C11\u4E00\u9879\u6709\u6548\u6570\u5B57\uFF08\u8BF7\u4F20 --snapshot-dir \u7531 CLI \u4ECE overview \u5408\u5E76\uFF09"
134698
134735
  );
134699
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
+ }
134700
134769
  return {
134701
134770
  ok: missing.length === 0,
134702
134771
  missing,
@@ -134704,7 +134773,8 @@ function validateYandexPeriodReportContent(data) {
134704
134773
  executiveSummaryCount: executiveSummary.length,
134705
134774
  recommendationCount: recommendations.length,
134706
134775
  sectionAnalysisCounts,
134707
- sectionSuggestionCounts
134776
+ sectionSuggestionCounts,
134777
+ rowInsightCoverage
134708
134778
  }
134709
134779
  };
134710
134780
  }
@@ -134715,7 +134785,7 @@ function formatYandexPeriodReportErrors(result) {
134715
134785
  `\u62A5\u544A JSON \u7F3A\u5C11 ${result.missing.length} \u9879\u5FC5\u542B\u5185\u5BB9\uFF08\u5BF9\u9F50 yandex-period-report.md\uFF09\uFF1A`,
134716
134786
  ...lines,
134717
134787
  "",
134718
- "\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",
134719
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"
134720
134790
  ].join("\n");
134721
134791
  }
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "slug": "siluzan-tso",
3
- "version": "1.1.45-beta.3",
4
- "publishedAt": 1786677354676
3
+ "version": "1.1.45-beta.4",
4
+ "publishedAt": 1786689638774
5
5
  }
@@ -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
 
@@ -272,6 +272,39 @@
272
272
  return n === undefined ? "—" : n.toFixed(2);
273
273
  }
274
274
 
275
+ /** 接口未回 CTR/CPC/转化率时,用展示/点击/消耗/转化补算(与 merge-snapshot 口径一致)。 */
276
+ function derivedCtr(r) {
277
+ const existing = toNum(r && r.ctr);
278
+ if (existing !== undefined) return existing;
279
+ const impressions = toNum(r && r.impressions);
280
+ const clicks = toNum(r && r.clicks);
281
+ return impressions > 0 && clicks !== undefined ? clicks / impressions : undefined;
282
+ }
283
+
284
+ function derivedCpc(r) {
285
+ const existing = toNum(r && r.averageCpc);
286
+ if (existing !== undefined) return existing;
287
+ const spend = toNum(r && r.spend);
288
+ const clicks = toNum(r && r.clicks);
289
+ return clicks > 0 && spend !== undefined ? spend / clicks : undefined;
290
+ }
291
+
292
+ function derivedCr(r) {
293
+ const existing = toNum(r && r.conversionRate);
294
+ if (existing !== undefined) return existing;
295
+ const clicks = toNum(r && r.clicks);
296
+ const conversions = toNum(r && r.conversions);
297
+ return clicks > 0 && conversions !== undefined ? conversions / clicks : undefined;
298
+ }
299
+
300
+ function derivedCpa(r) {
301
+ const existing = toNum(r && r.costPerConversion);
302
+ if (existing !== undefined) return existing;
303
+ const spend = toNum(r && r.spend);
304
+ const conversions = toNum(r && r.conversions);
305
+ return conversions > 0 && spend !== undefined ? spend / conversions : undefined;
306
+ }
307
+
275
308
  function asArr(v) {
276
309
  return Array.isArray(v) ? v : [];
277
310
  }
@@ -343,39 +376,61 @@
343
376
  .slice(0, n);
344
377
  }
345
378
 
346
- function metricCells(r, currency) {
347
- return (
348
- '<td class="num">' +
349
- money(r.spend, currency) +
350
- '</td><td class="num">' +
351
- int(r.impressions) +
352
- '</td><td class="num">' +
353
- int(r.clicks) +
354
- '</td><td class="num">' +
355
- pct(r.ctr) +
356
- '</td><td class="num">' +
357
- money(r.averageCpc, currency) +
358
- '</td><td class="num">' +
359
- num2(r.conversions) +
360
- '</td><td class="num">' +
361
- pct(r.conversionRate) +
362
- '</td><td class="num">' +
363
- money(r.costPerConversion, currency) +
364
- "</td>"
365
- );
379
+ function normKey(v) {
380
+ return String(v ?? "").trim().toLowerCase();
366
381
  }
367
382
 
368
- /** 对齐开发样例关键词/搜索词/地域/设备表格的列集合(含 cr% 转化率)。 */
369
- const METRIC_HEADERS = [
370
- { label: "消耗", num: true },
371
- { label: "展示", num: true },
372
- { label: "点击", num: true },
373
- { label: "CTR", num: true },
374
- { label: "CPC", num: true },
375
- { label: "转化", num: true },
376
- { label: "转化率", num: true },
377
- { label: "CPA", num: true },
378
- ];
383
+ /**
384
+ * Agent 可选提供的 `narrative.rowInsights.<dim>[]` 里按主字段(如关键词原文/搜索词原文)
385
+ * 找匹配行的洞察(翻译/分类/问题/建议)。找不到返回 null——渲染层一律显示「—」,不编造内容。
386
+ * 每条洞察支持 `{match:{...}, ...}` 或扁平写法 `{keyword, campaignName, ...}`。
387
+ */
388
+ function findInsight(list, primaryField, primaryValue, row) {
389
+ const target = normKey(primaryValue);
390
+ if (!target) return null;
391
+ const candidates = asArr(list).filter(function (it) {
392
+ const m = (it && it.match) || it || {};
393
+ return normKey(m[primaryField]) === target;
394
+ });
395
+ if (candidates.length <= 1) return candidates[0] || null;
396
+ const cn = normKey(row && row.campaignName);
397
+ const narrowed = candidates.find(function (it) {
398
+ const m = (it && it.match) || it || {};
399
+ return normKey(m.campaignName) === cn;
400
+ });
401
+ return narrowed || candidates[0];
402
+ }
403
+
404
+ function rowInsightsOf(data, key) {
405
+ return asArr(((data.narrative || {}).rowInsights || {})[key]);
406
+ }
407
+
408
+ /**
409
+ * 「样本」徽标——纯阈值判断(基于点击量),不依赖大模型/不编造:
410
+ * 点击 <30 视为极小样本,效率类结论不可用;<100 转化类结论慎出;否则样本充足。
411
+ */
412
+ function sampleBadge(clicks) {
413
+ const c = toNum(clicks) || 0;
414
+ if (c < 30) {
415
+ return '<span class="badge b-danger">点击 ' + int(c) + ",样本极小,效率类结论不可用</span>";
416
+ }
417
+ if (c < 100) {
418
+ return '<span class="badge b-warn">点击 ' + int(c) + ",转化类结论慎出</span>";
419
+ }
420
+ return '<span class="badge b-ok">点击 ' + int(c) + ",样本充足</span>";
421
+ }
422
+
423
+ /** Campaign 状态徽标——只用真实 status/state 字段做颜色归类。 */
424
+ function campaignStatusBadge(r) {
425
+ const raw = r.status || r.state || "";
426
+ if (!raw) return '<span class="badge b-gray">—</span>';
427
+ const low = String(raw).toLowerCase();
428
+ let cls = "b-gray";
429
+ if (/(active|running|accept|enable)/.test(low)) cls = "b-ok";
430
+ else if (/(reject|declin|error)/.test(low)) cls = "b-danger";
431
+ else if (/(moderat|review|pending)/.test(low)) cls = "b-warn";
432
+ return '<span class="badge ' + cls + '">' + escapeHtml(String(raw)) + "</span>";
433
+ }
379
434
 
380
435
  function renderHero(data) {
381
436
  const meta = data.meta || {};
@@ -687,6 +742,10 @@
687
742
  );
688
743
  }
689
744
 
745
+ /**
746
+ * 通用维度表格:`opts.headers` / `opts.rowCells(r, currency, data)` 由调用方给出
747
+ * 完整列定义与单元格 HTML,逐维对齐开发样例的列顺序(而不是共用一套固定指标列)。
748
+ */
690
749
  function dimSection(opts) {
691
750
  const data = opts.data;
692
751
  const currency = currencyOf(data);
@@ -698,22 +757,10 @@
698
757
  if (rows.length) {
699
758
  const rowHtml = rows
700
759
  .map(function (r) {
701
- const name = opts.nameOf(r);
702
- return (
703
- "<tr><td>" +
704
- escapeHtml(name) +
705
- "</td>" +
706
- (opts.extraCells ? opts.extraCells(r, currency) : "") +
707
- metricCells(r, currency) +
708
- "</tr>"
709
- );
760
+ return "<tr>" + opts.rowCells(r, currency, data) + "</tr>";
710
761
  })
711
762
  .join("");
712
- const headers = [{ label: opts.nameLabel }].concat(
713
- opts.extraHeaders || [],
714
- METRIC_HEADERS
715
- );
716
- body = tableHtml(headers, rowHtml);
763
+ body = tableHtml(opts.headers, rowHtml);
717
764
  } else {
718
765
  body = '<div class="empty-note">' + EMPTY_NOTE + "</div>";
719
766
  }
@@ -732,6 +779,11 @@
732
779
  sectionHead(opts.title, desc) +
733
780
  '<div class="panel">' +
734
781
  body +
782
+ (opts.footnote
783
+ ? '<div class="empty-note" style="text-align:left;padding:0 16px 14px;">' +
784
+ escapeHtml(opts.footnote) +
785
+ "</div>"
786
+ : "") +
735
787
  analysisBlock(sec) +
736
788
  "</div>" +
737
789
  "</div></section>"
@@ -852,9 +904,42 @@
852
904
  tableKey: "search",
853
905
  title: "广告类型 · Search",
854
906
  desc: "搜索网络(Search)投放汇总",
855
- nameLabel: "网络",
856
- nameOf: function (r) {
857
- return r.network || "SEARCH";
907
+ headers: [
908
+ { label: "Network" },
909
+ { label: "Spend", num: true },
910
+ { label: "Impressions", num: true },
911
+ { label: "Clicks", num: true },
912
+ { label: "CPC", num: true },
913
+ { label: "Conversions", num: true },
914
+ { label: "CPA", num: true },
915
+ { label: "ROI", num: true },
916
+ { label: "CTR", num: true },
917
+ { label: "样本" },
918
+ ],
919
+ rowCells: function (r, currency) {
920
+ return (
921
+ "<td>" +
922
+ escapeHtml(r.network || "Search") +
923
+ '</td><td class="num">' +
924
+ money(r.spend, currency) +
925
+ '</td><td class="num">' +
926
+ int(r.impressions) +
927
+ '</td><td class="num">' +
928
+ int(r.clicks) +
929
+ '</td><td class="num">' +
930
+ money(r.averageCpc, currency) +
931
+ '</td><td class="num">' +
932
+ num2(r.conversions) +
933
+ '</td><td class="num">' +
934
+ money(r.costPerConversion, currency) +
935
+ '</td><td class="num">' +
936
+ pct(r.roi) +
937
+ '</td><td class="num">' +
938
+ pct(r.ctr) +
939
+ "</td><td>" +
940
+ sampleBadge(r.clicks) +
941
+ "</td>"
942
+ );
858
943
  },
859
944
  })
860
945
  );
@@ -866,17 +951,55 @@
866
951
  tableKey: "campaigns",
867
952
  title: "Campaign",
868
953
  desc: "按消耗排序的系列明细",
869
- nameLabel: "系列",
870
- nameOf: function (r) {
871
- return r.campaignName || r.campaignId || "—";
872
- },
873
- extraHeaders: [{ label: "状态" }, { label: "类型" }],
874
- extraCells: function (r) {
954
+ headers: [
955
+ { label: "Campaign" },
956
+ { label: "预算", num: true },
957
+ { label: "战略" },
958
+ { label: "目标CPA(API)", num: true },
959
+ { label: "Spend", num: true },
960
+ { label: "Impr", num: true },
961
+ { label: "Clicks", num: true },
962
+ { label: "CPC", num: true },
963
+ { label: "Conv", num: true },
964
+ { label: "CPA", num: true },
965
+ { label: "状态" },
966
+ { label: "问题" },
967
+ { label: "建议" },
968
+ ],
969
+ rowCells: function (r, currency, data) {
970
+ const insight = findInsight(
971
+ rowInsightsOf(data, "campaigns"),
972
+ "campaignName",
973
+ r.campaignName,
974
+ r
975
+ );
875
976
  return (
876
977
  "<td>" +
877
- escapeHtml(r.status || r.state || "—") +
978
+ escapeHtml(r.campaignName || r.campaignId || "—") +
979
+ '</td><td class="num">' +
980
+ (r.budget != null ? money(r.budget, currency) : "—") +
981
+ "</td><td>" +
982
+ escapeHtml(r.strategy || "—") +
983
+ '</td><td class="num">' +
984
+ (r.targetCpa != null ? money(r.targetCpa, currency) : "—") +
985
+ '</td><td class="num">' +
986
+ money(r.spend, currency) +
987
+ '</td><td class="num">' +
988
+ int(r.impressions) +
989
+ '</td><td class="num">' +
990
+ int(r.clicks) +
991
+ '</td><td class="num">' +
992
+ money(r.averageCpc, currency) +
993
+ '</td><td class="num">' +
994
+ num2(r.conversions) +
995
+ '</td><td class="num">' +
996
+ money(r.costPerConversion, currency) +
997
+ "</td><td>" +
998
+ campaignStatusBadge(r) +
878
999
  "</td><td>" +
879
- escapeHtml(r.campaignType || "—") +
1000
+ escapeHtml((insight && insight.issue) || "—") +
1001
+ "</td><td>" +
1002
+ escapeHtml((insight && insight.suggestion) || "—") +
880
1003
  "</td>"
881
1004
  );
882
1005
  },
@@ -891,17 +1014,55 @@
891
1014
  tableKey: "keywords",
892
1015
  title: "关键词",
893
1016
  desc: "按消耗排序的关键词明细",
894
- nameLabel: "关键词",
895
- nameOf: function (r) {
896
- return r.keyword || "—";
897
- },
898
- extraHeaders: [{ label: "系列" }, { label: "匹配" }],
899
- extraCells: function (r) {
1017
+ footnote:
1018
+ "「中文翻译」「问题/建议」为 Agent 撰写报告时提供(narrative.rowInsights.keywords),未提供的行显示「—」,不做机械占位。",
1019
+ headers: [
1020
+ { label: "Campaign" },
1021
+ { label: "Group" },
1022
+ { label: "Keyword" },
1023
+ { label: "中文翻译" },
1024
+ { label: "Impr", num: true },
1025
+ { label: "Clicks", num: true },
1026
+ { label: "Spend", num: true },
1027
+ { label: "Conv", num: true },
1028
+ { label: "CR%", num: true },
1029
+ { label: "CPA", num: true },
1030
+ { label: "CTR", num: true },
1031
+ { label: "CPC", num: true },
1032
+ { label: "问题" },
1033
+ { label: "建议" },
1034
+ ],
1035
+ rowCells: function (r, currency, data) {
1036
+ const insight = findInsight(rowInsightsOf(data, "keywords"), "keyword", r.keyword, r);
900
1037
  return (
901
1038
  "<td>" +
902
1039
  escapeHtml(r.campaignName || "—") +
903
1040
  "</td><td>" +
904
- escapeHtml(r.criteriaType || "—") +
1041
+ escapeHtml(r.adGroupId != null ? String(r.adGroupId) : "—") +
1042
+ "</td><td>" +
1043
+ escapeHtml(r.keyword || "—") +
1044
+ "</td><td>" +
1045
+ escapeHtml((insight && insight.translation) || "—") +
1046
+ '</td><td class="num">' +
1047
+ int(r.impressions) +
1048
+ '</td><td class="num">' +
1049
+ int(r.clicks) +
1050
+ '</td><td class="num">' +
1051
+ money(r.spend, currency) +
1052
+ '</td><td class="num">' +
1053
+ num2(r.conversions) +
1054
+ '</td><td class="num">' +
1055
+ pct(derivedCr(r)) +
1056
+ '</td><td class="num">' +
1057
+ money(derivedCpa(r), currency) +
1058
+ '</td><td class="num">' +
1059
+ pct(derivedCtr(r)) +
1060
+ '</td><td class="num">' +
1061
+ money(derivedCpc(r), currency) +
1062
+ "</td><td>" +
1063
+ escapeHtml((insight && insight.issue) || "—") +
1064
+ "</td><td>" +
1065
+ escapeHtml((insight && insight.suggestion) || "—") +
905
1066
  "</td>"
906
1067
  );
907
1068
  },
@@ -916,17 +1077,54 @@
916
1077
  tableKey: "searchTerms",
917
1078
  title: "搜索词",
918
1079
  desc: "按消耗排序的搜索词明细(仅近 180 天数据)",
919
- nameLabel: "搜索词",
920
- nameOf: function (r) {
921
- return r.searchQuery || "—";
922
- },
923
- extraHeaders: [{ label: "匹配关键词" }, { label: "系列" }],
924
- extraCells: function (r) {
1080
+ footnote:
1081
+ "「问题/建议」为 Agent 撰写报告时提供(narrative.rowInsights.searchTerms),未提供的行显示「—」,不做机械占位。",
1082
+ headers: [
1083
+ { label: "Search Query" },
1084
+ { label: "匹配关键词" },
1085
+ { label: "Impr", num: true },
1086
+ { label: "Clicks", num: true },
1087
+ { label: "CTR", num: true },
1088
+ { label: "CPC", num: true },
1089
+ { label: "Spend", num: true },
1090
+ { label: "Conv", num: true },
1091
+ { label: "cr%", num: true },
1092
+ { label: "CPA", num: true },
1093
+ { label: "问题" },
1094
+ { label: "建议" },
1095
+ ],
1096
+ rowCells: function (r, currency, data) {
1097
+ const insight = findInsight(
1098
+ rowInsightsOf(data, "searchTerms"),
1099
+ "searchQuery",
1100
+ r.searchQuery,
1101
+ r
1102
+ );
925
1103
  return (
926
1104
  "<td>" +
1105
+ escapeHtml(r.searchQuery || "—") +
1106
+ "</td><td>" +
927
1107
  escapeHtml(r.matchedKeyword || "—") +
1108
+ '</td><td class="num">' +
1109
+ int(r.impressions) +
1110
+ '</td><td class="num">' +
1111
+ int(r.clicks) +
1112
+ '</td><td class="num">' +
1113
+ pct(derivedCtr(r)) +
1114
+ '</td><td class="num">' +
1115
+ money(derivedCpc(r), currency) +
1116
+ '</td><td class="num">' +
1117
+ money(r.spend, currency) +
1118
+ '</td><td class="num">' +
1119
+ num2(r.conversions) +
1120
+ '</td><td class="num">' +
1121
+ pct(derivedCr(r)) +
1122
+ '</td><td class="num">' +
1123
+ money(derivedCpa(r), currency) +
928
1124
  "</td><td>" +
929
- escapeHtml(r.campaignName || "—") +
1125
+ escapeHtml((insight && insight.issue) || "—") +
1126
+ "</td><td>" +
1127
+ escapeHtml((insight && insight.suggestion) || "—") +
930
1128
  "</td>"
931
1129
  );
932
1130
  },
@@ -941,14 +1139,55 @@
941
1139
  tableKey: "geo",
942
1140
  title: "地域",
943
1141
  desc: "按消耗排序的地域明细",
944
- nameLabel: "地域",
945
- nameOf: function (r) {
946
- return (
1142
+ footnote: "「国家」取自账户投放地区(meta.region);「City」为 Yandex 返回的地域名称。",
1143
+ headers: [
1144
+ { label: "国家" },
1145
+ { label: "City" },
1146
+ { label: "Spend", num: true },
1147
+ { label: "Clicks", num: true },
1148
+ { label: "Impr", num: true },
1149
+ { label: "CTR", num: true },
1150
+ { label: "CPC", num: true },
1151
+ { label: "Conv", num: true },
1152
+ { label: "cr%", num: true },
1153
+ { label: "CPA", num: true },
1154
+ { label: "问题" },
1155
+ { label: "建议" },
1156
+ ],
1157
+ rowCells: function (r, currency, data) {
1158
+ const cityName =
947
1159
  r.locationName ||
948
1160
  r.targetingLocationName ||
949
1161
  r.location ||
950
1162
  r.name ||
951
- (r.locationId != null ? "地域 " + r.locationId : "—")
1163
+ (r.locationId != null ? "地域 " + r.locationId : "—");
1164
+ const insight = findInsight(rowInsightsOf(data, "geo"), "locationName", cityName, r);
1165
+ return (
1166
+ "<td>" +
1167
+ escapeHtml((data.meta && data.meta.region) || "—") +
1168
+ "</td><td>" +
1169
+ escapeHtml(cityName) +
1170
+ '</td><td class="num">' +
1171
+ money(r.spend, currency) +
1172
+ '</td><td class="num">' +
1173
+ int(r.clicks) +
1174
+ '</td><td class="num">' +
1175
+ int(r.impressions) +
1176
+ '</td><td class="num">' +
1177
+ pct(derivedCtr(r)) +
1178
+ '</td><td class="num">' +
1179
+ money(derivedCpc(r), currency) +
1180
+ '</td><td class="num">' +
1181
+ num2(r.conversions) +
1182
+ '</td><td class="num">' +
1183
+ pct(derivedCr(r)) +
1184
+ '</td><td class="num">' +
1185
+ money(derivedCpa(r), currency) +
1186
+ "</td><td>" +
1187
+ escapeHtml((insight && insight.issue) || "—") +
1188
+ "</td><td>" +
1189
+ escapeHtml((insight && insight.suggestion) || "—") +
1190
+ "</td>"
952
1191
  );
953
1192
  },
954
1193
  limit: 30,
@@ -962,9 +1201,46 @@
962
1201
  tableKey: "devices",
963
1202
  title: "设备",
964
1203
  desc: "按消耗排序的设备明细",
965
- nameLabel: "设备",
966
- nameOf: function (r) {
967
- return r.device || "";
1204
+ headers: [
1205
+ { label: "Device" },
1206
+ { label: "Spend", num: true },
1207
+ { label: "impr", num: true },
1208
+ { label: "Clicks", num: true },
1209
+ { label: "ctr", num: true },
1210
+ { label: "CPC", num: true },
1211
+ { label: "Conv", num: true },
1212
+ { label: "cr%", num: true },
1213
+ { label: "CPA", num: true },
1214
+ { label: "问题" },
1215
+ { label: "建议" },
1216
+ ],
1217
+ rowCells: function (r, currency, data) {
1218
+ const insight = findInsight(rowInsightsOf(data, "devices"), "device", r.device, r);
1219
+ return (
1220
+ "<td>" +
1221
+ escapeHtml(r.device || "—") +
1222
+ '</td><td class="num">' +
1223
+ money(r.spend, currency) +
1224
+ '</td><td class="num">' +
1225
+ int(r.impressions) +
1226
+ '</td><td class="num">' +
1227
+ int(r.clicks) +
1228
+ '</td><td class="num">' +
1229
+ pct(derivedCtr(r)) +
1230
+ '</td><td class="num">' +
1231
+ money(derivedCpc(r), currency) +
1232
+ '</td><td class="num">' +
1233
+ num2(r.conversions) +
1234
+ '</td><td class="num">' +
1235
+ pct(derivedCr(r)) +
1236
+ '</td><td class="num">' +
1237
+ money(derivedCpa(r), currency) +
1238
+ "</td><td>" +
1239
+ escapeHtml((insight && insight.issue) || "—") +
1240
+ "</td><td>" +
1241
+ escapeHtml((insight && insight.suggestion) || "—") +
1242
+ "</td>"
1243
+ );
968
1244
  },
969
1245
  limit: 20,
970
1246
  })
@@ -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
 
@@ -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.45-beta.3'
12
+ $PKG_VERSION = '1.1.45-beta.4'
13
13
  $CLI_BIN = 'siluzan-tso'
14
14
  $SKILL_LABEL = 'Siluzan TSO'
15
15
  $INSTALL_CMD = 'npm install -g siluzan-tso-cli@beta'
@@ -9,7 +9,7 @@ set -euo pipefail
9
9
  # -- Package info (injected at build time) ------------------------------------
10
10
  readonly PKG_NAME="siluzan-tso-cli"
11
11
  # PKG_VERSION 锁定到与本脚本同批构建产物一致的版本,避免与 dist/skill 错位
12
- readonly PKG_VERSION="1.1.45-beta.3"
12
+ readonly PKG_VERSION="1.1.45-beta.4"
13
13
  readonly CLI_BIN="siluzan-tso"
14
14
  readonly SKILL_LABEL="Siluzan TSO"
15
15
  readonly INSTALL_CMD="npm install -g siluzan-tso-cli@beta"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "siluzan-tso-cli",
3
- "version": "1.1.45-beta.3",
3
+ "version": "1.1.45-beta.4",
4
4
  "description": "Siluzan 广告账户管理 CLI — 查询账户、余额、消耗数据,管理绑定关系与充值。",
5
5
  "keywords": [
6
6
  "ad-account",