在 Hexo + Fluid 博客中实现可复用的 Python 交互运行组件(Pyodide + PJAX)

写 Python 包用法博客时,常见的困扰是:为了展示命令行输出效果,需要额外贴一堆终端截图或手写输出。

更理想的方式是:在文章内嵌一个“运行按钮”,让读者直接在网页里运行示例代码并看到输出。➡️


本文给出一套可复用的工程化方案:

  • 每篇文章只写一个容器(一行 HTML)
  • 所有 UI/CSS/逻辑集中在站点级 CSS/JS 文件中,可在多篇文章复用
  • Pyodide 按需加载:只有点击“运行”才加载运行时,首次慢、后续浏览器缓存
  • 兼容 Fluid PJAX:站点开启 PJAX 时,页面切换后组件仍可正常初始化

效果概览

组件包含四块内容:

  1. 说明(首次加载较慢、后续缓存)
  2. Python 代码(可编辑)
  3. 模拟命令行参数输入(相当于 python main.py ...
  4. 输出窗口

读者修改代码或参数后点击运行,即可看到输出。


方案设计要点

1. 全站只初始化一次 Pyodide

  • 通过全局变量缓存 loadPyodide() 的 Promise 与 pyodide 实例
  • 同一页多个 demo 不会重复下载运行时
  • 只有用户点击“运行”时才触发下载,避免首屏变慢

2. 代码提取的两种模式

组件支持两种模式:

  • data-from="prev":自动截取容器前一个代码块作为初始代码(文章侧最省事)
  • data-from="inline":容器内部提供隐藏源码(更稳,避免高亮结构导致换行丢失)

实践建议:

  • 大多数文章用 prev 够用;
  • 若你的主题/高亮器把换行做成 span.line + CSS(DOM 无真实 \n),建议用 inline 强保证。

3. 处理高亮器伪换行和末尾 PYTHON

很多 Hexo 高亮输出是“每行一个 span”,靠 CSS 换行,DOM 里没有 \n。直接 textContent 会把整段代码粘成一行,造成语法/缩进错误。本文方案会:

  • 优先检测 span.line / hljs-ln-line 等行元素,手动 join("\n") 恢复真实换行
  • 过滤某些主题在代码块末尾追加的语言标签(例如 PYTHON

实现步骤

Step 1:添加全站 CSS(可复用)

在 Hexo 根目录创建:

source/css/py-demo.css

点击展开:py-demo.css 的代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/* 仅影响组件内部:.py-demo */
.py-demo {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, "Noto Sans";
margin: 16px 0;
}

.py-demo .py-demo__grid { display: grid; grid-template-columns: 1fr; gap: 12px; }
.py-demo .py-demo__card { border: 1px solid #ddd; border-radius: 10px; padding: 12px; background: #fff; }
.py-demo .py-demo__label { font-weight: 600; display: block; margin-bottom: 6px; }
.py-demo .py-demo__hint { color: #666; font-size: 13px; line-height: 1.5; }

.py-demo textarea,
.py-demo input {
width: 100%;
box-sizing: border-box;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono","Courier New", monospace;
border: 1px solid #ccc;
border-radius: 8px;
}

.py-demo textarea { height: 240px; padding: 10px; }
.py-demo input { padding: 10px; }

.py-demo .py-demo__bar { display: flex; gap: 10px; margin-top: 10px; align-items: center; flex-wrap: wrap; }

.py-demo button {
padding: 10px 14px;
border-radius: 8px;
border: 1px solid #333;
background: #111;
color: #fff;
cursor: pointer;
}
.py-demo button:disabled { opacity: .6; cursor: not-allowed; }

.py-demo pre.py-demo__out {
white-space: pre-wrap;
word-break: break-word;
padding: 10px;
border-radius: 8px;
border: 1px solid #ccc;
background: #fafafa;
min-height: 90px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono","Courier New", monospace;
}

/* inline 源码容器可隐藏 */
.py-demo .py-demo__source { display: none; }

Step 2:添加全站 JS

在 Hexo 根目录创建:

source/js/py-demo.js

点击展开:py-demo.js 的代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
(function () {
// Pyodide 版本统一在此管理
const PYODIDE_BASE = "https://cdn.jsdelivr.net/pyodide/v0.29.2/full/";
const PYODIDE_JS = PYODIDE_BASE + "pyodide.js";

function injectPyodideScriptOnce() {
if (window.__pyDemoPyodideScriptPromise) return window.__pyDemoPyodideScriptPromise;

window.__pyDemoPyodideScriptPromise = new Promise((resolve, reject) => {
if (window.loadPyodide) return resolve();

const s = document.createElement("script");
s.src = PYODIDE_JS;
s.async = true;
s.onload = () => resolve();
s.onerror = () => reject(new Error("Failed to load pyodide.js"));
document.head.appendChild(s);
});

return window.__pyDemoPyodideScriptPromise;
}

async function getPyodideOnce() {
if (window.__pyDemoPyodide) return window.__pyDemoPyodide;
if (window.__pyDemoPyodidePromise) return window.__pyDemoPyodidePromise;

window.__pyDemoPyodidePromise = (async () => {
await injectPyodideScriptOnce();
const pyodide = await window.loadPyodide({ indexURL: PYODIDE_BASE });
window.__pyDemoPyodide = pyodide;
return pyodide;
})();

return window.__pyDemoPyodidePromise;
}

// -------------------------------
// Code extraction & sanitation
// -------------------------------

// B 方案:容器内 inline 源码(强保证)
// 支持:
// 1) <textarea class="py-demo__source"> ... </textarea>
// 2) <script type="text/plain" class="py-demo__source"> ... </script>
function getInlineSource(root) {
const ta = root.querySelector("textarea.py-demo__source");
if (ta) return ta.value || ta.textContent || "";
const scr = root.querySelector("script.py-demo__source");
if (scr) return scr.textContent || "";
return "";
}

// 把“高亮器用 CSS 换行”的 DOM 恢复成包含真实 \n 的源码字符串
function extractCodeWithNewlines(node) {
if (!node) return "";

const lineEls = node.querySelectorAll(
"span.line, span.hljs-ln-line, div.line, div.hljs-ln-line"
);

if (lineEls && lineEls.length) {
const lines = Array.from(lineEls).map(el => el.textContent);

// 去掉末尾空行
while (lines.length && !lines[lines.length - 1].trim()) lines.pop();

// 去掉某些主题在代码块末尾附加的语言标签(例如:PYTHON)
if (lines.length && lines[lines.length - 1].trim().toUpperCase() === "PYTHON") {
lines.pop();
}

return lines.join("\n");
}

// 处理 <br> 换行的情况
const clone = node.cloneNode(true);
clone.querySelectorAll("br").forEach(br => br.replaceWith("\n"));
let s = clone.textContent || "";

// 兜底清理末尾语言标签
s = s.replace(/\n[ \t]*PYTHON[ \t]*\n?$/i, "\n");
return s;
}

// 统一净化:换行、NBSP、零宽字符、Tab 等
function normalizePythonCode(raw) {
if (!raw) return "";
let s = String(raw);

// 统一换行
s = s.replace(/\r\n?/g, "\n");

// NBSP -> 普通空格(避免 IndentationError)
s = s.replace(/\u00A0/g, " ");

// 去除零宽字符 / BOM
s = s.replace(/[\u200B-\u200D\uFEFF]/g, "");

// Tab 统一为 4 空格(避免 tab/space 混用)
s = s.replace(/\t/g, " ");

// 去掉首尾多余空行
s = s.replace(/^\s*\n+/, "").replace(/\n+\s*$/, "\n");

return s;
}

// 去“公共缩进”,不破坏内部相对缩进
function dedent(s) {
const lines = String(s || "").split("\n");
let minIndent = null;

for (const line of lines) {
if (!line.trim()) continue;
const m = line.match(/^ +/);
const indent = m ? m[0].length : 0;
minIndent = (minIndent === null) ? indent : Math.min(minIndent, indent);
if (minIndent === 0) break;
}

if (!minIndent) return lines.join("\n");

const pad = " ".repeat(minIndent);
return lines.map(l => (l.startsWith(pad) ? l.slice(minIndent) : l)).join("\n");
}

// 从“容器前一个代码块”提取 Python 源码(兼容 Hexo 常见高亮结构)
function findPrevCodeText(root) {
let el = root.previousElementSibling;
while (el) {
// 1) Hexo highlight:figure.highlight > table > td.code > pre
if (el.matches && el.matches("figure.highlight")) {
const pre = el.querySelector("td.code pre");
const code = extractCodeWithNewlines(pre);
if (code && code.trim()) return code;
}

// 2) 普通 fenced:<pre><code>...</code></pre>
const preCode = el.querySelector && el.querySelector("pre code");
if (preCode) {
const code = extractCodeWithNewlines(preCode);
if (code && code.trim()) return code;
}

// 3) 兜底:直接 pre
if (el.matches && el.matches("pre")) {
const code = extractCodeWithNewlines(el);
if (code && code.trim()) return code;
}

el = el.previousElementSibling;
}
return "";
}

// -------------------------------
// UI
// -------------------------------

function buildUI(root, defaultCode, defaultArgv) {
root.innerHTML = `
<div class="py-demo__grid">
<div class="py-demo__card">
<div class="py-demo__hint">
说明:点击运行后才会加载 Pyodide。首次加载会下载运行时,之后通常会被浏览器缓存。
</div>
</div>

<div class="py-demo__card">
<label class="py-demo__label">Python 代码(可编辑)</label>
<textarea class="py-demo__code"></textarea>
</div>

<div class="py-demo__card">
<label class="py-demo__label">模拟命令行参数(相当于:python hello.py ...)</label>
<input class="py-demo__argv" placeholder="例如:Alice 或:--help" />
<div class="py-demo__bar">
<button class="py-demo__run">运行</button>
<span class="py-demo__status py-demo__hint"></span>
</div>
</div>

<div class="py-demo__card">
<label class="py-demo__label">输出</label>
<pre class="py-demo__out"></pre>
</div>
</div>
`;

const codeEl = root.querySelector(".py-demo__code");
const argvEl = root.querySelector(".py-demo__argv");
codeEl.value = defaultCode || "";
argvEl.value = defaultArgv || "";
}

// 用 Python 内部 redirect_stdout/redirect_stderr 捕获输出(避免 setStdout 全局污染)
async function runCode({ code, argv, statusEl }) {
statusEl.textContent = "正在加载 Pyodide…(首次可能较慢)";

const pyodide = await getPyodideOnce();

// 可选:根据 imports 自动加载 Pyodide 自带包(对 stdlib 一般不需要)
try {
statusEl.textContent = "解析依赖并准备运行…";
await pyodide.loadPackagesFromImports(code);
} catch (_) {}

statusEl.textContent = "运行中…";

const runner =
`import sys, shlex, traceback, io, contextlib
argv_str = ${JSON.stringify(argv || "")}
sys.argv = ["hello.py"] + shlex.split(argv_str)

_code = ${JSON.stringify(code || "")}

_stdout = io.StringIO()
_stderr = io.StringIO()

try:
with contextlib.redirect_stdout(_stdout), contextlib.redirect_stderr(_stderr):
exec(_code, {"__name__": "__main__"})
except SystemExit:
# argparse 的 --help / 参数错误通常会触发 SystemExit
pass
except Exception:
traceback.print_exc(file=_stderr)

_out = _stdout.getvalue()
_err = _stderr.getvalue()
_result = _out + (("\\n" if _out and _err else "") + _err)
_result
`;
const out = await pyodide.runPythonAsync(runner);
statusEl.textContent = "完成。";
return String(out ?? "");
}

// -------------------------------
// Init
// -------------------------------

function initOne(root) {
if (root.dataset.pyDemoInited === "1") return;
root.dataset.pyDemoInited = "1";

const from = root.dataset.from || "prev"; // prev | inline
const defaultArgv = root.dataset.argv || "";

// 关键:先取源码(buildUI 会覆盖 root.innerHTML)
let defaultCode = "";
if (from === "inline") {
defaultCode = getInlineSource(root);
} else {
defaultCode = findPrevCodeText(root);
}

defaultCode = dedent(normalizePythonCode(defaultCode));

buildUI(root, defaultCode, defaultArgv);

const outEl = root.querySelector(".py-demo__out");
const statusEl = root.querySelector(".py-demo__status");
const runBtn = root.querySelector(".py-demo__run");
const codeEl = root.querySelector(".py-demo__code");
const argvEl = root.querySelector(".py-demo__argv");

if (!defaultCode.trim()) {
statusEl.textContent = (from === "inline")
? "未找到容器内的源码(.py-demo__source)。"
: "未找到上一段代码块,请确认容器紧跟在 Python 代码块后面。";
}

// 串行队列,避免同一页并发运行
if (!window.__pyDemoQueue) window.__pyDemoQueue = Promise.resolve();

runBtn.addEventListener("click", () => {
outEl.textContent = "";
runBtn.disabled = true;

window.__pyDemoQueue = window.__pyDemoQueue.then(async () => {
try {
const output = await runCode({
code: codeEl.value,
argv: argvEl.value,
statusEl
});
outEl.textContent = output;
} catch (e) {
outEl.textContent = "[JS Error]\n" + (e && e.stack ? e.stack : String(e));
statusEl.textContent = "运行失败。";
} finally {
runBtn.disabled = false;
}
});
});
}

function initAll() {
document.querySelectorAll("[data-py-demo]").forEach(initOne);
}

// 首次加载
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initAll);
} else {
initAll();
}

// 兼容 Fluid PJAX:页面局部刷新后重新扫描并初始化
document.addEventListener("pjax:complete", initAll);
})();

Step 3:在 Fluid 配置中全站引入 CSS/JS

在 Fluid 主题配置(通常是 themes/fluid/_config.yml_config.fluid.yml)中加入:

1
2
custom_css: /css/py-demo.css
custom_js: /js/py-demo.js

Step 4:文章里只放“一行容器”

方式 A:自动读取“上一段代码块”(最省事)

.md 文件中写代码块:

1
2
3
4
5
6
7
8
9
10
11
12
```python
import argparse

def main():
parser = argparse.ArgumentParser(description="Say hello to someone.")
parser.add_argument("name", help="The person's name")
args = parser.parse_args()
print(f"Hello, {args.name}!")

if __name__ == "__main__":
main()
```

下面紧跟着写:

1
2
3
{% raw %}
<div class="py-demo" data-py-demo data-from="prev" data-argv="Alice"></div>
{% endraw %}

要求:容器必须紧跟在代码块后面(中间不要插入太多其他元素)。

方式 B:容器内自带源码(更稳,强保证)

当你的高亮器结构非常复杂、或你希望不依赖“上一段代码块”时,使用 inline 模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{% raw %}
<div class="py-demo" data-py-demo data-from="inline" data-argv="Alice">
<textarea class="py-demo__source" style="display:none;">
import argparse

def main():
parser = argparse.ArgumentParser(description="Say hello to someone.")
parser.add_argument("name", help="The person's name")
args = parser.parse_args()
print(f"Hello, {args.name}!")

if __name__ == "__main__":
main()
</textarea>
</div>
{% endraw %}

注意事项与常见坑

  1. 强烈建议按需加载(本文默认)

    不要在页面加载时就初始化 Pyodide,否则首屏会变慢;按需加载只在点击运行时触发下载。

  2. buildUI 会覆盖容器内部内容

    所以 inline 模式下必须在 buildUI() 之前先读取 py-demo__source,本文 JS 已处理。

  3. 高亮器“换行不是 ”是最常见坑

    若你发现提取出来粘成一行,说明高亮器用 span.line + CSS 换行,必须像本文一样 join \n

  4. 末尾出现 PYTHON 等语言标签

    这是主题/插件把语言名塞进了代码块 DOM,提取时要过滤。本文已对末尾 PYTHON 做了精确过滤(只删最后一行完全等于标签的情况)。

  5. 缩进/不可见字符导致 Python 报错

    • NBSP(\u00A0) 可能引发 IndentationError
    • 零宽字符可能引发 SyntaxError 本文在 normalizePythonCode() 中已做净化。
  6. PJAX 场景必须在 pjax:complete 重新扫描初始化

    否则切换页面后组件 DOM 出现但脚本不再执行。本文已监听 pjax:complete

  7. 安全边界(重要)

    • Pyodide 代码运行在浏览器沙箱内,但仍可能造成长时间占用 CPU(死循环)。
    • 读者有可能误操作,可以加一个“超时”机制(例如把运行放进 Web Worker 或在 Python 侧加入简单的超时保护)。可以在本文基础上进一步设置,这里不再展开。

总结

  • 通过把 UI 与逻辑抽到全站的 py-demo.css + py-demo.js,可以在任意文章中用“一行容器”快速嵌入 Python 交互演示,同时兼顾加载性能与 PJAX 兼容性。
  • 后续只需维护这两个文件,就能在所有文章中统一升级样式与功能。

祝你玩得开心!🚀


在 Hexo + Fluid 博客中实现可复用的 Python 交互运行组件(Pyodide + PJAX)
https://nanana-szz.github.io/05-python-interaction/
作者
John Doe
发布于
2026年1月24日
许可协议