// ==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 = `
Pyodide Python 环境(Matplotlib 浏览器兼容)
×
状态:未加载
Matplotlib 图片将在此展示
`; // 创建选中执行临时提示 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 已支持浏览器可视化展示`); })();