// ==UserScript==
// @name 网页注入 Pyodide 环境(Matplotlib 浏览器兼容+完整功能版)
// @namespace https://github.com/yourname/userscripts
// @version 1.0.7
// @description 支持 Matplotlib 浏览器可视化输出、动态加载 Numpy/Pandas/Jieba/Matplotlib/Sklearn 等库,取消全局快捷键
// @author Your Name
// @match *://*/*
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @require https://cdn.jsdelivr.net/pyodide/v0.25.0/full/pyodide.js
// @run-at document-end
// ==/UserScript==
(function() {
'use strict';
// 一、样式定义(包含 Matplotlib 图片展示样式)
GM_addStyle(`
#pyodide-container {
position: fixed;
right: 20px;
top: 20px;
width: 600px;
height: 800px; /* 增高面板,容纳图片区域 */
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 99999999;
display: flex;
flex-direction: column;
overflow: hidden;
}
#pyodide-header {
padding: 12px 16px;
background: #2c3e50;
color: #fff;
font-size: 16px;
font-weight: 600;
display: flex;
justify-content: space-between;
align-items: center;
}
#pyodide-close {
cursor: pointer;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
background: rgba(255,255,255,0.2);
transition: background 0.2s;
}
#pyodide-close:hover {
background: rgba(255,255,255,0.3);
}
#pyodide-code-editor {
flex: 1;
padding: 16px;
border: none;
outline: none;
resize: none;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 14px;
background: #f8f9fa;
border-bottom: 1px solid #e0e0e0;
}
#pyodide-controls {
padding: 12px 16px;
background: #f1f3f5;
display: flex;
gap: 10px;
border-bottom: 1px solid #e0e0e0;
}
#pyodide-run {
padding: 8px 16px;
background: #27ae60;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: background 0.2s;
}
#pyodide-run:hover {
background: #219653;
}
#pyodide-clear {
padding: 8px 16px;
background: #e74c3c;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: background 0.2s;
}
#pyodide-clear:hover {
background: #c0392b;
}
#pyodide-status {
margin-left: auto;
line-height: 32px;
color: #666;
font-size: 14px;
}
/* 结果+图片包装器 */
#pyodide-result-wrapper {
display: flex;
flex-direction: column;
flex: 0 0 280px; /* 固定高度,容纳结果和图片 */
overflow: hidden;
}
#pyodide-result {
flex: 0 0 140px;
padding: 16px;
background: #f8f9fa;
overflow-y: auto;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 14px;
color: #2c3e50;
border-bottom: 1px solid #e0e0e0;
}
#pyodide-mpl-img {
flex: 0 0 140px;
padding: 10px;
background: #f8f9fa;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
#pyodide-mpl-img img {
max-width: 100%;
max-height: 100%;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.pyodide-hidden {
display: none !important;
}
/* 选中执行提示临时样式 */
.pyodide-select-tip {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
padding: 12px 24px;
background: rgba(0,0,0,0.8);
color: #fff;
border-radius: 6px;
z-index: 99999999;
font-size: 14px;
opacity: 0;
transition: opacity 0.3s;
}
.pyodide-select-tip.show {
opacity: 1;
}
`);
// 二、创建 DOM 结构(包含 Matplotlib 图片展示区域)
const pyodideContainer = document.createElement('div');
pyodideContainer.id = 'pyodide-container';
pyodideContainer.className = 'pyodide-hidden';
pyodideContainer.innerHTML = `
状态:未加载
`;
// 创建选中执行临时提示
const selectTip = document.createElement('div');
selectTip.id = 'pyodide-select-tip';
selectTip.className = 'pyodide-select-tip';
document.body.appendChild(pyodideContainer);
document.body.appendChild(selectTip);
// 三、核心变量与元素获取
let pyodide = null; // 存储 Pyodide 实例
const loadedPackages = new Set(); // 缓存已加载的包,避免重复加载
const $ = (id) => document.getElementById(id);
const codeEditor = $('pyodide-code-editor');
const resultPanel = $('pyodide-result');
const mplImgPanel = $('pyodide-mpl-img'); // Matplotlib 图片展示区域
const statusText = $('pyodide-status');
const runBtn = $('pyodide-run');
const clearBtn = $('pyodide-clear');
const closeBtn = $('pyodide-close');
const selectTipEl = $('pyodide-select-tip');
// 四、工具函数(包含核心 Matplotlib 浏览器兼容函数)
/**
* 追加结果到结果面板
* @param {string} content 要追加的内容
* @param {boolean} isError 是否为错误信息
*/
const appendResult = (content, isError = false) => {
const contentElement = document.createElement('div');
contentElement.style.margin = '4px 0';
contentElement.style.color = isError ? '#e74c3c' : '#2c3e50';
// 格式化换行和空格
contentElement.textContent = content;
resultPanel.appendChild(contentElement);
// 滚动到底部
resultPanel.scrollTop = resultPanel.scrollHeight;
};
/**
* 更新加载状态
* @param {string} status 状态文本
* @param {string} color 文本颜色
*/
const updateStatus = (status, color = '#666') => {
statusText.textContent = `状态:${status}`;
statusText.style.color = color;
};
/**
* 格式化时间戳(用于 Console 日志)
* @returns {string} 格式化后的时间戳
*/
const formatTimestamp = () => {
const now = new Date();
return `[${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}]`;
};
/**
* 显示临时提示信息
* @param {string} msg 提示内容
* @param {boolean} isError 是否为错误提示
*/
const showTip = (msg, isError = false) => {
selectTipEl.textContent = msg;
selectTipEl.style.color = isError ? '#ff6b6b' : '#fff';
selectTipEl.classList.add('show');
setTimeout(() => {
selectTipEl.classList.remove('show');
}, 2000);
};
/**
* 提取网页中用户选中的文本内容
* @returns {string} 去重空白后的选中内容
*/
const getSelectedText = () => {
// 兼容不同浏览器的选中内容获取
let selectedCode = '';
if (window.getSelection) {
selectedCode = window.getSelection().toString().trim();
} else if (document.selection && document.selection.type === 'Text') {
selectedCode = document.selection.createRange().text.trim();
}
// 过滤纯空白内容
return selectedCode.replace(/\s+/g, ' ') !== ' ' ? selectedCode : '';
};
/**
* 检测代码中是否包含需要的库关键字,返回需要加载的包列表
* 支持:numpy、pandas、jieba、matplotlib、sklearn、scipy、statsmodels
* @param {string} code 待执行的 Python 代码
* @returns {Array} 需要加载的包名列表
*/
const detectRequiredPackages = (code) => {
const requiredPackages = [];
// 1. 基础科学计算库
const hasNumpy = /numpy|np\./i.test(code);
const hasPandas = /pandas|pd\./i.test(code);
const hasScipy = /scipy|sp\./i.test(code); // 常用统计库
// 2. 自然语言处理:jieba
const hasJieba = /jieba/i.test(code);
// 3. 数据可视化:matplotlib
const hasMatplotlib = /matplotlib|plt\./i.test(code);
// 4. 机器学习:sklearn(scikit-learn)
const hasSklearn = /sklearn|scikit-learn/i.test(code);
// 5. 统计建模:statsmodels(常用统计库)
const hasStatsmodels = /statsmodels|sm\./i.test(code);
// 按依赖优先级判断,未加载则加入列表(注意:Pyodide 包名与导入名可能不一致)
const packageMap = [
{ key: hasNumpy, pkg: 'numpy' },
{ key: hasScipy, pkg: 'scipy' },
{ key: hasPandas, pkg: 'pandas' },
{ key: hasJieba, pkg: 'jieba' },
{ key: hasMatplotlib, pkg: 'matplotlib' },
{ key: hasSklearn, pkg: 'scikit-learn' }, // 注意:包名是 scikit-learn,不是 sklearn
{ key: hasStatsmodels, pkg: 'statsmodels' }
];
packageMap.forEach(item => {
if (item.key && !loadedPackages.has(item.pkg)) {
requiredPackages.push(item.pkg);
}
});
return requiredPackages;
};
/**
* 动态加载指定的 Pyodide 包
* @param {Array} packages 待加载的包名列表
*/
const loadRequiredPackages = async (packages) => {
if (packages.length === 0 || !pyodide) return;
try {
// 拼接包名,更新状态和日志
const packageStr = packages.join('、');
console.log(`${formatTimestamp()} [Pyodide] 开始动态加载包:${packageStr}...`);
updateStatus(`正在动态加载 ${packageStr}...`, '#3498db');
showTip(`正在加载 ${packageStr}...`);
// 批量加载包(Pyodide 自动处理依赖关系)
await pyodide.loadPackage(packages);
// 标记包已加载,更新状态和日志
packages.forEach(pkg => {
loadedPackages.add(pkg);
});
console.log(`${formatTimestamp()} [Pyodide] 包 ${packageStr} 加载完成!`);
updateStatus(`包 ${packageStr} 加载完成`, '#27ae60');
showTip(`包 ${packageStr} 加载完成!`);
} catch (error) {
const packageStr = packages.join('、');
console.error(`${formatTimestamp()} [Pyodide] 包 ${packageStr} 加载失败:`, error);
updateStatus(`包 ${packageStr} 加载失败`, '#e74c3c');
appendResult(`【错误】包 ${packageStr} 加载失败:${error.message}`, true);
showTip(`包 ${packageStr} 加载失败!`, true);
throw error;
}
};
/**
* 核心:Matplotlib 图片浏览器展示函数(纯 Python 执行核心逻辑,解决 format 未定义错误)
* @param {object} plt Matplotlib pyplot 实例
*/
const mplShowInBrowser = async (plt) => {
try {
// 步骤1:将完整的 Python 逻辑写入字符串,仅使用英文标点+正确注释
const base64Str = await pyodide.runPythonAsync(`
from io import BytesIO
import base64
import traceback
# 从全局变量中获取传入的 plt 实例(Python 环境内,正确注释用 #)
global_plt = globals()['input_plt']
# 步骤1:创建内存缓冲区(纯 Python 语法,英文标点)
buf = BytesIO()
# 步骤2:保存图片到缓冲区(纯 Python 关键字参数,英文逗号+正确注释)
global_plt.savefig(
buf,
format='png', # 明确图片格式,无跨环境解析错误(英文逗号+# 注释)
dpi=150, # 高清分辨率(英文逗号)
bbox_inches='tight' # 去除图片边缘空白(最后一行无逗号)
)
# 步骤3:重置缓冲区指针,转换为 Base64 编码
buf.seek(0)
base64_data = base64.b64encode(buf.getvalue()).decode('utf-8')
# 步骤4:关闭绘图对象,释放 Python 环境内存
global_plt.close('all')
# 步骤5:返回 Base64 字符串,供 JS 处理
base64_data
`, {
// 将 plt 实例注入 Python 全局环境,命名为 input_plt(英文标点)
globals: { input_plt: plt }
});
// 步骤2:JS 环境仅负责创建图片 DOM,展示 Base64 结果
mplImgPanel.innerHTML = ''; // 清空原有内容(英文标点)
const img = document.createElement('img');
img.src = `data:image/png;base64,${base64Str}`;
img.alt = 'Matplotlib 生成的图片(浏览器兼容)';
mplImgPanel.appendChild(img);
// 日志与结果提示
console.log(`${formatTimestamp()} [Matplotlib] 图片已成功转换并在浏览器展示`);
appendResult(`【Matplotlib】图片已成功渲染展示`, false);
} catch (error) {
// 错误处理
console.error(`${formatTimestamp()} [Matplotlib] 图片展示失败:`, error);
const errorMsg = error.message.slice(0, 100);
mplImgPanel.innerHTML = `图片渲染失败:${errorMsg}`;
appendResult(`【错误】Matplotlib 图片展示失败:${errorMsg}`, true);
}
};
// 五、Pyodide 核心逻辑
/**
* 加载 Pyodide 核心环境(不预加载第三方库)
*/
const loadPyodide = async () => {
if (pyodide) {
console.info(`${formatTimestamp()} [Pyodide] 核心环境已加载,无需重复初始化`);
return pyodide; // 已加载直接返回
}
try {
// 控制台输出开始加载日志
console.log(`${formatTimestamp()} [Pyodide] 开始加载核心环境(CDN v0.25.0)...`);
updateStatus('正在加载 Pyodide 核心环境...', '#3498db');
runBtn.disabled = true;
// 初始化 Pyodide 环境,配置 onProgress 捕获加载进度
pyodide = await window.loadPyodide({
indexURL: "https://cdn.jsdelivr.net/pyodide/v0.25.0/full/",
stdout: (msg) => appendResult(msg), // 捕获 Python print 输出
stderr: (msg) => appendResult(msg, true), // 捕获 Python 错误输出
onProgress: (progressEvent) => {
// 格式化进度数据,输出到 Console
const { loaded, total, phase } = progressEvent;
const progressPercent = total ? Math.round((loaded / total) * 100) : '未知';
const loadedMB = (loaded / 1024 / 1024).toFixed(2);
const totalMB = total ? (total / 1024 / 1024).toFixed(2) : '?';
// 区分阶段输出日志,进度信息用 console.info
console.info(`${formatTimestamp()} [Pyodide] 【${phase}】进度:${progressPercent}%(${loadedMB}MB / ${totalMB}MB)`);
// 同步更新面板状态
if (progressPercent !== '未知') {
updateStatus(`正在加载 Pyodide:${progressPercent}%`, '#3498db');
}
}
});
// 加载完成后初始化基础库
await pyodide.runPythonAsync(`
import sys
import os
`);
// 关键:将 mplShowInBrowser 注入到 Pyodide 全局环境,供 Python 代码调用
pyodide.globals.set('mpl_show', mplShowInBrowser);
// 控制台输出加载完成日志
console.log(`${formatTimestamp()} [Pyodide] 核心环境加载完成!Python 版本:${pyodide.runPython('sys.version').split('\\n')[0]}`);
console.log(`${formatTimestamp()} [Matplotlib] 浏览器兼容函数 mpl_show() 已注入,可直接调用`);
updateStatus('核心环境加载完成,可运行代码', '#27ae60');
runBtn.disabled = false;
return pyodide;
} catch (error) {
// 控制台输出加载失败错误日志
console.error(`${formatTimestamp()} [Pyodide] 核心环境加载失败:`, error);
updateStatus('核心环境加载失败', '#e74c3c');
appendResult(`Pyodide 核心环境加载失败:${error.message}`, true);
showTip(`Pyodide 加载失败:${error.message.slice(0, 50)}`, true);
throw error;
}
};
/**
* 运行指定的 Python 代码(含 Matplotlib 浏览器兼容逻辑)
* @param {string} code 要执行的 Python 代码
* @param {boolean} isSelectedCode 是否为选中的代码
*/
const runSpecifiedPythonCode = async (code, isSelectedCode = false) => {
if (!code) {
const warnMsg = isSelectedCode ? '未选中有效 Python 代码' : '未输入有效 Python 代码';
console.warn(`${formatTimestamp()} [Pyodide] 执行失败:${warnMsg}`);
appendResult(`错误:${warnMsg}`, true);
showTip(warnMsg, true);
return;
}
try {
const logPrefix = isSelectedCode ? '选中的' : '';
console.info(`${formatTimestamp()} [Pyodide] 开始执行${logPrefix}Python 代码...`);
updateStatus(`正在执行${logPrefix}代码...`, '#3498db');
runBtn.disabled = true;
isSelectedCode && showTip('正在执行选中的代码...');
// 步骤1:加载 Pyodide 核心环境
const pyodideInstance = await loadPyodide();
// 步骤2:检测需要加载的第三方库并动态加载
const requiredPackages = detectRequiredPackages(code);
await loadRequiredPackages(requiredPackages);
// 步骤3:Matplotlib 专属配置(无界面后端 + 中文兼容)
if (requiredPackages.includes('matplotlib')) {
await pyodideInstance.runPythonAsync(`
import matplotlib
matplotlib.use('Agg') # 强制无界面后端,兼容浏览器
matplotlib.rcParams['font.sans-serif'] = ['DejaVu Sans', 'SimHei'] # 中文兼容
matplotlib.rcParams['axes.unicode_minus'] = False # 负号正常显示
`);
console.log(`${formatTimestamp()} [Matplotlib] 已配置 Agg 后端和中文兼容`);
}
// 步骤4:执行 Python 代码
if (!isSelectedCode) appendResult('=== 执行开始 ===');
else appendResult('=== 选中代码执行开始 ===');
const result = await pyodideInstance.runPythonAsync(code);
// 步骤5:追加返回结果(若有)
if (result !== undefined && result !== null) {
const resultTitle = isSelectedCode ? '=== 选中代码返回结果===' : '=== 返回结果 ===';
appendResult(`${resultTitle}\n${result}`);
}
if (!isSelectedCode) appendResult('=== 执行结束 ===');
else appendResult('=== 选中代码执行结束 ===');
// 控制台输出执行完成日志
console.log(`${formatTimestamp()} [Pyodide] ${logPrefix}Python 代码执行完成`);
updateStatus('执行完成', '#27ae60');
isSelectedCode && showTip('选中代码执行完成!');
} catch (error) {
// 控制台输出执行错误日志
console.error(`${formatTimestamp()} [Pyodide] Python 代码执行失败:`, error);
const errorTitle = isSelectedCode ? '=== 选中代码执行错误 ===' : '=== 执行错误 ===';
appendResult(`${errorTitle}\n${error.message}`, true);
updateStatus('执行失败', '#e74c3c');
showTip(`执行失败:${error.message.slice(0, 50)}`, true);
} finally {
runBtn.disabled = false;
}
};
/**
* 运行面板中的 Python 代码(原有功能)
*/
const runPythonCode = async () => {
const code = codeEditor.value.trim();
await runSpecifiedPythonCode(code, false);
};
/**
* 运行选中的网页 Python 代码(保留功能,仅移除全局快捷键)
*/
const runSelectedPythonCode = async () => {
const selectedCode = getSelectedText();
// 若面板隐藏,执行前自动显示面板(方便查看结果)
if (pyodideContainer.classList.contains('pyodide-hidden')) {
pyodideContainer.classList.remove('pyodide-hidden');
}
await runSpecifiedPythonCode(selectedCode, true);
};
// 六、事件绑定(取消全局快捷键,优化清空功能)
// 1. 运行按钮点击事件(面板代码)
runBtn.addEventListener('click', runPythonCode);
// 2. 清空按钮点击事件(清空结果+图片区域)
clearBtn.addEventListener('click', () => {
resultPanel.innerHTML = '';
mplImgPanel.innerHTML = 'Matplotlib 图片将在此展示';
console.info(`${formatTimestamp()} [Pyodide] 结果面板和图片区域已清空`);
showTip('结果面板和图片区域已清空');
});
// 3. 关闭按钮点击事件(隐藏面板)
closeBtn.addEventListener('click', () => {
pyodideContainer.classList.add('pyodide-hidden');
console.info(`${formatTimestamp()} [Pyodide] 操作面板已隐藏`);
});
// 4. 面板编辑器快捷键支持(Ctrl+Enter 运行代码,仅面板内有效)
codeEditor.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
runPythonCode();
}
});
// 5. 【已删除】全局快捷键绑定(Ctrl+Shift+P 执行选中代码)
// 6. 注册油猴菜单命令(打开 Pyodide 面板)
GM_registerMenuCommand('打开 Pyodide Python 环境(Matplotlib 浏览器兼容)', () => {
pyodideContainer.classList.remove('pyodide-hidden');
console.info(`${formatTimestamp()} [Pyodide] 操作面板已打开`);
// 首次打开自动加载 Pyodide 核心环境
if (!pyodide && statusText.textContent.includes('未加载')) {
loadPyodide();
}
});
// 脚本初始化完成日志
console.log(`${formatTimestamp()} [Pyodide UserScript] 脚本注入完成,Matplotlib 已支持浏览器可视化展示`);
})();