feat: add daily report skill

This commit is contained in:
Jeffrey Wu
2026-08-20 16:06:13 +08:00
commit e50bacae1a
11 changed files with 867 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
*.swp
__pycache__/
*.pyc
+3
View File
@@ -0,0 +1,3 @@
# Jeffrey Wu's Skills
Jeffrey Wu's personal skills
+92
View File
@@ -0,0 +1,92 @@
# SW Ads Daily Report
手动调用的 Codex skill,用 SW Ads MCP 读取当前广告账户,生成每日投放分析、Wise 风格 HTML 和一张可直接分享的 PNG 长图。
![效果示意图](examples/swads-daily-report-preview.png)
## 报告内容
- 账户整体状态:Spend、CTR、CPI、ROI、ROAS。
- 异常 Campaign 与相对优质 Campaign。
- 素材表现、发布账号贡献及归因线索。
- 三条基于数据的优化建议。
- 需要人工确认的预算、状态、ROI 目标和素材操作。
技能只执行查询和本地渲染,不会修改线上投放。
## 安装
本地需要 Python 3.9 或更高版本。生成 PNG 还需要 Google Chrome 或 Chromium;浏览器不可用时仍可输出 HTML。
在本仓库根目录执行:
```bash
mkdir -p "${CODEX_HOME:-$HOME/.codex}/skills"
cp -R skills/swads-daily-report "${CODEX_HOME:-$HOME/.codex}/skills/swads-daily-report"
```
如果目标目录已经存在,请先备份旧版本,避免两个版本的文件混在一起。安装后重新打开 Codex 会话。
也可以把下面这段话直接发送给 LLM,让它代为安装:
```text
请把当前仓库的 skills/swads-daily-report 安装到
${CODEX_HOME:-$HOME/.codex}/skills/swads-daily-report。
如果存在旧版本,请先备份;安装完成后检查 SW Ads MCP 是否可用,
只验证只读的 swads_whoami,不要修改任何广告账户或投放配置。
如果缺少 swads MCP,请提示我向内部管理员申请 Token,并给出
使用 bearer-token-env-var 的 codex mcp add 安装命令;不要让我在聊天中发送 Token。
```
## SW Ads MCP 依赖
本 Skill 依赖名为 `swads` 的 SW Ads MCP,服务地址为 `https://ads.mincode.cn/mcp`。Skill 每次运行都会先检查 MCP 工具与身份;未安装、未连接或鉴权失败时会停止生成报告,并提示用户完成配置,不会伪造投放数据。
首次配置时,先向公司内部管理员申请 SW Ads MCP Token,然后在终端执行:
```bash
export SWADS_MCP_TOKEN='<向内部管理员申请的 Token>'
codex mcp add swads \
--url https://ads.mincode.cn/mcp \
--bearer-token-env-var SWADS_MCP_TOKEN
```
请确保 Codex 启动时仍能读取 `SWADS_MCP_TOKEN`,且不要把 Token 写入本仓库。重新打开 Codex 后,可以发送以下指令验证连接:
```text
使用 swads MCP 调用 swads_whoami,确认当前身份和可见账户;不要执行任何写操作。
```
连接成功后,在 Codex 中输入:
```text
$swads-daily-report
```
也可以附加范围:
```text
$swads-daily-report 使用当前账户,生成昨天完整业务日的报告
$swads-daily-report 使用指定账户 123456789,生成 2026-08-01 的报告
```
## 输出
默认写入 `~/swads-reports/YYYY-MM-DD/`
```text
report-data.json
swads-daily-report.html
swads-daily-report.png
```
JSON 是 MCP 数据的归一化快照;HTML 适合浏览和二次编辑;PNG 适合直接发送。字段规范见 [references/report-data-schema.md](references/report-data-schema.md)。
## 安全边界
- 以最近一个完整业务日作为决策依据;当天未完整数据只作为监控提示。
- 缺少分母的 CTR/CPI 显示 `N/A`,不会解释为真实的零。
- 平台原生归因只作为相关性线索,不宣称增量因果。
- 暂停、恢复、调预算、改 ROI 目标、钉选/移除素材及发布操作必须人工确认。
效果图的数据为脱敏演示数据。复现方式见 [examples/README.md](examples/README.md)。
+74
View File
@@ -0,0 +1,74 @@
---
name: swads-daily-report
description: Generate a complete SW Ads morning delivery report as Wise-styled HTML and PNG.
---
# SW Ads Daily Report
Generate one read-only report for the current SW Ads account. Default to the most recent complete business day, with the previous seven complete days as context. Honor an account or date override when the user supplies one.
## Preflight
Before collecting data, inspect the available tools for the `swads` MCP and require these read-only capabilities:
- `swads_whoami`
- `metrics_catalog`
- `metrics_semantic_query`
If those tools are absent, stop before report generation and tell the user that the SW Ads MCP dependency is missing. Provide this setup guide with the token left as a placeholder:
```bash
export SWADS_MCP_TOKEN='<request this Token from your internal administrator>'
codex mcp add swads \
--url https://ads.mincode.cn/mcp \
--bearer-token-env-var SWADS_MCP_TOKEN
```
Tell the user to keep `SWADS_MCP_TOKEN` available when Codex starts, restart the Codex session, and invoke `$swads-daily-report` again. Never request that a real token be pasted into chat or committed to a repository.
When the tools exist, call `swads_whoami` as the connection and identity check. Distinguish an authentication or permission error from a missing MCP installation, report the returned error without exposing credentials, and stop until access is fixed. Do not fabricate data or render an empty report.
## Collect
1. Use the successful `swads_whoami` preflight response and its current account, reporting timezone, UTC offset, and currency. If no current account exists, stop and ask the user to select one.
2. Call `metrics_catalog` before semantic queries. Use supported metric and dimension names from the returned catalog.
3. Query the report period and comparison period with `metrics_semantic_query`:
- account summary: spend, impressions, clicks, CTR, CPI, revenue, orders, ROI/ROAS, and cost per result;
- daily time series for the previous seven complete business days;
- campaign breakdown, excluding zero-spend rows;
- creative breakdown with creative ID, title, creator, spend, revenue, orders, product impressions, product clicks, and product CTR.
4. Read `runtime_account_overview`, `runtime_list_proposals`, and `runtime_get_config`. Read `runtime_findings` as best-effort: derive findings from semantic metrics when it errors or is unavailable.
5. Use `campaign_list` to map external campaign IDs to names and current remote status. Paginate only until every campaign in the breakdown is mapped or the list is exhausted.
Map every semantic row through its returned `columns[].name`; positional assumptions about raw MCP rows are invalid.
## Analyze
Produce the same five report sections on every run:
1. Account status: Spend, CTR, CPI, ROI, and ROAS. Treat missing denominators as `N/A`, not zero. For GMV Max, label GMV/spend as ROAS and label `(GMV-spend)/spend` as simplified advertising ROI; state that product, commission, refund, and fulfillment costs are absent.
2. Abnormal and strong campaigns. Rank by both efficiency and sample size. Prefer active campaigns in the main tables; identify recently disabled spend only in a note when material.
3. Creative performance and attribution clues. Separate account-owned posts from other creators using the current account name. Present correlation as a clue, not causality, and disclose attribution source and creative-level coverage.
4. Exactly three prioritized recommendations tied to observed rows.
5. Manual confirmation: every pause/resume, budget, target, pin/unpin, publishing, or other platform write requires explicit user confirmation. This skill never performs platform writes.
Use the latest complete business day for decisions. A partial current day may appear only as a clearly labeled monitoring note.
## Render and deliver
Read [references/report-data-schema.md](references/report-data-schema.md), then create a normalized JSON document matching it. Write daily artifacts under a durable date folder such as `~/swads-reports/YYYY-MM-DD/` unless the user specifies another destination.
Run:
```bash
python3 <skill-directory>/scripts/render_daily_report.py \
--data /absolute/path/report-data.json \
--html /absolute/path/swads-daily-report.html \
--png /absolute/path/swads-daily-report.png
```
The renderer uses [assets/report.css](assets/report.css). If Chromium is unavailable, return the HTML and explain that PNG rendering was skipped; do not install dependencies automatically. Inspect the PNG with `view_image`. Re-render with a larger `--image-height` only when content is visibly cut off.
If the local sandbox blocks headless Chrome, request approval and rerun only the same renderer command with escalated local execution. This approval is for rendering local artifacts, not for changing SW Ads state.
Deliver the PNG inline when supported, plus clickable links to the PNG, HTML, and normalized JSON. Summarize the three recommendations and which actions need confirmation in no more than five lines.
+14
View File
@@ -0,0 +1,14 @@
interface:
display_name: "SW Ads 每日投放报告"
short_description: "一键生成 Wise 风格的 SW Ads 每日投放报告"
brand_color: "#9fe870"
default_prompt: "使用 $swads-daily-report 生成当前账户的每日投放报告,并输出 HTML 与 PNG。"
dependencies:
tools:
- type: "mcp"
value: "swads"
description: "读取 SW Ads 账户、指标、Campaign 与素材表现"
transport: "streamable_http"
url: "https://ads.mincode.cn/mcp"
policy:
allow_implicit_invocation: false
+131
View File
@@ -0,0 +1,131 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;900&display=swap');
:root {
--primary: #9fe870;
--primary-pale: #e2f6d5;
--canvas: #ffffff;
--canvas-soft: #e8ebe6;
--ink: #0e0f0c;
--ink-deep: #163300;
--body: #454745;
--mute: #868685;
--line: #d4dad1;
--positive: #2ead4b;
--positive-deep: #054d28;
--warning: #ffd11a;
--warning-deep: #b86700;
--warning-bg: #fff4c2;
--warning-content: #4a3b1c;
--negative: #d03238;
--negative-deep: #a72027;
--negative-bg: #320707;
}
* { box-sizing: border-box; }
html { background: var(--canvas-soft); }
body {
width: 100%;
margin: 0;
color: var(--ink);
background: var(--canvas-soft);
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
font-feature-settings: "calt";
font-size: 16px;
line-height: 1.5;
}
.report { width: 100%; max-width: 1320px; margin: 0 auto; padding: 56px 44px; }
.hero { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 48px; align-items: center; }
.eyebrow { display: inline-block; padding: 6px 14px; border-radius: 9999px; color: var(--ink-deep); background: var(--primary); font-size: 12px; font-weight: 600; letter-spacing: .08em; }
h1 { margin: 24px 0 16px; font-size: 72px; font-weight: 900; line-height: .88; letter-spacing: -.055em; }
.subtitle { margin: 0; color: var(--body); }
.status { padding: 28px; border-radius: 24px; color: var(--primary); background: var(--ink); }
.status strong { display: block; font-size: 24px; font-weight: 600; }
.status span { display: block; margin-top: 10px; color: var(--canvas-soft); font-size: 14px; }
.kpis { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-top: 48px; }
.kpi { min-width: 0; min-height: 168px; padding: 24px; border-radius: 24px; background: var(--canvas); }
.kpi.accent { background: var(--primary-pale); }
.kpi-label { color: var(--body); font-size: 14px; font-weight: 600; }
.kpi-value { display: block; margin-top: 22px; font-size: 39px; font-weight: 900; line-height: .98; letter-spacing: -.04em; white-space: nowrap; }
.kpi-note { display: block; margin-top: 10px; color: var(--body); font-size: 14px; }
.good { color: var(--positive-deep); }
.bad { color: var(--negative-deep); }
.warn { color: var(--warning-deep); }
.callout { display: grid; grid-template-columns: 24px 1fr; gap: 12px; margin-top: 16px; padding: 22px 24px; border-radius: 24px; color: var(--warning-content); background: var(--warning-bg); }
.callout strong { font-weight: 600; }
.callout p { margin: 3px 0 0; font-size: 14px; }
section { margin-top: 56px; }
.section-head { display: flex; justify-content: space-between; gap: 32px; align-items: end; margin-bottom: 24px; }
h2 { margin: 0; font-size: 40px; font-weight: 900; line-height: .95; letter-spacing: -.04em; }
.section-head p { max-width: 560px; margin: 0; color: var(--body); font-size: 14px; text-align: right; }
.panel-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; }
.panel { overflow: hidden; border-radius: 24px; background: var(--canvas); }
.panel-head { display: flex; justify-content: space-between; gap: 16px; align-items: center; padding: 22px 24px; border-bottom: 1px solid var(--line); }
.panel-title { font-weight: 600; }
.badge { padding: 4px 12px; border-radius: 9999px; font-size: 12px; font-weight: 600; white-space: nowrap; }
.badge-good { color: var(--positive-deep); background: var(--primary-pale); }
.badge-bad { color: var(--canvas); background: var(--negative-bg); }
.badge-warn { color: var(--warning-content); background: var(--warning); }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th { padding: 12px 16px; color: var(--body); font-size: 12px; font-weight: 600; text-align: right; }
th:first-child, td:first-child { text-align: left; }
td { padding: 15px 16px; border-top: 1px solid var(--line); text-align: right; vertical-align: top; }
td:first-child { max-width: 250px; font-weight: 600; }
.row-note { display: block; margin-top: 2px; color: var(--mute); font-size: 12px; font-weight: 400; }
.trend-panel { padding: 24px; }
.trend { display: grid; grid-template-columns: repeat(7, 1fr); gap: 12px; min-height: 190px; align-items: end; border-bottom: 1px solid var(--ink); }
.trend-item { display: grid; grid-template-rows: 1fr auto auto; gap: 5px; height: 100%; align-items: end; text-align: center; }
.bar { width: 38px; max-width: 78%; min-height: 20px; margin: 0 auto; border-radius: 12px 12px 0 0; background: var(--primary); }
.bar.partial { background: var(--warning); }
.trend-value { font-size: 12px; font-weight: 600; }
.trend-label { color: var(--mute); font-size: 12px; }
.source-list { padding: 24px; }
.source-row { display: grid; grid-template-columns: 180px 1fr 80px; gap: 12px; align-items: center; margin: 16px 0; font-size: 14px; }
.meter { height: 12px; overflow: hidden; border-radius: 9999px; background: var(--canvas-soft); }
.meter span { display: block; height: 100%; border-radius: inherit; background: var(--primary); }
.source-row:nth-child(2n) .meter span { background: var(--warning); }
.source-value { text-align: right; font-weight: 600; }
.insights { padding: 12px 24px; }
.insight { display: grid; grid-template-columns: 32px 1fr; gap: 12px; padding: 15px 0; border-top: 1px solid var(--line); }
.insight:first-child { border-top: 0; }
.insight-num { display: grid; width: 32px; height: 32px; place-items: center; border-radius: 9999px; color: var(--ink-deep); background: var(--primary); font-size: 14px; font-weight: 600; }
.insight p { margin: 4px 0 0; color: var(--body); font-size: 14px; }
.recommendations { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
.recommendation { min-height: 250px; padding: 24px; border-radius: 24px; background: var(--canvas); }
.recommendation-top { display: flex; justify-content: space-between; gap: 12px; align-items: center; }
.recommendation-num { font-size: 40px; font-weight: 900; line-height: 1; }
.recommendation h3 { margin: 24px 0 10px; font-size: 24px; font-weight: 600; line-height: 1.3; }
.recommendation p { margin: 0; color: var(--body); }
.confirmations { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.confirmation { display: grid; grid-template-columns: 28px 1fr; gap: 12px; padding: 22px 24px; border-radius: 24px; background: var(--canvas); }
.confirmation strong { display: block; font-weight: 600; }
.confirmation span { display: block; margin-top: 3px; color: var(--body); font-size: 14px; }
.notes { margin: 48px 0 0; padding: 24px; border-radius: 24px; color: var(--canvas-soft); background: var(--ink); font-size: 13px; }
.notes strong { color: var(--primary); }
.notes ul { margin: 8px 0 0; padding-left: 20px; }
@media (max-width: 1023px) {
.report { padding: 40px 24px; }
.hero { grid-template-columns: 1fr; gap: 24px; }
.kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.panel-grid { grid-template-columns: 1fr; }
.recommendations { grid-template-columns: 1fr; }
.recommendation { min-height: 0; }
}
@media (max-width: 767px) {
.report { padding: 32px 16px; }
h1 { font-size: 48px; }
h2 { font-size: 32px; line-height: 1.15; }
.kpis { grid-template-columns: 1fr; }
.kpi { min-height: 0; }
.kpi-value { font-size: 32px; white-space: normal; }
.section-head { display: block; }
.section-head p { margin-top: 8px; text-align: left; }
.confirmations { grid-template-columns: 1fr; }
.source-row { grid-template-columns: 120px 1fr 60px; }
}
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
+23
View File
@@ -0,0 +1,23 @@
# 效果示意与复现
![SW Ads Daily Report preview](swads-daily-report-preview.png)
示意图使用脱敏的电商账户数据,重点展示最终交付的版式:
- Wise Green `#9fe870` 作为唯一品牌强调色。
- Sage Canvas `#e8ebe6` 作为报告底色,白色 24px 圆角卡片承载指标与表格。
- Inter 900 模拟 Wise Sans 的重型标题,Inter 600 承载功能层级。
- 正向、警告、负向信号分别使用语义色,不用品牌绿色替代风险状态。
## 重新生成
在仓库根目录执行:
```bash
python3 skills/swads-daily-report/scripts/render_daily_report.py \
--data skills/swads-daily-report/examples/sample-report-data.json \
--html /tmp/swads-daily-report-preview.html \
--png skills/swads-daily-report/examples/swads-daily-report-preview.png
```
更新样例数据时保留脱敏原则:使用虚构账户 ID,不提交真实凭据、访问令牌、店铺 ID 或可识别个人的信息。
@@ -0,0 +1,81 @@
{
"meta": {
"account_name": "Demo Commerce",
"account_id": "demo-account-001",
"currency": "MYR",
"reporting_timezone": "Asia/Kuala_Lumpur",
"period_label": "2026-08-19",
"comparison_label": "Previous 7 complete days",
"freshness_label": "2026-08-20 14:48 MYT",
"attribution_label": "TikTok GMV Max native attribution",
"status_label": "谨慎乐观"
},
"summary": {
"spend": 1837.84,
"revenue": 11593.35,
"orders": 663,
"ctr_percent": null,
"cpi": null,
"roas": 6.31,
"simple_roi_percent": 530.8,
"cost_per_order": 2.77
},
"monitoring_note": "当前日数据尚未完整,预算决策以最近完整业务日为准。",
"trend": [
{"label": "08/13", "roas": 6.22, "partial": false},
{"label": "08/14", "roas": 6.45, "partial": false},
{"label": "08/15", "roas": 6.68, "partial": false},
{"label": "08/16", "roas": 7.27, "partial": false},
{"label": "08/17", "roas": 6.22, "partial": false},
{"label": "08/18", "roas": 6.16, "partial": false},
{"label": "08/19", "roas": 6.05, "partial": false}
],
"campaigns": {
"winners": [
{"name": "Product A · Always-on", "id": "campaign-demo-001", "status": "ENABLE", "spend": 551.85, "revenue": 4292.29, "orders": 308, "roas": 7.78, "reason": "最大规模贡献者"},
{"name": "Product B · Growth", "id": "campaign-demo-002", "status": "ENABLE", "spend": 83.29, "revenue": 810.51, "orders": 47, "roas": 9.73, "reason": "效率与样本均衡"},
{"name": "Product C · New", "id": "campaign-demo-003", "status": "ENABLE", "spend": 36.05, "revenue": 414.81, "orders": 16, "roas": 11.51, "reason": "高效率,中等样本"}
],
"anomalies": [
{"name": "Product D · Test", "id": "campaign-demo-004", "status": "ENABLE", "spend": 112.51, "revenue": 84.8, "orders": 3, "roas": 0.75, "reason": "GMV 低于广告费"},
{"name": "Product E · Test", "id": "campaign-demo-005", "status": "ENABLE", "spend": 116.97, "revenue": 185.16, "orders": 5, "roas": 1.58, "reason": "高消耗、低回收"},
{"name": "Product F · New", "id": "campaign-demo-006", "status": "ENABLE", "spend": 59.1, "revenue": 83.0, "orders": 3, "roas": 1.4, "reason": "低订单样本"}
]
},
"creative": {
"row_count": 692,
"coverage_percent": 75.2,
"source_groups": [
{"label": "达人 / 联盟素材", "spend": 1232.55, "revenue": 7535.94, "orders": 463, "roas": 6.11},
{"label": "Demo Commerce 自有素材", "spend": 150.2, "revenue": 121.0, "orders": 8, "roas": 0.81}
],
"winners": [
{"name": "真实使用场景素材 A", "id": "creative-demo-001", "creator": "Creator A", "spend": 20.34, "revenue": 352.83, "orders": 20, "roas": 17.35, "clicks": 89, "product_ctr_percent": 7.0},
{"name": "痛点演示素材 B", "id": "creative-demo-002", "creator": "Creator B", "spend": 25.64, "revenue": 331.4, "orders": 22, "roas": 12.93, "clicks": 104, "product_ctr_percent": 7.62}
],
"anomalies": [
{"name": "高点击零成交素材 C", "id": "creative-demo-003", "creator": "Creator C", "spend": 22.94, "revenue": 0, "orders": 0, "roas": 0, "clicks": 48, "product_ctr_percent": 4.97},
{"name": "自有素材 D", "id": "creative-demo-004", "creator": "Demo Commerce", "spend": 21.45, "revenue": 0, "orders": 0, "roas": 0, "clicks": 31, "product_ctr_percent": 4.05}
],
"insights": [
"成交主要集中于达人真实使用场景内容。",
"高点击零成交素材更像是点击后链路问题,应检查价格、库存、优惠与商品锚点。",
"平台归因只能说明相关性,不能证明增量因果。"
]
},
"recommendations": [
{"title": "控制低效 Campaign 消耗", "detail": "优先检查 Product D 和 Product E;确认毛利底线后再分阶段降预算或暂停。", "confirmation": "required"},
{"title": "对赢家进行小步放量", "detail": "Product A 和 Product B 可尝试 10%–15% 的预算增量,并观察一个完整业务日。", "confirmation": "required"},
{"title": "重构素材池与点击后链路", "detail": "保留高成交达人素材,排查高点击零成交素材对应商品的价格、库存与锚点。", "confirmation": "partial"}
],
"manual_confirmations": [
{"action": "暂停、恢复或调整预算", "required": true, "reason": "会直接改变实时投放。"},
{"action": "修改目标 ROI", "required": true, "reason": "会改变竞价与跑量策略。"},
{"action": "更换、钉选或移除素材", "required": true, "reason": "会改变 GMV Max 素材供给。"},
{"action": "只读检查商品链路", "required": false, "reason": "排查无需投放授权;实际改价仍需确认。"}
],
"notes": [
"CTR 与 CPI 缺少有效分母时显示 N/A,不解释为零。",
"简化广告 ROI 未包含商品成本、佣金、退款和履约费用。"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 461 KiB

@@ -0,0 +1,69 @@
# Normalized report data
The renderer consumes normalized report data, not raw MCP responses. Build each semantic row by zipping `columns[].name` with the row values, then populate this schema. Ratios such as ROAS use multiplier units (`6.31` means `6.31×`); percentage fields use percentage points (`4.18` means `4.18%`). Money values use the account currency.
## Required shape
```json
{
"meta": {
"account_name": "Demo Commerce",
"account_id": "demo-account-001",
"currency": "MYR",
"reporting_timezone": "Asia/Kuala_Lumpur",
"period_label": "2026-08-19",
"comparison_label": "Previous 7 complete days",
"freshness_label": "2026-08-20 14:48 MYT",
"attribution_label": "TikTok GMV Max native attribution",
"status_label": "Cautiously positive"
},
"summary": {
"spend": 1837.84,
"revenue": 11593.35,
"orders": 663,
"ctr_percent": null,
"cpi": null,
"roas": 6.31,
"simple_roi_percent": 530.8,
"cost_per_order": 2.77
},
"monitoring_note": "Optional partial-day or data-quality note.",
"trend": [
{"label": "08/14", "roas": 6.45, "partial": false}
],
"campaigns": {
"winners": [
{"name": "Campaign name", "id": "external id", "status": "ENABLE", "spend": 10, "revenue": 80, "orders": 5, "roas": 8, "reason": "Balanced scale and efficiency"}
],
"anomalies": []
},
"creative": {
"row_count": 692,
"coverage_percent": 75.2,
"source_groups": [
{"label": "Affiliate / creator", "spend": 1232.55, "revenue": 7535.94, "orders": 463, "roas": 6.11},
{"label": "Account-owned", "spend": 150.2, "revenue": 121, "orders": 8, "roas": 0.81}
],
"winners": [
{"name": "Creative title", "id": "creative id", "creator": "creator", "spend": 20, "revenue": 350, "orders": 20, "roas": 17.5, "clicks": 89, "product_ctr_percent": 7.0}
],
"anomalies": [],
"insights": ["Short evidence-backed attribution clue"]
},
"recommendations": [
{"title": "Action title", "detail": "Observed evidence and bounded next step.", "confirmation": "required"}
],
"manual_confirmations": [
{"action": "Pause or change budget", "required": true, "reason": "Changes live delivery"}
],
"notes": ["Attribution and unavailable-data caveats"]
}
```
## Completion rules
- `recommendations` contains exactly three items. `confirmation` is `required`, `partial`, or `none`.
- Winner and anomaly tables should each contain three to five rows when sufficient data exists. Keep low-sample rows but identify the sample limitation in `reason`.
- Missing CTR/CPI/ROI inputs are JSON `null`. Never encode missing data as numeric zero.
- `creative.source_groups` may be empty when creator identity is unavailable. Keep `creative.insights` evidence-based and avoid causal language.
- `manual_confirmations` covers every recommended platform write. Read-only checks may use `required: false`.
@@ -0,0 +1,377 @@
#!/usr/bin/env python3
"""Render normalized SW Ads report data to Wise-styled HTML and PNG."""
from __future__ import annotations
import argparse
import html
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
from typing import Any
SKILL_DIR = Path(__file__).resolve().parent.parent
CSS_PATH = SKILL_DIR / "assets" / "report.css"
def esc(value: Any) -> str:
return html.escape("" if value is None else str(value), quote=True)
def number(value: Any) -> float | None:
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def money(value: Any, currency: str) -> str:
value = number(value)
return "N/A" if value is None else f"{currency} {value:,.2f}"
def ratio(value: Any) -> str:
value = number(value)
return "N/A" if value is None else f"{value:.2f}×"
def percent(value: Any) -> str:
value = number(value)
return "N/A" if value is None else f"{value:.1f}%"
def integer(value: Any) -> str:
value = number(value)
return "N/A" if value is None else f"{int(round(value)):,}"
def decimal(value: Any) -> str:
value = number(value)
return "N/A" if value is None else f"{value:,.2f}"
def text_or(value: Any, fallback: str = "N/A") -> str:
return fallback if value is None or str(value).strip() == "" else str(value)
def render_kpi(label: str, value: str, note: str, *, accent: bool = False, tone: str = "") -> str:
classes = "kpi accent" if accent else "kpi"
value_class = f"kpi-value {tone}".strip()
return (
f'<div class="{classes}"><span class="kpi-label">{esc(label)}</span>'
f'<span class="{value_class}">{esc(value)}</span>'
f'<span class="kpi-note">{esc(note)}</span></div>'
)
def render_campaign_table(items: list[dict[str, Any]], *, empty_label: str) -> str:
if not items:
rows = f'<tr><td colspan="5">{esc(empty_label)}</td></tr>'
else:
rendered = []
for item in items[:5]:
reason = text_or(item.get("reason"), "")
suffix = f'<span class="row-note">{esc(reason)}</span>' if reason else ""
rendered.append(
"<tr>"
f'<td>{esc(text_or(item.get("name"), item.get("id", "Unnamed campaign")))}{suffix}</td>'
f'<td>{esc(decimal(item.get("spend")))}</td>'
f'<td>{esc(decimal(item.get("revenue")))}</td>'
f'<td>{esc(integer(item.get("orders")))}</td>'
f'<td><strong>{esc(ratio(item.get("roas")))}</strong></td>'
"</tr>"
)
rows = "".join(rendered)
return (
'<div class="table-wrap"><table><thead><tr>'
'<th>Campaign</th><th>Spend</th><th>GMV</th><th>订单</th><th>ROAS</th>'
f'</tr></thead><tbody>{rows}</tbody></table></div>'
)
def render_creative_table(items: list[dict[str, Any]], *, anomaly: bool) -> str:
if not items:
return '<div class="table-wrap"><table><tbody><tr><td>暂无足够数据</td></tr></tbody></table></div>'
rendered = []
for item in items[:5]:
title = text_or(item.get("name"), "Untitled creative")
creator = text_or(item.get("creator"), "Unknown creator")
creative_id = text_or(item.get("id"), "")
meta = " · ".join(part for part in (creator, creative_id) if part)
if anomaly:
rendered.append(
"<tr>"
f'<td>{esc(title)}<span class="row-note">{esc(meta)}</span></td>'
f'<td>{esc(decimal(item.get("spend")))}</td>'
f'<td>{esc(integer(item.get("clicks")))}</td>'
f'<td>{esc(integer(item.get("orders")))}</td>'
f'<td class="bad"><strong>{esc(ratio(item.get("roas")))}</strong></td>'
"</tr>"
)
else:
rendered.append(
"<tr>"
f'<td>{esc(title)}<span class="row-note">{esc(meta)}</span></td>'
f'<td>{esc(decimal(item.get("spend")))}</td>'
f'<td>{esc(decimal(item.get("revenue")))}</td>'
f'<td>{esc(integer(item.get("orders")))}</td>'
f'<td class="good"><strong>{esc(ratio(item.get("roas")))}</strong></td>'
"</tr>"
)
if anomaly:
headings = "<th>素材 / 发布账号</th><th>Spend</th><th>点击</th><th>订单</th><th>ROAS</th>"
else:
headings = "<th>素材 / 发布账号</th><th>Spend</th><th>GMV</th><th>订单</th><th>ROAS</th>"
return f'<div class="table-wrap"><table><thead><tr>{headings}</tr></thead><tbody>{"".join(rendered)}</tbody></table></div>'
def render_html(data: dict[str, Any], css: str) -> str:
meta = data.get("meta") or {}
summary = data.get("summary") or {}
campaigns = data.get("campaigns") or {}
creative = data.get("creative") or {}
currency = text_or(meta.get("currency"), "USD")
account_name = text_or(meta.get("account_name"), "SW Ads Account")
period_label = text_or(meta.get("period_label"), "Latest complete business day")
comparison = text_or(meta.get("comparison_label"), "Previous 7 complete days")
kpis = "".join(
[
render_kpi("Spend", money(summary.get("spend"), currency), "报告期总消耗"),
render_kpi("GMV", money(summary.get("revenue"), currency), f'{integer(summary.get("orders"))} 单 · 平台归因'),
render_kpi("ROAS", ratio(summary.get("roas")), "GMV ÷ Spend", accent=True, tone="good"),
render_kpi("简化广告 ROI", percent(summary.get("simple_roi_percent")), "未扣商品与履约成本", accent=True, tone="good"),
render_kpi("CTR", percent(summary.get("ctr_percent")), "无有效分母时显示 N/A", tone="warn"),
render_kpi("CPI", money(summary.get("cpi"), currency), "电商账户通常不适用"),
]
)
trend = data.get("trend") or []
trend_values = [number(item.get("roas")) or 0 for item in trend]
trend_max = max(trend_values, default=1) or 1
trend_items = []
for item in trend[:7]:
value = number(item.get("roas")) or 0
height = 24 + (126 * value / trend_max)
partial = " partial" if item.get("partial") else ""
trend_items.append(
'<div class="trend-item">'
f'<div class="bar{partial}" style="height:{height:.1f}px"></div>'
f'<span class="trend-value">{esc(ratio(value))}</span>'
f'<span class="trend-label">{esc(text_or(item.get("label"), ""))}</span></div>'
)
sources = creative.get("source_groups") or []
max_source_roas = max([number(item.get("roas")) or 0 for item in sources], default=1) or 1
source_rows = []
for item in sources:
width = max(3, 100 * (number(item.get("roas")) or 0) / max_source_roas)
source_rows.append(
'<div class="source-row">'
f'<span>{esc(text_or(item.get("label"), "Unknown source"))}</span>'
f'<div class="meter"><span style="width:{width:.1f}%"></span></div>'
f'<span class="source-value">{esc(ratio(item.get("roas")))}</span></div>'
)
if not source_rows:
source_rows.append('<p>发布账号归因信息不可用。</p>')
insights = creative.get("insights") or []
insight_rows = []
for index, insight in enumerate(insights[:4], start=1):
insight_rows.append(
f'<div class="insight"><span class="insight-num">{index}</span>'
f'<div><strong>归因线索</strong><p>{esc(insight)}</p></div></div>'
)
if not insight_rows:
insight_rows.append('<div class="insight"><span class="insight-num">1</span><div><strong>归因线索</strong><p>当前样本不足以形成稳定判断。</p></div></div>')
recommendations = data.get("recommendations") or []
recommendation_rows = []
labels = {"required": "需人工确认", "partial": "部分需确认", "none": "无需投放确认"}
badge_classes = {"required": "badge-bad", "partial": "badge-warn", "none": "badge-good"}
for index, recommendation in enumerate(recommendations[:3], start=1):
confirmation = text_or(recommendation.get("confirmation"), "required")
recommendation_rows.append(
'<article class="recommendation">'
f'<div class="recommendation-top"><span class="recommendation-num">{index:02d}</span>'
f'<span class="badge {badge_classes.get(confirmation, "badge-bad")}">{esc(labels.get(confirmation, "需人工确认"))}</span></div>'
f'<h3>{esc(text_or(recommendation.get("title"), "优化建议"))}</h3>'
f'<p>{esc(text_or(recommendation.get("detail"), ""))}</p></article>'
)
confirmations = data.get("manual_confirmations") or []
confirmation_rows = []
for item in confirmations:
required = bool(item.get("required"))
icon = "" if required else ""
tone = "bad" if required else "good"
confirmation_rows.append(
'<div class="confirmation">'
f'<span class="{tone}"><strong>{icon}</strong></span><div>'
f'<strong>{esc(text_or(item.get("action"), "Manual review"))}</strong>'
f'<span>{esc(text_or(item.get("reason"), ""))}</span></div></div>'
)
notes = [text_or(meta.get("attribution_label"), "Platform attribution")]
notes.extend(str(note) for note in (data.get("notes") or []))
note_items = "".join(f"<li>{esc(note)}</li>" for note in notes if note)
monitoring_note = text_or(data.get("monitoring_note"), "")
callout = ""
if monitoring_note:
callout = (
'<div class="callout"><span>⚠</span><div><strong>监控提示</strong>'
f'<p>{esc(monitoring_note)}</p></div></div>'
)
coverage = number(creative.get("coverage_percent"))
coverage_text = "N/A" if coverage is None else f"{coverage:.1f}%"
freshness = text_or(meta.get("freshness_label"), "Freshness unavailable")
status = text_or(meta.get("status_label"), "Needs review")
account_id = text_or(meta.get("account_id"), "")
return f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{esc(account_name)} 每日投放报告</title>
<style>{css}</style>
</head>
<body>
<main class="report">
<header class="hero">
<div><span class="eyebrow">SW ADS · DELIVERY INTELLIGENCE</span><h1>{esc(account_name)}<br>每日投放报告</h1>
<p class="subtitle">{esc(period_label)} · {esc(meta.get("reporting_timezone"))} · {esc(meta.get("attribution_label"))}</p></div>
<div class="status"><strong>整体状态:{esc(status)}</strong><span>{esc(comparison)} · {esc(freshness)}{f" · {esc(account_id)}" if account_id else ""}</span></div>
</header>
<div class="kpis">{kpis}</div>
{callout}
<section><div class="section-head"><h2>七日 ROAS 趋势</h2><p>{esc(comparison)};不完整业务日只作为监控信号。</p></div>
<div class="panel trend-panel"><div class="trend" role="img" aria-label="Seven-day ROAS trend">{"".join(trend_items)}</div></div>
</section>
<section><div class="section-head"><h2>Campaign 分层</h2><p>强弱判断同时考虑效率、消耗与订单样本,不把 Campaign 名称中的 ROI 当作真实配置。</p></div>
<div class="panel-grid">
<div class="panel"><div class="panel-head"><span class="panel-title">相对优质 Campaign</span><span class="badge badge-good">观察放量</span></div>{render_campaign_table(campaigns.get("winners") or [], empty_label="暂无足够样本")}</div>
<div class="panel"><div class="panel-head"><span class="panel-title">异常 / 低效 Campaign</span><span class="badge badge-bad">优先检查</span></div>{render_campaign_table(campaigns.get("anomalies") or [], empty_label="未发现明显异常")}</div>
</div>
</section>
<section><div class="section-head"><h2>素材表现与归因线索</h2><p>{esc(integer(creative.get("row_count")))} 条有消耗素材;素材层覆盖账户消耗约 {esc(coverage_text)}。</p></div>
<div class="panel-grid">
<div class="panel"><div class="panel-head"><span class="panel-title">发布账号贡献对比</span><span class="badge badge-good">平台相关性</span></div><div class="source-list">{"".join(source_rows)}</div></div>
<div class="panel insights">{"".join(insight_rows)}</div>
</div>
<div class="panel-grid" style="margin-top:24px">
<div class="panel"><div class="panel-head"><span class="panel-title">高价值素材</span><span class="badge badge-good">保留 / 复用</span></div>{render_creative_table(creative.get("winners") or [], anomaly=False)}</div>
<div class="panel"><div class="panel-head"><span class="panel-title">高消耗低转化素材</span><span class="badge badge-bad">降权 / 排查</span></div>{render_creative_table(creative.get("anomalies") or [], anomaly=True)}</div>
</div>
</section>
<section><div class="section-head"><h2>三条优化建议</h2><p>建议只描述下一步;本报告不执行任何平台写入。</p></div><div class="recommendations">{"".join(recommendation_rows)}</div></section>
<section><div class="section-head"><h2>人工确认清单</h2><p>预算、目标、状态、素材授权与发布均保持人工控制。</p></div><div class="confirmations">{"".join(confirmation_rows)}</div></section>
<footer class="notes"><strong>数据与口径</strong><ul>{note_items}</ul></footer>
</main>
</body>
</html>
"""
def find_chrome(explicit: str | None) -> str | None:
candidates = [
explicit,
os.environ.get("CHROME_BIN"),
shutil.which("google-chrome"),
shutil.which("google-chrome-stable"),
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
shutil.which("chromium"),
shutil.which("chromium-browser"),
]
for candidate in candidates:
if candidate and Path(candidate).is_file():
return str(candidate)
return None
def render_png(html_path: Path, png_path: Path, *, chrome: str | None, width: int, height: int) -> None:
chrome_bin = find_chrome(chrome)
if not chrome_bin:
raise RuntimeError("Chromium/Google Chrome was not found; HTML was created but PNG rendering was skipped")
png_path.parent.mkdir(parents=True, exist_ok=True)
command = [
chrome_bin,
"--headless=new",
"--disable-gpu",
"--hide-scrollbars",
"--force-device-scale-factor=1",
f"--window-size={width},{height}",
f"--screenshot={png_path}",
html_path.resolve().as_uri(),
]
result = subprocess.run(command, capture_output=True, text=True, timeout=90)
if result.returncode != 0 or not png_path.exists():
detail = (result.stderr or result.stdout or "unknown Chromium error").strip()
raise RuntimeError(f"PNG rendering failed: {detail}")
magick = shutil.which("magick")
if magick:
with tempfile.TemporaryDirectory(prefix="swads-report-trim-") as temp_dir:
trimmed = Path(temp_dir) / "trimmed.png"
trim_result = subprocess.run(
[magick, str(png_path), "-fuzz", "2%", "-trim", "+repage", "-bordercolor", "#e8ebe6", "-border", "24", str(trimmed)],
capture_output=True,
text=True,
timeout=90,
)
if trim_result.returncode == 0 and trimmed.exists():
shutil.copy2(trimmed, png_path)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--data", required=True, type=Path, help="Normalized report JSON")
parser.add_argument("--html", required=True, type=Path, help="Destination HTML")
parser.add_argument("--png", type=Path, help="Optional destination PNG")
parser.add_argument("--chrome", help="Explicit Chrome/Chromium executable")
parser.add_argument("--image-width", type=int, default=1440)
parser.add_argument("--image-height", type=int, default=5200)
return parser.parse_args()
def main() -> int:
args = parse_args()
data = json.loads(args.data.read_text(encoding="utf-8"))
if not isinstance(data, dict) or not isinstance(data.get("meta"), dict) or not isinstance(data.get("summary"), dict):
raise SystemExit("report JSON must contain object fields: meta and summary")
recommendations = data.get("recommendations") or []
if len(recommendations) != 3:
raise SystemExit("report JSON must contain exactly three recommendations")
css = CSS_PATH.read_text(encoding="utf-8")
rendered = render_html(data, css)
args.html.parent.mkdir(parents=True, exist_ok=True)
args.html.write_text(rendered, encoding="utf-8")
output: dict[str, str] = {"html": str(args.html.resolve())}
if args.png:
try:
render_png(args.html, args.png, chrome=args.chrome, width=args.image_width, height=args.image_height)
output["png"] = str(args.png.resolve())
except RuntimeError as error:
print(str(error), file=sys.stderr)
print(json.dumps(output, ensure_ascii=False))
return 2
print(json.dumps(output, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())