feat: minis launch, cold-start watch and delivery watch skills
Three TikTok Minis skills for Codex and Claude Code, with the cold-start playbook and five benchmark accounts through 88776-1 (2026-09-30). install.sh links them into ~/.codex/skills and ~/.claude/skills. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: swads-minis-launch-watch
|
||||
description: 新户 Minis 冷启动开投后的盯盘:每 10 分钟一轮看全户、计划、主力广告,按四个真实户总结出的判断点提示该关、该等、该加还是该关户;同时记录生命周期(开投、起量、变差、干预、停投),停投或被罚时对照基准户写小结并沉淀经验。只经 swads MCP 网关读数,写平台一律等运营点名。运营开投后手动 /swads-minis-launch-watch 触发。
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# 新户冷启动盯盘
|
||||
|
||||
盯的是**刚开投的 Minis 户**:从开投、起量、判断好坏、放量、收尾到关户,一整天。
|
||||
判断点全部来自 `references/playbook.md`(四个真实户:一个赚钱、一个开局就亏、两个被罚),
|
||||
基准数据在 `references/benchmarks.json`。通用的 4 条规则盯盘是 `swads-delivery-watch`,那一份和
|
||||
服务端自动提案保持一致;这一份专门管冷启动,阈值不同,不要混用。
|
||||
|
||||
## 开始前问运营三件事
|
||||
|
||||
1. **账户 id**(`external_account_id`)。说不清就用 `intake_list_accounts` 列出来让他选。
|
||||
2. **实际开投时间**(账户时区,精确到分钟)。已经开了就问几点开的;还没开就开完再来。
|
||||
阶段划分全靠这个时间,用计划的创建时间或原定时间都会算错。
|
||||
3. **工作目录**,默认 `~/minis-watch/<户名或账户尾号>`。同一个户每一轮都用同一个目录,生命周期记录在里面。
|
||||
|
||||
顺带确认:这个户用的素材有没有碰 `playbook.md` 的素材红线,尤其是被罚户用过的素材。碰了就先说清风险,
|
||||
这比盯盘重要。
|
||||
|
||||
## 每一轮
|
||||
|
||||
```
|
||||
python3 <skill 目录>/scripts/watch.py --account <id> --start "YYYY-MM-DD HH:MM" --dir <工作目录>
|
||||
```
|
||||
|
||||
- 用 `/loop 10m` 或 `CronCreate`(`*/10 * * * *`)每 10 分钟跑一轮。它们只在会话开着、空闲时触发:
|
||||
会话关了就不跑,忙的时候会迟到,漏掉的轮次要在汇报里说明。
|
||||
- 脚本自己连 swads 网关:读 `SWADS_MCP_URL` / `SWADS_MCP_TOKEN`,没有就读 `~/.claude.json` 里的 `swads`
|
||||
服务器配置。运营没配过 swads MCP 就先帮他配,token 不要写进任何文件。
|
||||
- 输出第一部分是预警(可能为空),最后一行是状态行:阶段、今天合计、最近 30 分钟、消耗前三的计划、
|
||||
数据截至几点,以及和基准户同阶段的累计 ROAS。
|
||||
|
||||
### 怎么转述
|
||||
|
||||
- 无预警:一句「一轮无异常,数据截至 HH:MM」+ 今天合计。起量后第一次出现广告行时,核对一下广告名
|
||||
是不是「剧名 Ad N(图文/视频)」;全是数字 id 说明对不上,查 `watch.py` 的 `admap`。
|
||||
- 有预警:逐条列,关停建议附广告 id 和依据(引 playbook 里的那一条、那个案例)。
|
||||
- 状态行里有「按天数据落后」:以 TikTok 小时数为准,不要按旧数下结论,更不要报停量。
|
||||
- `‼ 建议暂停关户` 开头的:放在最前面,说清是哪条健康度规则、现在亏多少,问运营关不关。
|
||||
- 运营问「怎么办 / 哪些该留」:按最近 30 分钟每条广告的 ROAS 分成「留」(≥ 1)和「关」(< 0.8 且消耗 ≥ 10),
|
||||
并按当前 ROAS 算出回到 0.9、回本各需要再花多少钱、大概几点,超过 18 点的直接说不现实。
|
||||
|
||||
## 写平台:只在运营点名后
|
||||
|
||||
| 运营说 | 调用 | 之后 |
|
||||
|---|---|---|
|
||||
| 关这条广告 | `campaign_set_ad_delivery(campaign_id, external_ad_id, status="stopped", external_account_id)` | 看返回 `remote_status` |
|
||||
| 关这个计划 / 关户 | `campaign_set_delivery(campaign_id, status="stopped", external_account_id)`,关户就逐个关所有在投计划 | `account_sync` 后读回计划状态 |
|
||||
| 加预算到 N | `campaign_set_budget(campaign_id, daily_budget=N, external_account_id)` | 看 `changed`、`previous_daily_budget` |
|
||||
|
||||
- 加预算每次都单独确认金额;17 点后要加,先把 playbook 里「18 点后加预算没赚到」告诉运营。
|
||||
- 批量关(「亏的都关」)先列出清单(广告名、id、消耗、ROAS)让运营点「好」。
|
||||
- 每次写平台后,在工作目录的 `events.jsonl` 追加一行(`kind` 用 关广告 / 关计划 / 加预算 / 停投 / 方案),
|
||||
写清是谁确认的、当时的数。运营定下的止损或收尾方案也记一行 `方案`。
|
||||
- 工具报错原文转告,不重试绕过,不去读服务源码。
|
||||
|
||||
## 停投或被罚时:小结
|
||||
|
||||
触发:运营说关户、计划全部变停用、或出现 `ADVERTISER_ACCOUNT_PUNISH`。
|
||||
|
||||
1. 停掉这个户的定时盯盘。
|
||||
2. 跑 `scripts/summary.py --account <id> --date <开投日> --dir <工作目录>`(隔天写就加 `--window yesterday`)。
|
||||
3. 按 `references/summary-template.md` 写小结给运营:结果、时间线、钱、广告、同生命周期对比、经验。
|
||||
4. 把这个户追加进 `references/benchmarks.json`,在运营的记忆里存一条这个户的生命周期;
|
||||
阈值被推翻的改 `playbook.md` 和 `watch.py`。基准户越多,判断越准。
|
||||
|
||||
## 边界
|
||||
|
||||
- 只读数和给建议;平台写操作只做运营点名的那几个。
|
||||
- 不改预算、出价、国家去「优化」;运营没说的都不动。
|
||||
- 不帮忙规避平台审核或处罚:不改图「去重」,不换户继续跑被罚素材。
|
||||
@@ -0,0 +1,187 @@
|
||||
{
|
||||
"note": "每小时消耗与 D0 回收(USD),账户报表时区。新户关户后把它追加进来:outcome 写结果,lesson 写一句教训。",
|
||||
"accounts": [
|
||||
{
|
||||
"short": "88775-5",
|
||||
"date": "2026-09-27",
|
||||
"kind": "good",
|
||||
"enabled": "10:55",
|
||||
"outcome": "消耗 1676.64 / 回收 2095.31 / ROAS 1.25,赚 419,未被罚,约 21:00 手动关",
|
||||
"lesson": "起量第一个整点 1.29;每计划 1 条主力 CTR ≥ 23%;前期预警都自己回升;16-17 点最赚,18 点起衰退",
|
||||
"hourly": {
|
||||
"11": [
|
||||
0.68,
|
||||
0.38
|
||||
],
|
||||
"12": [
|
||||
82.44,
|
||||
106.38
|
||||
],
|
||||
"13": [
|
||||
336.33,
|
||||
453.4
|
||||
],
|
||||
"14": [
|
||||
365.39,
|
||||
396.63
|
||||
],
|
||||
"15": [
|
||||
184.6,
|
||||
242.2
|
||||
],
|
||||
"16": [
|
||||
108.13,
|
||||
200.48
|
||||
],
|
||||
"17": [
|
||||
191.26,
|
||||
289.46
|
||||
],
|
||||
"18": [
|
||||
212.82,
|
||||
211.89
|
||||
],
|
||||
"19": [
|
||||
106.92,
|
||||
118.26
|
||||
],
|
||||
"20": [
|
||||
78.03,
|
||||
73.49
|
||||
],
|
||||
"21": [
|
||||
7.05,
|
||||
4.93
|
||||
],
|
||||
"22": [
|
||||
2.08,
|
||||
2.9
|
||||
],
|
||||
"23": [
|
||||
0.91,
|
||||
0.7
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"short": "88794-9",
|
||||
"date": "2026-09-29",
|
||||
"kind": "bad",
|
||||
"enabled": "10:08",
|
||||
"outcome": "消耗 218.97 / 回收 201.70 / ROAS 0.92,亏 17,未被罚,19:00 手动关",
|
||||
"lesson": "起量就 0.86;CTR 全 20%+ 仍亏;按 ROAS 关 5 条后该段 1.12;跨 18 点冲回本反而回吐",
|
||||
"hourly": {
|
||||
"11": [
|
||||
2.43,
|
||||
1.88
|
||||
],
|
||||
"12": [
|
||||
85.49,
|
||||
73.76
|
||||
],
|
||||
"13": [
|
||||
81.31,
|
||||
71.89
|
||||
],
|
||||
"14": [
|
||||
7.15,
|
||||
10.63
|
||||
],
|
||||
"15": [
|
||||
7.89,
|
||||
10.8
|
||||
],
|
||||
"16": [
|
||||
30.43,
|
||||
28.81
|
||||
],
|
||||
"17": [
|
||||
3.55,
|
||||
3.63
|
||||
],
|
||||
"18": [
|
||||
0.84,
|
||||
0.47
|
||||
],
|
||||
"19": [
|
||||
0.01,
|
||||
0.0
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"short": "88775-9",
|
||||
"date": "2026-09-26",
|
||||
"kind": "punished",
|
||||
"enabled": "13:37(重开)",
|
||||
"outcome": "消耗 280.69 / ROAS 1.84,约 15:00 ADVERTISER_ACCOUNT_PUNISH",
|
||||
"lesson": "与剧无关猎奇图 + 性暗示;前一天开投约 4 小时被整体停用未查原因就重开;ROAS 高也被罚",
|
||||
"hourly": {
|
||||
"13": [
|
||||
0.06,
|
||||
1.03
|
||||
],
|
||||
"14": [
|
||||
280.63,
|
||||
522.34
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"short": "88775-7",
|
||||
"date": "2026-09-26",
|
||||
"kind": "punished",
|
||||
"enabled": "约 17:00",
|
||||
"outcome": "消耗 1041.71 / ROAS 0.79,约 21:27 ADVERTISER_ACCOUNT_PUNISH",
|
||||
"lesson": "晚上开投撞衰退时段;20 点一小时花 644 仍在亏;猎奇素材且检测 502 无人检查",
|
||||
"hourly": {
|
||||
"17": [
|
||||
0.06,
|
||||
0.0
|
||||
],
|
||||
"18": [
|
||||
85.11,
|
||||
74.36
|
||||
],
|
||||
"19": [
|
||||
204.46,
|
||||
192.82
|
||||
],
|
||||
"20": [
|
||||
644.45,
|
||||
486.49
|
||||
],
|
||||
"21": [
|
||||
107.63,
|
||||
72.95
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"short": "88776-1",
|
||||
"date": "2026-09-30",
|
||||
"kind": "bad",
|
||||
"enabled": "09:59",
|
||||
"outcome": "停时消耗 38.11 / 回收 24.93 / ROAS 0.65,亏约 13,未被罚,13:13 按运营「亏损不超 20」全停",
|
||||
"lesson": "20 张与剧无关猎奇图;CPM 约 0.1、CTR 20-30% 但回收差,4 部剧全在 0.5-0.75;起量第一个整点 0.64,SilverWing 在本户也亏(前两户 >1.1)",
|
||||
"hourly": {
|
||||
"10": [
|
||||
0.04,
|
||||
0.0
|
||||
],
|
||||
"11": [
|
||||
8.23,
|
||||
4.88
|
||||
],
|
||||
"12": [
|
||||
25.11,
|
||||
16.07
|
||||
],
|
||||
"13": [
|
||||
4.73,
|
||||
3.98
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
# 判断点手册
|
||||
|
||||
来自 2026-09-25 至 09-30 五个 WISH 图文户(`benchmarks.json`)。ROAS 一律是 D0 广告变现 ÷ 消耗,
|
||||
时间一律是账户报表时区。`scripts/watch.py` 里的阈值和这里一一对应,改一边就改另一边。
|
||||
样本只有 5 户,出现反例就改阈值并在 `benchmarks.json` 追加那个户。
|
||||
|
||||
## 六句话
|
||||
|
||||
1. 素材决定会不会被封,ROAS 救不了(88775-9 重开 ROAS 1.86,1.5 小时后被处罚)。
|
||||
2. 起量后第一个整点看好坏:≥ 1.2 好户,< 0.9 差户(88775-5 是 1.29,88794-9 是 0.86);< 0.7 直接提示可以提前止损(88776-1 是 0.64)。
|
||||
3. 关广告看 ROAS,不看 CTR(88794-9 的 20 条广告 CTR 全在 20% 以上,照样亏)。
|
||||
4. 前 3 小时的小样本预警多半自己回来;单条消耗 < 5 美元不下结论。
|
||||
5. 18 点是分界线,两个健康户都是 18 点后每小时 ROAS 掉到 1 附近。
|
||||
6. 不跨 18 点冲回本,不在 18 点后加预算。
|
||||
|
||||
## 阶段
|
||||
|
||||
| 阶段 | 时间 | 做什么 | 不做什么 |
|
||||
|---|---|---|---|
|
||||
| 开投前 | 建计划当天 | 素材过红线;核对账户 id/小程序/身份/剧集页;视频转 1080×1920;推送后先不启用,核对表确认后再开 | 不用被罚户的素材,不改图「去重」 |
|
||||
| 等量期 | 开投后 0–1.5h | 只看过审、曝光、有没有被整体停用;满 2h 仍 0 消耗再同步查审核 | 不关、不加 |
|
||||
| 判断期 | 起量后 1–3h | 看起量后第一个整点 ROAS;找每个计划的主力广告(通常 1 条吃掉 70–90% 消耗) | 小样本不关;后台和系统数对不上时不关 |
|
||||
| 放量期 | 开投 3–7h,约 13–18 点 | 盯主力的 ROAS 和 CTR;好户 17 点前可以加预算;差户进入止损 | 不因为 CTR 高就留住亏损广告 |
|
||||
| 收尾期 | 18 点后或开投满 7–8h | 最近 30 分钟 ROAS < 1 就收 | 不加预算,不冲回本 |
|
||||
|
||||
## 判断点速查
|
||||
|
||||
| 动作 | 条件 | 依据 |
|
||||
|---|---|---|
|
||||
| 立刻关户 | 状态出现 `ADVERTISER_ACCOUNT_PUNISH`,或没人操作但计划全部变停用 | 88775-9、88775-7 被罚前后都是这样 |
|
||||
| 先确认,别误报 | 全户 10–20 分钟不花钱 | 先看 TikTok 小时数(`runtime_hourly_metrics`),再同步看状态;按天数据常晚 30–60 分钟 |
|
||||
| 放弃这个户 | 开投满 4h,累计 ROAS < 0.9,最近 30 分钟也 < 0.9 | 88794-9 全天 0.92;88775-7 全天 0.79 |
|
||||
| 按差户处理 | 起量后连续两个整点 ROAS < 0.9 | 88794-9 12 点 0.86、13 点 0.88 |
|
||||
| 提示提前止损 | 起量后第一个整点 ROAS < 0.7;运营给了亏损上限就按上限提前停,预留停后回补的几美元 | 88776-1 12 点 0.64,13:13 亏 13 停(上限 20) |
|
||||
| 关户止损 | 最近 30 分钟花 ≥ 30 美元,这段 ROAS < 0.8 | 放量阶段越跑越亏 |
|
||||
| 关广告 | 单条消耗 ≥ 10 美元,累计 ROAS < 0.8,最近 30 分钟也 < 0.8 | 88794-9 关掉 5 条后下一段 1.12 |
|
||||
| 关计划 | 峰值 ROAS > 1 后,连续两个 10 分钟窗口回收 < 消耗 | 别把早上赚的吐回去 |
|
||||
| 等一手 | 前 3h 计划 ROAS < 0.8 但单条消耗 < 5 美元 | 88775-5 前期几次预警全部回升 |
|
||||
| 等一手 | 后台 ROAS 与系统数对不上,或按天数据没更新 | D0 回收晚 10–20 分钟补齐(0.48 → 0.93) |
|
||||
| 加预算 | 预算用到 80%,ROAS > 1.3,17 点前 | 18 点后加的 200 美元只跑出约 1.0 |
|
||||
| 收尾 | 18 点后或开投满 8h,最近 30 分钟 ROAS < 1 | 两个健康户都在 18 点起衰退 |
|
||||
|
||||
## 做了什么,ROAS 会回来
|
||||
|
||||
| 操作 | 结果 | 用不用 |
|
||||
|---|---|---|
|
||||
| 按 ROAS 关掉亏损主力 | 88794-9:下一段 ROAS 0.76 → 1.12,亏损 24.4 → 16 | 用,最有效的止损 |
|
||||
| 等回收补齐 | SilverWing Ad 1:0.48 → 10 分钟后 0.93 | 用,小样本和数据延迟时 |
|
||||
| 好户前期预警不关 | 88775-5 的几个计划从 0.5–0.7 回到全天 1.1–1.3 | 用 |
|
||||
| 18 点后加预算 | 88775-5:加完两段 1.16、0.84,基本保本 | 不用 |
|
||||
| 跨 18 点冲回本 | 88794-9:亏 15.99 → 17.27 | 不用 |
|
||||
| 换户继续用被罚户的素材 | 88775-9 第一天整体停用,第二天处罚 | 不用,也不改图规避 |
|
||||
|
||||
## 素材红线
|
||||
|
||||
与剧无关的猎奇/搞笑图;性暗示(臀胯特写、紧身背影、食物摆成性器官);像诈骗的诱饵图(畸形身体特写、
|
||||
价签商品、钱和奖品);明星、名人、政要的脸,包括恶搞表情包;别人社交帖的整屏截图(带点赞图标或水印);
|
||||
烟酒、危险行为、儿童;被罚户用过的素材。系统检测只查性内容和诈骗诱饵图,检测返回 502 等于没检查。
|
||||
优先用剧本身的画面:剧照、剧集截图、剧集视频片段。
|
||||
@@ -0,0 +1,35 @@
|
||||
# 停投小结模板
|
||||
|
||||
停投(运营关户、计划全部停用)或被罚时,先跑 `scripts/summary.py`,再按下面的结构写。每个数字都要能在
|
||||
summary.py 的输出里找到;找不到的不写。写给投放新手看:先结论,再时间线,再原因。
|
||||
|
||||
```
|
||||
# <户名>(<账户 id>)停投小结(<日期>)
|
||||
|
||||
**结果**:消耗 X,D0 回收 Y,ROAS Z,盈亏 ±N。<没被罚 / 几点被处罚>,<谁、几点关的>。
|
||||
|
||||
## 时间线
|
||||
| 时间 | 发生了什么 |
|
||||
开投 → 首次曝光 → 起量(第一个整点 ROAS)→ 预警与处理 → 最赚/最亏的时点 → 衰退 → 停投
|
||||
|
||||
## 钱
|
||||
- 亏得最多 / 赚得最多是什么时候,多少
|
||||
- 每次干预(关广告、加预算、关计划)前后那一段的 ROAS,各救回或多亏了多少
|
||||
|
||||
## 广告
|
||||
消耗前 5 到 7 条的消耗、ROAS、CTR;哪几条 ROAS ≥ 1;哪个计划没跑起来
|
||||
|
||||
## 同生命周期对比
|
||||
summary.py 的「同生命周期对比」表,挑 3–4 行,并说明本户像哪个基准户(好户 / 差户 / 被罚户)
|
||||
|
||||
## 经验
|
||||
3–5 条,每条带数字;和 references/playbook.md 冲突的要明说(阈值该不该改)
|
||||
```
|
||||
|
||||
写完之后:
|
||||
|
||||
1. 在 `references/benchmarks.json` 的 `accounts` 里追加这个户:`hourly` 用 summary.py 的每小时数,
|
||||
`kind` 取 good / bad / punished,`outcome` 一句结果,`lesson` 一句教训。
|
||||
2. 在运营的 Claude Code 记忆里存一条这个户的生命周期(结果、起量时间、第一个整点 ROAS、转折点、教训),
|
||||
并链接已有的同类户。
|
||||
3. 阈值被这个户推翻的,改 `references/playbook.md` 和 `scripts/watch.py` 里对应的那一条。
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Gather everything a lifecycle summary needs after an account is stopped or punished.
|
||||
|
||||
python3 summary.py --account <external_account_id> --date 2026-09-29 --dir ~/minis-watch/88794-9 [--window today|yesterday]
|
||||
|
||||
Prints: hourly curve (direct TikTok), per-ad totals, the milestone events, the loss/peak points from
|
||||
the timeline, and the same-lifecycle comparison against references/benchmarks.json. The narrative is
|
||||
written by the agent from this output using references/summary-template.md.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import swads_mcp as mcp # noqa: E402
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--account", required=True)
|
||||
ap.add_argument("--date", required=True)
|
||||
ap.add_argument("--dir", required=True)
|
||||
ap.add_argument("--window", default="today", help="ad breakdown window: today or yesterday (the day being summarised)")
|
||||
a = ap.parse_args()
|
||||
D = os.path.expanduser(a.dir)
|
||||
|
||||
h = mcp.call("runtime_hourly_metrics", {"external_account_id": a.account, "date": a.date})
|
||||
hours = {}
|
||||
for r in h.get("rows") or []:
|
||||
x = hours.setdefault(int(r["hour"]), [0.0, 0.0])
|
||||
x[0] += float(r.get("spend") or 0); x[1] += float(r.get("ad_revenue_d0") or 0)
|
||||
print(f"== 每小时(直读 TikTok,{h.get('reporting_timezone')},{h.get('date')})")
|
||||
cs = cr = 0.0
|
||||
for k in sorted(hours):
|
||||
s, r = hours[k]
|
||||
if s <= 0:
|
||||
continue
|
||||
cs += s; cr += r
|
||||
print(f"{k:02d} 点 消耗 {s:8.2f} ROAS {r / s:5.2f} 累计 {cs:8.2f}/{cr:8.2f} = {cr / cs:.2f}")
|
||||
print(f"合计 消耗 {cs:.2f} 回收 {cr:.2f} ROAS {(cr / cs if cs else 0):.2f} 盈亏 {cr - cs:+.2f}")
|
||||
|
||||
q = mcp.call("metrics_semantic_query", {"external_account_id": a.account, "query": {
|
||||
"query_kind": "ad_breakdown", "metrics": ["spend", "impressions", "clicks", "d0_ad_revenue"],
|
||||
"dimensions": ["external_campaign_name", "external_ad_name", "external_ad_id"],
|
||||
"window": a.window, "exclude_zero_spend": True, "order_by_metric": "spend"}})
|
||||
cols = [c["name"] for c in q.get("columns") or []]
|
||||
print("\n== 每条广告(按消耗)")
|
||||
for r in q.get("rows") or []:
|
||||
d = dict(zip(cols, r)); s = d["spend"] or 0; rv = d["d0_ad_revenue"] or 0
|
||||
ctr = d["clicks"] / d["impressions"] * 100 if d.get("impressions") else 0
|
||||
print(f"{d['external_campaign_name']} | {' '.join(d['external_ad_name'].split(' ')[-2:])} | {d['external_ad_id']} | 消耗 {s:.2f} | ROAS {(rv / s if s else 0):.2f} | CTR {ctr:.1f}%")
|
||||
|
||||
ev = os.path.join(D, "events.jsonl")
|
||||
if os.path.exists(ev):
|
||||
print("\n== 生命周期事件")
|
||||
for line in open(ev):
|
||||
x = json.loads(line)
|
||||
if x["kind"] not in ("广告审核",):
|
||||
print(x["t"], x["kind"], x["text"][:160])
|
||||
|
||||
tl = os.path.join(D, "timeline.jsonl")
|
||||
if os.path.exists(tl):
|
||||
snaps = [json.loads(l) for l in open(tl) if l.strip() and l.startswith('{"t": "' + a.date)]
|
||||
snaps = [s for s in snaps if s.get("spend")]
|
||||
if snaps:
|
||||
worst = max(snaps, key=lambda s: s["spend"] - s["rev"])
|
||||
best = max(snaps, key=lambda s: s["rev"] / s["spend"] if s["spend"] >= 20 else 0)
|
||||
print(f"\n== 关键点\n亏得最多:{worst['t']}(数据截至 {worst['fresh']})消耗 {worst['spend']} 回收 {worst['rev']} 亏 {worst['spend'] - worst['rev']:.2f}")
|
||||
print(f"累计 ROAS 最高:{best['t']} {best['rev'] / best['spend']:.2f}")
|
||||
|
||||
bench = json.load(open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "references", "benchmarks.json")))
|
||||
mine = sorted((k, v) for k, v in hours.items() if v[0] > 0)
|
||||
ramp = [k for k, v in mine if v[0] >= 20]
|
||||
print("\n== 同生命周期对比(以各户第一个消耗 ≥ 20 的整点为起量第 1 小时,列累计 ROAS)")
|
||||
rows = [("本户", ramp[0] if ramp else None, dict(mine))]
|
||||
for b in bench["accounts"]:
|
||||
hs = {int(k): v for k, v in b["hourly"].items()}
|
||||
rh = [k for k in sorted(hs) if hs[k][0] >= 20]
|
||||
rows.append((f"{b['short']}({b['kind']})", rh[0] if rh else None, hs))
|
||||
print("起量第 N 小时 | " + " | ".join(r[0] for r in rows))
|
||||
for n in range(0, 10):
|
||||
cells = []
|
||||
for _, r0, hs in rows:
|
||||
if r0 is None:
|
||||
cells.append("-"); continue
|
||||
upto = [v for k, v in hs.items() if k <= r0 + n]
|
||||
s = sum(v[0] for v in upto); r = sum(v[1] for v in upto)
|
||||
cells.append(f"{r / s:.2f}" if s and (r0 + n) in hs else "-")
|
||||
if any(c != "-" for c in cells):
|
||||
print(f"第 {n + 1} 小时 | " + " | ".join(cells))
|
||||
for b in bench["accounts"]:
|
||||
print(f"- {b['short']} {b['date']}:{b['outcome']}。{b['lesson']}")
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Minimal Streamable-HTTP client for the swads MCP gateway, so one watch round is one script run.
|
||||
|
||||
Credentials are never stored here. Resolution order:
|
||||
1. SWADS_MCP_URL + SWADS_MCP_TOKEN environment variables;
|
||||
2. the `swads` entry under `mcpServers` in ~/.claude.json (project-scoped first, then global),
|
||||
i.e. the same server the operator's Claude Code session already uses;
|
||||
3. the `swads` entry under `mcp_servers` in ~/.codex/config.toml (Codex users).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def _load_config():
|
||||
url, token = os.environ.get("SWADS_MCP_URL"), os.environ.get("SWADS_MCP_TOKEN")
|
||||
if url and token:
|
||||
return url, {"Authorization": f"Bearer {token}"}
|
||||
path = os.path.expanduser("~/.claude.json")
|
||||
try:
|
||||
cfg = json.load(open(path))
|
||||
except (OSError, ValueError):
|
||||
cfg = {}
|
||||
candidates = []
|
||||
cwd = os.environ.get("SWADS_PROJECT_DIR") or os.getcwd()
|
||||
projects = cfg.get("projects") or {}
|
||||
for proj in [cwd] + [p for p in projects if p != cwd]:
|
||||
server = ((projects.get(proj) or {}).get("mcpServers") or {}).get("swads")
|
||||
if server:
|
||||
candidates.append(server)
|
||||
if (cfg.get("mcpServers") or {}).get("swads"):
|
||||
candidates.append(cfg["mcpServers"]["swads"])
|
||||
for server in candidates:
|
||||
if server.get("url", "").startswith("http") and "127.0.0.1" not in server["url"]:
|
||||
return server["url"], dict(server.get("headers") or {})
|
||||
for server in candidates:
|
||||
if server.get("url"):
|
||||
return server["url"], dict(server.get("headers") or {})
|
||||
try:
|
||||
import tomllib
|
||||
server = (tomllib.load(open(os.path.expanduser("~/.codex/config.toml"), "rb"))
|
||||
.get("mcp_servers") or {}).get("swads") or {}
|
||||
except (OSError, ValueError, ImportError):
|
||||
server = {}
|
||||
if server.get("url"):
|
||||
return server["url"], dict(server.get("http_headers") or {})
|
||||
raise SystemExit("找不到 swads MCP 配置:设置 SWADS_MCP_URL / SWADS_MCP_TOKEN,或先在 Claude Code / Codex 里配置 swads MCP 服务器")
|
||||
|
||||
|
||||
URL, HEADERS = _load_config()
|
||||
_sid = None
|
||||
_n = 0
|
||||
|
||||
|
||||
def _post_once(body):
|
||||
global _sid
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream", **HEADERS}
|
||||
if _sid:
|
||||
headers["Mcp-Session-Id"] = _sid
|
||||
req = urllib.request.Request(URL, data=json.dumps(body).encode(), headers=headers, method="POST")
|
||||
with urllib.request.urlopen(req, timeout=300) as resp:
|
||||
if resp.headers.get("Mcp-Session-Id"):
|
||||
_sid = resp.headers["Mcp-Session-Id"]
|
||||
raw = resp.read().decode()
|
||||
ctype = resp.headers.get("Content-Type", "")
|
||||
if not raw.strip():
|
||||
return None
|
||||
if "event-stream" in ctype:
|
||||
msgs = [json.loads(line[5:]) for line in raw.splitlines() if line.startswith("data:")]
|
||||
return msgs[-1] if msgs else None
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def _post(body, tries=4):
|
||||
"""Retry transport failures (SSL EOF, 5xx, dropped connections); never retry a 4xx."""
|
||||
for i in range(tries):
|
||||
try:
|
||||
return _post_once(body)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code < 500 or i == tries - 1:
|
||||
raise
|
||||
except Exception:
|
||||
if i == tries - 1:
|
||||
raise
|
||||
time.sleep(5 * (i + 1))
|
||||
|
||||
|
||||
def init():
|
||||
global _sid
|
||||
_sid = None
|
||||
_post({"jsonrpc": "2.0", "id": 0, "method": "initialize",
|
||||
"params": {"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "minis-launch-watch", "version": "1"}}})
|
||||
_post({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
||||
|
||||
|
||||
def call(name, args):
|
||||
"""Call one gateway tool and return its structured result; raise on a tool error."""
|
||||
global _n
|
||||
_n += 1
|
||||
r = _post({"jsonrpc": "2.0", "id": _n, "method": "tools/call", "params": {"name": name, "arguments": args}})
|
||||
if "error" in r:
|
||||
raise RuntimeError(json.dumps(r["error"], ensure_ascii=False))
|
||||
res = r["result"]
|
||||
sc = res.get("structuredContent")
|
||||
if sc is not None:
|
||||
out = sc.get("result", sc) if isinstance(sc, dict) and list(sc) == ["result"] else sc
|
||||
else:
|
||||
text = "".join(c.get("text", "") for c in res.get("content", []))
|
||||
try:
|
||||
out = json.loads(text)
|
||||
except ValueError:
|
||||
out = text
|
||||
if res.get("isError"):
|
||||
raise RuntimeError(str(out)[:2000])
|
||||
if isinstance(out, dict) and out.get("error_code") and out.get("upstream_status"):
|
||||
raise RuntimeError(json.dumps(out, ensure_ascii=False)[:2000])
|
||||
return out
|
||||
|
||||
|
||||
init()
|
||||
@@ -0,0 +1,353 @@
|
||||
"""One watch round for a freshly launched Minis account. Prints the alerts, then one status line.
|
||||
|
||||
python3 watch.py --account <external_account_id> --start "2026-09-29 10:08" --dir ~/minis-watch/88794-9
|
||||
|
||||
Everything it concludes is a suggestion; it never writes to the platform. State, the per-round
|
||||
timeline and the milestone events live in --dir, so the same directory must be passed every round.
|
||||
Thresholds come from references/playbook.md; keep the two in step.
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from collections import Counter
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import swads_mcp as mcp # noqa: E402
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--account", required=True)
|
||||
ap.add_argument("--start", required=True, help="actual enable time, account timezone, 'YYYY-MM-DD HH:MM'")
|
||||
ap.add_argument("--dir", required=True)
|
||||
ap.add_argument("--prefix", default="SW-ADS-")
|
||||
ap.add_argument("--sync-every", type=int, default=3, help="sync platform status every N rounds")
|
||||
a = ap.parse_args()
|
||||
|
||||
A = a.account
|
||||
D = os.path.expanduser(a.dir)
|
||||
os.makedirs(D, exist_ok=True)
|
||||
SF, TL, EV = f"{D}/state.json", f"{D}/timeline.jsonl", f"{D}/events.jsonl"
|
||||
st = json.load(open(SF)) if os.path.exists(SF) else {}
|
||||
rep = st.setdefault("reported", {})
|
||||
lc = st.setdefault("lc", {})
|
||||
|
||||
|
||||
def f(x):
|
||||
try:
|
||||
return float(x)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def short(name):
|
||||
s = re.sub(r"^" + re.escape(a.prefix) + r"[^-]+-", "", name)
|
||||
return re.sub(r"(-图文)?-\d{8}$", "", s)
|
||||
|
||||
|
||||
def rows(res):
|
||||
cols = [c if isinstance(c, str) else c.get("name") for c in res.get("columns") or []]
|
||||
return [r if isinstance(r, dict) else dict(zip(cols, r)) for r in res.get("rows") or []]
|
||||
|
||||
|
||||
# ---- clock: the account's reporting timezone, not the machine's ----
|
||||
ctx = mcp.call("campaign_list", {"external_account_id": A, "page_size": 100})
|
||||
acct = ctx.get("account_context") or {}
|
||||
TZ = datetime.timezone(datetime.timedelta(minutes=int(acct.get("reporting_utc_offset_minutes", 480))))
|
||||
now = datetime.datetime.now(TZ)
|
||||
START = datetime.datetime.strptime(a.start, "%Y-%m-%d %H:%M").replace(tzinfo=TZ)
|
||||
hours_in = (now - START).total_seconds() / 3600
|
||||
today = now.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def event(kind, text, t=None):
|
||||
open(EV, "a").write(json.dumps({"t": t or now.strftime("%Y-%m-%d %H:%M"), "kind": kind, "text": text}, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
if not lc.get("started"):
|
||||
lc["started"] = True
|
||||
event("开投", f"账户 {A} 开投", t=START.strftime("%Y-%m-%d %H:%M"))
|
||||
|
||||
camps = {c["campaign_name"]: c for c in ctx["items"]
|
||||
if c["campaign_name"].startswith(a.prefix) and c.get("operation_status") == "ENABLE"}
|
||||
|
||||
# ---- ad labels: 剧名 Ad N(图文/视频), keyed by every remote id the workspace knows ----
|
||||
admap, workspaces = {}, {}
|
||||
for name, c in camps.items():
|
||||
w = mcp.call("campaign_workspace", {"campaign_id": c["campaign_id"], "external_account_id": A})
|
||||
workspaces[name] = w
|
||||
for ad in w["groups"][0]["ads"]:
|
||||
kind = "视频" if any(v.get("variant_kind") == "video" for v in ad["creative_variants"]) else "图文"
|
||||
label = f"{short(name)} {' '.join(ad['ad_name'].split(' ')[-2:])}({kind})"
|
||||
p = ad.get("payload") or {}
|
||||
for rid in (ad.get("remote_id"), p.get("smart_plus_ad_id"), p.get("external_ad_id"), p.get("ad_id")):
|
||||
if rid:
|
||||
admap[str(rid)] = (label, name)
|
||||
|
||||
# ---- today's per-ad numbers (synced; can lag 10-60 min) ----
|
||||
q = mcp.call("metrics_semantic_query", {"external_account_id": A, "query": {
|
||||
"query_kind": "ad_breakdown", "metrics": ["spend", "impressions", "clicks", "d0_ad_revenue"],
|
||||
"dimensions": ["external_campaign_name", "external_ad_name", "external_ad_id"],
|
||||
"window": "today", "exclude_zero_spend": True, "order_by_metric": "spend"}})
|
||||
fr = [x.get("freshness") for x in q["query_meta"].get("attribution_resolutions", []) if x.get("freshness")]
|
||||
fresh_iso = fr[0] if fr else None
|
||||
fresh = datetime.datetime.fromisoformat(fresh_iso.replace("Z", "+00:00")).astimezone(TZ).strftime("%H:%M") if fresh_iso else "?"
|
||||
ads, agg = [], {}
|
||||
for r in rows(q):
|
||||
name = r.get("external_campaign_name")
|
||||
if name not in camps:
|
||||
continue
|
||||
sp, rv = f(r.get("spend")), f(r.get("d0_ad_revenue"))
|
||||
im, cl = f(r.get("impressions")), f(r.get("clicks"))
|
||||
ad = str(r.get("external_ad_id"))
|
||||
label = admap.get(ad, (short(name) + ' ' + ' '.join((r.get('external_ad_name') or ad).split(' ')[-2:]), name))[0]
|
||||
ads.append(dict(camp=name, ad=ad, label=label, sp=sp, rv=rv, ctr=cl / im * 100 if im else 0.0, ro=rv / sp if sp else 0.0))
|
||||
g = agg.setdefault(name, [0.0, 0.0])
|
||||
g[0] += sp
|
||||
g[1] += rv
|
||||
tsp, trv = sum(v[0] for v in agg.values()), sum(v[1] for v in agg.values())
|
||||
cum = trv / tsp if tsp else 0.0
|
||||
|
||||
# ---- direct TikTok hourly numbers: the truth when the synced numbers lag ----
|
||||
hourly = mcp.call("runtime_hourly_metrics", {"external_account_id": A})
|
||||
hsum = {}
|
||||
for r in hourly.get("rows") or []:
|
||||
x = hsum.setdefault(int(r["hour"]), [0.0, 0.0, 0.0])
|
||||
x[0] += f(r.get("spend")); x[1] += f(r.get("ad_revenue_d0")); x[2] += f(r.get("impressions"))
|
||||
h_total = sum(v[0] for v in hsum.values())
|
||||
|
||||
alerts, H = [], []
|
||||
|
||||
|
||||
def once(key, sp, ro):
|
||||
"""Report the first time; again only if spend doubled or ROAS fell another 0.1."""
|
||||
p = rep.get(key)
|
||||
if p is None or sp >= 2 * p["sp"] or ro <= p["ro"] - 0.1:
|
||||
rep[key] = {"sp": sp, "ro": ro}
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def line(x):
|
||||
return f"{x['label']} [{x['ad']}] 消耗 {x['sp']:.2f} 回收 {x['rv']:.2f} ROAS {x['ro']:.2f} CTR {x['ctr']:.1f}%"
|
||||
|
||||
|
||||
# ---- 30-minute window from our own snapshots (same day only) ----
|
||||
snaps = []
|
||||
if os.path.exists(TL):
|
||||
snaps = [json.loads(l) for l in open(TL) if l.strip()]
|
||||
snaps = [s for s in snaps if s["t"][:10] == today]
|
||||
older = [s for s in snaps if datetime.datetime.strptime(s["t"], "%Y-%m-%d %H:%M").replace(tzinfo=TZ) <= now - datetime.timedelta(minutes=30)]
|
||||
w30, ad_w30 = None, {}
|
||||
if older:
|
||||
b = older[-1]
|
||||
bs = sum(v[0] for v in b["camps"].values()); br = sum(v[1] for v in b["camps"].values())
|
||||
if tsp - bs > 0.5:
|
||||
w30 = (tsp - bs, trv - br, (trv - br) / (tsp - bs))
|
||||
prev_ads = {x[1]: x for x in b.get("ads", [])}
|
||||
for x in ads:
|
||||
p = prev_ads.get(x["ad"])
|
||||
ds = x["sp"] - (p[2] if p else 0); dr = x["rv"] - (p[3] if p else 0)
|
||||
if ds > 0.5:
|
||||
ad_w30[x["ad"]] = (ds, dr, dr / ds)
|
||||
|
||||
# ---- rule: campaign below 0.8 (look inside, name the losing main ads by ROAS) ----
|
||||
by_camp = {}
|
||||
for x in ads:
|
||||
by_camp.setdefault(x["camp"], []).append(x)
|
||||
for name, (sp, rv) in sorted(agg.items(), key=lambda kv: -kv[1][0]):
|
||||
ro = rv / sp if sp else 0.0
|
||||
cid = camps[name]["external_campaign_id"]
|
||||
if sp >= 1 and ro < 0.8 and once(f"camp08|{cid}", sp, ro):
|
||||
msg = f"计划低效预警 {short(name)}({cid}):今天消耗 {sp:.2f} 回收 {rv:.2f} ROAS {ro:.2f}"
|
||||
small = sp < 5 or hours_in < 3
|
||||
if small:
|
||||
msg += "\n 样本小或仍在前 3 小时:多数会自己回来,先等"
|
||||
for x in sorted(by_camp.get(name, []), key=lambda x: -x["sp"])[:4]:
|
||||
if x["sp"] >= 1:
|
||||
msg += f"\n {line(x)}"
|
||||
alerts.append(msg)
|
||||
|
||||
# ---- rule: losing ad — judged on ROAS, not CTR (88794-9: CTR 20%+ everywhere, still losing) ----
|
||||
for x in ads:
|
||||
w = ad_w30.get(x["ad"])
|
||||
if x["sp"] >= 10 and x["ro"] < 0.8 and (w is None or w[2] < 0.8) and once(f"adloss|{x['ad']}", x["sp"], x["ro"]):
|
||||
wtxt = f",最近 30 分钟 {w[0]:.2f}/{w[1]:.2f} = {w[2]:.2f}" if w else ""
|
||||
alerts.append(f"亏损广告 {line(x)}{wtxt} → 建议关这条")
|
||||
|
||||
# ---- rule: decay — a main ad that had CTR >= 25% falls below ----
|
||||
hi = st.setdefault("ctr_hi", {})
|
||||
for x in ads:
|
||||
if x["sp"] >= 1 and x["ctr"] >= 25:
|
||||
hi[x["ad"]] = True
|
||||
elif hi.get(x["ad"]) and x["sp"] >= 1 and x["ctr"] < 25 and f"decay|{x['ad']}" not in rep:
|
||||
rep[f"decay|{x['ad']}"] = {"sp": x["sp"], "ro": x["ro"]}
|
||||
alerts.append(f"衰退观察 {line(x)}(之前 CTR ≥ 25%)")
|
||||
|
||||
# ---- rule: earned then losing, two 10-minute windows in a row ----
|
||||
peak = st.setdefault("peak", {}); streak = st.setdefault("loss_streak", {})
|
||||
prev = st.get("snap")
|
||||
for name, (sp, rv) in agg.items():
|
||||
if sp >= 1:
|
||||
peak[name] = max(peak.get(name, 0.0), rv / sp)
|
||||
if prev and prev.get("fresh") != fresh_iso and prev.get("day") == today:
|
||||
for name, (sp, rv) in agg.items():
|
||||
psp, prv = prev["camps"].get(name, [0.0, 0.0])
|
||||
ds, dr = sp - psp, rv - prv
|
||||
losing = ds >= 1 and dr < ds
|
||||
streak[name] = streak.get(name, 0) + 1 if losing else 0
|
||||
if losing and peak.get(name, 0) > 1 and streak[name] >= 2 and f"slide|{name}|{now:%H}" not in rep:
|
||||
rep[f"slide|{name}|{now:%H}"] = {"sp": sp, "ro": 0}
|
||||
alerts.append(f"先赚后亏 {short(name)}({camps[name]['external_campaign_id']}):最近 10 分钟 {ds:.2f}/{dr:.2f},连续 {streak[name]} 个窗口亏;今天 {sp:.2f} ROAS {rv / sp:.2f},峰值 {peak[name]:.2f} → 建议关这个计划")
|
||||
st["snap"] = {"fresh": fresh_iso, "day": today, "camps": {n: v for n, v in agg.items()}}
|
||||
|
||||
# ---- rule: budget 80% / 95% while profitable; say whether adding still makes sense ----
|
||||
for name, (sp, rv) in agg.items():
|
||||
c = camps[name]; bud = f(c.get("budget")); ro = rv / sp if sp else 0.0
|
||||
if ro > 1 and bud:
|
||||
for pct in (95, 80):
|
||||
if sp >= bud * pct / 100:
|
||||
key = f"budget{pct}|{c['external_campaign_id']}|{bud}"
|
||||
if key not in rep:
|
||||
rep[key] = {"sp": sp, "ro": ro}
|
||||
late = now.hour >= 17
|
||||
hint = "已过 17 点,手册建议不加" if late else ("ROAS > 1.3,可以加" if ro > 1.3 else "ROAS 不到 1.3,加不加看你")
|
||||
alerts.append(f"预算快用完 {short(name)}:今天 {sp:.0f}/{bud:.0f}({sp / bud * 100:.0f}%)ROAS {ro:.2f} → {hint}")
|
||||
break
|
||||
|
||||
# ---- judgement: first full hours after the account starts spending ----
|
||||
ramp = [h for h in sorted(hsum) if hsum[h][0] >= 20 and h < now.hour]
|
||||
if ramp and "judge" not in lc:
|
||||
h0 = ramp[0]; r0 = hsum[h0][1] / hsum[h0][0]
|
||||
verdict = "好户" if r0 >= 1.2 else ("观察" if r0 >= 0.9 else "差户信号")
|
||||
lc["judge"] = {"hour": h0, "roas": round(r0, 2), "verdict": verdict}
|
||||
event("判断", f"起量后第一个整点 {h0} 点:消耗 {hsum[h0][0]:.2f} ROAS {r0:.2f} → {verdict}")
|
||||
alerts.append(f"起量判断 {h0} 点这一小时消耗 {hsum[h0][0]:.2f} ROAS {r0:.2f} → {verdict}(好户 ≥ 1.2,差户 < 0.9)")
|
||||
if len(ramp) >= 2 and "judge2" not in lc:
|
||||
r1, r2 = (hsum[h][1] / hsum[h][0] for h in ramp[:2])
|
||||
if r1 < 0.9 and r2 < 0.9:
|
||||
lc["judge2"] = True
|
||||
H.append(f"起量后连续两个整点 ROAS < 0.9({ramp[0]} 点 {r1:.2f}、{ramp[1]} 点 {r2:.2f}),按差户处理:只留最近 30 分钟 ROAS ≥ 1 的广告,其余按亏损关")
|
||||
|
||||
# ---- platform status: every few rounds, and whenever spend looks stalled ----
|
||||
lc["round"] = lc.get("round", 0) + 1
|
||||
stalled_sync = lc.get("last_sp") is not None and tsp - lc["last_sp"] < 0.5 and lc["last_sp"] >= 5 and fresh != lc.get("last_fresh")
|
||||
h_stalled = lc.get("last_h") is not None and h_total - lc["last_h"] < 0.3 and lc["last_h"] >= 5
|
||||
status = {}
|
||||
if lc["round"] % a.sync_every == 1 or stalled_sync or h_stalled:
|
||||
try:
|
||||
sid = mcp.call("account_sync", {"external_account_id": A})["sync_run_id"]
|
||||
for _ in range(40):
|
||||
s_ = mcp.call("account_sync_status", {"sync_run_id": sid})
|
||||
if s_["status"].startswith("completed") or s_["status"] in ("failed", "error"):
|
||||
break
|
||||
time.sleep(15)
|
||||
for name, c in camps.items():
|
||||
w = mcp.call("campaign_workspace", {"campaign_id": c["campaign_id"], "external_account_id": A})
|
||||
g = w["groups"][0]
|
||||
status[short(name)] = {"campaign": w.get("platform_status"), "group": g.get("platform_status"),
|
||||
"ads": dict(Counter(ad.get("platform_status") for ad in g["ads"]))}
|
||||
except Exception as e:
|
||||
status = {"error": str(e)[:200]}
|
||||
old = lc.get("status", {})
|
||||
for n, v in status.items():
|
||||
if n == "error":
|
||||
continue
|
||||
pv = old.get(n, {})
|
||||
if (pv.get("campaign"), pv.get("group")) != (v["campaign"], v["group"]):
|
||||
event("状态", f"{n}:计划 {pv.get('campaign')}→{v['campaign']},广告组 {pv.get('group')}→{v['group']}")
|
||||
if pv.get("ads") != v["ads"]:
|
||||
event("广告审核", f"{n}:广告 {pv.get('ads')}→{v['ads']}")
|
||||
if status and "error" not in status:
|
||||
lc["status"] = status
|
||||
|
||||
# ---- health: when to call the operator to stop the account ----
|
||||
if "PUNISH" in json.dumps(status):
|
||||
H.insert(0, "账户被 TikTok 处罚(ADVERTISER_ACCOUNT_PUNISH),继续开只会更糟")
|
||||
if status and "error" not in status and camps and all(v.get("campaign") != "CAMPAIGN_STATUS_ENABLE" for v in status.values()):
|
||||
H.append("在投计划在平台上全部不是启用状态(不是本工具停的),先去后台看原因")
|
||||
if stalled_sync and h_stalled:
|
||||
H.append(f"全户在投但消耗停住(同步数与 TikTok 小时数都不动,累计 {tsp:.2f})")
|
||||
if hours_in >= 4 and tsp >= 50 and cum < 0.90 and w30 and w30[2] < 0.90:
|
||||
H.append(f"开投 {hours_in:.1f} 小时累计 ROAS {cum:.2f} < 0.90,最近 30 分钟 {w30[2]:.2f} 也 < 0.90")
|
||||
if w30 and w30[0] >= 30 and w30[2] < 0.80:
|
||||
H.append(f"最近 30 分钟放量 {w30[0]:.2f} USD 但 ROAS 只有 {w30[2]:.2f} < 0.80")
|
||||
if (now.hour >= 18 or hours_in >= 8) and w30 and w30[0] >= 3 and w30[2] < 1.0:
|
||||
H.append(f"进入收尾期(18 点后或开投满 8 小时),最近 30 分钟 ROAS {w30[2]:.2f} < 1")
|
||||
if tsp >= 50 and cum >= 1.0 and lc.get("was_below_1") and "breakeven" not in lc:
|
||||
lc["breakeven"] = now.strftime("%H:%M")
|
||||
H.append(f"累计 ROAS 回到 1 以上({cum:.2f}),可以考虑收尾锁住")
|
||||
if tsp >= 50 and cum < 1.0:
|
||||
lc["was_below_1"] = True
|
||||
if H:
|
||||
key = f"health|{now:%Y-%m-%d %H}|{len(H)}"
|
||||
if key not in rep:
|
||||
rep[key] = {"sp": tsp, "ro": cum}
|
||||
msg = "‼ 建议暂停关户:" + ";".join(H) + f"。今天 {tsp:.2f}/{trv:.2f} ROAS {cum:.2f}。要关就告诉我"
|
||||
alerts.insert(0, msg)
|
||||
event("健康度", msg)
|
||||
|
||||
# ---- milestones ----
|
||||
if tsp > 0 and not lc.get("had_spend"):
|
||||
lc["had_spend"] = True
|
||||
event("起量", f"首次出现消耗:{tsp:.2f} USD ROAS {cum:.2f}(数据截至 {fresh})")
|
||||
cp = lc.setdefault("camp", {})
|
||||
for name, (sp, rv) in agg.items():
|
||||
k = short(name); ro = rv / sp if sp else 0.0; c = cp.setdefault(k, {})
|
||||
if sp > 0 and not c.get("first"):
|
||||
c["first"] = now.strftime("%H:%M"); event("起量", f"{k} 开始花钱:{sp:.2f} ROAS {ro:.2f}")
|
||||
if sp >= 5:
|
||||
if ro > c.get("peak", 0):
|
||||
c["peak"], c["peak_t"] = ro, now.strftime("%H:%M")
|
||||
if ro < 1 and not c.get("below1"):
|
||||
c["below1"] = now.strftime("%H:%M"); event("变差", f"{k} ROAS 跌破 1:{sp:.2f} / {ro:.2f}(峰值 {c.get('peak', 0):.2f} @ {c.get('peak_t')})")
|
||||
if ro >= 1 and c.get("below1") and not c.get("recovered"):
|
||||
c["recovered"] = now.strftime("%H:%M"); event("回升", f"{k} ROAS 回到 1 以上:{ro:.2f}")
|
||||
if stalled_sync and h_stalled and not lc.get("stall_logged"):
|
||||
lc["stall_logged"] = now.strftime("%H:%M"); event("停量", f"全户消耗停住(累计 {tsp:.2f})")
|
||||
elif not (stalled_sync and h_stalled):
|
||||
lc.pop("stall_logged", None)
|
||||
for al in alerts:
|
||||
if not al.startswith("‼"):
|
||||
event("预警", al.split("\n")[0])
|
||||
lc["last_sp"], lc["last_fresh"], lc["last_h"] = tsp, fresh, h_total
|
||||
|
||||
open(TL, "a").write(json.dumps({
|
||||
"t": now.strftime("%Y-%m-%d %H:%M"), "fresh": fresh, "spend": round(tsp, 2), "rev": round(trv, 2),
|
||||
"hourly_direct": {str(h): [round(v[0], 2), round(v[1], 2)] for h, v in sorted(hsum.items())},
|
||||
"camps": {short(n): [round(v[0], 2), round(v[1], 2)] for n, v in agg.items()},
|
||||
"ads": [[x["label"], x["ad"], round(x["sp"], 2), round(x["rv"], 2), round(x["ctr"], 1)] for x in ads],
|
||||
"status": status or None}, ensure_ascii=False) + "\n")
|
||||
json.dump(st, open(SF, "w"), indent=1, ensure_ascii=False)
|
||||
|
||||
# ---- same-lifecycle comparison against the benchmark accounts ----
|
||||
bench_line = ""
|
||||
try:
|
||||
bench = json.load(open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "references", "benchmarks.json")))
|
||||
if ramp:
|
||||
k = now.hour - ramp[0] # hours since this account ramped
|
||||
parts = []
|
||||
for b in bench["accounts"]:
|
||||
hs = sorted((int(h), v) for h, v in b["hourly"].items())
|
||||
rh = [h for h, v in hs if v[0] >= 20]
|
||||
if not rh:
|
||||
continue
|
||||
upto = [v for h, v in hs if h <= rh[0] + k]
|
||||
s_ = sum(v[0] for v in upto); r_ = sum(v[1] for v in upto)
|
||||
if s_:
|
||||
parts.append(f"{b['short']} {r_ / s_:.2f}")
|
||||
mine = sum(v[1] for h, v in hsum.items() if h <= now.hour) / max(h_total, 0.01)
|
||||
bench_line = f";同阶段(起量后第 {k + 1} 小时)累计 ROAS:本户 {mine:.2f} vs " + "、".join(parts)
|
||||
except (OSError, ValueError, KeyError):
|
||||
pass
|
||||
|
||||
print("\n".join(alerts) if alerts else "一轮无异常")
|
||||
top = sorted(agg.items(), key=lambda kv: -kv[1][0])[:3]
|
||||
stage = "等量期" if not lc.get("had_spend") and hours_in < 2 else ("判断期" if hours_in < 3 else ("放量期" if now.hour < 18 and hours_in < 8 else "收尾期"))
|
||||
w30t = f";最近 30 分钟 {w30[0]:.2f}/{w30[1]:.2f} ROAS {w30[2]:.2f}" if w30 else ""
|
||||
lag = f";按天数据落后,TikTok 小时数合计 {h_total:.2f}" if h_total - tsp > 2 else ""
|
||||
print(f"[{stage} {hours_in:.1f}h|{len(camps)} 个在投计划 今天 {tsp:.2f}/{trv:.2f} ROAS {cum:.2f}{w30t};"
|
||||
+ "、".join(f"{short(n)} {v[0]:.2f}/{(v[1] / v[0] if v[0] else 0):.2f}" for n, v in top)
|
||||
+ f";数据截至 {fresh},现在 {now:%H:%M}{lag}{bench_line}]")
|
||||
Reference in New Issue
Block a user