/~learn/s/19694d0f
> 目标:回答「今天这个网站一共跑了多少流量?都是谁跑的?」——尤其在用手机热点时, > 能看清各模块(数据抓取 / 游戏交易 / 隧道)每天各自消耗多少。
https://quant.aohan.net/system(「网络流量监控」卡片)GET /api/system/trafficadata/aohan/quant/utils/traffic_monitor.pyadata/aohan/quant/db/system_status.py(traffic_history 表)adata/aohan/quant/utils/system_monitor.py(_scheduler_loop 每 60s)---
macOS 没有现成的「按进程/按模块按天」流量账本,因此一期采用采样 + 差分的方式自建总账:
每 60s:
nettop -P -x -l 1 -J bytes_in,bytes_out # 每个进程的「累计」收发字节
netstat -ib # 每张网卡的「累计」收发字节
│
▼
与上一次采样做差分 → 得到这 60s 的增量
│
▼
PID → 模块 归因(PID 文件 + 进程树父链)
│
▼
写入 traffic_history(SQLite),按 module 聚合
nettop 只给进程启动至今的累计值,不给区间增量。所以必须自己记住上一次的累计值,
两次相减才是这一分钟的真实流量:
流量是挂在 PID 上的,而用户关心的是模块(抓取 / 游戏交易 / 隧道)。归因两条链路:
.xxx.pid,traffic_monitor.PID_FILE_MAP 把文件名映射到模块 key:
| PID 文件 | 模块 key | 展示名 |
| --------- | --------- | -------- |
.run_fetch.pid | fetch | 🔄 ETF数据拉取 |
.run_stock_fetchd.pid | stock_fetchd | 📊 股票选股 |
.run_pushd.pid | pushd | 📨 推送调度 |
.run_market_observe.pid | market_observe | 📈 行情观察 |
.web_server.pid | web_daemon | 🌐 Web服务+游戏交易 |
.web_tunnel.pid | tunnel | 🔒 Cloudflare隧道 |
.tushare_probe.pid | tushare_probe | 🔍 Tushare探测 |
unified_server.py 拉起的子进程、
Playwright 的浏览器进程)本身没有 PID 文件。通过 ps -axo pid,ppid 构建父子关系,
沿父链向上找到最近的「已知 PID」,把子进程流量归到同一模块(带环检测,防死循环)。
两者都匹配不上的 → 归入 other(🖥️ 系统/其他进程),属正常兜底。
traffic_history(ts, module, pid, in_bytes, out_bytes),INSERT OR REPLACE 幂等。DELETE WHERE ts < now - 30d。date(ts,'unixepoch','localtime')。---
{
"ok": true,
"traffic": {
"collected_at": 1789313411.7,
"interval_s": 86.5, // 距上次采样间隔(正常≈60,重启后偏大)
"iface": "en0",
"realtime": { "in_bps": 745408.2, "out_bps": 61559.9 },
"today": {
"total_bytes": 339540573,
"modules": [ { "module": "web_daemon", "display_name": "🌐 Web服务+游戏交易",
"in_bytes": 59142024, "out_bytes": 1256580, "total_bytes": 60398604 }, ... ]
},
"daily": [ { "day": "2026-09-13", "total_bytes": 339540573 } ],
"modules_window": [ ... ], // 最近一个采样窗口内各模块增量
"top_processes": [ { "pid": 66645, "module": "web_daemon", ... } ],
"iface_total": { "total_bytes": 229301208376 }, // 网卡自开机累计
"level": "normal"
}
}
失败时返回 {"ok": false, "error": "..."} 或 500,前端会显示原因,不静默。
---
/system 页面新增卡片 #sysTraffic(unified_server.py 内联 HTML/JS):
今日 ↓310.7MB / ↑13.1MB · 实时 ↓727.9KB/s ↑60.1KB/s#trafficBadge:今日合计容量串(如 323.8MB)#trafficBody:实时上下行、今日合计、网卡累计、今日流量构成(按模块,带占比条形)、按日趋势(有历史后出现)、最近窗口 Top 进程。
setInterval(loadTraffic, 30000);页面初始化与 loadStatus() 内各触发一次。
> ⚠️ 内联模板经 Python .format() 渲染,JS/CSS 里的花括号必须写成 {{ }}。
---
traffic_monitor 用 .web_server.pid 归因 web_daemon 模块,但 restart.sh 启动
unified_server.py 时没有写这个 PID 文件(只有 run_web_daemon.sh 写)。
结果:快速重启后 PID 文件是陈旧值,正在跑的服务器永远归不到 web_daemon,
全被算进 other。
→ 修复:restart.sh 启动成功后 echo "$PID" > "$BASE_DIR/.web_server.pid";
并加回归测试 TestPidFileContract 锁死「PID_FILE_MAP 指向 .web_server.pid」+
「restart.sh 必须写 .web_server.pid」两条契约。
nettop 进程名可能带空格
不能按空格简单切分,需 line.rsplit(None, 2),再用正则 \.(\d+)$ 取末尾 PID。
netstat -ib 列会错位
en0 有 MAC 地址列,lo0 没有 → 必须定位 <Link#> 列后再按是否有 MAC 条件跳过,
否则字段全部左移。
lo0 上的本机回环流量不代表真实外网消耗,统计时过滤,只认物理网卡(en0 等)。
_scheduler_loop 内采样失败只 print 日志、不阻塞主循环;但写库路径不得 except: pass。
---
# 单测(含解析/归因/差分/落库/接线/PID 契约)
.venv/bin/python -m unittest tests.adata_test.utils.test_traffic_monitor -v
# 接口
curl -s http://127.0.0.1:8765/api/system/traffic | head -c 1200
# 前端 DOM 实测(接口 200 ≠ 渲染正确)
# 在 /system 页面控制台执行,断言 card===1、bodyChildren>0、subtitle 含「今日」、badge 非 "--"
---
unified_server.py 进程树,一期归为
web_daemon,无法拆出「游戏交易占多少」。
fetch 是整个 ETF 抓取守护进程的账,而非「某一次抓取」。
要回答「19:03 全量抓取 40 页 = 12.4MB」,需在应用层直接累计响应体大小:
adata/common/utils/sunrequests.py:SunRequests 响应读取处累计 len(resp.content)。urllib 调用点:包一层 counted_urlopen。response 事件累计 response.body() 大小(content-length 可能缺失)。unified_server:wfile.write 处累加 len(body)。
汇总写入同一张 traffic_history,module 细化到 web:game_trade / fetch:etf_full,
从而把一期合并的 web_daemon 拆开。