Electron 实现仿豆包划词取词功能:从 AI 生成到落地踩坑记

0 阅读7分钟

作为一名前端开发,最近接到了一个「划词取词」的需求 —— 老板希望做一个类似豆包、有道词典的划词识别功能,核心要求是低成本、离线可用、Windows 平台优先。整个开发过程一波三折,从 AI 生成的「截屏 + AI 识别」方案,到离线 OCR,最后落地到「划词 + Ctrl+C + 命名管道通信」,踩了不少坑,也积累了一些实战经验,特此记录。

需求背景

核心诉求:用户在任意窗口(浏览器、文档、办公软件等)用鼠标划选文字后,能快速获取选中的文本内容,用于后续的翻译 / 解释等操作,要求:

  • 离线运行,无网络依赖;
  • 仅支持 Windows 系统(公司主流办公环境);
  • 低成本(避免调用付费 OCR/AI 接口);
  • 尽可能不干扰用户原有操作。

三版方案的迭代之路

第一版:截屏 + AI 识别(被打回)

最初想着「快速搞定」,直接让 AI 生成了一份 Python 代码:监听鼠标按下 / 抬起的坐标,截取对应区域的屏幕截图,然后调用 AI 接口识别图片中的文字。

代码核心逻辑是用PIL.ImageGrab截屏,再通过 base64 传给 AI 接口:

# 第一版核心(简化)
def on_click_up(x, y, button, pressed):
    if not pressed:
        # 计算鼠标划选区域
        left = min(last_x, x)
        top = min(last_y, y)
        right = max(last_x, x)
        bottom = max(last_y, y)
        # 截屏
        img = ImageGrab.grab(bbox=(left, top, right, bottom))
        # 调用AI接口识别
        img_base64 = base64.b64encode(img_bytes).decode()
        res = requests.post(AI_API, json={"image": img_base64})
        text = res.json()["text"]
        print("识别的文字:", text)

问题:老板看到 AI 接口的调用成本后直接打回 —— 按公司的使用量,每月要额外支出数千元,完全不符合「低成本」要求。

第二版:离线 OCR(放弃)

既然 AI 接口不能用,那就换离线 OCR(比如 Tesseract)。但实际测试后发现:

  • 不同字体、字号、背景色下,OCR 准确率极低(尤其是小字体 / 模糊文字);
  • 需要用户额外安装 OCR 引擎,部署成本高;
  • 对截图的分辨率、区域裁剪要求极高,适配成本高。

最终因为「准确率达不到老板预期」,这个方案也被放弃了。

第三版:划词 + Ctrl+C + 跨进程通信(最终落地)

某天突然想到:用户划选文字后,系统本身已经把选中的内容「暂存」了,只要调用Ctrl+C复制,就能直接从剪贴板拿到文本 —— 这才是最直接、零成本、准确率 100% 的方案!

核心思路:

  1. Python 脚本监听鼠标划选动作(按下→拖动→抬起);
  2. 判定为有效划词后,自动触发Ctrl+C复制选中内容;
  3. 从剪贴板读取文本,通过「命名管道」传给 Electron 主进程;
  4. Electron 接收数据后,再分发给渲染进程做后续处理。

技术实现拆解

最终方案分为「Python 端(监听 + 复制 + 通信)」和「Electron 端(管道服务 + 数据处理)」两部分,核心依赖 Windows 的「命名管道(Named Pipe)」实现跨进程通信。

1. Python 端:监听划词并发送数据

Python 负责核心的「人机交互监听」和「剪贴板操作」,使用pynput监听鼠标 / 键盘,pyperclip操作剪贴板,win32file实现命名管道通信。

核心逻辑

from pynput import mouse, keyboard
import pyautogui
import pyperclip
import win32file
import pywintypes
import json
import win32gui
import win32process
import psutil

class ClipboardMonitor:
    def __init__(self):
        self.last_mouse_down_time = 0
        self.last_mouse_down_position = (0, 0)
        self.last_user_clipboard_content = None  # 保存用户原有剪贴板内容
        self.keyboard_activity = False  # 避免键盘操作干扰

    # 监听鼠标按下:记录起始位置+发送坐标给Electron
    def on_click_down(self, x, y, button, pressed):
        if pressed:
            self.last_mouse_down_position = (x, y)
            # 发送鼠标按下坐标给Electron(用于判断是否在目标窗口内)
            message = f"click_down_mouse_position:{x},{y}"
            self.send_to_electron(message)
            # 记录当前聚焦的应用(用于过滤禁用列表)
            self.last_mouse_down_client = self.get_focused_application()

    # 监听鼠标抬起:判定有效划词并复制
    def on_click_up(self, x, y, button, pressed):
        if not pressed:
            # 计算鼠标拖动距离(过滤误点击)
            distance = ((x - self.last_mouse_down_position[0]) **2 + (y - self.last_mouse_down_position[1])** 2) **0.5
            # 有效划词:距离>10px + 无键盘/鼠标干扰
            if distance > 10 and not self.keyboard_activity:
                # 检查配置:是否允许打开悬浮窗、当前应用是否在禁用列表
                if self.check_can_open_float_win() and self.last_mouse_down_client not in self.get_disable_client_list():
                    # 保存用户原有剪贴板内容(避免覆盖)
                    self.last_user_clipboard_content = pyperclip.paste()
                    # 自动触发Ctrl+C复制选中内容
                    pyautogui.hotkey('ctrl', 'c')
                    new_clipboard_content = pyperclip.paste()
                    # 对比剪贴板:确认为新选中的内容
                    if new_clipboard_content != self.last_user_clipboard_content:
                        # 封装数据并发送给Electron
                        self.send_clipboard_data(x, y, new_clipboard_content)
                    # 还原用户剪贴板(核心!避免干扰用户)
                    pyperclip.copy(self.last_user_clipboard_content)

    # 获取当前聚焦的应用名称(用于过滤)
    def get_focused_application(self):
        hwnd = win32gui.GetForegroundWindow()
        _, pid = win32process.GetWindowThreadProcessId(hwnd)
        try:
            process = psutil.Process(pid)
            return process.name()
        except:
            return "Unknown"

    # 命名管道发送数据给Electron
    def send_to_electron(self, message):
        pipe_name = r'\.\pipe\quick_word_electron_python_pipe'
        try:
            handle = win32file.CreateFile(
                pipe_name,
                win32file.GENERIC_WRITE,
                0,
                None,
                win32file.OPEN_EXISTING,
                0,
                None
            )
            win32file.WriteFile(handle, message.encode())
            win32file.CloseHandle(handle)
        except pywintypes.error as e:
            print(f"管道通信失败:{e}")

    # 启动监听
    def start(self):
        mouse_listener_down = mouse.Listener(on_click=self.on_click_down)
        mouse_listener_up = mouse.Listener(on_click=self.on_click_up)
        keyboard_listener = keyboard.Listener(on_press=self.on_key_press, on_release=self.on_key_release)
        mouse_listener_down.start()
        mouse_listener_up.start()
        keyboard_listener.start()
        mouse_listener_down.join()

if __name__ == "__main__":
    monitor = ClipboardMonitor()
    monitor.start()

关键细节

  • 剪贴板还原:必须保存用户原有剪贴板内容,复制后还原,否则会干扰用户操作;
  • 应用过滤:读取配置文件中的「禁用应用列表」,避免在指定应用内触发划词;
  • 误触过滤:通过鼠标拖动距离、键盘活动状态,过滤点击、误拖动等无效操作。

2. Electron 端:命名管道服务 + Python 管理

Electron 作为主进程,负责:

  • 启动 / 管理 Python 脚本;
  • 创建命名管道服务,接收 Python 发送的数据;
  • 处理数据并分发给渲染进程。

第一步:封装命名管道服务(namedPipeServer.js)

基于 Node.js 的net模块实现 Windows 命名管道服务,支持连接队列(避免并发问题):

const net = require('net');

class NamedPipeServer {
  constructor(pipeName, cb) {
    this.pipeName = pipeName;
    this.server = null;
    this.maxConnections = 10; // 最大连接数
    this.currentConnections = 0;
    this.connectionQueue = [];
    cb(this)
  }

  // 启动管道服务
  start(onDataCallback) {
    this.server = net.createServer((socket) => {
      // 连接数控制:超出则加入队列
      if (this.currentConnections >= this.maxConnections) {
        this.connectionQueue.push(socket);
      } else {
        this.currentConnections++;
        this.handleConnection(socket, onDataCallback);
      }
    });

    this.server.on('error', (err) => {
      console.error(`管道服务错误:${err.message}`);
    });

    // 监听命名管道
    this.server.listen(this.pipeName, () => {
      console.log(`命名管道监听中:${this.pipeName}`);
    });
  }

  // 处理连接:接收数据
  handleConnection(socket, onDataCallback) {
    socket.on('data', (data) => {
      const message = data.toString().trim();
      onDataCallback(message); // 回调处理数据
    });

    // 连接断开:复用队列中的连接
    socket.on('end', () => {
      this.currentConnections--;
      if (this.connectionQueue.length > 0) {
        this.handleConnection(this.connectionQueue.shift(), onDataCallback);
      }
    });

    socket.on('error', (err) => {
      console.error(`Socket错误:${err.message}`);
    });
  }

  // 停止管道服务
  stop() {
    if (this.server) {
      this.server.close(() => {
        console.log("命名管道服务已关闭");
      });
    }
  }
}

module.exports = { NamedPipeServer };

第二步:初始化 Python 环境 + 管道通信(quickWordLookup.js)

Electron 启动时,自动解压 Python 环境(避免用户手动安装),启动命名管道,再调用 Python 脚本:

const AdmZip = require("adm-zip");
const { NamedPipeServer } = require('./namedPipeServer');
const { exec } = require('child_process');
const path = require('path');
const fs = require('fs');

class QuickWordLookup {
    constructor() {
        this.platform = process.platform;
        this.env = process.env.NODE_ENV || "production";
    }

    // 初始化Python环境+命名管道
    initPython() {
        if (this.platform !== "win32") return;

        // 1. 解压Python环境(打包在应用内的zip包)
        const pluginsPath = this.env === "development" 
            ? path.join(app.getAppPath(), 'plugins') 
            : process.resourcesPath;
        const pythonZipPath = path.join(pluginsPath, "vendors", "python3.11.zip");
        this.pythonDirPath = path.join(pluginsPath, "vendors", "python3.11");
        
        if (!fs.existsSync(this.pythonDirPath)) {
            const zip = new AdmZip(pythonZipPath);
            zip.extractAllTo(this.pythonDirPath, true); // 解压
        }

        // 2. 创建命名管道服务
        const pipeServer = new NamedPipeServer(
            '\\.\pipe\quick_word_electron_python_pipe', 
            () => {
                console.log("管道服务启动成功,启动Python脚本");
                this.openPythonExe(); // 管道就绪后启动Python
            }
        );

        // 3. 处理Python发送的数据
        pipeServer.start((message) => {
            if (message.startsWith("click_down_mouse_position:")) {
                // 处理鼠标按下坐标(判断是否在目标窗口内)
                const [x, y] = message.slice("click_down_mouse_position:".length).split(",").map(Number);
                const isInside = this.handleMousePosition(x, y);
                if (!isInside) return;
            } else if (message.startsWith("messgae_to_send:")) {
                // 处理划词内容:发给渲染进程
                const data = JSON.parse(message.slice("messgae_to_send:".length));
                this.sendToRenderer(data);
            }
        });
    }

    // 启动Python脚本
    openPythonExe() {
        if (this.platform !== "win32") return;
        const exePath = path.join(this.pythonDirPath, 'python.exe');
        // Python脚本路径(打包在应用内)
        const tempFilePath = this.env === "development" 
            ? path.join(__dirname, "../../public/python/underlineWord.py") 
            : path.join(process.resourcesPath, "vendors", "python/underlineWord.py");
        
        const cmd = `"${exePath}" "${tempFilePath}"`;
        exec(cmd, { encoding: 'utf-8' }, (error, stdout, stderr) => {
            if (error) {
                console.error(`Python启动失败:${error.message}`);
            } else {
                console.log("Python划词监听已启动");
            }
        });
    }

    // 发送数据到渲染进程
    sendToRenderer(data) {
        // 主进程→渲染进程通信(根据Electron版本调整)
        const mainWindow = BrowserWindow.getFocusedWindow();
        if (mainWindow) {
            mainWindow.webContents.send('word-lookup-data', data);
        }
    }
}

踩坑总结

  1. 命名管道的跨进程通信

    • Windows 命名管道路径格式必须是\\.\pipe\xxx,Node.js 的net模块需适配这个格式;
    • 必须保证「管道服务先启动,Python 再连接」,否则会出现连接失败;
    • 处理连接并发:添加连接队列,避免多客户端同时连接导致的异常。
  2. 剪贴板操作的坑

    • 直接调用pyautogui.hotkey('ctrl', 'c')在部分应用(如某些加密文档)中无效,需备用方案(win32api.SendMessage发送 WM_COPY 消息);
    • 必须还原用户原有剪贴板内容,否则会引发用户投诉。
  3. Python 环境打包

    • 将 Python 解释器 + 依赖包打包成 zip,Electron 启动时自动解压,避免用户手动安装;
    • 开发 / 生产环境的路径差异:需区分app.getAppPath()process.resourcesPath
  4. 应用兼容性

    • 不同应用的「划词 + 复制」逻辑不同(如某些游戏 / 加密软件屏蔽 Ctrl+C),需做兼容处理;
    • 通过psutil获取当前聚焦应用,支持「禁用应用列表」配置。

优化方向

  1. 增加 Python 进程守护:监控 Python 脚本是否崩溃,自动重启;
  2. 支持更多快捷键:除了鼠标划词,支持用户自定义快捷键触发;
  3. 剪贴板内容过滤:过滤空内容、特殊字符,提升体验;
  4. 跨平台适配:后续可扩展 macOS(使用 Unix 域套接字替代命名管道)。

总结

这次需求从「AI 生成快速方案」到「落地可用」,核心是回归「用户操作的本质」—— 划词后系统本身已有选中内容,无需复杂的截屏 / OCR,只需「借力」系统剪贴板 + 跨进程通信即可搞定。

技术选型上,Electron 负责界面和进程管理,Python 负责底层的系统事件监听,两者通过命名管道高效通信,既满足了离线、低成本的要求,又保证了准确率和用户体验。

这个案例也让我明白:有时候最有效的方案,往往不是最「高科技」的,而是最贴合用户操作习惯、最利用现有系统能力的。