news 2026/10/24 2:46:50

Jupyter Notebook转Python脚本:.ipynb转.py的完整方案与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Jupyter Notebook转Python脚本:.ipynb转.py的完整方案与避坑指南

不知道你有没有碰到过这种场景:手里拿到一份别人发过来的.ipynb文件,或者自己在 Jupyter Notebook 里反复调了一下午,最终要交付的却是一个干净的.py脚本。Notebook 里的代码块、Markdown 笔记、中间试错的输出,全都混在一个 JSON 包装的格式里。提交到 Git 仓库时 diff 乱成一团,部署到服务器上又离不开 Jupyter 环境,想用 argparse 做参数化跑批量任务更是无从下手。

把.ipynb转成.py看似是个小需求,但背后牵扯到执行结果怎么处理、魔法命令怎么保留、相对路径会不会失效、多 cell 之间的变量依赖怎么捋清,这些坑如果不提前搞清楚,转出来的.py文件根本不能直接用。这篇文章我会把从最简单到工程化的几种转换方式全部过一遍,包括你可能会踩到的坑和我自己实测下来的处理经验,希望能帮你一次性搞明白这件事。

1. 为什么要做这个转换:场景决定方案

先别急着找工具,先想清楚一件事:你转出来的.py文件要拿来干什么?转换目的不同,采用的方式完全不一样。

1.1 版本管理:Git 才是真正的痛点

最常见的需求是版本控制。Notebook 文件本身是 JSON 格式,里面塞了代码、输出、执行次数、元数据。哪怕你只是改了一行代码,整个文件的 diff 也可能变得非常难以阅读,因为输出结果、执行顺序信息都会跟着变化。代码评审的时候,对方看到的一大堆内容,根本分不清哪一行是你真正改动的。

这种情况下,转成一个干净的.py文件,只保留代码逻辑,就是最简单有效的办法。但这里有个关键细节:Notebook 里的代码往往带有执行顺序的隐含依赖,而.py文件是顺序执行的,这一点在转换时要特别留意。

1.2 生产部署:脱离 Jupyter 环境

很多团队会用 Notebook 做研究和试验,但最终上生产环境的服务,还是需要脚本。生产环境通常不会装一套完整的 Jupyter 服务,而且 Notebook 的工作模式是交互式的,一个 cell 出错不会停掉整个执行链,这对生产任务来说是致命的。

转成.py之后,脚本可以用标准的 Python 方式运行,配合 cron 定时、容器化部署、消息队列消费等场景。如果是深度学习训练任务,还可以直接在命令行用python train.py --epochs 100 --batch_size 64这样去控制,而这些在 Notebook 里做起来会非常别扭。

1.3 代码复用与模块化

如果你在 Notebook 里写了一些不错的函数和逻辑,想放到正式的项目里,让它被其他模块引用或打包成 pip 包,那你必须把代码转移到标准的.py文件里。Notebook 中的变量、函数和 cell 之间的关系,无法被外部直接import使用。

不过这种需求下,我建议不要简单地做文件格式转换,而是要做一次真正的代码重构:把纯函数放到model.py、utils.py这类模块文件里,Notebook 只保留调用流程。如果直接整个转成一个大.py文件,后期维护起来会非常痛苦。

1.4 理解 Notebook 的本质:它就是个 JSON

在动手转换之前,建议先建立一个底层认知。.ipynb文件本质上是 JSON,外面包裹了一层结构化的字段:

  • cells:单元格列表,每个单元格有cell_type(code、markdown、raw)、source(代码原文)、outputs(执行结果)等字段。
  • metadata:Kernel 信息、语言信息等。
  • nbformat:Notebook 格式版本号,目前主流是 4.x。

所以,任何转换工具的原理,本质都是读取这个 JSON,提取source字段,把 code 单元格的代码按顺序拼接成一个.py文件。理解了这一点,后面遇到那些奇怪的报错,你就能迅速定位问题。

2. 最直接的方案:Jupyter Notebook 自带的导出功能

如果你只是偶尔转一次,而且笔记本还在运行,那么最快的方式就是直接用 Jupyter Notebook 自带的导出功能,不用写任何代码,也不用安装额外的东西。

2.1 操作步骤与导出结果

在打开的.ipynb页面里,点击菜单栏的File→Download as→Python (.py),浏览器就会自动下载一个.py文件。

这个方式背后调用的其实也是nbconvert工具,将 Notebook 转换为 Python 脚本。导出的.py文件里,代码 cell 会被原样保留为可执行代码,而 Markdown cell 会被转换成注释,用#开头,穿插在代码中间。这样既能保留你的实验笔记,又不影响脚本执行。

2.2 这个方式的隐患

听起来很简单,但它有几个实际问题:

第一,导出的位置取决于当前浏览器下载设置,文件可能被下载到某个不易找到的目录,而不是 Notebook 文件所在目录,文件多了以后管理会很麻烦。

第二,如果你改了代码但还没保存,导出的是 Notebook 最近一次保存到磁盘的版本,而不是你当前编辑器里显示的最新内容。这个非常容易踩坑,我见过不止一次,有人改了半天的代码,导出之后才发现是旧版本。

第三,它是手动操作,无法批量处理,一次只能处理一个文件,不适合需要经常同步的场景。

所以这个方法适合什么情况呢?临时拿到一个文件、手头没有配置命令行环境、只需一次性转换的时候。

3. 命令行批量转换:nbconvert 的完整用法

如果项目里有一批.ipynb要转成.py,或者希望把转换过程固化到工作流里,那就得用命令行工具。

3.1 环境准备与基础命令

nbconvert是 Jupyter 生态的组成部分,如果你已经安装了 Jupyter Notebook,它会自动包含。如果没有,单独安装即可:

pip install jupyter nbconvert

最基础的转换命令:

jupyter nbconvert --to script notebook.ipynb

执行完成后,会在同一目录下生成一个notebook.py,这就是转换后的脚本。注意,它不会覆盖原文件,两者会同时存在。

3.2 常用参数和进阶用法

默认输出到原目录,如果想指定输出目录和文件名,可以用:

jupyter nbconvert --to script notebook.ipynb --output my_script --output-dir ./scripts

这里--output只写文件名主体,不用加.py后缀。--output-dir指定存放目录,目录需要提前建好,否则会报错。

批量转换也很直接,在 bash 里加上循环:

for f in *.ipynb; do jupyter nbconvert --to script "$f" --output-dir ./py_scripts done

这样会把当前目录下所有.ipynb转成.py放到py_scripts目录,文件名字保持一致。

我平时用的一个批量转换脚本还会顺带打印执行日志,方便排查:

for f in notebooks/*.ipynb; do echo "转换中: $f" jupyter nbconvert --to script "$f" --output-dir ./scripts --log-level WARN if [ $? -eq 0 ]; then echo "成功: ${f%.ipynb}.py" else echo "失败: $f" fi done

--log-level WARN可以隐藏掉一大堆无关紧要的提示信息,只有出现真正的警告或错误时才会显示,批量处理时画面会干净很多。

3.3 隐藏的坑:模板路径和自定义导出

nbconvert的转换工作基于模板。默认的 script 模板会把 Markdown cell 转成#开头的注释,Code cell 原样输出。如果你的 Notebook 里有 raw cell,它会被原样输出,不经过任何处理。

如果你想定制导出的格式,可以自己写模板。例如,你想在生成的.py文件顶部自动插入一段版权声明或导入语句,可以创建一个自定义模板文件,指定--template参数引用它。这里给一个最简单实用的模板思路:先写一个my_template.py.j2,放在本地目录,然后转换时引用。

jupyter nbconvert --to script notebook.ipynb --template my_template.py.j2

模板语法是 Jinja2 的。对大多数场景来说,默认模板已经够用了,自定义模板属于进阶需求,等真正有需要再研究也不迟。

4. 自己写 Python 脚本:实现完全可控的转换

nbconvert虽然方便,但它的行为是固定的。有些需求它满足不了,比如:去掉所有代码输出、过滤掉某些魔法命令、把 Markdown 转成#注释的同时按特定格式整理,或者按 cell 执行顺序生成注释分隔线。

这时候,自己写一个转换脚本反而是更好的选择。前面说过,.ipynb本质是 JSON,所以读进来、提取内容、拼字符串、写文件,事情就做完了。

4.1 极简版转换器

下面这个脚本是我常用基础版,可以直接复制使用:

import json import sys import pathlib def ipynb_to_py(ipynb_path, py_path): with open(ipynb_path, "r", encoding="utf-8") as f: nb = json.load(f) lines = [] cell_count = 0 for cell in nb.get("cells", []): cell_type = cell.get("cell_type", "code") source = "".join(cell.get("source", [])) if cell_type == "markdown": lines.extend(["# " + line for line in source.strip().split("\n")]) lines.extend([""]) elif cell_type == "code": cell_count += 1 if cell_count > 1: lines.extend([""]) lines.extend(["# --- Cell {} ---".format(cell_count)]) lines.extend(source.split("\n")) lines.extend([""]) with open(py_path, "w", encoding="utf-8") as f: f.writelines("\n".join(lines)) print("转换完成,共处理 {} 个代码单元格,输出到 {}".format(cell_count, py_path)) if __name__ == "__main__": src = pathlib.Path(sys.argv[1]) dst = pathlib.Path(sys.argv[2]) ipynb_to_py(src, dst)

使用方式:

python convert_ipynb.py input.ipynb output.py

这段脚本做了几件事:读取 JSON,遍历cells;Markdown 单元格的每一行前加#变成注释;代码单元格原样保留,并在每个代码单元格前插入一行# --- Cell N ---作为分隔线。这样做的好处是,生成的文件读起来结构清晰,哪个 cell 对应什么逻辑一目了然。

4.2 去掉输出和魔法命令

如果是用于部署的脚本,通常不需要 Notebook 里的执行输出,也不需要像%matplotlib inline这样的魔法命令。你可以修改代码,跳过包含魔法命令的行:

MAGIC_PREFIXES = ("%", "!") def clean_code_source(source_lines): cleaned = [] for line in source_lines: stripped = line.strip() if stripped.startswith(MAGIC_PREFIXES): continue cleaned.append(line) return cleaned

把!pip install这类 shell 魔法命令、%matplotlib inline这类自身有依赖环境的函数的指令都过滤掉,脚本才能在没有 Jupyter 环境的机器上正常运行。注意,过滤时直接用startswith判断即可,不用写复杂的正则。

4.3 处理相对路径问题

在 Notebook 里,当前工作目录通常是 Notebook 文件所在目录。而生成的.py落地之后,运行时的当前目录取决于你在哪个目录执行python xxx.py。两者不一致时,原本能正常读取的数据文件路径就会失效。

解决这个问题,最好在生成的脚本开头,自动加上一段路径切换逻辑。我自己常用的写法是:

import os import sys # 自动切换到脚本所在目录,保证相对路径稳定 os.chdir(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

这段代码放到转换脚本里,生成的.py文件在运行时,无论从哪个目录调用,工作目录都会被切回脚本自身所在的位置,相对路径就不会出错了。

4.4 保留函数定义而忽略重复执行

Notebook 的一个特点是可以重复运行同一个 cell,而且前一次执行产生的变量会保留在内存中。但.py文件每次运行都是全新开始。如果在 Notebook 里,你先定义了一个函数,接着在另一个 cell 里调用它,转换后的.py文件仍然会保留这个顺序,因为代码 cell 本身是按顺序拼接的。

但有个容易忽略的问题:如果 Notebook 中某个 cell 被多次执行,或者有覆盖式定义,比如第一次df = pd.read_csv("a.csv"),第二次df = pd.read_csv("b.csv")时,转换脚本会把两次都写进.py文件。这在 Notebook 里只是历史痕迹,但在脚本里就是重复工作。所以转出来之后,最好检查一遍是否有重复定义、重复赋值的代码,必要时手动删减。

5. 工程化实践:让转换自动化融入日常开发

手动转换总会忘。如果项目里存在“Notebook 写原型,py 脚本上生产”的常规流程,我更推荐把转换做成自动化环节。

5.1 用 Git Hook 自动同步转换文件

一种常见的做法是在 Git 的pre-commit阶段检查:如果某个.ipynb被修改了,那么对应的.py文件也需要同步更新。这样可以让所有改动在提交节点保持一致。

一个简单的 hooks 脚本思路如下:先在项目里准备一个scripts/convert_notebooks.py脚本(可以直接用上面那段自己写的转换逻辑),然后在.git/hooks/pre-commit里写:

#!/bin/bash # 检查改动的 .ipynb 文件,批量转换 changed_notebooks=$(git diff --cached --name-only --diff-filter=ACM | grep '\.ipynb$' || true) if [ -n "$changed_notebooks" ]; then echo "检测到 Notebook 变更,生成对应的 .py 文件..." python scripts/convert_notebooks.py $changed_notebooks git add . fi

这样在提交时,所有改动的 Notebook 都会自动更新对应的.py文件,并一起提交进去。不过注意,这个操作会把转换结果直接git add,所以转换脚本必须稳定、可复现,否则会引入大量无意义 diff。

5.2 通过 Makefile 管理转换任务

如果你不喜欢 Git Hook 这种略微隐式的操作,也可以在项目根目录放一个Makefile,把转换固化成一个显式的命令:

PY_FILES := $(patsubst notebooks/%.ipynb, scripts/%.py, $(wildcard notebooks/*.ipynb)) convert: $(PY_FILES) scripts/%.py: notebooks/%.ipynb @mkdir -p scripts jupyter nbconvert --to script $< --output-dir scripts --log-level WARN @echo "已转换: $< -> $@" clean: rm -f scripts/*.py

在项目根目录执行make convert,所有notebooks/下的 ipynb 就会自动转换到scripts/。这种方式是可重复执行的,团队成员用起来也简单,只依赖 Make 和 nbconvert 两个常见的工具。

5.3 CI/CD 中用转换后的脚本做测试

有些场景下,Notebook 里的代码是否真的能在干净环境里跑通,只有在 CI 里才能验证。一个可行的思路是:在 CI 工作流中先执行jupyter nbconvert --to script --execute来直接运行整个 Notebook,再运行生成的.py文件进行校验。

nbconvert有一个--execute参数,可以在转换前先执行所有的代码单元格,保留输出结果。这样可以在转换的同时验证 Notebook 的可执行性。如果某个 cell 抛异常,转换会中断并给出错误信息。这在收尾检查时非常好用。

jupyter nbconvert --to script --execute --inplace notebook.ipynb

注意--inplace表示直接在当前 Notebook 文件里写执行结果,这是一种比较容易误操作的方式,因为它会修改原始文件。我建议保留一个备份,或者在副本上执行。

6. 常见问题与排查技巧实录

下面这些问题,都是我实际帮同事处理转换时遇到过的,几乎每个月都会碰到一次。

6.1 导出的 .py 中文乱码

默认情况下,Python 3 源码文件使用 UTF-8 编码,正常不会乱码。但如果你的 Notebook 元数据里指定了其他编码,或者转换时的读取编码不对,就可能导致乱码。

排查方法很简单:用file命令查看文件编码,再用 Python 的open函数指定encoding="utf-8"读取。如果源文件本身没问题,问题可能出在source字段里的特殊字符。稳妥的做法是在你自己的转换脚本里,统一用encoding="utf-8"读写两侧文件。

6.2 Notebook 中的魔法命令让脚本无法运行

Notebook 里的%matplotlib inline、%load_ext autoreload、%%time这类魔法命令,放到.py脚本里执行时大多会报错,因为这些命令是 IPython Kernel 提供的,标准 Python 解释器不认识。

处理方式前面已经提过,就是在转换时过滤掉%和!开头的行。但有一点要特别注意:!pip install xxx这种命令如果被过滤掉,脚本在部署环境时可能缺少依赖。所以更稳妥的做法是,把环境依赖统一写在requirements.txt或environment.yml里,而不是在 Notebook 中安装。

6.3 转换后的脚本运行顺序与 Notebook 不一致

Notebook 允许任意顺序执行 cell,而脚本是自上而下顺序执行的。如果你的 Notebook 里有这样的结构:cell 3 使用了 cell 1 定义的变量,但你在操作时先把 cell 3 跑了一遍,然后才去跑 cell 1,那么 Notebook 是正确的,但转成脚本后就是错误代码。

这个问题只能靠人工检查。我有个习惯:转完.py后,先看一遍脚本,把每个 cell 的依赖关系梳理出来,如果有前向引用,就在函数定义处加上if __name__ == "__main__"保护,或者调整代码顺序。

6.4 输出内容太多,nbconvert 处理超时

如果你的 Notebook 里有大量print输出或很长的 DataFrame 展示,nbconvert转换时可能会因为处理大量输出而变得很慢,甚至直接超时。

解决方法是转换前清空输出。Notebook 的Edit → Clear Outputs → Clear All Outputs会把所有输出清空,文件体积瞬间变小,转换速度大幅提升。或者在命令行里用jupyter nbconvert --ClearOutputPreprocessor.enabled=True --to script notebook.ipynb,同样可以。

6.5 转换后 # 注释的前缀混乱

当 Markdown 里有结构化的列表、标题(# 标题)时,转换工具会在每行前加一个#,但原有内容里的#是 Markdown 标题的语法,加完之后可能出现# # 标题这样双重井号的情况。这在脚本里不致命,只是看起来比较乱。

如果你在意这个细节,可以在自己的转换脚本里对 Markdown 源做一次清理:先把多余的#合并成#,确保注释美观。也可以接受,反正不影响运行。

6.6 ipynb 文件损坏或非标准结构

有些.ipynb文件是手工编辑过的,或者由第三方工具生成,缺少某些字段。直接用json.load读取可能报错,用nbconvert也可能会抛 KeyError。

面对这种情况,我建议先用 Python 做一次容错读取,设置default值,保证cells字段缺失时也能继续处理。例如在 4.1 的脚本里,nb.get("cells", [])就是容错处理。对于真正损坏的文件,则只能从 Git 或备份中恢复,或者找对方重新导出。

7. 一些适用场景的综合对比与最终建议

到这里,三种主要方式都讲完了。我整理了一张自己的选型参考表,每次处理 Notebook 转换时,我基本会按照这张表来选路。

需求场景推荐方案原因
偶尔转单个文件,人在电脑前Jupyter 自带导出零安装,操作直观,适合临时救急
项目里有一批 Notebook,需要批量处理命令行 nbconvert支持循环批量,稳定可靠,团队可复现
需要自定义格式、过滤魔法命令、清理输出自定义转换脚本逻辑完全可控,想怎么处理都行
要把转换流程固化到团队协作中Git Hook / Makefile自动化最能保证一致性,省去手动操作
需要确保 Note book 代码本身可运行nbconvert --execute执行即校验,发现问题当场暴露

我的个人建议是:第一个先学会用jupyter nbconvert --to script,它是功能与复杂度之间的最佳平衡点。命令行的灵活性已经能满足 80% 的场景,而且可批量、可脚本化。剩下 20% 的特殊需求,再动手写自定义转换脚本,这样的投入产出比最高。

如果你在公司团队中工作,我强烈推荐把转换脚本做成交付物的一部分:不管你是用Makefile还是用自定义转换脚本,都把它放仓库里,README 里写清楚用法。这样每个人拿到的 Notebook 和.py都能保持同步,代码评审也会顺畅很多。

说一个实际场景吧——有一个项目,负责人交给我一个 Notebook,让我把里面的图像处理的算法提取到生产脚本里,同时还要保证数据路径正确。我当时就是用自写的转换脚本,过滤了所有的%开头魔法命令和输出,加上了os.chdir自动切换到脚本目录,再把核心算法抽成独立函数。那一次经历让我深刻体会到:直接简单转出来的文件只能算半成品,真正能用于生产环境,一定要做路径、命令清理和函数结构的二次整理。

8. 最后补充几个平时最容易忽略的小细节

转换这件事本身不难,但细节决定了转出来的东西能不能直接用。建议你在转换之后,把这几个点过一遍。

第一,转换后认真读一遍脚本,特别是 cell 之间的数据流。Notebook 里的运行顺序带有很多“藏起来”的状态,比如某个变量可能是某次手动执行的结果,并没有在代码里体现。这类历史遗留问题,转成脚本后绝不会自动解决,只能人工清理。

第二,关于%matplotlib inline,在 Notebook 里它是标配,但在脚本里如果你还想看到图,需要改成plt.show()。转换脚本时最好将这样一个映射关系处理好,否则图上不显示,很容易误以为代码出错了。

第三,ipynb 转为 py 后,尽量保持函数化的写法,而不是一大串脚本逻辑。Notebook 天然是脚本式的,每块代码执行都产生副作用。到了.py里,如果稍后要测试、维护、复用,函数化会轻松很多。你可以在转换后手动包装一下,把每个 cell 的逻辑抽象为函数,再在__main__里按顺序调用。

第四,不要忘了正则转换后的文件大小变化。Notebook 中如果存了大量输出图像或者长文本输出,文件会很大,转换为.py后会瘦身,但反过来也说明 Notebook 里塞了一堆不该提交的东西。如果想要清爽的 Git diff,建议从一开始就统一提交策略:要么只提交.py,要么自动同步且只提交最小的 diff。

我自己现在的习惯比较固定:Notebook 只用作交互探索和可视化分析,一旦逻辑稳定,会立刻手动把核心逻辑迁移到src/目录下的正式模块中,Notebook 里只保留调用代码。这样.ipynb转.py这件事就不再是一个需要反复做的操作,而是一个规范流程里的普通环节。这比你找到一个天衣无缝的转换工具,要靠谱得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 2:45:33

基于STM32的永磁同步电机无感FOC控制策略与工程实践

引言&#xff1a;为什么大家都在搞无感FOC三年前我第一次拿到一块带FOC库的开发板&#xff0c;运行例程让电机转起来那一刻&#xff0c;说实话有点懵——电机嗡嗡响着转得挺欢&#xff0c;但代码里我没接任何位置传感器。霍尔没焊、编码器没插&#xff0c;就靠三相电流和母线电…

作者头像 李华
网站建设 2026/10/24 2:45:03

意图识别与文本向量化:4道NLP高频考题拆解与实战经验

“意图识别”和“文本向量化”这两个词&#xff0c;在 NLP 相关的笔试和面试里出现频率高得惊人。我这些年带团队、面试候选人的过程中&#xff0c;几乎每个候选人都绕不开这道坎&#xff1a;要么是把意图识别做成了一堆 if-else&#xff0c;要么把向量化理解成单纯的词频统计&…

作者头像 李华
网站建设 2026/10/24 2:42:46

URP Kawase Blur 全屏模糊 RendererFeature 拆解

简介&#xff1a;这是一份面向Unity URP学习者与渲染开发者的实验性Kawase模糊项目&#xff0c;基于Unity 2019.3构建&#xff0c;重点演示如何通过ScriptableRenderFeature在URP中实现后处理式模糊效果。项目围绕CommandBuffer多趟渲染、弹性降采样、可变模糊次数与临时纹理Bl…

作者头像 李华
网站建设 2026/10/24 2:40:56

Transcriber语音标注工具:高精度毫秒级声学事件标注实践指南

简介&#xff1a;本资源是开源语音标注工具Transcriber的完整安装与运行包&#xff0c;面向语音处理研究者、AI模型训练工程师及多媒体内容制作者&#xff0c;解决语音数据人工转录效率低、时间轴对齐难、多人协作不便等核心问题。压缩包共307个文件&#xff0c;涵盖87个Tcl脚本…

作者头像 李华
网站建设 2026/10/24 2:40:55

SpringBoot 实战:RTSP/RTMP 转 HTTP-FLV 与 WebSocket-FLV 流媒体网关

简介&#xff1a;面向 Java 后端开发者的流媒体播放集成资源&#xff0c;聚焦于将 RTSP、RTMP 等流地址经服务端转换后推送给前端&#xff0c;以 FLV 与 WebSocket 两种方式实现浏览器端播放&#xff0c;适合具备 Spring Boot 基础、需要快速落地视频监控或直播预览场景的中级开…

作者头像 李华
网站建设 2026/10/24 2:40:49

技术博文生成需匹配技术素材:部署、接口与排错规范

这个标题和配套材料&#xff08;“国际传播纪录片《大道——永远的长征》发布仪式”、“将台堡红军长征会师纪念园”等&#xff09;属于新闻/历史纪念类题材&#xff0c;与 CSDN 技术博客所需的技术项目、工具、模型、部署、接口、批量任务等内容不匹配&#xff1b;同时这类历史…

作者头像 李华