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

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

需求背景

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

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

三版方案的迭代之路

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

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

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

python 复制代码
# 第一版核心(简化)
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实现命名管道通信。

核心逻辑

python 复制代码
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 命名管道服务,支持连接队列(避免并发问题):

javascript 复制代码
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 脚本:

javascript 复制代码
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 负责底层的系统事件监听,两者通过命名管道高效通信,既满足了离线、低成本的要求,又保证了准确率和用户体验。

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

相关推荐
cxxcode1 小时前
I/O 多路复用:从浏览器到 Linux 内核
前端
用户5433081441942 小时前
AI 时代,前端逆向的门槛已经低到离谱 — 以 Upwork 为例
前端
JarvanMo2 小时前
Flutter 版本的 material_ui 已经上架 pub.dev 啦!快来抢先体验吧。
前端
JohnYan2 小时前
工作笔记-CodeBuddy应用探索
javascript·ai编程·aiops
恋猫de小郭2 小时前
AI 可以让 WIFI 实现监控室内人体位置和姿态,无需摄像头?
前端·人工智能·ai编程
哀木2 小时前
给自己整一个 claude code,解锁编程新姿势
前端
程序员鱼皮2 小时前
GitHub 关注突破 2w,我总结了 10 个涨星涨粉技巧!
前端·后端·github
UrbanJazzerati2 小时前
Vue3 父子组件通信完全指南
前端·面试
是一碗螺丝粉2 小时前
5分钟上手LangChain.js:用DeepSeek给你的App加上AI能力
前端·人工智能·langchain