Jenkins + Python 全自动跑通「禅道任务/BUG 导出 → 月度开发考核表」
一条 Jenkins 流水线,从登录禅道、导出全量 Task 与 Bug,到生成可直接打分的月度开发考核汇总表,全程零手工。
本文把两个可复用的成果拆开讲:成果一:全量导出通道 ,成果二:考核指标统计引擎,最后给出整体设计思路与几个必须踩过的坑。
一、背景:手工统计一个月,错在四个地方
做测试主管这几年,每月初都要交一份「开发人员 BUG 统计 + 评分」。手工版本的流程大概是:登录禅道 → 任务列表逐个项目点导出 → BUG 列表再导一遍 → 复制粘贴到 Excel → 按人手工求和。
问题不在慢,在于口径:
| 问题 | 表现 | 后果 |
|---|---|---|
| 主任务和子任务工时重复计 | 一个父任务下挂 5 个子任务,父任务的"最初预计"本身是子任务的汇总,又被加了一次 | 有人月工时统计到 60+ 人天,明显不可能 |
| 父任务没有 parent 字段 | 禅道导出表里只有「任务名称」用 > 前缀表示层级,没有父任务 ID |
想用代码自动剔除父任务,无从下手 |
| BUG 归属算错人 | 「指派给」和「解决者」不是同一个人 | 别人提的、别人修的 BUG 算到某个人头上 |
| 0 工时的展示兜底值当了分母 | 工时为 0 时为了表格好看写成 0.01 | BUG 率被放大成 200、100 这种离谱数字 |
所以这件事的价值不是"省了半小时",而是把考核口径固化成代码,每月跑同一套规则,不再靠人肉对齐。
二、成果一:一条流水线,全量导出 Task / Bug
2.1 运行视图
Job 路径:Jenkins / 统计_月度开发bug / bug_count / #21,执行结果 Finished: SUCCESS,控制台输出如下:
text
=== 导出 Task (全量) ===
访问全量任务列表页,重置筛选会话
访问导出 iframe 页,初始化导出上下文
POST 完整表单导出
✅ Task导出成功: task_638.xlsx (34.96 KB)
=== 导出 Bug (全量) ===
访问全量 Bug 列表页,重置筛选会话
访问导出 iframe 页,初始化导出上下文
POST 完整表单导出
✅ Bug导出成功: bug_638.xlsx (149.79 KB)
=== 统计分析 ===
Task 行数: 79 | Bug 行数: 8
Task 树解析: 总行数 79,父任务 14 个,叶子任务 65 行
工时口径校验: 整数合计 268.0 → 仅叶子合计 162.5 (已剔除 105.5)
✅ 汇总结果已生成: result_638.xlsx
全部流程执行成功
Archiving artifacts
Finished: SUCCESS
关键点:每一步都打印进度。批处理脚本最怕的不是报错,是"没报错但结果是空的"。所以每个阶段都输出"我在做什么 + 拿到了什么",构建日志本身就是运行证据。
2.2 为什么不是"一个 POST 就导完"
这是第一个大坑。禅道类系统的导出接口,直接 POST 会返回一个 几 KB 的 xlsx 空模板 ,HTTP 200、响应头 Content-Type 正常、文件开头还是标准的 PK 字节(xlsx 就是 zip)。也就是说:看起来完全成功,实际一条数据都没有。
浏览器里能导出成功,是因为它走了三步,服务端靠 Session 里的筛选上下文来决定"导哪些行":
text
① GET 全量列表页 → 把当前筛选条件种进服务端 Session
② GET 导出 iframe 页 → 初始化导出上下文、拿到导出表单的字段注册
③ POST 同一个导出地址 → 带上完整的 exportFields[] 才真正吐数据
脚本必须原样复刻这三步,并且:
- 列表页地址要区分对象:Task 用
project-task-{pid}-all.html,Bug 用project-bug-{pid}.html; exportFields[]整段照抄抓包结果,一个字段都不能删------后端会校验字段列表与页面注册的是否一致,不一致同样静默返回空表;- 登录后还要手动补业务 Cookie(
lastProject、preProjectID设成项目 ID),否则导出接口会被拦。
2.3 登录:两层 md5
python
# 1) 取随机数(必须带 XHR 头,否则返回 HTML)
r = session.get(f"{BASE}/user-refreshRandom.html",
headers={"X-Requested-With": "XMLHttpRequest"})
verify_rand = r.text.strip()
# 2) 密码 = md5(md5(明文) + verifyRand),单层 md5 会被直接拒绝
pwd = hashlib.md5((hashlib.md5(PASSWORD.encode()).hexdigest()
+ verify_rand).encode()).hexdigest()
session.post(f"{BASE}/user-login.html", data={
"account": USER, "password": pwd,
"verifyRand": verify_rand, "keepLogin": 1,
})
2.4 导出三步的代码骨架
python
def export(session, list_url, export_url, fields, out_path):
session.get(list_url) # ① 种筛选上下文
session.get(export_url.replace("export", "export-iframe", 1)) # ② 初始化
resp = session.post(export_url, data=[("exportFields[]", f) for f in fields]) # ③
# 校验:xlsx 必须是 PK 开头;体积过小先怀疑空表
if not resp.content.startswith(b"PK"):
sys.exit("导出失败:响应不是 xlsx")
if len(resp.content) < 8 * 1024:
print("⚠ 文件偏小,可能是空表,请核对行数")
with open(out_path, "wb") as f:
f.write(resp.content)
PK 头能挡掉"返回登录页 HTML"这种情况,但挡不掉空模板------空模板也是合法 xlsx。所以真正的兜底是导出后立刻读一遍行数,把"Success 但 0 行"变成显式失败。
三、成果二:自动统计出考核汇总表
3.1 从 79 行到 65 个叶子任务
Task 导出 79 行,其中父任务 14 个,真正该计工时的叶子任务 65 行。工时的口径校验是这一步的核心:
text
整数合计 268.0 → 仅叶子合计 162.5 (已剔除 105.5)
268 是"整表 最初预计 直接求和",162.5 是"只算叶子任务"。差的 105.5 就是父子任务重复计入的部分。这个差值必须打印出来------只看到"数字变小了"并不能说明扣对了,看到"扣了 105.5、剩余 162.5"才知道扣的是什么。
3.2 最终考核表
result_638.xlsx 归档为 月度开发考核汇总.xlsx,表结构如下(姓名已脱敏):
| 开发人员 | 月开发人天 | 开发时间 | 总BUG数 | 有效负责BUG数 | 非常严重 | 严重 | 一般 | 月BUG率 | 月评分 |
|---|---|---|---|---|---|---|---|---|---|
| 开发 A | 0.01 | 0.0 | 0 | 0 | 0 | 0 | 0 | 0.0 | 10 |
| 开发 B | 1.57 | 11.0 | 1 | 1 | 0 | 0 | 1 | 0.64 | 4 |
| 开发 C | 2.36 | 16.5 | 0 | 0 | 0 | 0 | 0 | 0.0 | 10 |
| 开发 D | 3.79 | 26.5 | 0 | 0 | 0 | 0 | 0 | 0.0 | 10 |
| 开发 E | 8.21 | 57.5 | 4 | 4 | 0 | 0 | 3 | 0.37 | 7 |
| 开发 F | 1.86 | 13.0 | 0 | 0 | 0 | 0 | 0 | 0.0 | 10 |
| 开发 G | 3.43 | 24.0 | 1 | 1 | 0 | 0 | 1 | 0.29 | 8 |
| 开发 H | 2.57 | 18.0 | 1 | 1 | 0 | 0 | 1 | 0.39 | 7 |
| 开发 I | 4.00 | 28.0 | 0 | 0 | 0 | 0 | 0 | 0.0 | 10 |
| 开发 J | 0.01 | 0.0 | 3 | 3 | 0 | 0 | 2 | 0.0 | 10 |
| 开发 K | 0.01 | 0.0 | 1 | 1 | 0 | 0 | 1 | 0.0 | 10 |
3.3 字段口径(这一栏比代码更重要)
| 字段 | 来源 | 口径说明 |
|---|---|---|
| 开发时间 | Task 表「最初预计」 | 仅叶子任务求和,单位小时 |
| 月开发人天 | 开发时间 / 7 |
团队按 7 小时 = 1 人天折算(28.0 → 4.00 人天) |
| 总BUG数 | Bug 表「指派给」归集 | 见全量 BUG 数,用于发现"分配了但没处理"的情况 |
| 有效负责BUG数 | Bug 表「解决者」+ 解决方案过滤 | 只算本人真正修掉的;重复/不予解决/设计如此不计 |
| 非常严重 / 严重 / 一般 | Bug 表「严重程度」 | 三个等级分列,方便回溯 |
| 月BUG率 | 加权 BUG 数 ÷ 真实人天 | 权重:非常严重 2、严重 1.5、一般 1 |
| 月评分 | 按月BUG率分档换算 | 0 率 → 10 分,0.29 → 8 分,0.37~0.39 → 7 分,0.64 → 4 分,分档表作为配置常量维护 |
拿"开发 E"验算一遍:57.5 小时 = 8.21 人天,该行 4 个 BUG 里落进严重程度分列的是 3 个"一般"(另外 1 个严重程度为空/未分级,不进加权),加权和 = 3 × 1 = 3,3 / 8.21 = 0.365 → 0.37,与表里一致。
这一步验算顺带暴露了两个必须显式处理的点:
- 严重程度可能为空 。"总BUG数 4 = 非常严重 0 + 严重 0 + 一般 3"并不成立,差的那 1 个是空值。归集时要么归到"未分级"单列,要么按最低权重计入,但必须选一个并写进口径,不能默默丢掉------丢掉的话分子就悄悄变小,比率变好看。
- 分子口径要能一键切换。加权(2 / 1.5 / 1)和简单计数(有效 BUG 数)是两套考核逻辑,混在公式里就再也说不清是谁的责任:
python
WEIGHT = {"非常严重": 2.0, "严重": 1.5, "一般": 1.0, "未分级": 1.0}
def weighted_bugs(counts: dict) -> float:
"""counts: {严重程度: 条数},未分级按最低权重计入"""
return sum(WEIGHT.get(level, 1.0) * cnt for level, cnt in counts.items())
BUG_RATE_MODE = "weighted" # 只改这一处:weighted / simple
考核数据一旦不能手算复现,被质疑时你就输了。所以公式必须能从原始导出表一步步推出来------这也是下面"中间量打印"存在的理由。
四、三个必须写对的技术细节
4.1 没有 parent 列,怎么重建任务树
禅道的层级只体现在「任务名称」的 > 前缀和行序上:> 子任务、>> 孙任务。用栈按原始行序扫一遍即可:
python
def count_leading_gt(name):
"""'>> 孙任务' → 2,'子任务' → 0;先 lstrip 去掉缩进,再数开头连续 '>'"""
s = str(name).lstrip()
n = 0
for ch in s:
if ch == ">":
n += 1
else:
break
return n
df["_level"] = df[名称列].apply(count_leading_gt)
stack, child_map = [], {}
for _, row in df.iterrows(): # 必须保持导出原始行序,禁止先排序
tid, lvl = row[编号列], row["_level"]
while stack and stack[-1][0] >= lvl: # 弹到比自己层级低的位置
stack.pop()
if stack:
child_map.setdefault(stack[-1][1], []).append(tid)
stack.append((lvl, tid))
df_leaf = df[~df[编号列].isin(child_map.keys())] # 只剩叶子
两个反直觉的点:
- 不能按"任务类型 = 子任务"过滤 。很多导出没有这个字段,或者取值对不上,一过滤就是 0 行。
>前缀 + 行序才是稳定判据。 - 不能只剔除"直接父任务"。中间层任务自己也挂着下级,它的工时同样要剔除。所以判据是"是否有任何子节点",不是"层级是否大于 0"。
4.2 展示兜底值绝对不能当分母
这是最阴的一个坑。为了让 0 工时的人在表里不显示刺眼的 0,展示列做了兜底:
python
def calc_man_day(hours): # 展示列:0 显示成 0.01
v = round(float(hours) / 7, 2)
return 0.01 if v == 0 else v
def calc_bug_rate(hours, bugs): # 计算列:分母用真实值,绝不兜底
real_day = float(hours) / 7
if real_day <= 0:
return 0.0 # 0 投入不参与比率,显式返回 0
return round(bugs / real_day, 2)
如果比率公式写成 bugs / calc_man_day(hours),开发 J 的 3 个 BUG 会被 0.01 放大成 200 ,开发 K 的 1 个放大成 100。表面看像"合并逻辑错了",实际是兜底值污染了分母。改成真实人天后异常消失,其他人的数值完全不变(57.5 → 8.21 → 0.37 依旧),据此可以确认只影响 0 工时分支。
表里开发 J、K 那两行 0.01 人天 / 0.0 工时 / 0.0 率 / 10 分 就是修复后的样子------展示上仍是 0.01,计算上却是 0。
4.3 列名不要猜
禅道导出表头是中文,且不同版本不统一:编号 / 任务名称 / 由谁完成 / 最初预计 与 id / 名称 / 指派给 / 负责人 经常对不上,指派给 里甚至存的是账号而非中文名。所以脚本第一行永远是:
python
print(df.columns.tolist()) # 先看清真实列名,再做统计
按英文字段名取列会静默 KeyError 或全 0;按中文名硬编码则在升级后崩。建议做成"候选列名映射 + 命中告警":
python
COLS = {"dev": ["由谁完成", "指派给", "负责人"], "hours": ["最初预计", "预计工时"]}
def pick(df, key):
for c in COLS[key]:
if c in df.columns:
return c
sys.exit(f"列缺失:{key},实际列={df.columns.tolist()}")
五、Jenkins 侧怎么落地
原来这套脚本是带 GUI 的桌面程序,上 Jenkins 有四个改动:
| 改造点 | 做法 |
|---|---|
| 去 GUI | 命令行参数 --project-id 638,或读环境变量 PROJECT_ID |
| 防并发覆盖 | 所有产物文件名带项目号:task_638.xlsx、bug_638.xlsx、result_638.xlsx |
| 产物交付 | 归档表达式填 月度开发考核汇总.xlsx(先把 result 复制成语义化名字) |
| 失败可见 | 任何异常 sys.exit(1),让构建变红而不是"Success 但没结果" |
一条命令跑完整条链:
bash
python export_and_report.py --project-id 638 \
--task-out task_638.xlsx --bug-out bug_638.xlsx --result result_638.xlsx
流水线本身用参数化构建(项目 ID、月份),每月初点一次「Build with Parameters」。
六、踩坑清单(建议收藏)
| # | 坑 | 现象 | 正解 |
|---|---|---|---|
| 1 | 只 POST 导出地址 | 几 KB 空表,PK 头正常,无报错 | 先 GET 列表页种 Session,再 GET iframe,最后 POST |
| 2 | 精简 exportFields[] |
同样静默返回空表 | 整段照抄抓包 |
| 3 | 密码单层 md5 | 登录直接失败 | md5(md5(pwd) + verifyRand) |
| 4 | 不补业务 Cookie | 导出接口 403 / 返回登录页 | 补 lastProject、preProjectID |
| 5 | 按英文字段名取列 | KeyError 或统计全 0 | 先打印 df.columns,做候选映射 |
| 6 | 按"任务类型"过滤父任务 | 叶子任务数为 0 | 用 > 前缀 + 行序重建树 |
| 7 | 只剔直接父任务 | 工时仍偏高 | 按"是否有任何子节点"判断 |
| 8 | 先排序再重建树 | 父子关系错乱 | 严格用导出原始行序 |
| 9 | 兜底值当分母 | BUG 率 200、100 | 展示列与计算分母分离 |
| 10 | 读 xlsx 报 File is not a zip file |
本地能读、服务器报错 | 先 zipfile.is_zipfile 区分真损坏 / 被 WPS 占用,别急着改逻辑 |
| 11 | Jenkins agent 环境不同 | 本地跑通、构建失败 | 首次在 agent 工作目录手跑一遍再上构建 |
七、整体思路
先把全貌放出来,再谈为什么这么设计。
#mermaid-svg-nnqWbBP2raUmgseg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-nnqWbBP2raUmgseg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-nnqWbBP2raUmgseg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-nnqWbBP2raUmgseg .error-icon{fill:#552222;}#mermaid-svg-nnqWbBP2raUmgseg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-nnqWbBP2raUmgseg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-nnqWbBP2raUmgseg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-nnqWbBP2raUmgseg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-nnqWbBP2raUmgseg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-nnqWbBP2raUmgseg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-nnqWbBP2raUmgseg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-nnqWbBP2raUmgseg .marker{fill:#333333;stroke:#333333;}#mermaid-svg-nnqWbBP2raUmgseg .marker.cross{stroke:#333333;}#mermaid-svg-nnqWbBP2raUmgseg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-nnqWbBP2raUmgseg p{margin:0;}#mermaid-svg-nnqWbBP2raUmgseg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-nnqWbBP2raUmgseg .cluster-label text{fill:#333;}#mermaid-svg-nnqWbBP2raUmgseg .cluster-label span{color:#333;}#mermaid-svg-nnqWbBP2raUmgseg .cluster-label span p{background-color:transparent;}#mermaid-svg-nnqWbBP2raUmgseg .label text,#mermaid-svg-nnqWbBP2raUmgseg span{fill:#333;color:#333;}#mermaid-svg-nnqWbBP2raUmgseg .node rect,#mermaid-svg-nnqWbBP2raUmgseg .node circle,#mermaid-svg-nnqWbBP2raUmgseg .node ellipse,#mermaid-svg-nnqWbBP2raUmgseg .node polygon,#mermaid-svg-nnqWbBP2raUmgseg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-nnqWbBP2raUmgseg .rough-node .label text,#mermaid-svg-nnqWbBP2raUmgseg .node .label text,#mermaid-svg-nnqWbBP2raUmgseg .image-shape .label,#mermaid-svg-nnqWbBP2raUmgseg .icon-shape .label{text-anchor:middle;}#mermaid-svg-nnqWbBP2raUmgseg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-nnqWbBP2raUmgseg .rough-node .label,#mermaid-svg-nnqWbBP2raUmgseg .node .label,#mermaid-svg-nnqWbBP2raUmgseg .image-shape .label,#mermaid-svg-nnqWbBP2raUmgseg .icon-shape .label{text-align:center;}#mermaid-svg-nnqWbBP2raUmgseg .node.clickable{cursor:pointer;}#mermaid-svg-nnqWbBP2raUmgseg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-nnqWbBP2raUmgseg .arrowheadPath{fill:#333333;}#mermaid-svg-nnqWbBP2raUmgseg .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-nnqWbBP2raUmgseg .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-nnqWbBP2raUmgseg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-nnqWbBP2raUmgseg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-nnqWbBP2raUmgseg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-nnqWbBP2raUmgseg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-nnqWbBP2raUmgseg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-nnqWbBP2raUmgseg .cluster text{fill:#333;}#mermaid-svg-nnqWbBP2raUmgseg .cluster span{color:#333;}#mermaid-svg-nnqWbBP2raUmgseg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-nnqWbBP2raUmgseg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-nnqWbBP2raUmgseg rect.text{fill:none;stroke-width:0;}#mermaid-svg-nnqWbBP2raUmgseg .icon-shape,#mermaid-svg-nnqWbBP2raUmgseg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-nnqWbBP2raUmgseg .icon-shape p,#mermaid-svg-nnqWbBP2raUmgseg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-nnqWbBP2raUmgseg .icon-shape .label rect,#mermaid-svg-nnqWbBP2raUmgseg .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-nnqWbBP2raUmgseg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-nnqWbBP2raUmgseg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-nnqWbBP2raUmgseg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 通过
通过
失败
失败
Jenkins 参数化构建
PROJECT_ID / 月份
登录禅道
verifyRand + 双层 md5
GET task 列表页
种筛选上下文
GET bug 列表页
种筛选上下文
GET iframe + POST 导出
task_638.xlsx
GET iframe + POST 导出
bug_638.xlsx
校验
PK 头 + 行数 > 0
校验
PK 头 + 行数 > 0
任务树重建
79 行 → 65 叶子
BUG 归集
解决者 + 严重程度
工时口径校验
268.0 → 162.5
按人合并
人天 = 工时/7
比率计算
加权BUG / 真实人天
分档评分
result_638.xlsx
归档 月度开发考核汇总.xlsx
sys.exit(1)
构建标红
整个设计其实就是四层,任何一层错了,结果都不可信:
第一层:拿到真数据。 这一层的核心不是"会调用接口",而是"能判断接口给的是不是真数据"。所以脚本里所有"看起来成功"的地方都加了一道硬校验:PK 头挡掉 HTML 登录页,体积阈值 + 行数挡掉空模板。批处理的静默失败比报错危险十倍,因为它会被当成结果直接拿去打分。
第二层:把口径写死。 工时只算叶子任务、BUG 只算自己修的、人天按 7 小时折算------这些规则平时都在人脑子里,一旦换个人统计就漂。写进代码的意义是可复算 :任何人拿到 task_638.xlsx 和 bug_638.xlsx,按文档里的公式能算出同样的 0.37 和 7 分。考核数据必须可复算,这是这套东西能拿去用的前提。
第三层:把中间量暴露出来。 日志里那几行"79 行 / 14 个父任务 / 65 个叶子 / 268.0 → 162.5(剔除 105.5)"是刻意打印的,不是调试残留。它们是审计线索------当有人质疑"我这个月明明不止 57.5 小时",你可以直接指到"你的父任务工时被剔除了"这一步,而不是重新跑一遍 Excel 猜。
第四层:把交付物固定。 文件名带项目号防覆盖,归档名字带业务语义(月度开发考核汇总.xlsx),失败用 exit(1) 标红。这样下游(发邮件、贴报表、做趋势)都只需要认一个路径。
如果要把这套思路搬到别的系统(Jira、TAPD、自研平台),顺序不要变,但取舍会变:
- 先抓包确认"导出是否依赖服务端上下文"。不依赖就一个 POST 搞定;依赖就必须复刻列表页 → iframe → POST 三步,这是最容易翻车的地方。
- 先打印表头和行数,再写任何统计代码 。列名和层级表示方式(
>前缀、缩进、parent 字段)决定后面全部逻辑。 - 先跑一次"整表合计 vs 口径合计"的对比,差值解释清楚再往下做。差值说不清的统计,不要交付。
- 凡是有兜底、有默认值的地方,问一句"这个值会不会被当分母"。这一问能省掉一次返工。
- 最后才接 Jenkins。本地命令行能稳定产出、日志能自证,再谈调度和归档;否则你会在 CI 里排查一个其实本地就有的问题。
最后再来看看jenkins的效果图:


这是开发人员的哈,测试人员思路相同,抓取bug数据,完全可以一键自动化考核。只有有考核逻辑,都不是问题,框架搭出来就行。
一句话总结:自动化的难点从来不是"跑起来",而是"跑出来的数敢不敢签字"。 上面所有的校验、差值打印、口径开关,都是为了让那张 11 行的表可以被质疑、被复算、被追溯。
环境:Python 3 / requests / pandas / openpyxl,Jenkins 2.x(Pipeline + 参数化构建)。文中人员姓名已脱敏。