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:
2026-09-30 15:10:54 +08:00
co-authored by Claude Opus 5.5
commit 9f9e70ea2d
18 changed files with 2250 additions and 0 deletions
+75
View File
@@ -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}]")