视频
B站视频链接:
视频内容概要
如果把想要分享的内容全部都介绍完,估计时间会很长,所以计划拆分为俩视频来介绍,每个视频把想要说明的核心要点给讲清楚就好。
- 视频 1:讲清楚 qwerty learner 是个啥?
- 上期视频简介:若不熟悉打字指法,建议回看一下上期视频的后半部分 —— 打字指法教程
- 对应的文档:027. 练习打字的工具分享
- 视频链接

- 介绍 qwerty learner 这款工具
- 简单说明第二个视频的内容
- 上期视频简介:若不熟悉打字指法,建议回看一下上期视频的后半部分 —— 打字指法教程
- 视频 2:
- 上期视频简介
- 介绍数据的获取方式
- 展示解析后的数据的结构
- 介绍 qwerty learner 词典数据源
- 介绍 qwerty learner 桌面版的获取方式
- 脚本使用 & 功能介绍
- 如何将数据导出为 PDF、word
视频源文件
第一期视频源文件:
第二期视频源文件:
第二期字幕.srt.zip
References
词典目录下载解析后的词典数据
一键下载所有资源:results.zip
| 封面 | 备注 |
|---|---|
![]() |
- 单词数量:2607 - 词典源数据下载:CET4_3.zip - 解析后的 markdown 下载:[markdown]CET4_3.zip |
|
|
|
- 单词数量:2345
- 词典源数据下载:CET6_3.zip
- 解析后的 markdown 下载:[markdown]CET6_3.zip
|
|
|
- 单词数量:3728
- 词典源数据下载:KaoYan_3.zip
- 解析后的 markdown 下载:[markdown]KaoYan_3.zip
|
| …… | …… |
Qwerty Learner 功能介绍?
对于 Qwerty Learner 工具的相关功能,可以通过 👇🏻👇🏻👇🏻 下面这个视频快速了解。
Qwerty Learner 练习打字 の 免费工具 —— part I_哔哩哔哩_bilibili
练习结束后的反馈面板?

当我们练习完一个 chapter 的内容之后,会弹出该面板。
- 面板左侧可以看到【正确率】【章节耗时】【码字速度】
- 面板右侧展示的内容主要是本次练习中出现错误的一些词汇,只需要将鼠标悬停在对应的词汇上即可查看该单词对应的中文意思
- 面板的底部的三个按钮【默写本章节
shift + 回车键】【重复本章节空格键】【下一章节回车键】都有对应的快捷键。(若在敲完后,不想把放在键盘上的手移开去摸鼠标,完全可以通过对应快捷键来选择自己想要的功能)
如何给 Qwerty Learner 项目提建议 or 问题?
直接给项目提 Issues(建议 or 问题)即可。
🎙 功能与建议
目前项目处于开发初期,新功能正在持续添加中,如果你对软件有任何功能与建议,欢迎在 Issues 中提出 项目的进展与未来计划在 Issue 中详细介绍,内部也包含对未来功能的意见征询等,如果对 Qwerty Learner 的未来感兴趣,欢迎参与讨论。 如果你也喜欢本软件的设计思想,欢迎提交 pr,非常感谢你对我们的支持!
![]()
🏄♂️ 贡献指南
如果您对本项目感兴趣,我们非常欢迎参与到项目的贡献中,我们会尽可能地提供帮助 在贡献前,希望您阅读 Issue #42 了解我们目前的开发计划,我们希望您能参与到”计划中”的工作亦或者 Issue 区 Label 为 “Help Wanted” 的工作,我们也非常欢迎您实现自己的想法。 如果您确定了想要参与的工作,希望在有基本进展后提交 draft pr,我们可以在 draft pr 上进行讨论,也有利于听取其他 collaborator 的意见。 再次感谢您对项目的贡献!🎉
词库的数据来源是?
数据来源
字典数据来自于kajweb,项目爬取了常见的字典,也是这个项目让我看到了实现本项目的希望。 语音数据来源于有道词典开放 API,感谢有道的贡献让我们这种小项目也可以用上非常专业的发音资源,感谢有道团队以及考神团队为中国教育与中外交流做出的重要贡献。 JS API 来自于react-code-game ,感谢项目对 JS API 的爬取与预处理。
如何导入自己的词库?
如何导入属于自己的生词本
👆🏻👆🏻👆🏻 上面这是官方介绍的导入自己单词本的方法
Qwerty Learner 桌面版的获取方式?桌面应用
如果你也认为这是一款非常不错的工具,并且经常使用的话,那么你可以通过安装它的桌面版,以降低访问成本。成功安装完之后,它就像你电脑上的浏览器一样,需要的时候,直接点击桌面图标即可快速打开。
项目下载:https://github.com/tw93/Pake
安装包:
- windows:Qwerty_x64.msi.zip
- mac:Qwerty.dmg.zip
提取单词数据的脚本介绍?脚本
这涉及到一部分编程相关的知识,暂时还没能力跟大家介绍清楚原理。所以就简单说明一下步骤,和实现逻辑。
步骤:
- 安装 node 环境
- 随便找个地方,新建两个文件夹
sources、results - 下载词典源数据到 sources 目录中,并解压
- 执行脚本
具体步骤呢,如果你感兴趣的话,可以回看第二期视频的后半部分。 这里大家听一乐就好,毕竟所有的词典数据源都已经趴下来了……
需要具备的编程知识主要有以下两点:
- 文件的 I O 操作
- 字符串拼接
const fs = require('fs')const path = require("path")// const {// EOL// } = require('os')/*** 源目录名*/const SOURCE_FOLDER_NAME = 'sources'/*** 目标目录名*/const RESULT_FOLDER_NAME = 'results'/*** 一个单词下边的子标题*/const SUB_TITLE = {sentence: '例句',phrase: '短语',trans: '词义',relWord: '关联词汇',ukphone: 'UK',usphone: 'US',syno: '同义词'}let sourcesFolderPath = path.join(__dirname, SOURCE_FOLDER_NAME); // sources 目录的绝对路径let resultsFolderPath = path.join(__dirname, RESULT_FOLDER_NAME); // results 目录的绝对路径const JSON_FileList = fs.readdirSync(sourcesFolderPath).filter(p => p.includes('.json')).map(p => path.join(sourcesFolderPath, p)) // sources 目录下所有 json 文件的绝对路径for (let i = 0; i < JSON_FileList.length; i++) {writeFile(JSON_FileList[i])}// 写入文件function writeFile(fileName) {// 处理文件内容let fileContent = fs.readFileSync(fileName, 'utf-8')fileContent = '[' + fileContent + ']'fileContent = fileContent.replaceAll(/}\r\n/g, '},').replaceAll(/},]/g, '}]')// console.log(fileContent);let data = JSON.parse(fileContent)// 解析的 json 词典名称const basename = path.basename(fileName, '.json')// 清空 results 目录下指定词典目录const resultFolderPath = path.join(resultsFolderPath, basename)emptyDir(resultFolderPath)rmEmptyDir(resultFolderPath)fs.mkdirSync(resultFolderPath)// 单词列表(只含单词)const headWords = data.map(w => w.headWord)// console.log(headWords)const checkString = `# ${basename}\n` + headWords.map((h, i) => {if (i % 20 === 0) return `\n#### Chapter ${i / 20 + 1}\n\n` + `- [ ] ${h}\n`else return `- [ ] ${h}\n`}).join('')fs.writeFileSync(path.join(resultFolderPath, `./${basename}.md`), checkString)let chapterString;let chapterNum = 0;data.forEach((it, i) => {if (i === data.length - 1) fs.writeFileSync(path.join(resultFolderPath, `./Chapter ${chapterNum}.md`), chapterString)// 拼接章节 四级标题if (i % 20 === 0) {if (chapterNum !== 0) fs.writeFileSync(path.join(resultFolderPath, `./Chapter ${chapterNum}.md`), chapterString)chapterNum++;chapterString = '';chapterString += `# Chapter ${chapterNum}`chapterString += '\n\n'// 拼接该 chapter 中的所有单词为 checkboxchapterString += headWords.slice((chapterNum - 1) * 20, chapterNum * 20).map(h => `- [ ] ${h}\n`).join('')chapterString += '\n'}// 拼接单词 五级标题chapterString += `# ${i % 20 + 1}. ${it.headWord}`chapterString += '\n\n'const word = it.content.word.content// 拼接发音chapterString += `${SUB_TITLE.usphone}: [${word.usphone}]` + '\n'chapterString += `${SUB_TITLE.ukphone}: [${word.ukphone}]` + '\n\n'// 拼接词义if (word.trans && word.trans.length > 0) {const trans = word.transchapterString += `## ${SUB_TITLE.trans}` + '\n\n'for (let i = 0; i < trans.length; i++) {const t = trans[i]if (t.pos && t.tranCn) chapterString += `${t.pos}. ${t.tranCn.replace(/\s/g, '')}` + '\n'if (t.tranOther) chapterString += `\`${t.tranOther}\``chapterString += '\n\n'}}// 拼接同义词if (word.syno && word.syno.synos && word.syno.synos.length > 0) {const synos = word.syno.synoschapterString += `## ${SUB_TITLE.syno}` + '\n\n'for (let i = 0; i < synos.length; i++) {const s = synos[i];const w = s.hwds.map(h => `\`${h.w}\``).join(' ')chapterString += `${s.pos}. ${s.tran}` + '\n' + w + '\n\n';}chapterString += '\n'}// 拼接短语if (word.phrase && word.phrase.phrases) {const phrase = word.phraseconst phrases = phrase.phraseschapterString += `## ${phrase.desc}` + '\n\n'phrases.forEach(p => {chapterString += `- \`${p.pContent}\` ${p.pCn}` + '\n'})chapterString += '\n'}// 拼接例句if (word.sentence && word.sentence.sentences) {const sentence = word.sentenceconst sentences = sentence.sentenceschapterString += `## ${sentence.desc}` + '\n\n'sentences.forEach(s => {chapterString += `\`${s.sContent}\`` + '\n' + s.sCn + '\n\n'})chapterString += '\n'}})}/*** 删除所有的文件(将所有文件夹置空)* @param {*} filePath*/function emptyDir(filePath) {try {const files = fs.readdirSync(filePath) // 读取该文件夹files.forEach((file) => {const nextFilePath = `${filePath}/${file}`const states = fs.statSync(nextFilePath)if (states.isDirectory()) {emptyDir(nextFilePath)} else {fs.unlinkSync(nextFilePath)// console.log(`删除文件 ${nextFilePath} 成功`)}})} catch (error) {// console.log(error)return}}/*** 删除所有的空文件夹* @param {*} filePath*/function rmEmptyDir(filePath) {try {const files = fs.readdirSync(filePath)if (files.length === 0) {fs.rmdirSync(filePath)// console.log(`删除空文件夹 ${filePath} 成功`)} else {let tempFiles = 0files.forEach((file) => {tempFiles++const nextFilePath = `${filePath}/${file}`rmEmptyDir(nextFilePath)})//删除母文件夹下的所有字空文件夹后,将母文件夹也删除if (tempFiles === files.length) {fs.rmdirSync(filePath)// console.log(`删除空文件夹 ${filePath} 成功`)}}} catch (error) {// console.log(error)return}}
脚本逻辑直接看代码就好,可能还存在些许 bug,就现阶段自测下来,是没有问题的。若有问题,欢迎大家反馈,会找时间处理 bug 的。

