├── src ├── analyze.ts ├── types.ts ├── translate.ts ├── index.ts └── maimemo.ts ├── .gitignore ├── public ├── icon.png └── info.json ├── tsconfig.json ├── rollup.config.dev.js ├── rollup.config.js ├── package.json ├── .github └── workflows │ └── release.yaml ├── scripts └── zip.js ├── appcast.json └── README.md /src/analyze.ts: -------------------------------------------------------------------------------- 1 | // TODO -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | dev 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chriscurrycc/bob-plugin-maimemo-notebook/HEAD/public/icon.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "module": "ESNext", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "skipLibCheck": true 8 | }, 9 | "include": ["src/**/*"], 10 | "exclude": ["node_modules", "**/*.spec.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /rollup.config.dev.js: -------------------------------------------------------------------------------- 1 | import typescript from "@rollup/plugin-typescript"; 2 | import copy from "rollup-plugin-copy"; 3 | 4 | export default { 5 | input: "src/index.ts", 6 | output: { 7 | file: "dev/bobplugin-maimemo-notebook.bobplugin/main.js", 8 | format: "cjs", 9 | }, 10 | plugins: [ 11 | typescript(), 12 | copy({ 13 | targets: [ 14 | { src: "public/*", dest: "dev/bobplugin-maimemo-notebook.bobplugin" }, // 复制 public 文件夹内容到 .bobplugin 文件夹 15 | ], 16 | }), 17 | ], 18 | watch: { 19 | include: "src/**", 20 | clearScreen: false, 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from "@rollup/plugin-typescript"; 2 | import copy from "rollup-plugin-copy"; 3 | import terser from "@rollup/plugin-terser"; 4 | import { exec } from "child_process"; 5 | 6 | export default { 7 | input: "src/index.ts", 8 | output: { 9 | file: "dist/main.js", 10 | format: "cjs", 11 | }, 12 | plugins: [ 13 | typescript(), 14 | terser(), 15 | copy({ 16 | targets: [{ src: "public/*", dest: "dist" }], 17 | }), 18 | { 19 | name: "zip", 20 | writeBundle() { 21 | exec("scripts/zip.js", (err, stdout, stderr) => { 22 | if (err) { 23 | console.error(`Compress zip error: ${err}`); 24 | return; 25 | } 26 | console.log(stdout); 27 | console.error(stderr); 28 | }); 29 | }, 30 | }, 31 | ], 32 | }; 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bobplugin-maimemo-notebook", 3 | "version": "0.3.1", 4 | "description": "Bob 查询的单词导入到墨墨背单词", 5 | "author": { 6 | "name": "Chris Curry", 7 | "email": "hichriscurry@gmail.com" 8 | }, 9 | "keywords": [ 10 | "bob-plugin", 11 | "maimemo", 12 | "translator", 13 | "notebook" 14 | ], 15 | "scripts": { 16 | "clean": "rm -rf dist", 17 | "prebuild": "chmod +x scripts/zip.js", 18 | "build": "npm run clean && rollup -c", 19 | "dev": "rollup -c rollup.config.dev.js --watch" 20 | }, 21 | "devDependencies": { 22 | "@rollup/plugin-terser": "^0.4.4", 23 | "@rollup/plugin-typescript": "^12.1.1", 24 | "archiver": "^7.0.1", 25 | "rollup": "^2.79.2", 26 | "rollup-plugin-copy": "^3.5.0", 27 | "typescript": "^5.8.3" 28 | }, 29 | "engines": { 30 | "node": "20" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Create GitHub Release with .bobplugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | build-and-release: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v2 18 | 19 | - name: Set up Node.js 20 | uses: actions/setup-node@v2 21 | with: 22 | node-version: "20" 23 | 24 | - name: Install dependencies 25 | run: npm install 26 | 27 | - name: Build plugin 28 | run: npm run build 29 | 30 | - name: Upload .bobplugin to GitHub Release 31 | uses: ncipollo/release-action@v1 32 | with: 33 | token: ${{ secrets.GITHUB_TOKEN }} 34 | artifacts: "dist/bobplugin-maimemo-notebook.bobplugin" 35 | tag: ${{ github.ref_name }} 36 | name: Release ${{ github.ref_name }} 37 | -------------------------------------------------------------------------------- /scripts/zip.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require("fs"); 4 | const archiver = require("archiver"); 5 | const path = require("path"); 6 | 7 | const outputPath = path.resolve( 8 | __dirname, 9 | "../dist/bobplugin-maimemo-notebook.bobplugin" 10 | ); 11 | 12 | function cleanDistFolder() { 13 | const distPath = path.resolve(__dirname, "../dist"); 14 | 15 | fs.readdir(distPath, (err, files) => { 16 | if (err) throw err; 17 | 18 | files.forEach((file) => { 19 | const filePath = path.join(distPath, file); 20 | 21 | if (filePath !== outputPath) { 22 | fs.rm(filePath, { recursive: true, force: true }, (err) => { 23 | if (err) throw err; 24 | console.log(`Deleted: ${filePath}`); 25 | }); 26 | } 27 | }); 28 | }); 29 | } 30 | 31 | function zipAndRename() { 32 | const output = fs.createWriteStream(outputPath); 33 | const archive = archiver("zip", { zlib: { level: 9 } }); 34 | 35 | output.on("close", () => { 36 | console.log(`Compress finish:${outputPath}`); 37 | cleanDistFolder(); 38 | }); 39 | 40 | archive.on("error", (err) => { 41 | throw err; 42 | }); 43 | 44 | archive.pipe(output); 45 | archive.glob("**/*", { 46 | cwd: path.resolve(__dirname, "../dist"), 47 | ignore: [path.basename(outputPath)], 48 | }); 49 | archive.finalize(); 50 | } 51 | 52 | zipAndRename(); 53 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | interface PluginOption { 2 | maimemoToken?: string; 3 | bigModelModel: string; 4 | bigModelApiKey?: string; 5 | canAddSentence: string; 6 | notepadId?: string; 7 | openaiApiKey?: string; 8 | openaiModel: string; 9 | } 10 | 11 | interface BobTranslationResult { 12 | result: { 13 | toParagraphs: string[]; 14 | }; 15 | } 16 | 17 | interface BobTranslationError { 18 | error: { 19 | type: BobTranslationErrorType; 20 | message?: string; 21 | }; 22 | } 23 | 24 | interface BobResponseJSONData { 25 | data: T; 26 | } 27 | 28 | interface BobDataObject { 29 | fromUTF8(value: string): BobDataObject; 30 | toUTF8(): string; 31 | } 32 | 33 | export enum BobTranslationErrorType { 34 | NoSecretKey = "secretKey", 35 | NotFound = "notFound", 36 | Network = "network", 37 | UnSupportedLanguage = "unsupportedLanguage", 38 | } 39 | 40 | type FuncOnCompletion = ( 41 | result: BobTranslationResult | BobTranslationError 42 | ) => void; 43 | 44 | export interface BobQuery { 45 | text: string; 46 | detectFrom: string; 47 | onCompletion: FuncOnCompletion; 48 | } 49 | 50 | declare global { 51 | const $option: PluginOption; 52 | const $log: { 53 | info: (message: any) => void; 54 | error: (message: any) => void; 55 | }; 56 | const $file: { 57 | read: (path: string) => BobDataObject; 58 | write: (param: { data: BobDataObject; path: string }) => void; 59 | exists: (path: string) => boolean; 60 | }; 61 | const $data: BobDataObject; 62 | const $http: { 63 | request: (options: { 64 | method: "GET" | "POST"; 65 | url: string; 66 | header: Record; 67 | body?: any; 68 | }) => Promise>; 69 | }; 70 | } 71 | 72 | // 确保文件被视为模块 73 | export {}; 74 | -------------------------------------------------------------------------------- /appcast.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "cc.chriscurry.bobplugin.maimemo.notebook", 3 | "versions": [ 4 | { 5 | "version": "0.3.1", 6 | "desc": "fix public version mismatch", 7 | "sha256": "129b765fa30d310f536bf60e43d724177210c555c4a7fe700b540cb693cce448", 8 | "url": "https://github.com/chriscurrycc/bob-plugin-maimemo-notebook/releases/download/v0.3.1/bobplugin-maimemo-notebook.bobplugin", 9 | "minBobVersion": "0.5.0", 10 | "timestamp": 1763717719280 11 | }, 12 | { 13 | "version": "0.3.0", 14 | "desc": "Filter out previously added vocabulary when adding new words", 15 | "sha256": "f54be5d97956f585b316ae4211920ce67743514e5d6ab753826097e7c7d77c15", 16 | "url": "https://github.com/chriscurrycc/bob-plugin-maimemo-notebook/releases/download/v0.3.0/bobplugin-maimemo-notebook.bobplugin", 17 | "minBobVersion": "0.5.0", 18 | "timestamp": 1763717138018 19 | }, 20 | { 21 | "version": "0.2.1", 22 | "desc": "Fix bugs", 23 | "sha256": "667f959688af41f5c0b81329dcd4264654495741cdebd386234d24b9c07bd613", 24 | "url": "https://github.com/chriscurrycc/bob-plugin-maimemo-notebook/releases/download/v0.2.1/bobplugin-maimemo-notebook.bobplugin", 25 | "minBobVersion": "0.5.0", 26 | "timestamp": 1752496564669 27 | }, 28 | { 29 | "version": "0.2.0", 30 | "desc": "Add OpenAI support", 31 | "sha256": "0eb38a548d823030a1fa9b6d2c68d96a96ecbf6b746bcf06c5cf4fdbd04115d9", 32 | "url": "https://github.com/chriscurrycc/bob-plugin-maimemo-notebook/releases/download/v0.2.0/bobplugin-maimemo-notebook.bobplugin", 33 | "minBobVersion": "0.5.0", 34 | "timestamp": 1750697566000 35 | }, 36 | { 37 | "version": "0.0.1", 38 | "desc": "Initial release", 39 | "sha256": "9a0fe87020644d9da05895483c0fff0dfb16cbb3a9cae1facfb497739ec49db1", 40 | "url": "https://github.com/chriscurrycc/bob-plugin-maimemo-notebook/releases/download/v0.0.1/bobplugin-maimemo-notebook.bobplugin", 41 | "minBobVersion": "0.5.0", 42 | "timestamp": 1731252613000 43 | } 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /src/translate.ts: -------------------------------------------------------------------------------- 1 | const bigModelApiEndpoint = 2 | "https://open.bigmodel.cn/api/paas/v4/chat/completions"; 3 | 4 | const openaiApiEndpoint = "https://api.openai.com/v1/responses"; 5 | 6 | interface BigModelCompletionResponse { 7 | choices?: { 8 | message?: { 9 | content?: string; 10 | }; 11 | }[]; 12 | } 13 | 14 | interface OpenAIResponse { 15 | output?: { 16 | content?: { 17 | text: string; 18 | }[]; 19 | }[]; 20 | } 21 | 22 | function getPrompt() { 23 | const detectFrom = "英语"; 24 | const detectTo = "中文简体"; 25 | const prompt = 26 | "你是一个翻译专家 : 专注于" + 27 | detectFrom + 28 | "到" + 29 | detectTo + 30 | "的翻译,请确保翻译结果的准确性和专业性,同时,请确保翻译结果的翻译质量,不要出现翻译错误,翻译时能够完美确保翻译结果的准确性和专业性,同时符合" + 31 | detectTo + 32 | "的表达和语法习惯。" + 33 | "你拥有如下能力:" + 34 | detectFrom + 35 | "到" + 36 | detectTo + 37 | "的专业翻译能力,理解并保持原意,熟悉" + 38 | detectTo + 39 | "表达习惯。" + 40 | "翻译时,请按照如下步骤: " + 41 | "1. 仔细阅读并理原文内容" + 42 | "2. 务必确保准确性和专业性" + 43 | "3. 校对翻译文本,确保符合" + 44 | detectTo + 45 | "表达习惯,并加以语法润色。" + 46 | "4. 请只输出最终翻译文本。"; 47 | 48 | return prompt; 49 | } 50 | 51 | async function translateByOpenAI(sentence: string) { 52 | return $http 53 | .request({ 54 | method: "POST", 55 | url: openaiApiEndpoint, 56 | header: { 57 | Authorization: `Bearer ${$option.openaiApiKey}`, 58 | "Content-Type": "application/json", 59 | }, 60 | body: { 61 | model: $option.openaiModel, 62 | instructions: getPrompt(), 63 | input: sentence, 64 | }, 65 | }) 66 | .then((_resp) => { 67 | const resp = _resp.data; 68 | const translation = resp.output?.[0]?.content?.[0]?.text; 69 | 70 | if (translation) { 71 | return translation; 72 | } else { 73 | throw new Error("例句翻译失败"); 74 | } 75 | }); 76 | } 77 | 78 | async function translateByBigModel(sentence: string) { 79 | return $http 80 | .request({ 81 | method: "POST", 82 | url: bigModelApiEndpoint, 83 | header: { 84 | Authorization: $option.bigModelApiKey!, 85 | "Content-Type": "application/json", 86 | }, 87 | body: { 88 | model: $option.bigModelModel, 89 | messages: [ 90 | { 91 | role: "system", 92 | content: getPrompt(), 93 | }, 94 | { 95 | role: "user", 96 | content: sentence, 97 | }, 98 | ], 99 | }, 100 | }) 101 | .then((_resp) => { 102 | const resp = _resp.data; 103 | const translation = resp.choices?.[0]?.message?.content; 104 | 105 | if (translation) { 106 | return translation; 107 | } else { 108 | throw new Error("例句翻译失败"); 109 | } 110 | }); 111 | } 112 | 113 | export async function translateByLLM(sentence: string) { 114 | if ($option.openaiApiKey) { 115 | return translateByOpenAI(sentence); 116 | } else { 117 | return translateByBigModel(sentence); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /public/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "cc.chriscurry.bobplugin.maimemo.notebook", 3 | "version": "0.3.1", 4 | "category": "translate", 5 | "name": "墨墨云词本", 6 | "summary": "导入查询单词到墨墨云词本,且支持关联查询句子和译文", 7 | "author": "Chris Curry ", 8 | "homepage": "https://github.com/chriscurrycc/bob-plugin-maimemo-notebook", 9 | "appcast": "https://github.com/chriscurrycc/bob-plugin-maimemo-notebook/raw/main/appcast.json", 10 | "minBobVersion": "0.5.0", 11 | "options": [ 12 | { 13 | "identifier": "maimemoToken", 14 | "type": "text", 15 | "title": "墨墨开放 API Token", 16 | "desc": "请填写墨墨开放 API Token,获取方式请参考 https://open.maimemo.com/", 17 | "textConfig": { 18 | "type": "secure" 19 | } 20 | }, 21 | { 22 | "identifier": "notepadId", 23 | "type": "text", 24 | "title": "墨墨云词本 ID", 25 | "desc": "请前往 https://open.maimemo.com/#/operations/maimemo.openapi.notepad.v1.NotepadService.ListNotepads 查询已有云词本。如果不填,则默认创建一个新词本", 26 | "textConfig": { 27 | "type": "secure" 28 | } 29 | }, 30 | { 31 | "identifier": "canAddSentence", 32 | "type": "menu", 33 | "title": "添加例句到生词", 34 | "desc": "如果需要支持添加例句到选定生词,请打开此项", 35 | "menuValues": [ 36 | { 37 | "value": "true", 38 | "title": "是", 39 | "defaultPluginName": "墨墨云词本(例句)" 40 | }, 41 | { 42 | "value": "false", 43 | "title": "否", 44 | "defaultPluginName": "墨墨云词本" 45 | } 46 | ], 47 | "defaultValue": "false", 48 | "isKeyOption": true 49 | }, 50 | { 51 | "identifier": "openaiApiKey", 52 | "type": "text", 53 | "title": "OpenAI API 密钥", 54 | "desc": "可前往 https://platform.openai.com/api-keys 查询已有 API 密钥。在配置了 OpenAI API 密钥后,将优先使用 OpenAI 而不是智谱" 55 | }, 56 | { 57 | "identifier": "openaiModel", 58 | "type": "menu", 59 | "title": "OpenAI 模型", 60 | "menuValues": [ 61 | { 62 | "value": "gpt-4.1-mini", 63 | "title": "GPT-4.1 mini" 64 | }, 65 | { 66 | "value": "gpt-4.1", 67 | "title": "GPT-4.1" 68 | } 69 | ], 70 | "defaultValue": "gpt-4.1-mini" 71 | }, 72 | { 73 | "identifier": "bigModelApiKey", 74 | "type": "text", 75 | "title": "智谱 API 密钥" 76 | }, 77 | { 78 | "identifier": "bigModelModel", 79 | "type": "menu", 80 | "title": "智谱语言模型", 81 | "defaultValue": "GLM-4-Flash", 82 | "menuValues": [ 83 | { 84 | "title": "高智能旗舰[GLM-4-Plus]", 85 | "value": "GLM-4-Plus" 86 | }, 87 | { 88 | "title": "超长输入[GLM-4-Long]", 89 | "value": "GLM-4-Long" 90 | }, 91 | { 92 | "title": "极速推理[GLM-4-AirX]", 93 | "value": "GLM-4-AirX" 94 | }, 95 | { 96 | "title": "高性价比[GLM-4-Air]", 97 | "value": "GLM-4-Air" 98 | }, 99 | { 100 | "title": "免费调用[GLM-4-Flash]", 101 | "value": "GLM-4-Flash" 102 | }, 103 | { 104 | "title": "高速低价[GLM-4-FlashX]", 105 | "value": "GLM-4-FlashX" 106 | }, 107 | { 108 | "title": "Agent模型[GLM-4-AllTools]", 109 | "value": "GLM-4-AllTools" 110 | }, 111 | { 112 | "title": "旧版旗舰[GLM-4]", 113 | "value": "GLM-4" 114 | } 115 | ] 116 | } 117 | ] 118 | } 119 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

墨墨背单词云词本 Bob 插件

3 |

4 | 5 | release 6 | 7 | 8 | GitHub Repo stars 9 | 10 | 11 | GitHub Repo stars 12 | 13 | 14 | GitHub Repo stars 15 | 16 |

17 |
18 | 19 | > 我开发了一个苹果快捷指令来实现同样的功能,可以借助 AI 实现自动分词,建议使用这个快捷指令。[点击此处查看使用文档](https://memo.chriscurry.cc/m/NtMnTGVJfkXuLoRJaMYKy5) 20 | 21 | ## 演示 22 | 23 | ### Bob 插件使用 24 | ![Nov-10-2024 22-30-22](https://github.com/user-attachments/assets/b6c0257f-d5da-496c-8443-24b1be085c18) 25 | 26 | ### 墨墨背单词软件 27 | ![919shots_so](https://github.com/user-attachments/assets/ce3449e9-343f-4b1c-a90e-050332170dfb) 28 | 29 | ## 简介 30 | 31 | 本插件可以将 Bob 软件查询的生词直接添加到墨墨云词本。 32 | 33 | 此外,如果翻译的是句子,在启用例句模式后,可以自行选择例句中的生词,将生词添加到云词本后,还会为所选生词创建当前例句(以及例句的翻译)。这让我们在墨墨不仅仅能背诵生词,还可以查看我们添加的例句。 34 | 35 | 保留了上下文的生词,相信你会记得更快! 36 | 37 | ## 为什么要开发这个插件 38 | 39 | 今年 6 月份左右,墨墨背单词推出了开放 API,那时我已经连续使用这个单词软件近 1 年。 40 | 41 | 我一直在思考如何让背单词与我的英语学习工作流结合起来,而不仅仅是像读书时候一样去背词书。由于工作中看的都是英文资料,平时喜欢听英文播客,这些材料中已经提供了大量的生词供我背诵,可面临的问题是单词的查询和录入是割裂的,在没有开放 API 之前,我只能在 Bob 里查询单词,然后隔一段时间再导出查询纪录,将单词复制到墨墨的单词本,久而久之我就放弃了: 42 | 43 | 1. Bob 历史纪录有限,如果查询太多,需要经常导出来录入 44 | 2. 需要记得来导出,并录入 45 | 3. 只能录入生词,没了上下文,去背诵这个单词的时候忘记是在哪看到的了 46 | 47 | 而如今,得益于 Bob 和墨墨开放 API,录入结合到了“查词”这个行为当中,查的同时就帮你完成录入,还能在查句子的同时,将句子添加到某个单词的例句,这也保留了上下文,这无疑提供了单词背诵的效率。 48 | 49 | 我向来也不喜欢死背单词,所以生词都来源于我日常的输入材料,如今,这一过程变得自动化,而未来,我还想做得更加智能:利用自然语言模型的能力,在用户翻译一个句子的时候,帮助用户推测这里面有哪些词对于用户是生词,从而自动添加。 50 | 51 | ### 我的墨墨使用纪录 52 | 53 | ![19C775E9-DE55-4F9A-A258-8E7131EC0DB6](https://github.com/user-attachments/assets/ca36b5f1-38ee-4cdc-8106-2e853b6d3440) 54 | 55 | ## 云词本使用方法 56 | 57 | 1. 安装 [Bob](https://bobtranslate.com/guide/#%E5%AE%89%E8%A3%85) (版本 >= 0.50),一款 macOS 平台的翻译和 OCR 软件 58 | 59 | 2. 下载此插件: [bobplugin-maimemo-notebook.bobplugin](https://github.com/chriscurrycc/bob-plugin-maimemo-notebook/releases/latest) 60 | 61 | 3. 下载完成后双击 `bobplugin-maimemo-notebook.bobplugin` 文件以安装此插件,并在服务中找到并添加 62 | 63 | ![step1](https://github.com/user-attachments/assets/6b58e2a3-a4fd-42de-84ce-539d205e5083) 64 | 65 | 4. 打开墨墨背单词 App,在「我的 > 更多设置 > 实验功能 > 开放 API」申请并复制 Token 66 | 67 | 5. 把 Token 填入 Bob 偏好设置 > 服务 > 此插件配置界面的「墨墨开放 API Token」的输入框中 68 | 69 | ![step2](https://github.com/user-attachments/assets/af829e76-f990-4419-bbd9-e4a5f41e1899) 70 | 71 | > 插件会帮你生成一个「Bob Plugin」的云词本来录入在 Bob 查询的生词。如果想把生词添加到自己已有的云词本,也可以在[墨墨开放 API 平台](https://open.maimemo.com/#/operations/maimemo.openapi.notepad.v1.NotepadService.ListNotepads)查询自己的云词本列表,并把对应词本 ID 复制填入到「墨墨云词本 ID」配置项 72 | 73 | ![Find your own notepad id](https://webp.chriscurry.cc/uZNenI.jpg) 74 | 75 | 6. 保存配置 76 | 77 | ## 创建例句 78 | 79 | > ⚠️ 注意:你的墨墨等级需要达到 Lv4 才能创建例句 80 | 81 | 完成以上配置就可以实现生词添加到云词本功能,插件只会识别到是单词时才会添加,如果是例句则会跳过。如果想把查询到的例句也添加到某个单词,则还需要如下的额外配置。 82 | 83 | > 注:由于目前 Bob 不支持插件自身来决定展开/折叠从而触发相应逻辑。因此我想到的是将此插件添加两次: 84 | > 1. 其中一个默认展开,只识别单词并录入生词本 85 | > 2. 另一个默认收起,甚至可以「隐藏并钉到语言栏」,当翻译句子时,自己补充好句子中不懂的生词,然后手动点击,来将单词添加到生词本,以及为单词创建该例句和对应翻译(见演示 gif 后半部分) 86 | 87 | 创建例句除了要使用墨墨开放 API Token 外,还需要借助于翻译大模型(因为墨墨不能只添加例句,还需要添加对应翻译),插件目前支持 OpenAI 和智谱 AI 作为翻译大模型提供商,OpenAI 的配置优先级更高。 88 | 89 | 如果你使用 OpenAI,那么无疑它是更好的选择,否则你可以使用免费的智谱 AI 模型: 90 | 91 | 1. 去[智谱 AI 开放平台](https://bigmodel.cn)注册并登录,复制 [API Key](https://bigmodel.cn/usercenter/apikeys) 粘贴到配置项中的「智谱 API 密钥」 92 | 93 | > 默认的智谱语言模型为免费的「GLM-4-Flash」,如果要切换到更高质量的模型请自行参考[智谱官方价格](https://open.bigmodel.cn/pricing)决定 94 | 95 | 2. 将「添加例句到生词」切换为「是」 96 | 97 | ![step3](https://github.com/user-attachments/assets/79518b40-d37f-4b38-95d0-a03374188c85) 98 | 99 | 3. 保存配置 100 | 101 | 4. 将例句识别这个版本折叠起来(强烈推荐,这样可以做到单词添加完毕后再手动执行插件) 102 | 103 | ![step4](https://github.com/user-attachments/assets/bd07333a-2b5d-4586-9d41-1bd1f84a35cc) 104 | 105 | ## 注意事项 106 | 1. 例句录入失败的多数原因是单词未在墨墨词库中收录,请去除单词时态等等重新尝试 107 | 2. 单词录入不会判断该单词是否存在于墨墨词库,云词本可以添加任意文本,但也只有墨墨词库中收录的词汇才能进行学习 108 | 109 | ## 致谢 110 | 首先要感谢 Bob 翻译的开发者 [@ripperhe](https://github.com/ripperhe),感谢他开发了如此优秀的翻译软件,并设计了插件系统,没有这些努力,也不会有这个插件。初期在构思这个插件的时候,和他还进行了一些交流,感谢他提供的思路和指导。 111 | 112 | 其次是感谢墨墨背单词,这是一款很优秀的背单词软件,我已经连续使用一年以上,墨墨也在今年推出了开放 API,让单词录入变得更加简单。 113 | 114 | 最后感谢智谱,提供了免费调用的大模型,让我们能够使用免费的高质量翻译服务。 115 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createNotepad, 3 | addSentenceToWord, 4 | addWordsToNotepad, 5 | notepadIdFilePath, 6 | } from "./maimemo"; 7 | import { translateByLLM } from "./translate"; 8 | import { BobQuery, BobTranslationErrorType } from "./types"; 9 | 10 | export function supportLanguages() { 11 | return ["zh-Hans", "en"]; 12 | } 13 | 14 | export function translate(query: BobQuery) { 15 | const { text, detectFrom, onCompletion } = query; 16 | const { 17 | maimemoToken, 18 | notepadId: _notepadId, 19 | canAddSentence: _canAddSentence, 20 | bigModelApiKey, 21 | openaiApiKey, 22 | } = $option; 23 | 24 | if (detectFrom !== "en") { 25 | onCompletion({ 26 | error: { 27 | type: BobTranslationErrorType.UnSupportedLanguage, 28 | message: "墨墨云词本只支持添加英文单词", 29 | }, 30 | }); 31 | return; 32 | } 33 | 34 | const wordNum = text.trim().split(/\s+/); 35 | const maybeSentence = wordNum.length > 2; 36 | const canAddSentence = _canAddSentence === "true"; 37 | 38 | if (maybeSentence && !canAddSentence) { 39 | onCompletion({ 40 | error: { 41 | type: BobTranslationErrorType.NotFound, 42 | message: "未检测到单词", 43 | }, 44 | }); 45 | return; 46 | } 47 | 48 | const paragraphs = text.split("\n").filter((line) => !!line.trim()); 49 | const words = paragraphs[0] 50 | .split(",") 51 | .map((word) => word.trim()) 52 | .filter((word) => !!word && word.split(/\s+/).length < 3); 53 | const sentence = paragraphs[1]?.trim?.() || ""; 54 | let notepadId = _notepadId; 55 | 56 | if (!maimemoToken) { 57 | onCompletion({ 58 | error: { 59 | type: BobTranslationErrorType.NoSecretKey, 60 | message: "墨墨开放 API Token 未配置", 61 | }, 62 | }); 63 | return; 64 | } 65 | 66 | if (words.length === 0) { 67 | onCompletion({ 68 | error: { 69 | type: BobTranslationErrorType.NotFound, 70 | message: "未检测到单词", 71 | }, 72 | }); 73 | return; 74 | } 75 | 76 | // Create sample sentence for words 77 | let finished = false; 78 | let partMessage = ""; 79 | if (canAddSentence) { 80 | if (!sentence) { 81 | partMessage = "例句创建失败(未检测到例句)"; 82 | finished = true; 83 | } else if (!bigModelApiKey && !openaiApiKey) { 84 | partMessage = "例句创建失败(未配置大模型 API Key)"; 85 | finished = true; 86 | } else { 87 | translateByLLM(sentence) 88 | .then((translation) => { 89 | const tasks = words.map((word) => 90 | addSentenceToWord(word, sentence, translation) 91 | ); 92 | Promise.allSettled(tasks).then((results) => { 93 | const hasAnySuccess = results.some( 94 | (task) => task.status === "fulfilled" 95 | ); 96 | 97 | if (finished) { 98 | if (hasAnySuccess) { 99 | onCompletion({ 100 | result: { 101 | toParagraphs: [ 102 | `${partMessage ? partMessage + "," : ""}例句创建成功`, 103 | ], 104 | }, 105 | }); 106 | } else { 107 | const failReason = results.find( 108 | (task) => task.status === "rejected" 109 | )?.reason; 110 | 111 | onCompletion({ 112 | error: { 113 | type: BobTranslationErrorType.Network, 114 | message: `${ 115 | partMessage ? partMessage + "," : "" 116 | }例句创建失败(${failReason})`, 117 | }, 118 | }); 119 | } 120 | } else { 121 | partMessage = `例句创建${hasAnySuccess ? "成功" : "失败"}`; 122 | finished = true; 123 | } 124 | }); 125 | }) 126 | .catch((error) => { 127 | const currentPartMessage = `例句创建失败(${error.message})`; 128 | if (finished) { 129 | onCompletion({ 130 | error: { 131 | type: BobTranslationErrorType.Network, 132 | message: `${ 133 | partMessage ? partMessage + "," : "" 134 | }${currentPartMessage}`, 135 | }, 136 | }); 137 | } else { 138 | partMessage = currentPartMessage; 139 | finished = true; 140 | } 141 | }); 142 | } 143 | } else { 144 | finished = true; 145 | } 146 | 147 | // Try to grab cached notepadId if user don't provide one 148 | if (!notepadId && $file.exists(notepadIdFilePath)) { 149 | notepadId = $file.read(notepadIdFilePath).toUTF8(); 150 | } 151 | 152 | let addWordsTask = null; 153 | if (notepadId) { 154 | // Add words to existing notepad 155 | addWordsTask = addWordsToNotepad(notepadId, words); 156 | } else { 157 | // Create new notepad 158 | addWordsTask = createNotepad(words); 159 | } 160 | 161 | addWordsTask 162 | .then((result) => { 163 | if (finished) { 164 | onCompletion({ 165 | result: { 166 | toParagraphs: [`${result}${partMessage ? "," + partMessage : ""}`], 167 | }, 168 | }); 169 | } else { 170 | partMessage = result; 171 | } 172 | }) 173 | .catch((error) => { 174 | if (finished) { 175 | onCompletion({ 176 | error: { 177 | type: BobTranslationErrorType.Network, 178 | message: `${error.message}${partMessage ? "," + partMessage : ""}`, 179 | }, 180 | }); 181 | } else { 182 | partMessage = error.message; 183 | } 184 | }) 185 | .finally(() => { 186 | finished = true; 187 | }); 188 | } 189 | -------------------------------------------------------------------------------- /src/maimemo.ts: -------------------------------------------------------------------------------- 1 | const apiEndpoint = "https://open.maimemo.com/open/api/v1"; 2 | export const notepadIdFilePath = "$sandbox/notepad-id.txt"; 3 | 4 | interface MaimemoResponse { 5 | success: boolean; 6 | data?: T; 7 | } 8 | 9 | type MaimemoNotepadResponse = MaimemoResponse<{ 10 | notepad?: { 11 | id: string; 12 | status: string; 13 | content: string; 14 | title: string; 15 | brief: string; 16 | tags: string[]; 17 | }; 18 | }>; 19 | 20 | type MaimemoVocabularyResponse = MaimemoResponse<{ 21 | voc?: { 22 | id: string; 23 | }; 24 | }>; 25 | 26 | function getHeader() { 27 | const token = $option.maimemoToken!; 28 | return { 29 | "Content-Type": "application/json", 30 | Authorization: token.startsWith("Bearer") ? token : `Bearer ${token}`, 31 | }; 32 | } 33 | 34 | export async function createNotepad(words: string[]) { 35 | const header = getHeader(); 36 | const todayDate = new Date().toLocaleDateString("en-CA"); 37 | 38 | return $http 39 | .request({ 40 | method: "POST", 41 | url: `${apiEndpoint}/notepads`, 42 | header, 43 | body: { 44 | notepad: { 45 | status: "PUBLISHED", 46 | content: `# ${todayDate}\n${words.join("\n")}\n`, 47 | title: "Bob Plugin", 48 | brief: "Bob 插件录入词汇", 49 | tags: ["词典"], 50 | }, 51 | }, 52 | }) 53 | .then((_resp) => { 54 | let resp = _resp.data; 55 | if (resp.success && resp.data?.notepad) { 56 | const notepadId = resp.data.notepad.id; 57 | $file.write({ 58 | data: $data.fromUTF8(notepadId), 59 | path: notepadIdFilePath, 60 | }); 61 | return `云词本创建成功,单词 ${words.join(", ")} 已添加`; 62 | } else { 63 | throw new Error("创建云词本失败,单词未能成功添加"); 64 | } 65 | }); 66 | } 67 | 68 | export async function addWordsToNotepad(notepadId: string, words: string[]) { 69 | const header = getHeader(); 70 | const todayDate = new Date().toLocaleDateString("en-CA"); 71 | 72 | return $http 73 | .request({ 74 | method: "GET", 75 | url: `${apiEndpoint}/notepads/${notepadId}`, 76 | header, 77 | }) 78 | .then((_resp) => { 79 | const resp = _resp.data; 80 | if (resp.success && resp.data && resp.data.notepad) { 81 | const { status, content, title, brief, tags } = resp.data.notepad; 82 | const lines = content.split("\n").map((line) => line.trim()); 83 | let targetLineIndex = lines.findIndex((line) => 84 | line.startsWith(`# ${todayDate}`) 85 | ); 86 | 87 | if (targetLineIndex === -1) { 88 | lines.unshift(""); 89 | lines.unshift(`# ${todayDate}`); 90 | targetLineIndex = 0; 91 | } 92 | 93 | // Deduplicate: filter out words that already exist in lines 94 | // Only check actual word entries, exclude headers (lines starting with #) and empty lines 95 | const existingWords = new Set( 96 | lines 97 | .filter(line => line && !line.startsWith('#')) 98 | .map(line => line.trim().toLowerCase()) 99 | ); 100 | const uniqueWords = []; 101 | const duplicateWords = []; 102 | for (const word of words) { 103 | const trimmedWord = word.trim().toLowerCase(); 104 | if (existingWords.has(trimmedWord)) { 105 | duplicateWords.push(word); 106 | } else { 107 | uniqueWords.push(word); 108 | } 109 | } 110 | 111 | lines.splice(targetLineIndex + 1, 0, ...uniqueWords); 112 | 113 | return { 114 | notepad: { 115 | status, 116 | content: lines.join("\n"), 117 | title, 118 | brief, 119 | tags, 120 | }, 121 | uniqueWords, 122 | duplicateWords, 123 | }; 124 | } else { 125 | throw new Error("添加单词到云词本失败(未找到云词本)"); 126 | } 127 | }) 128 | .then((result) => { 129 | return $http.request({ 130 | method: "POST", 131 | url: `${apiEndpoint}/notepads/${notepadId}`, 132 | header, 133 | body: { 134 | notepad: result.notepad, 135 | }, 136 | }).then((_resp) => { 137 | const resp = _resp.data; 138 | if (resp?.success) { 139 | const messages = []; 140 | if (result.uniqueWords.length > 0) { 141 | messages.push(`单词 ${result.uniqueWords.join(", ")} 已添加到云词本`); 142 | } 143 | if (result.duplicateWords.length > 0) { 144 | messages.push(`${result.duplicateWords.join(", ")} 在云词本中已存在`); 145 | } 146 | return messages.join(";"); 147 | } else { 148 | throw new Error("添加单词到云词本失败"); 149 | } 150 | }); 151 | }); 152 | } 153 | 154 | export async function addSentenceToWord( 155 | word: string, 156 | sentence: string, 157 | translation: string 158 | ) { 159 | const header = getHeader(); 160 | 161 | return $http 162 | .request({ 163 | method: "GET", 164 | url: `${apiEndpoint}/vocabulary?spelling=${word}`, 165 | header, 166 | }) 167 | .then((_resp) => { 168 | let resp = _resp.data; 169 | if (resp.success && resp.data && resp.data.voc?.id) { 170 | return resp.data.voc.id; 171 | } else { 172 | throw new Error("未找到单词"); 173 | } 174 | }) 175 | .then((wordId) => { 176 | return $http.request({ 177 | method: "POST", 178 | url: `${apiEndpoint}/phrases`, 179 | header, 180 | body: { 181 | phrase: { 182 | voc_id: wordId, 183 | phrase: sentence, 184 | interpretation: translation, 185 | tags: ["词典"], 186 | origin: "Bob Plugin", 187 | }, 188 | }, 189 | }); 190 | }) 191 | .then((_resp) => { 192 | const resp = _resp.data; 193 | if (resp.success) { 194 | return `例句已添加到单词 ${word}`; 195 | } else { 196 | throw new Error(`添加例句到单词 ${word} 失败`); 197 | } 198 | }); 199 | } 200 | --------------------------------------------------------------------------------