夜雨聆风学习资料网

ARTICLE · 1271445

批量 API 巡检台源码:Postman 做精修、这个做批量探活

批量 API 巡检台源码:Postman 做精修、这个做批量探活

Postman 给开发调单个接口刚好,但运维值班每天要做的是把 50 个 URL 挨个敲一遍看谁挂了、谁慢了——这两件事不是一个工具能同时做好的。这个「批量 API 调用台」是纯本地 Python 小工具:API 库(JSON 持久化 + 搜索 + 去重)→ Excel/CSV 批量导入 URL 清单 → 后台线程串行发 GET(不并发不打挂生产)→ Treeview 原位回填 + 颜色标签 → pandas 一键导出 xlsx 报告。本文先完整走一遍功能,再拆解 8 个技术要点(Tkinter 线程安全那一条是写 GUI 工具的通用铁律),最后附今天刚踩的 PyInstaller 打包工具坑(requests / pandas 被 EXCLUDE_MODULES 默认排除 → ModuleNotFoundError,解法是给打包工具加 per-tool 放行机制)。

一、它能做什么

能力亮点
API 库管理JSON 持久化(data/api_library.json)、UUID 去重、搜索过滤、单独添加 / 批量添加到调用列表、从调用列表反向保存回库
URL 批量管理手动添加 / 单选 / 多选删除 / 一键清空 / Excel / CSV 批量导入(pandas,自动识别 URL 列)
批量调用后台线程跑,不卡死 UI;串行 GET、10 秒超时;进度条实时走;失败不中断,每个 URL 独立记结果
结果展示Treeview 六列(序号 / URL / 状态码 / 内容长度 / 响应时间 / 结果);2xx 绿底 / 4xx5xx 橙底 / 异常红底
导出归档pandas 写 xlsx / CSV;还能一键自动保存到 data/api_results/ 目录 + 打开文件夹
右键快捷Treeview 右键菜单:删除选中 / 保存到 API 库 / 查看详情
打包兼容sys.frozen 自动识别脚本 / 打包两种身份,数据目录始终在「程序所在目录 / data」

刻意不做:POST / PUT / DELETE(只发 GET)、自定义 Header / Body、鉴权、并发、代理——这是画好的边界,复杂请求构造请交给 Postman,本工具只负责「批量探活 + 出报告」。

二、界面结构

┌────────── 蓝紫渐变横幅:API 调用工具 v3.0 ──────────┐
├───────────────────────────────────────────────────────┤
│  ┌─ 左栏 ─────────────┐ ┌─ 右栏 ────────────────────┐ │
│  │ 📚 API 库管理      │ │ 📋 API 调用列表           │ │
│  │ 统计: 12 个 API    │ │ ┌─────────────────────────┐│ │
│  │ 管理API库 / 添加 / │ │ │序号 │URL    │状态码 │长度 ││ │
│  │ 批量加到调用列表   │ │ │ 1   │http..│200   │1234 ││ │
│  └───────────────────┘ │ │ │ 2   │https│500   │   0 ││ │
│  ┌─ URL 管理 ────────┐ │ │ │ 3   │http..│超时 │   - ││ │
│  │ ➕添加URL  🗑删除选中│ │ │ └─────────────────────────┘│ │
│  │ 💾保存到库 🗑清空   │ │ └───────────────────────────┘│ │
│  │ 📥导入URLs 📋模板  │ │ ┌─ 📄 响应详情 ────────────┐ │
│  └───────────────────┘ │ │ │ 点某行显示完整信息       │ │
│                        │ │ └──────────────────────────┘ │
│                        │ │ ▶️调用 APIs 📥一键保存 📤导出 │
│                        │ │ [========== 进度条 ==========] │
│                        │ └──────────────────────────────┘ │
├───────────────────────────────────────────────────────┤
│  状态栏:正在调用 http://...:4000...                   │
└───────────────────────────────────────────────────────┘

操作流:导入 URL 清单 → 点「调用 APIs」→ 看结果 → 「一键保存」自动存 Excel。全程 3 个动作,值班同学 10 秒上手。

三、技术点 1:Tkinter 多线程的唯一铁律——self.after(0, ...) 排队回主线程

批量调用 50 个 URL,每个最坏等 10 秒。如果在主线程跑,窗口直接白屏无响应。但 Tkinter 不是线程安全的——子线程直接碰控件,随时可能段崩溃。

唯一安全的姿势:活儿在子线程干,界面更新一律 self.after(0, callback) 排队回主线程。

defcall_apis(self):
self.call_button.config(state=tk.DISABLED)     # 禁用按钮防重复
self.progress['maximum'] = len(self.urls)
thread = threading.Thread(target=self._call_apis_threaded, daemon=True)
thread.start()

def_call_apis_threaded(self):
fori, urlinenumerate(self.urls):
try:
self.after(0, self.update_status, f"正在调用: {url[:40]}...")
start_time = datetime.now()
response = requests.get(url, timeout=10)
elapsed = (datetime.now() -start_time).total_seconds()
# 绝不直接碰 Treeview,after 回主线程
self.after(0, self.update_tree_item, i, url,
response.status_code, len(response.content), elapsed, ...)
exceptrequests.RequestExceptionase:
self.after(0, self.update_tree_item, i, url, "❌ 错误",
str(e), 0, "❌ 请求失败", 'error')
self.after(0, self.progress.step, 1)
self.after(0, self.call_complete)              # 最后一个恢复按钮

三个关键细节:

  • daemon=True:守护线程,关窗口时不用手动 join,进程退出线程自动带走,避免"窗口关了进程还挂着"

  • requests.RequestException:一个异常族兜住超时、DNS 失败、连接拒绝、SSL 所有网络层问题,出错的行写错误信息而不是让整批崩掉

  • 调用期间三个按钮 state=DISABLED,结束在 call_complete 里统一恢复,防止用户重复点击起一堆线程

after(0, callback) 的含义是"主线程忙完当前事件后尽快执行",它把子线程产生的数据安全地投递回 Tk 的事件循环。这是所有 Tkinter 耗时任务的通用解法——记这一个模式就够了。

四、技术点 2:故意串行,不做并发

新手写这种工具第一反应是上线程池:50 个 URL 同时发,多快。本工具故意用了串行循环,原因有三个:

  1. 巡检工具的第一目标是安全,不是速度。对着生产环境 200 个接口同时开 50 并发,和一次小型 CC 攻击没区别——本来是去查谁挂了,结果把没挂的也打挂了

  2. 串行的响应时间才可比。并发下大家抢连接池、抢 CPU,测出来的响应时间互相污染,没法作为性能证据

  3. 结果表格按顺序回填最简单(见下一节),不用担心乱序

配合 timeout=10,最坏总时长是可预估的:URL 数 × 10 秒。真要加速,正解是让用户自己决定加并发数(旋钮在他手里),而不是工具默认替他冒险。

五、技术点 3:Treeview 按「位置」原位更新——别拿序号当 iid

URL 列表是先全部插入 Treeview、调用时再逐行回填结果的。这里有个坑:Treeview 的行标识 item_id 不一定等于插入顺序——删除过行之后,iid 会乱,不能想当然地拿序号当 id。

稳妥做法是每次取当前所有子项,按下标拿真正的 iid:

defupdate_tree_item(self, index, url, code, length, elapsed, text, tag=''):
ifindex<len(self.tree.get_children()):
item_id = self.tree.get_children()[index]   # 位置 → 真实 iid
self.tree.item(
item_id,
values=(index+1, url, code, length,
f"{elapsed:.3f}s"ifisinstance(elapsed, (int, float)) elseelapsed,
text),
tags=(tag,) iftagelse ()
        )

颜色区分通过 Treeview 的 tags 机制实现,提前给 tag 配好底色:

self.tree.tag_configure('success', background='#e8f8f0')   # 2xx 成功浅绿
self.tree.tag_configure('warning', background='#fdf3e0')   # 4xx / 5xx 浅橙
self.tree.tag_configure('error',   background='#fdeaea')   # 异常浅红

状态码分级直白:2xx 成功、4xx 客户端错误、5xx 服务器错误、其余归为"其他状态"——运维一眼扫过去知道哪行有问题。

六、技术点 4:API 库用 UUID + 时间戳做唯一标识

API 库存 JSON(data/api_library.json),增删改查要能稳定对应同一条记录。最简单的做法是 URL 当 key,但用户改了 URL 就找不到了;更好的做法是给每条记录一个不重复的 id。

defload_api_library(self):
library_file = os.path.join(_get_module_data_dir(), "api_library.json")
ifnotos.path.exists(library_file):
return []
withopen(library_file, 'r', encoding='utf-8') asf:
library = json.load(f)
forapiinlibrary:
if'id'notinapi:
api['id'] = str(uuid.uuid4())    # 老数据补 id,向后兼容
returnlibrary

# 新增时
api_id = str(uuid.uuid4())
self.api_library.append({
'id': api_id,
'name': name,
'url': url,
'added_time': datetime.now().strftime("%Y-%m-%d %H:%M:%S")
})

四个配套设计:

  • UUID 去重:if any(api.get('url') == url for api in self.api_library) 先查 URL 是否已存在,重复就弹警告

  • 搜索框实时过滤:search_var.trace("w", lambda *args: search_api()) 每敲一个字符重新过滤,name / url 两个字段同时匹配

  • 添加时间自动记录:added_time 字段,方便回顾"这个 API 什么时候加的"

  • 老数据向后兼容:load_api_library 里给没有 id 的旧记录补上 UUID,升级不炸

七、技术点 5:pandas 做批量导入导出——模糊匹配 URL 列 + CSV 编码兜底

几十个 URL 没人愿意手敲。导入走 Excel / CSV,pandas 读写各一行,但有两个细节容易踩:

第一,别假设 URL 列名就是 "URL"。不同人存的表头可能是 url、API地址、接口地址、api_endpoint……程序做模糊匹配:

url_col = None
name_col = None
forcolindf.columns:
col_lower = col.lower()
if'url'incol_lower:
url_col = col
elif'名称'incol_loweror'name'incol_lower:
name_col = col
ifurl_colisNoneandlen(df.columns) >0:
url_col = df.columns[0]    # 都没匹配上就拿第一列,假设那是 URL

第二,CSV 编码不只有 UTF-8。国内同事用 Excel 存 CSV 默认是 GBK,直接读会炸。做三档兜底:

encodings = ['utf-8', 'gbk', 'latin1']
df = None
forencodinginencodings:
try:
df = pd.read_csv(filename, encoding=encoding)
break
exceptException:
continue
ifdfisNone:
raiseException("无法解析文件编码")

导出更简单——self.results 内存里每一项就是固定键的字典(序号 / URL / 状态码 / 内容长度 / 响应时间(秒) / 结果),这是关键设计:让内存中的数据结构和最终报表的列一一对应,pd.DataFrame(self.results).to_excel(path, index=False) 直接落盘,不用任何转换。

八、技术点 6:一键自动保存 + 打开结果目录

点「▶️ 调用 APIs」跑完后,用户第一件事是要一份报告存档。一键保存按钮自动把 Excel 写到 data/api_results/ 目录:

defquick_save(self):
results_dir = os.path.join(_get_module_data_dir(), 'api_results')
os.makedirs(results_dir, exist_ok=True)
filename = f"api_results_{len(self.results)}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
filepath = os.path.join(results_dir, filename)
pd.DataFrame(self.results).to_excel(filepath, index=False)
ifmessagebox.askyesno("打开文件夹", "是否打开结果文件夹?"):
os.startfile(results_dir)    # Windows 专属,双击体验

自动打开文件夹这步是用户反复提的需求——跑完了 Excel 存哪了?直接帮他打开省得找。

九、技术点 7:数据目录兼容脚本 / 打包两种身份

API 库和结果文件要持久化,存哪?开发时文件在源码目录,PyInstaller 打包后文件在 exe 旁边,两个路径不一样。用 sys.frozen 判断运行身份:

def_get_module_data_dir(name):
ifgetattr(sys, 'frozen', False):
root = os.path.dirname(sys.executable)       # 打包后:exe 同级
else:
root = os.path.dirname(os.path.abspath(__file__))  # 脚本:py 同级
returnos.path.join(root, "data")

这样无论 python api_client.py 还是双击 exe,data/api_library.json 和 data/api_results/ 都生成在"程序所在目录 / data",绿色软件,卸载就是删文件夹。想在两台电脑间迁移 API 库,拷一个 JSON 就行。

十、技术点 8:打包工具的 EXCLUDE_MODULES 陷阱——requests / pandas 默认被排除

今天刚踩的坑,专门加一节给你。

打包工具(packager.py)顶部有一个 EXCLUDE_MODULES 列表——因为项目里绝大多数小工具只依赖标准库,PyInstaller 打包时排除 pandas / requests 等大型库能显著减小体积(本来可能 80MB,排完只剩 15MB)。但 API 调试器偏偏需要这两个库,直接打包出的 exe 启动就报:

ModuleNotFoundError: No module named 'requests'
ModuleNotFoundError: No module named 'pandas'

解法是在打包工具里加一个 FORCED_INCLUDE 字典,声明哪些工具需要额外放行哪些模块,打包时动态把这些模块从 EXCLUDE_MODULES 里剔除:

# packager.py 顶部新增
EXCLUDE_MODULES = [
"numpy", "pandas", "matplotlib", "scipy", "sympy", "PIL", "cv2",
"PyQt5", "PyQt6", "requests", "urllib3", "certifi", "charset_normalizer",
    ...
]

# 新增:少数工具使用了非标准库,打包时必须放行
FORCED_INCLUDE = {
"18-API调试器": ["requests", "urllib3", "certifi", "charset_normalizer",
"pandas", "numpy", "python-dateutil", "pytz"],
"api_client":  ["requests", "urllib3", "certifi", "charset_normalizer",
"pandas", "numpy", "python-dateutil", "pytz"],
}

打包命令生成那里,原来直接遍历 EXCLUDE_MODULES 加 --exclude-module;改后根据工具名动态计算实际排除列表:

# 原来
formodinEXCLUDE_MODULES:
cmd += ["--exclude-module", mod]

# 改成:剔除 FORCED_INCLUDE 里声明的模块
forced = set(FORCED_INCLUDE.get(name, []))       # 按工具名匹配
forced |= set(FORCED_INCLUDE.get(script.stem, []))  # 或脚本文件名匹配
excluded = [mforminEXCLUDE_MODULESifmnotinforced]
formodinexcluded:
cmd += ["--exclude-module", mod]
ifforced:
self.log_q.put(("log", f"📦 放行非标准库:{', '.join(sorted(forced))}\n", "dim"))

以后新工具如果也用了非标准库,只需要在 FORCED_INCLUDE 里加一行就行,不用改 EXCLUDE_MODULES 整体。

十一、边界处理速查表

情况处理
URL 列表为空点调用弹 warning "没有 URL 可调用"
网络异常(超时 / DNS / 连接拒绝)单行记 ❌ 请求失败 + 错误信息,不中断整体
线程运行中重复点调用按钮 state=DISABLED,结束恢复
删除过的行,序号重排renumber_items() 遍历 Treeview 重新标序号
API 库里重复 URLif any(api.get('url') == url ...) 弹警告
导入 Excel 没有 URL 列拿第一列兜底 + 弹提示
CSV 编码非 UTF-8utf-8 → gbk → latin1 三档自动探测
没有 pandas启动即报错(pandas 是硬依赖,没法降级)
打包后 Python 版本不匹配两个解释器各自 pip install(py vs D:/Python/Python39/python.exe)
PyInstaller EXCLUDE_MODULES 排除了 requests / pandasFORCED_INCLUDE 放行(见上一节)

十二、依赖

requests        → HTTP 探测
pandas + openpyxl  → Excel / CSV 读写
tkinter / json / threading / datetime / uuid / os  → 全标准库

十三、完整源码

以下是 api_client.py 全文(1320 行)。独立运行版入口在文件末尾,直接 python api_client.py 启动。如果走主工具箱(通过 set_dependencies 注入颜色 / 主应用引用),调用方式和其他小工具一致。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
importtkinterastk
fromtkinterimportttk, messagebox, filedialog, simpledialog
importrequests
importthreading
importpandasaspd
importjson
importos
fromdatetimeimportdatetime
fromtkinterimportscrolledtext
importtkinter.fontastkFont
importuuid

importosas_os, sysas_sys
def_get_module_data_dir(name):
"""toolbox 独立运行模式:返回工具自身目录下的 data/ 子目录。
    PyInstaller 打包后 (_sys.frozen) 返回 exe 同级目录下的 data/。"""
ifgetattr(_sys, 'frozen', False):
root = _os.path.dirname(_sys.executable)
else:
root = _os.path.dirname(_os.path.abspath(__file__))
return_os.path.join(root, "data")


classAPICallerFrame(tk.Frame):
def__init__(self, parent):
super().__init__(parent)
self.main_app = None
self.colors = None
self.pack(fill=tk.BOTH, expand=True)

self.default_colors = {
'primary': '#1a237e', 'secondary': '#283593',
'success': '#2e7d32', 'warning': '#ff8f00', 'danger': '#c62828',
'light': '#f5f5f5', 'dark': '#212121', 'background': '#ffffff',
'card_bg': '#ffffff', 'border': '#e0e0e0', 'highlight': '#e3f2fd',
        }
self.font_title = ("微软雅黑", 14, "bold")
self.font_large = ("微软雅黑", 11)
self.font_normal = ("微软雅黑", 10)
self.font_small = ("微软雅黑", 9)

self.urls = []
self.results = []
self.api_library = self.load_api_library()

self.library_stats = None
self.stats_label = None
self.tree = None
self.detail_text = None
self.call_button = None
self.quick_save_button = None
self.export_button = None
self.progress = None

defset_dependencies(self, colors=None, username=None, user_type=None,
data_manager=None, main_app=None, **kwargs):
ifcolors:
self.colors = self.default_colors.copy()
self.colors.update(colors)
else:
self.colors = self.default_colors
self.main_app = main_app
self.create_interface()
self.configure_styles()
self.quick_save_button.config(state=tk.DISABLED)
self.export_button.config(state=tk.DISABLED)

defcreate_interface(self):
forwidgetinself.winfo_children():
widget.destroy()

main_content = tk.Frame(self, bg=self.colors['background'])
main_content.pack(fill=tk.BOTH, expand=True, padx=15, pady=15)

left_panel = tk.Frame(main_content, bg=self.colors['background'])
left_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=False, padx=(0, 10))
self.create_api_library_card(left_panel)
self.create_url_management_card(left_panel)

right_panel = tk.Frame(main_content, bg=self.colors['background'])
right_panel.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)
self.create_api_list_card(right_panel)
self.create_detail_action_card(right_panel)
self.update_library_stats()

# ---------- API 库卡片 ----------
defcreate_api_library_card(self, parent):
card = tk.Frame(parent, bg='white', relief=tk.FLAT, bd=1,
highlightbackground=self.colors['border'], highlightthickness=1)
card.pack(fill=tk.BOTH, pady=(0, 10))
title_frame = tk.Frame(card, bg=self.colors['primary'])
title_frame.pack(fill=tk.X)
tk.Label(title_frame, text="📚 API 库管理", font=self.font_large,
fg='white', bg=self.colors['primary']).pack(side=tk.LEFT, padx=12, pady=8)
self.library_stats = tk.Label(title_frame, text="0 个API", font=self.font_small,
fg='white', bg=self.colors['primary'])
self.library_stats.pack(side=tk.RIGHT, padx=12)

button_frame = tk.Frame(card, bg='white', padx=12, pady=12)
button_frame.pack(fill=tk.X)
buttons = [
            ("📖 管理API库", self.manage_api_library, self.colors['primary']),
            ("➕ 添加API", self.add_api_to_library_dialog, self.colors['success']),
            ("📥 批量添加到调用列表", self.add_library_to_caller, self.colors['secondary']),
        ]
fortext, command, colorinbuttons:
btn = tk.Button(button_frame, text=text, command=command, bg=color, fg='white',
font=self.font_normal, padx=15, pady=10, relief=tk.FLAT, cursor='hand2',
activebackground=color, activeforeground='white')
btn.pack(fill=tk.X, pady=3)
btn.bind("<Enter>", lambdae, b=btn: b.config(bg=self.lighten_color(b.cget('bg'))))
btn.bind("<Leave>", lambdae, b=btn, c=color: b.config(bg=c))

deflighten_color(self, color):
ifcolor.startswith('#'):
r = min(255, int(color[1:3], 16) +20)
g = min(255, int(color[3:5], 16) +20)
b = min(255, int(color[5:7], 16) +20)
returnf'#{r:02x}{g:02x}{b:02x}'
returncolor

# ---------- URL 管理卡片 ----------
defcreate_url_management_card(self, parent):
card = tk.Frame(parent, bg='white', relief=tk.FLAT, bd=1,
highlightbackground=self.colors['border'], highlightthickness=1)
card.pack(fill=tk.BOTH)
title_frame = tk.Frame(card, bg=self.colors['dark'])
title_frame.pack(fill=tk.X)
tk.Label(title_frame, text="🔗 URL 管理", font=self.font_large,
fg='white', bg=self.colors['dark']).pack(side=tk.LEFT, padx=12, pady=8)

bf = tk.Frame(card, bg='white', padx=12, pady=12)
bf.pack(fill=tk.X)
forrow_configin [
            [("➕ 添加URL", self.add_url, self.colors['primary']),
             ("🗑️ 删除选中", self.delete_selected_urls, self.colors['danger'])],
            [("💾 保存到库", self.save_selected_to_library, self.colors['success']),
             ("🗑️ 清空列表", self.clear_urls, self.colors['warning'])],
            [("📥 导入URLs", self.import_urls, self.colors['secondary']),
             ("📋 下载模板", self.download_template, self.colors['secondary'])],
        ]:
row = tk.Frame(bf, bg='white')
row.pack(fill=tk.X, pady=3)
fortext, cmd, colorinrow_config:
btn = tk.Button(row, text=text, command=cmd, bg=color, fg='white',
font=self.font_normal, padx=15, pady=10, relief=tk.FLAT,
cursor='hand2', width=12)
side = 'left'ifrow_config.index((text, cmd, color)) == 0else'right'
btn.pack(side=side, padx=2)

# ---------- 调用列表卡片 ----------
defcreate_api_list_card(self, parent):
card = tk.Frame(parent, bg='white', relief=tk.FLAT, bd=1,
highlightbackground=self.colors['border'], highlightthickness=1)
card.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
tf = tk.Frame(card, bg=self.colors['primary'])
tf.pack(fill=tk.X)
tk.Label(tf, text="📋 API 调用列表", font=self.font_large,
fg='white', bg=self.colors['primary']).pack(side=tk.LEFT, padx=12, pady=8)
self.stats_label = tk.Label(tf, text="总计: 0", font=self.font_small,
fg='white', bg=self.colors['primary'])
self.stats_label.pack(side=tk.RIGHT, padx=12)

tf2 = tk.Frame(card, bg='white')
tf2.pack(fill=tk.BOTH, expand=True, padx=2, pady=2)
columns = ("序号", "URL", "状态码", "内容长度", "响应时间", "结果")
self.tree = ttk.Treeview(tf2, columns=columns, show='headings',
selectmode='extended', style="Custom.Treeview")
col_widths = {"序号": 60, "URL": 450, "状态码": 80,
"内容长度": 100, "响应时间": 100, "结果": 120}
forcolincolumns:
self.tree.heading(col, text=col, anchor=tk.CENTERifcol!= "URL"elsetk.W)
self.tree.column(col, width=col_widths[col], anchor=tk.CENTERifcol!= "URL"elsetk.W)
vsb = ttk.Scrollbar(tf2, orient="vertical", command=self.tree.yview)
hsb = ttk.Scrollbar(tf2, orient="horizontal", command=self.tree.xview)
self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
self.tree.grid(row=0, column=0, sticky="nsew")
vsb.grid(row=0, column=1, sticky="ns")
hsb.grid(row=1, column=0, sticky="ew")
tf2.grid_rowconfigure(0, weight=1)
tf2.grid_columnconfigure(0, weight=1)
self.tree.bind('<<TreeviewSelect>>', self.show_detail)
self.tree.bind("<Button-3>", self.show_context_menu)

defshow_context_menu(self, event):
menu = tk.Menu(self, tearoff=0)
menu.add_command(label="删除选中", command=self.delete_selected_urls)
menu.add_command(label="保存到API库", command=self.save_selected_to_library)
menu.add_separator()
menu.add_command(label="查看详情", command=lambda: self.show_detail(None))
try:
menu.tk_popup(event.x_root, event.y_root)
finally:
menu.grab_release()

# ---------- 详情 + 操作卡片 ----------
defcreate_detail_action_card(self, parent):
card = tk.Frame(parent, bg='white', relief=tk.FLAT, bd=1,
highlightbackground=self.colors['border'], highlightthickness=1)
card.pack(fill=tk.BOTH)

df = tk.Frame(card, bg='white')
df.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
tk.Label(df, text="📄 响应详情", font=self.font_normal, bg='white').pack(anchor=tk.W, pady=(0, 8))
self.detail_text = scrolledtext.ScrolledText(df, height=6, font=("Consolas", 9),
bg=self.colors['light'], relief=tk.FLAT,
borderwidth=1, highlightbackground=self.colors['border'],
highlightthickness=1)
self.detail_text.pack(fill=tk.BOTH, expand=True)

af = tk.Frame(card, bg='white')
af.pack(fill=tk.X, padx=10, pady=(0, 10))
self.call_button = tk.Button(af, text="▶️ 调用APIs", command=self.call_apis,
bg=self.colors['success'], fg='white',
font=self.font_normal, padx=25, pady=12,
relief=tk.FLAT, cursor='hand2')
self.call_button.pack(side=tk.LEFT, padx=5)
self.quick_save_button = tk.Button(af, text="💾 一键保存", command=self.quick_save,
bg=self.colors['secondary'], fg='white',
font=self.font_normal, padx=25, pady=12,
relief=tk.FLAT, cursor='hand2', state=tk.DISABLED)
self.quick_save_button.pack(side=tk.LEFT, padx=5)
self.export_button = tk.Button(af, text="📤 导出结果", command=self.export_results,
bg=self.colors['primary'], fg='white',
font=self.font_normal, padx=25, pady=12,
relief=tk.FLAT, cursor='hand2', state=tk.DISABLED)
self.export_button.pack(side=tk.LEFT, padx=5)
self.progress = ttk.Progressbar(af, mode='determinate',
style="Custom.Horizontal.TProgressbar", length=200)
self.progress.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=10)

defconfigure_styles(self):
style = ttk.Style()
if'clam'instyle.theme_names():
style.theme_use('clam')
style.configure("Custom.Treeview", background=self.colors['light'],
foreground=self.colors['dark'], rowheight=28,
fieldbackground=self.colors['light'], font=self.font_small, borderwidth=0)
style.configure("Custom.Treeview.Heading", background=self.colors['secondary'],
foreground='white', relief=tk.FLAT,
font=("微软雅黑", 9, "bold"), borderwidth=1)
style.configure("Custom.Horizontal.TProgressbar", troughcolor=self.colors['light'],
background=self.colors['success'], thickness=20, borderwidth=0)
self.tree.tag_configure('success', background='#e8f8f0')
self.tree.tag_configure('warning', background='#fdf3e0')
self.tree.tag_configure('error',   background='#fdeaea')

# ---------- API 库持久化 ----------
defload_api_library(self):
library_file = os.path.join(_get_module_data_dir(), "api_library.json")
ifnotos.path.exists(library_file):
return []
try:
withopen(library_file, 'r', encoding='utf-8') asf:
library = json.load(f)
forapiinlibrary:
if'id'notinapi:
api['id'] = str(uuid.uuid4())
returnlibrary
exceptExceptionase:
print(f"加载API库失败: {e}")
return []

defsave_api_library(self):
os.makedirs(_get_module_data_dir(), exist_ok=True)
library_file = os.path.join(_get_module_data_dir(), "api_library.json")
try:
withopen(library_file, 'w', encoding='utf-8') asf:
json.dump(self.api_library, f, ensure_ascii=False, indent=2)
returnTrue
exceptExceptionase:
messagebox.showerror("错误", f"保存API库时出错: {e}")
returnFalse

defupdate_library_stats(self):
ifself.library_stats:
self.library_stats.config(text=f"{len(self.api_library)} 个API")

defupdate_stats(self):
self.stats_label.config(text=f"总计: {len(self.urls)}")

defupdate_status(self, message):
ifself.main_appandhasattr(self.main_app, 'update_status'):
self.main_app.update_status(message)
else:
print(f"状态: {message}")

# ---------- API 库管理对话框 ----------
defmanage_api_library(self):
library_window = tk.Toplevel(self)
library_window.title("API库管理")
library_window.geometry("800x500")
library_window.configure(bg='white')
library_window.transient(self)
library_window.grab_set()

main_frame = tk.Frame(library_window, bg='white', padx=20, pady=20)
main_frame.pack(fill=tk.BOTH, expand=True)
tk.Label(main_frame, text="📚 API 库管理", font=self.font_title,
bg='white', fg=self.colors['primary']).pack(side=tk.LEFT, pady=(0, 15))

# 搜索框
search_frame = tk.Frame(main_frame, bg='white')
search_frame.pack(side=tk.RIGHT, pady=(0, 15))
tk.Label(search_frame, text="搜索:", font=self.font_small,
bg='white').pack(side=tk.LEFT, padx=(0, 5))
search_var = tk.StringVar()
tk.Entry(search_frame, textvariable=search_var, font=self.font_small,
width=25, relief=tk.FLAT, bg=self.colors['light']).pack(side=tk.LEFT)

tc = tk.Frame(main_frame, bg='white')
tc.pack(fill=tk.BOTH, expand=True)
columns = ("序号", "名称", "URL", "添加时间")
tree = ttk.Treeview(tc, columns=columns, show='headings',
selectmode='extended', style="Custom.Treeview")
forcol, w, ancinzip(columns, [60, 150, 400, 150],
                               [tk.CENTER, tk.W, tk.W, tk.CENTER]):
tree.heading(col, text=col, anchor=anc)
tree.column(col, width=w, anchor=anc)
foridx, apiinenumerate(self.api_library, 1):
tree.insert("", tk.END, values=(idx, api.get('name', '未命名'),
api.get('url', ''), api.get('added_time', '')),
tags=(api.get('id', ''),))
vsb = ttk.Scrollbar(tc, orient="vertical", command=tree.yview)
tree.configure(yscrollcommand=vsb.set)
tree.grid(row=0, column=0, sticky="nsew")
vsb.grid(row=0, column=1, sticky="ns")
tc.grid_rowconfigure(0, weight=1)
tc.grid_columnconfigure(0, weight=1)

defsearch_api():
query = search_var.get().lower()
foritemintree.get_children():
tree.delete(item)
foridx, apiinenumerate(self.api_library, 1):
name = api.get('name', '').lower()
url_ = api.get('url', '').lower()
ifqueryinnameorqueryinurl_:
tree.insert("", tk.END, values=(idx, api.get('name', '未命名'),
api.get('url', ''), api.get('added_time', '')),
tags=(api.get('id', ''),))
search_var.trace("w", lambda*a: search_api())

defdelete_selected():
sel = tree.selection()
ifnotsel:
messagebox.showwarning("警告", "请选择要删除的API"); return
ifnotmessagebox.askyesno("确认", f"确定删除选中的 {len(sel)} 个API吗?"):
return
deleted_ids = [tree.item(i)['tags'][0] foriinsel]
self.api_library = [aforainself.api_libraryifa.get('id') notindeleted_ids]
ifself.save_api_library():
foriteminsel:
tree.delete(item)
foridx, iteminenumerate(tree.get_children(), 1):
vals = list(tree.item(item)['values']); vals[0] = idx
tree.item(item, values=vals)
self.update_library_stats()

defadd_to_caller():
sel = tree.selection()
ifnotsel:
messagebox.showwarning("警告", "请选择要添加的API"); return
added = 0
foriteminsel:
vals = tree.item(item)['values']
url_ = vals[2] iflen(vals) >= 3else''
ifurl_andurl_notinself.urls:
self.urls.append(url_)
self.tree.insert("", tk.END, values=(len(self.urls), url_, "", "", "", ""))
added += 1
ifadded>0:
self.update_stats()
messagebox.showinfo("成功", f"已添加 {added} 个API到调用列表")

bf = tk.Frame(main_frame, bg='white')
bf.pack(fill=tk.X, pady=(15, 0))
tk.Button(bf, text="🗑️ 删除选中", command=delete_selected,
bg=self.colors['danger'], fg='white', font=self.font_normal,
padx=20, pady=8, relief=tk.FLAT, cursor='hand2').pack(side=tk.LEFT, padx=5)
tk.Button(bf, text="➕ 添加到调用列表", command=add_to_caller,
bg=self.colors['success'], fg='white', font=self.font_normal,
padx=20, pady=8, relief=tk.FLAT, cursor='hand2').pack(side=tk.LEFT, padx=5)
tk.Button(bf, text="关闭", command=library_window.destroy,
bg=self.colors['dark'], fg='white', font=self.font_normal,
padx=20, pady=8, relief=tk.FLAT, cursor='hand2').pack(side=tk.RIGHT)

defadd_api_to_library_dialog(self):
dialog = tk.Toplevel(self)
dialog.title("添加API")
dialog.geometry("500x300"); dialog.configure(bg='white')
dialog.transient(self); dialog.grab_set()
mf = tk.Frame(dialog, bg='white', padx=30, pady=30); mf.pack(fill=tk.BOTH, expand=True)
tk.Label(mf, text="➕ 添加新API", font=self.font_title, bg='white',
fg=self.colors['primary']).pack(anchor=tk.W, pady=(0, 20))

tk.Label(mf, text="API名称:", font=self.font_normal, bg='white').pack(anchor=tk.W)
name_var = tk.StringVar()
tk.Entry(mf, textvariable=name_var, font=self.font_normal,
bg=self.colors['light'], relief=tk.FLAT, width=40).pack(fill=tk.X, pady=(0, 15))
tk.Label(mf, text="API URL:", font=self.font_normal, bg='white').pack(anchor=tk.W)
url_var = tk.StringVar()
tk.Entry(mf, textvariable=url_var, font=self.font_normal,
bg=self.colors['light'], relief=tk.FLAT, width=40).pack(fill=tk.X, pady=(0, 20))

defsave_api():
name = name_var.get().strip(); url_ = url_var.get().strip()
ifnoturl_:
messagebox.showwarning("警告", "请输入URL"); return
ifnotname:
name = f"API_{len(self.api_library) + 1}"
ifany(a.get('url') == url_forainself.api_library):
messagebox.showwarning("警告", "URL已存在"); return
self.api_library.append({
'id': str(uuid.uuid4()), 'name': name, 'url': url_,
'added_time': datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            })
ifself.save_api_library():
self.update_library_stats()
messagebox.showinfo("成功", "API已添加")
dialog.destroy()

bf = tk.Frame(mf, bg='white'); bf.pack(fill=tk.X)
tk.Button(bf, text="保存", command=save_api, bg=self.colors['success'],
fg='white', font=self.font_normal, padx=25, pady=8,
relief=tk.FLAT, cursor='hand2').pack(side=tk.LEFT)
tk.Button(bf, text="取消", command=dialog.destroy, bg=self.colors['dark'],
fg='white', font=self.font_normal, padx=25, pady=8,
relief=tk.FLAT, cursor='hand2').pack(side=tk.RIGHT)

defadd_library_to_caller(self):
ifnotself.api_library:
messagebox.showinfo("提示", "API库为空"); return
ifmessagebox.askyesno("确认",
f"确定要将所有 {len(self.api_library)} 个API添加到调用列表吗?"):
added = 0
forapiinself.api_library:
url_ = api.get('url')
ifurl_andurl_notinself.urls:
self.urls.append(url_)
self.tree.insert("", tk.END, values=(len(self.urls), url_, "", "", "", ""))
added += 1
ifadded>0:
self.update_stats()
messagebox.showinfo("成功", f"已添加 {added} 个API到调用列表")

defsave_selected_to_library(self):
sel = self.tree.selection()
ifnotsel:
messagebox.showwarning("警告", "请选择要保存的URL"); return
foriteminsel:
vals = self.tree.item(item)['values']
url_ = vals[1] iflen(vals) >= 2else''
ifany(a.get('url') == url_forainself.api_library):
continue
name = simpledialog.askstring("API名称", f"为URL设置名称:\n{url_}", parent=self)
ifname:
self.api_library.append({
'id': str(uuid.uuid4()), 'name': name, 'url': url_,
'added_time': datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                })
ifself.save_api_library():
self.update_library_stats()
messagebox.showinfo("成功", "已保存到API库")

# ---------- URL 操作 ----------
defadd_url(self):
url_ = simpledialog.askstring("添加URL", "请输入API URL:", parent=self)
ifurl_:
self.urls.append(url_)
self.tree.insert("", tk.END, values=(len(self.urls), url_, "", "", "", ""))
self.update_stats()
ifmessagebox.askyesno("保存到API库", "是否将此URL保存到API库?"):
name = simpledialog.askstring("API名称", f"为URL设置名称:\n{url_}", parent=self)
ifnameandnotany(a.get('url') == url_forainself.api_library):
self.api_library.append({
'id': str(uuid.uuid4()), 'name': name, 'url': url_,
'added_time': datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                    })
self.save_api_library(); self.update_library_stats()

defdelete_selected_urls(self):
sel = list(self.tree.selection())
ifnotsel:
messagebox.showwarning("警告", "请选择要删除的URL"); return
ifnotmessagebox.askyesno("确认", f"确定删除选中的 {len(sel)} 个URL吗?"):
return
sel.reverse()
deleted_indices = []
foriteminsel:
vals = self.tree.item(item)['values']
url_ = vals[1] iflen(vals) >= 2else''
ifurl_inself.urls:
deleted_indices.append(self.urls.index(url_))
self.urls.remove(url_)
self.tree.delete(item)
ifself.results:
foridxinsorted(deleted_indices, reverse=True):
ifidx<len(self.results):
self.results.pop(idx)
self.renumber_items(); self.update_stats()
ifnotself.results:
self.quick_save_button.config(state=tk.DISABLED)
self.export_button.config(state=tk.DISABLED)

defclear_urls(self):
ifnotself.urls: return
ifmessagebox.askyesno("确认", "确定要清空所有URL吗?"):
self.urls.clear(); self.results.clear()
foriteminself.tree.get_children(): self.tree.delete(item)
self.detail_text.delete(1.0, tk.END)
self.update_stats()
self.quick_save_button.config(state=tk.DISABLED)
self.export_button.config(state=tk.DISABLED)

defrenumber_items(self):
fori, iteminenumerate(self.tree.get_children(), 1):
vals = list(self.tree.item(item)['values'])
ifvals: vals[0] = i
self.tree.item(item, values=vals)

# ---------- 核心:批量调用 ----------
defcall_apis(self):
ifnotself.urls:
messagebox.showwarning("警告", "没有URL可调用"); return
self.call_button.config(state=tk.DISABLED)
self.quick_save_button.config(state=tk.DISABLED)
self.export_button.config(state=tk.DISABLED)
self.progress['value'] = 0
self.progress['maximum'] = len(self.urls)
thread = threading.Thread(target=self._call_apis_threaded, daemon=True)
thread.start()

def_call_apis_threaded(self):
self.results = []
fori, url_inenumerate(self.urls):
try:
self.after(0, self.update_status, f"正在调用: {url_[:40]}...")
start = datetime.now()
response = requests.get(url_, timeout=10)
elapsed = (datetime.now() -start).total_seconds()
code = response.status_code
length = len(response.content)
if200<= code<300:
result_text, tag = "✅ 成功", 'success'
elif400<= code<500:
result_text, tag = "⚠️ 客户端错误", 'warning'
elif500<= code<600:
result_text, tag = "⚠️ 服务器错误", 'warning'
else:
result_text, tag = "ℹ️ 其他状态", ''
self.after(0, self.update_tree_item, i, url_, code, length, elapsed, result_text, tag)
self.results.append({"序号": i+1, "URL": url_, "状态码": code,
"内容长度": length, "响应时间(秒)": elapsed, "结果": result_text})
exceptrequests.RequestExceptionase:
error_msg = str(e)
self.after(0, self.update_tree_item, i, url_, "❌ 错误", error_msg, 0,
"❌ 请求失败", 'error')
self.results.append({"序号": i+1, "URL": url_, "状态码": "错误",
"内容长度": error_msg, "响应时间(秒)": 0, "结果": "请求失败"})
self.after(0, self.progress.step, 1)
self.after(0, self.call_complete)

defupdate_tree_item(self, index, url_, code, length, elapsed, text, tag=''):
ifindex<len(self.tree.get_children()):
item_id = self.tree.get_children()[index]
self.tree.item(item_id, values=(
index+1, url_, code, length,
f"{elapsed:.3f}s"ifisinstance(elapsed, (int, float)) elseelapsed,
text), tags=(tag,) iftagelse ())

defcall_complete(self):
self.call_button.config(state=tk.NORMAL)
ifself.resultsandlen(self.results) >0:
self.quick_save_button.config(state=tk.NORMAL)
self.export_button.config(state=tk.NORMAL)
messagebox.showinfo("完成", f"API调用完成!\n共处理 {len(self.urls)} 个API")

# ---------- 详情 ----------
defshow_detail(self, event):
sel = self.tree.selection()
ifnotsel: return
vals = self.tree.item(sel[0])['values']
detail = f"📌 URL: {vals[1]}\n📊 状态码: {vals[2]}\n📏 内容长度: {vals[3]}"
iflen(vals) >4: detail += f"\n⏱️ 响应时间: {vals[4]}"
iflen(vals) >5: detail += f"\n✅ 结果: {vals[5]}"
self.detail_text.delete(1.0, tk.END)
self.detail_text.insert(1.0, detail)

# ---------- 导入导出 ----------
defdownload_template(self):
try:
df = pd.DataFrame(columns=["URL", "名称", "备注"])
filename = filedialog.asksaveasfilename(
defaultextension=".xlsx", initialfile="api_template.xlsx",
title="保存模板文件",
filetypes=[("Excel文件", "*.xlsx"), ("CSV文件", "*.csv")])
iffilename:
iffilename.endswith('.xlsx'):
df.to_excel(filename, index=False)
else:
df.to_csv(filename, index=False, encoding='utf-8-sig')
messagebox.showinfo("成功", f"模板已保存到 {filename}")
exceptExceptionase:
messagebox.showerror("错误", f"下载模板时出错: {e}")

defimport_urls(self):
try:
filename = filedialog.askopenfilename(
title="选择文件",
filetypes=[("Excel文件", "*.xlsx"), ("CSV文件", "*.csv"), ("所有文件", "*.*")])
ifnotfilename: return

iffilename.endswith('.xlsx'):
df = pd.read_excel(filename)
else:
df = None
forencin ['utf-8', 'gbk', 'latin1']:
try:
df = pd.read_csv(filename, encoding=enc); break
exceptException: pass
ifdfisNone: raiseException("无法解析文件编码")

url_col = None; name_col = None
forcolindf.columns:
cl = col.lower()
if'url'incl: url_col = col
elif'名称'inclor'name'incl: name_col = col
ifurl_colisNoneandlen(df.columns) >0: url_col = df.columns[0]
ifurl_colisNone:
messagebox.showerror("错误", "未找到URL列"); return

save_to_lib = False
ifname_colandname_colindf.columns:
save_to_lib = messagebox.askyesno(
"保存到API库",
f"检测到名称列('{name_col}'),是否将导入的URL保存到API库?")

added = 0; saved = 0
for_, rowindf.iterrows():
url_ = str(row[url_col]).strip() ifnotpd.isna(row[url_col]) else''
ifurl_andurl_notinself.urls:
self.urls.append(url_)
self.tree.insert("", tk.END, values=(len(self.urls), url_, "", "", "", ""))
added += 1
ifsave_to_libandurl_andnotany(a.get('url') == url_forainself.api_library):
name = str(row[name_col]).strip() ifnotpd.isna(row[name_col]) else''
ifnotname: name = f"API_{len(self.api_library) + saved + 1}"
self.api_library.append({
'id': str(uuid.uuid4()), 'name': name, 'url': url_,
'added_time': datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                    })
saved += 1

ifsaved:
self.save_api_library(); self.update_library_stats()
self.update_stats()
ifadded>0:
messagebox.showinfo("成功", f"导入 {added} 个URL"+
                                    (f",{saved} 个已保存到API库"ifsavedelse""))
exceptExceptionase:
messagebox.showerror("错误", f"导入URLs时出错: {e}")

defexport_results(self):
ifnotself.results:
messagebox.showwarning("警告", "没有可导出的结果,请先调用API"); return
try:
filename = filedialog.asksaveasfilename(
defaultextension=".xlsx",
initialfile=f"api_results_{len(self.results)}_"
f"{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx",
title="保存结果文件",
filetypes=[("Excel文件", "*.xlsx"), ("CSV文件", "*.csv")])
iffilename:
df = pd.DataFrame(self.results)
iffilename.endswith('.xlsx'):
df.to_excel(filename, index=False)
else:
df.to_csv(filename, index=False, encoding='utf-8-sig')
messagebox.showinfo("成功", f"结果已保存到 {filename}")
exceptExceptionase:
messagebox.showerror("错误", f"导出结果时出错: {e}")

defquick_save(self):
ifnotself.results:
messagebox.showwarning("警告", "没有可保存的结果,请先调用API"); return
try:
results_dir = os.path.join(_get_module_data_dir(), 'api_results')
os.makedirs(results_dir, exist_ok=True)
filename = f"api_results_{len(self.results)}_"\
f"{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
filepath = os.path.join(results_dir, filename)
pd.DataFrame(self.results).to_excel(filepath, index=False)
ifmessagebox.askyesno("打开文件夹", "是否打开结果文件夹?"):
os.startfile(results_dir)
exceptExceptionase:
messagebox.showerror("错误", f"一键保存时出错: {e}")


# ---------- 独立运行版 ----------
classAPICallerApp:
def__init__(self):
self.root = tk.Tk()
self.root.title("API 调用工具 v3.0")
self.root.geometry("1200x800")
self.frame = APICallerFrame(self.root)
self.frame.set_dependencies(main_app=self)
self.frame.pack(fill=tk.BOTH, expand=True)
self.status_bar = tk.Label(self.root, text="就绪", bd=1,
relief=tk.SUNKEN, anchor=tk.W)
self.status_bar.pack(side=tk.BOTTOM, fill=tk.X)

defupdate_status(self, message):
self.status_bar.config(text=message)
self.root.update_idletasks()

defrun(self):
self.root.mainloop()


if__name__ == "__main__":
APICallerApp().run()

十四、写在最后

这个工具值得说的不是 1300 行代码,而是开头那两个"刻意":刻意串行不做并发、刻意只做 GET 不做复杂请求。砍完之后,界面、并发模型、数据结构全都自然变简单——值班同学双击打开、导入 Excel、点一下按钮、拿到一份可归档的报告,整条链路没有学习成本。

今天刚踩的打包坑也是这个思路的延伸:绝大多数小工具只依赖标准库 → EXCLUDE_MODULES 排除大型库减小体积,但偏偏有那么一两个工具(比如这个)真的需要非标准库 → 加一个 FORCED_INCLUDE 字典让它自己声明放行哪些模块,既不破坏其他工具的打包体积,又能正确打包这个特例。

做内部工具尤其要警惕"功能越多越强"的陷阱:一个按钮位置都不用找、双击就出报告的流水线,对值班同学来说比一百个高级功能有用得多。

相关学习资料