├── .editorconfig ├── .eslintignore ├── .eslintrc.cjs ├── .github └── ISSUE_TEMPLATE │ ├── bug-report.yml │ └── feature-request.yml ├── .gitignore ├── .np-config.js ├── .npmignore ├── .npmrc ├── .prettierrc.js ├── .vscode └── settings.json ├── CHANGELOG.md ├── README.md ├── bin └── index.js ├── docs ├── GET_TOEKN.md └── assets │ ├── attachments.png │ ├── demo.gif │ ├── getoken.png │ ├── logo.png │ ├── public_pwd.png │ └── server.png ├── package.json ├── pnpm-lock.yaml ├── rollup.config.ts ├── server-lib └── bundle.js ├── src ├── api.ts ├── cli.ts ├── constant.ts ├── crypto │ └── index.ts ├── download │ ├── article.ts │ ├── attachments.ts │ ├── common.ts │ ├── list.ts │ └── video.ts ├── index.ts ├── parse │ ├── Summary.ts │ ├── ast.ts │ ├── fix.ts │ └── sheet.ts ├── server.ts ├── types │ ├── ArticleResponse.ts │ ├── KnowledgeBaseResponse.ts │ ├── index.ts │ └── lib.d.ts └── utils │ ├── ProgressBar.ts │ ├── index.ts │ └── log.ts ├── test ├── __snapshots__ │ ├── cli.test.ts.snap │ └── index.test.ts.snap ├── api.test.ts ├── cli.test.ts ├── crypto.test.ts ├── download │ ├── __snapshots__ │ │ ├── article.test.ts.snap │ │ ├── attachments.test.ts.snap │ │ └── list.test.ts.snap │ ├── article.test.ts │ ├── attachments.test.ts │ └── list.test.ts ├── helpers │ └── TestTools.ts ├── index.test.ts ├── mocks │ ├── assets │ │ ├── 1.jpeg │ │ ├── 2.jpeg │ │ └── test.pdf │ ├── data │ │ ├── appData.json │ │ ├── attachments.json │ │ ├── boardData.json │ │ ├── docMd.json │ │ ├── docMd2.json │ │ ├── sheetData.json │ │ ├── tableData.json │ │ └── welfare │ │ │ ├── appData.json │ │ │ ├── docMd.json │ │ │ └── index.md │ ├── handlers.ts │ ├── server.ts │ └── utils.ts ├── parse │ ├── __snapshots__ │ │ ├── fix.test.ts.snap │ │ ├── sheet.test.ts.snap │ │ └── summary.test.ts.snap │ ├── fix.test.ts │ ├── sheet.test.ts │ └── summary.test.ts ├── realRequest.test.ts └── utils.test.ts ├── tsconfig.base.json ├── tsconfig.json └── vitest.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | # 最后一行换行 取消掉 11 | insert_final_newline = false 12 | trim_trailing_whitespace = true 13 | 14 | [*.md] 15 | insert_final_newline = false 16 | trim_trailing_whitespace = false 17 | 18 | [*.snap] 19 | insert_final_newline = false 20 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | types 3 | node_modules 4 | bin 5 | temp 6 | server-lib 7 | download -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true, 4 | }, 5 | parser: '@typescript-eslint/parser', 6 | plugins: [ 7 | '@typescript-eslint', 8 | ], 9 | extends: [ 10 | 'eslint:recommended', 11 | 'plugin:@typescript-eslint/recommended', 12 | ], 13 | rules: { 14 | semi: ['error', 'never'], 15 | quotes: ['error', 'single'], 16 | 'no-console': 'off', 17 | 'no-plusplus': ['error', { allowForLoopAfterthoughts: true }], 18 | // TODO 19 | '@typescript-eslint/no-explicit-any': 'off', 20 | '@typescript-eslint/no-non-null-assertion': 'off' 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug 反馈 2 | description: 发现bug? 快来举报它!🐛🔍 3 | labels: [bug] 4 | body: 5 | - type: input 6 | attributes: 7 | label: Node 版本号: 8 | # description: | 9 | # 你的当前复现问题的 Node 版本号 10 | placeholder: | 11 | 使用 “node -v” 命令,在控制台得到版本号(例如:v18.14.0) 12 | validations: 13 | required: false 14 | - type: input 15 | attributes: 16 | label: yuque-dl版本: 17 | # description: | 18 | # 你的当前复现问题 yuque-dl版本: 19 | placeholder: | 20 | 使用 “yuque-dl -v” 命令,在控制台得到版本号 21 | validations: 22 | required: false 23 | - type: input 24 | attributes: 25 | label: 操作系统: 26 | placeholder: | 27 | 请输入你所在的操作系统(例如: Windows 10) 28 | validations: 29 | required: false 30 | - type: input 31 | attributes: 32 | label: 方便的话提供语雀知识库的URL: 33 | placeholder: | 34 | 例如: https://www.yuque.com/yuque/thyzgp 35 | validations: 36 | required: false 37 | - type: markdown 38 | attributes: 39 | value: | 40 | > [!WARNING] 41 | > 检查是否因开启vpn导致的异常 42 | - type: textarea 43 | attributes: 44 | label: | 45 | Bug具体信息: 46 | placeholder: | 47 | 1. 问题描述 48 | 49 | 2. 如何复现 50 | 51 | 3. 预期值 52 | 53 | 4. 实际得到的结果 54 | validations: 55 | required: true 56 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: 💡 我有新点子或者改进建议 2 | description: 对yuque-dl有新的想法或者需要改进的地方... 3 | labels: [feature request] 4 | body: 5 | - type: textarea 6 | attributes: 7 | label: 请详细告知你的新点子: 8 | placeholder: | 9 | 1. 您期望能够实现什么功能。 10 | 11 | 2. 您的理由(如:我一直被什么问题困扰……)。 12 | validations: 13 | required: true 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | .DS_Store 133 | dist 134 | temp 135 | /types 136 | 137 | 138 | /download 139 | /download2 140 | -------------------------------------------------------------------------------- /.np-config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '2fa': false 3 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | node_modules 3 | test 4 | temp 5 | .vscode 6 | .np-config.js 7 | .eslintrc.js 8 | .eslintignore 9 | .editorconfig 10 | docs 11 | rollup.config.ts 12 | *.log 13 | .prettierrc.js 14 | .eslintrc.cjs 15 | download 16 | download2 17 | CHANGELOG.md 18 | vitest.config.ts 19 | coverage 20 | .github 21 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # .npmrc 2 | engine-strict = true 3 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | export default { 2 | semi: false, 3 | endOfLine: 'lf', 4 | singleQuote: true, 5 | tabWidth: 2, 6 | useTabs: false, 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "appenders", 4 | "Gener", 5 | "mdast", 6 | "pako", 7 | "sourcecode", 8 | "yuque" 9 | ], 10 | "cSpell.ignorePaths": [ 11 | "node_modules", // this will ignore anything the node_modules directory 12 | "**/node_modules", // the same for this one 13 | "**/node_modules/**", // the same for this one 14 | "node_modules/**", // Doesn't currently work due to how the current working directory is determined. 15 | "vscode-extension", // 16 | ".git", // Ignore the .git directory 17 | "*.dll", // Ignore all .dll files. 18 | "**/*.dll", // Ignore all .dll files 19 | ".gitignore", 20 | ".eslintrc.js", 21 | "pnpm-lock.yaml" 22 | ], 23 | 24 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.0.79](https://github.com/gxr404/yuque-dl/compare/v1.0.78...v1.0.79) (2025-04-19) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * update "pull-md-img" ([53b2980](https://github.com/gxr404/yuque-dl/commit/53b2980f75fcab11803878f5a5607f8d950d086f)) 7 | 8 | 9 | 10 | ## [1.0.78](https://github.com/gxr404/yuque-dl/compare/v1.0.77...v1.0.78) (2025-03-28) 11 | 12 | 13 | ### Features 14 | 15 | * add cli option "--hideFooter" ([0d15525](https://github.com/gxr404/yuque-dl/commit/0d155251b50c2726a6280243297bf86adccbef66)) 16 | 17 | 18 | 19 | ## [1.0.77](https://github.com/gxr404/yuque-dl/compare/v1.0.76...v1.0.77) (2025-03-24) 20 | 21 | 22 | ### Bug Fixes 23 | 24 | * video 标签 markdown中不支持自闭合标签 ([7205f10](https://github.com/gxr404/yuque-dl/commit/7205f107d3a5912056f6806f4a2cfdb40ab1e4dd)) 25 | 26 | 27 | 28 | ## [1.0.76](https://github.com/gxr404/yuque-dl/compare/v1.0.75...v1.0.76) (2025-03-24) 29 | 30 | 31 | ### Bug Fixes 32 | 33 | * handleMdData 缺少参数 ([e28e1f6](https://github.com/gxr404/yuque-dl/commit/e28e1f62d8b3ea59d10d85f74530918505584914)) 34 | * test snap ([4f5faf3](https://github.com/gxr404/yuque-dl/commit/4f5faf37e134350b01ce9c5b612130d5fb94e9d2)) 35 | * 生成目录一级link 转为`## `作为标题 ([c6725f0](https://github.com/gxr404/yuque-dl/commit/c6725f0bd7428ca3bfa3e5a231e81ecb09c103f5)) 36 | 37 | 38 | ### Features 39 | 40 | * add "convertMarkdownVideoLinks" cli 选项 & server sidebar 菜单正确排序 ([f146d5f](https://github.com/gxr404/yuque-dl/commit/f146d5f0dc33e51295cbe3334b21e93605a503e4)) 41 | 42 | 43 | 44 | ## [1.0.75](https://github.com/gxr404/yuque-dl/compare/v1.0.74...v1.0.75) (2025-03-13) 45 | 46 | 47 | ### Bug Fixes 48 | 49 | * update github issuse template ([a6ef0b2](https://github.com/gxr404/yuque-dl/commit/a6ef0b2a2f1e7e0fee26563d0be99dfd6b7438e3)) 50 | * update github issuse template ([42a5a72](https://github.com/gxr404/yuque-dl/commit/42a5a729d485a394f7b4126cb4de92052aca0a87)) 51 | * update github issuse template ([5a99149](https://github.com/gxr404/yuque-dl/commit/5a99149f8a06d863569b5031c36829d5b74eb0cd)) 52 | 53 | 54 | ### Features 55 | 56 | * progress.json 添加对应时间字段 ([6a61d06](https://github.com/gxr404/yuque-dl/commit/6a61d062cc4ca57ba0eee94fafa47138aeb5669e)) 57 | * 增加 cli "incremental" 选项, 增量更新 功能 ([fbf17c9](https://github.com/gxr404/yuque-dl/commit/fbf17c98b8658da54a74c3f75a800c87e7f5d19b)) 58 | 59 | 60 | 61 | ## [1.0.74](https://github.com/gxr404/yuque-dl/compare/v1.0.73...v1.0.74) (2025-02-13) 62 | 63 | 64 | ### Bug Fixes 65 | 66 | * **server:** remove handler breaks ([92b5989](https://github.com/gxr404/yuque-dl/commit/92b59892614bd3f6244e084d04c2774118d88717)) 67 | 68 | 69 | ### Features 70 | 71 | * **server:** add cli options ([d79ce29](https://github.com/gxr404/yuque-dl/commit/d79ce298293d494b76fc5dfa06cb27d1e1f4ee08)) 72 | 73 | 74 | 75 | ## [1.0.73](https://github.com/gxr404/yuque-dl/compare/v1.0.72...v1.0.73) (2024-11-12) 76 | 77 | 78 | ### Bug Fixes 79 | 80 | * **server:** handler breaks ([e78a535](https://github.com/gxr404/yuque-dl/commit/e78a535fecad418139775b8686d19aaf2547a8d0)) 81 | * **server:** upgrade vitepress version & handler breaks ([a7f4738](https://github.com/gxr404/yuque-dl/commit/a7f4738e6545a7d7f4c1d607e20d454d65f57005)) 82 | 83 | 84 | 85 | ## [1.0.72](https://github.com/gxr404/yuque-dl/compare/v1.0.71...v1.0.72) (2024-11-12) 86 | 87 | 88 | ### Bug Fixes 89 | 90 | * npm ignore `.github` ([3de7ae6](https://github.com/gxr404/yuque-dl/commit/3de7ae60b39b39c26ad78e09678afc62bab8391e)) 91 | * **server:** vitepress改为解析markdown中html标签 ([1642916](https://github.com/gxr404/yuque-dl/commit/164291646ab35a707465c3872ef66cff7ca4afdd)) 92 | 93 | 94 | 95 | ## [1.0.71](https://github.com/gxr404/yuque-dl/compare/v1.0.70...v1.0.71) (2024-11-06) 96 | 97 | 98 | ### Features 99 | 100 | * add timeout args ([f57d8a7](https://github.com/gxr404/yuque-dl/commit/f57d8a7820f3d447d31233c89d67ab0dde0f17e5)) 101 | 102 | 103 | 104 | ## [1.0.70](https://github.com/gxr404/yuque-dl/compare/v1.0.69...v1.0.70) (2024-09-14) 105 | 106 | 107 | ### Bug Fixes 108 | 109 | * 移除 `
`替换为`\n` ([8a46bf6](https://github.com/gxr404/yuque-dl/commit/8a46bf6d40cc148aa7fbbf66b55be54092e82481)) 110 | 111 | 112 | 113 | ## [1.0.69](https://github.com/gxr404/yuque-dl/compare/v1.0.68...v1.0.69) (2024-09-11) 114 | 115 | 116 | ### Bug Fixes 117 | 118 | * 优化nodejs版本过低提示 ([30a87eb](https://github.com/gxr404/yuque-dl/commit/30a87ebf15dc8d159fca2b966d3a7ae2cb2bc01e)) 119 | 120 | 121 | 122 | ## [1.0.68](https://github.com/gxr404/yuque-dl/compare/v1.0.67...v1.0.68) (2024-09-08) 123 | 124 | 125 | ### Bug Fixes 126 | 127 | * 修复uuid含特殊字符导致附近保存异常 ([b21a77e](https://github.com/gxr404/yuque-dl/commit/b21a77e70681f698ab7a4fb3e2812bb79f81a689)) 128 | 129 | 130 | 131 | ## [1.0.67](https://github.com/gxr404/yuque-dl/compare/v1.0.66...v1.0.67) (2024-09-04) 132 | 133 | 134 | ### Features 135 | 136 | * add article update date ([96a515d](https://github.com/gxr404/yuque-dl/commit/96a515df4871331040d73d5a002e523deed66644)) 137 | 138 | 139 | 140 | ## [1.0.66](https://github.com/gxr404/yuque-dl/compare/v1.0.65...v1.0.66) (2024-08-16) 141 | 142 | 143 | ### Features 144 | 145 | * 音频下载 ([ba7a08f](https://github.com/gxr404/yuque-dl/commit/ba7a08f9e53c22b79109f7c107f3d269ff6ec13d)) 146 | 147 | 148 | 149 | ## [1.0.65](https://github.com/gxr404/yuque-dl/compare/v1.0.64...v1.0.65) (2024-08-16) 150 | 151 | 152 | ### Bug Fixes 153 | 154 | * 移除多余打印 ([af6b61c](https://github.com/gxr404/yuque-dl/commit/af6b61c3024d2fa16d2760d3dc8e6b38b1978e5f)) 155 | 156 | 157 | 158 | ## [1.0.64](https://github.com/gxr404/yuque-dl/compare/v1.0.63...v1.0.64) (2024-08-16) 159 | 160 | 161 | ### Bug Fixes 162 | 163 | * env test axios proxy false ([3828178](https://github.com/gxr404/yuque-dl/commit/3828178fac8ee0c9bdf410675b6b7df56877a01e)) 164 | 165 | 166 | ### Features 167 | 168 | * 添加音视频下载功能——音频待定 ([37ff91e](https://github.com/gxr404/yuque-dl/commit/37ff91e9e8a587820b21b91947c19be8440befd3)) 169 | 170 | 171 | ## [1.0.63](https://github.com/gxr404/yuque-dl/compare/v1.0.62...v1.0.63) (2024-08-15) 172 | 173 | 174 | ### Bug Fixes 175 | 176 | * changelog markdown ([e8f7a0b](https://github.com/gxr404/yuque-dl/commit/e8f7a0b19e19374ffee2061a038a05af0dfca1a8)) 177 | * changelog markdown ([7dc7fb6](https://github.com/gxr404/yuque-dl/commit/7dc7fb6b9fc03eec8c7a83c480fbb39dde345bbd)) 178 | * **test:** Invalid URL ([e792cdc](https://github.com/gxr404/yuque-dl/commit/e792cdc5dc2aa84840217c7bc265bfc04dc0ed24)) 179 | 180 | 181 | ### Features 182 | 183 | * 移除图片水印 ([663d5d0](https://github.com/gxr404/yuque-dl/commit/663d5d07bfd2796406cc1a566343e8bb57a9a35e)) 184 | 185 | 186 | ## [1.0.62](https://github.com/gxr404/yuque-dl/compare/v1.0.61...v1.0.62) (2024-08-09) 187 | 188 | 189 | ### Features 190 | 191 | * 优化错误提示 ([9356004](https://github.com/gxr404/yuque-dl/commit/935600415e13dcffb5a9b95c556e5cbadf9e1af2)) 192 | 193 | 194 | 195 | ## [1.0.61](https://github.com/gxr404/yuque-dl/compare/v1.0.60...v1.0.61) (2024-08-01) 196 | 197 | 198 | ### Bug Fixes 199 | 200 | * 修复文档中表格含单选时解析错误 ([f6f30b6](https://github.com/gxr404/yuque-dl/commit/f6f30b641e311985420a0f6af5f573f000781071)) 201 | 202 | 203 | 204 | ## [1.0.60](https://github.com/gxr404/yuque-dl/compare/v1.0.59...v1.0.60) (2024-08-01) 205 | 206 | 207 | ### Bug Fixes 208 | 209 | * 修复文档中表格含单选时解析错误 ([1f0c7c3](https://github.com/gxr404/yuque-dl/commit/1f0c7c35c0d091221797064bcf4216d3c7ace51a)) 210 | 211 | 212 | 213 | ## [1.0.60](https://github.com/gxr404/yuque-dl/compare/v1.0.59...v1.0.60) (2024-08-01) 214 | 215 | 216 | ### Bug Fixes 217 | 218 | * 修复文档中表格含单选时解析错误 ([1f0c7c3](https://github.com/gxr404/yuque-dl/commit/1f0c7c35c0d091221797064bcf4216d3c7ace51a)) 219 | 220 | 221 | 222 | ## [1.0.59](https://github.com/gxr404/yuque-dl/compare/v1.0.58...v1.0.59) (2024-07-15) 223 | 224 | 225 | ### Bug Fixes 226 | 227 | * 修复附件下载失败和优化错误提示 ([0433fd5](https://github.com/gxr404/yuque-dl/commit/0433fd52f4c71de982182813cebe7063d79c7801)) 228 | 229 | 230 | 231 | ## [1.0.58](https://github.com/gxr404/yuque-dl/compare/v1.0.57...v1.0.58) (2024-07-01) 232 | 233 | 234 | ### Bug Fixes 235 | 236 | * **server:** 不解析markdown的html标签 ([1b2ec46](https://github.com/gxr404/yuque-dl/commit/1b2ec460e4fa05e696b3bde149f55a79a0982957)) 237 | 238 | 239 | 240 | ## [1.0.57](https://github.com/gxr404/yuque-dl/compare/v1.0.56...v1.0.57) (2024-07-01) 241 | 242 | 243 | ### Bug Fixes 244 | 245 | * **server:** 修复window路径`\xx\132`识别为特殊字符 ([7e8bbc8](https://github.com/gxr404/yuque-dl/commit/7e8bbc87e4f916be703996e44efd2cf8b3991229)) 246 | 247 | 248 | 249 | ## [1.0.56](https://github.com/gxr404/yuque-dl/compare/v1.0.55...v1.0.56) (2024-06-24) 250 | 251 | 252 | ### Bug Fixes 253 | 254 | * server 需忽略掉附件文件夹 ([8ef1a03](https://github.com/gxr404/yuque-dl/commit/8ef1a038d6a3efdb5202fb7af658745bdf63dcc4)) 255 | 256 | 257 | 258 | ## [1.0.55](https://github.com/gxr404/yuque-dl/compare/v1.0.54...v1.0.55) (2024-06-24) 259 | 260 | 261 | ### Bug Fixes 262 | 263 | * type fix ([02265b2](https://github.com/gxr404/yuque-dl/commit/02265b2a86e3f4102c8a3aff976eca790b7e97e7)) 264 | 265 | 266 | ### Features 267 | 268 | * 添加附件下载功能 ([a2e588d](https://github.com/gxr404/yuque-dl/commit/a2e588d9bc44be2b9ea92be7d4fbc5eab433a8eb)) 269 | 270 | 271 | 272 | ## [1.0.54](https://github.com/gxr404/yuque-dl/compare/v1.0.53...v1.0.54) (2024-06-04) 273 | 274 | 275 | ### Bug Fixes 276 | 277 | * 移除多余警告 ([9f3fc32](https://github.com/gxr404/yuque-dl/commit/9f3fc32aac4faf39cfc9ae1c3b45bd1525d1c401)) 278 | 279 | 280 | 281 | ## [1.0.53](https://github.com/gxr404/yuque-dl/compare/v1.0.52...v1.0.53) (2024-06-04) 282 | 283 | 284 | ### Bug Fixes 285 | 286 | * **types:** 修复type打包失败 ([a87c130](https://github.com/gxr404/yuque-dl/commit/a87c13022e3c95e0c54d2cb9c4e95078ea2f4100)) 287 | 288 | 289 | ### Features 290 | 291 | * 添加`yuque-dl server`命令 ([3dacf60](https://github.com/gxr404/yuque-dl/commit/3dacf60efcc88ea1b7689fc6f99a02b61589e2fe)) 292 | 293 | 294 | 295 | ## [1.0.52](https://github.com/gxr404/yuque-dl/compare/v1.0.51...v1.0.52) (2024-06-04) 296 | 297 | 298 | ### Features 299 | 300 | * **test:** add coverage ([44b4584](https://github.com/gxr404/yuque-dl/commit/44b45845e2e6e37138df9c0ee76d58fc163b4bf6)) 301 | * update package ([6fbf3d4](https://github.com/gxr404/yuque-dl/commit/6fbf3d42029e0448b43dc6594f78efd9c7099a1a)) 302 | 303 | 304 | 305 | ## [1.0.51](https://github.com/gxr404/yuque-dl/compare/v1.0.50...v1.0.51) (2024-05-29) 306 | 307 | 308 | ### Bug Fixes 309 | 310 | * update pull-md-img ([ff9410e](https://github.com/gxr404/yuque-dl/commit/ff9410e14346dc695f967370e87c5b558f6bc0cc)) 311 | 312 | 313 | 314 | ## [1.0.50](https://github.com/gxr404/yuque-dl/compare/v1.0.49...v1.0.50) (2024-05-29) 315 | 316 | 317 | ### Bug Fixes 318 | 319 | * 修复部分图片异常 ([5b99de4](https://github.com/gxr404/yuque-dl/commit/5b99de455f74e7738048f96ed56943997531e55b)) 320 | 321 | 322 | 323 | ## [1.0.49](https://github.com/gxr404/yuque-dl/compare/v1.0.48...v1.0.49) (2024-05-29) 324 | 325 | 326 | ### Features 327 | 328 | * add test & organize files ([9eeb00f](https://github.com/gxr404/yuque-dl/commit/9eeb00fa75439a8126cb795a278e7604e7fb1f6b)) 329 | 330 | 331 | 332 | ## [1.0.48](https://github.com/gxr404/yuque-dl/compare/v1.0.47...v1.0.48) (2024-05-23) 333 | 334 | 335 | ### Bug Fixes 336 | 337 | * 修复下载错误时图片替换顺序出错 ([87ddf3b](https://github.com/gxr404/yuque-dl/commit/87ddf3b00bf378364202ae25513fa3df4c2486e0)) 338 | 339 | 340 | 341 | ## [1.0.47](https://github.com/gxr404/yuque-dl/compare/v1.0.46...v1.0.47) (2024-05-23) 342 | 343 | 344 | ### Bug Fixes 345 | 346 | * 更新 pull-md-img ([5916fe0](https://github.com/gxr404/yuque-dl/commit/5916fe0aa8f2dc09f4c375a65fc1cf4bb305e66e)) 347 | 348 | 349 | 350 | ## [1.0.46](https://github.com/gxr404/yuque-dl/compare/v1.0.45...v1.0.46) (2024-05-22) 351 | 352 | 353 | ### Bug Fixes 354 | 355 | * 更新 pull-md-img ([3daaaaa](https://github.com/gxr404/yuque-dl/commit/3daaaaac38dd0af89d6569ce53f26aa8ed74f593)) 356 | 357 | 358 | 359 | ## [1.0.45](https://github.com/gxr404/yuque-dl/compare/v1.0.44...v1.0.45) (2024-05-21) 360 | 361 | 362 | ### Bug Fixes 363 | 364 | * 修复图片下载异常,更改解决方案,使用html模式的接口返回的图片重置md的图片url ([8ccfa6c](https://github.com/gxr404/yuque-dl/commit/8ccfa6c5763775ee359c7454c5d4b259d831bdc4)) 365 | 366 | 367 | 368 | ## [1.0.44](https://github.com/gxr404/yuque-dl/compare/v1.0.43...v1.0.44) (2024-05-16) 369 | 370 | 371 | ### Bug Fixes 372 | 373 | * changelog未 add ([74cd3d1](https://github.com/gxr404/yuque-dl/commit/74cd3d13ec7f48d52c8350991b9f6bf066b46e4d)) 374 | * 修复 changelog生成错误 ([9d81329](https://github.com/gxr404/yuque-dl/commit/9d813295842b4611f8f8295be679523c9cfad4f6)) 375 | * 修复"标题为文档"时图片引用路径错误 ([ccf150d](https://github.com/gxr404/yuque-dl/commit/ccf150d8601217374fcd7f3398103ec7621b7a11)) 376 | 377 | 378 | 379 | ## [1.0.43](https://github.com/gxr404/yuque-dl/compare/v1.0.42...v1.0.43) (2024-05-16) 380 | 381 | 382 | ### Bug Fixes 383 | 384 | * 修复changelog生成 ([f15888b](https://github.com/gxr404/yuque-dl/commit/f15888b21937afcf20eab1a13c6fda2c90eed2fa)) 385 | 386 | 387 | 388 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # yuque-dl 2 | 3 | 语雀知识库下载为本地markdown 4 | 5 | ![header](https://socialify.git.ci/gxr404/yuque-dl/image?description=1&descriptionEditable=%E8%AF%AD%E9%9B%80%E7%9F%A5%E8%AF%86%E5%BA%93%E4%B8%8B%E8%BD%BD&issues=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fgxr404%2Fyuque-dl%2Fmain%2Fdocs%2Fassets%2Flogo.png&name=1&pattern=Circuit%20Board&pulls=1&stargazers=1&theme=Light) 6 | 7 | ## Prerequisite 8 | 9 | - Node.js 18.4 or later 10 | 11 | ## Install 12 | 13 | ```bash 14 | npm i -g yuque-dl 15 | ``` 16 | 17 | ## Usage 18 | 19 | ```bash 20 | $ yuque-dl --help 21 | 22 | Usage: 23 | $ yuque-dl 24 | 25 | Commands: 26 | 语雀知识库url 27 | server 启动web服务 28 | 29 | For more info, run any command with the `--help` flag: 30 | $ yuque-dl --help 31 | $ yuque-dl server --help 32 | 33 | Options: 34 | -d, --dist-dir 下载的目录 eg: -d download (default: download) 35 | -i, --ignore-img 忽略图片不下载 (default: false) 36 | -k, --key 语雀的cookie key, 默认是 "_yuque_session", 在某些企业版本中 key 不一样 37 | -t, --token 语雀的cookie key 对应的值 38 | --toc 是否输出文档toc目录 (default: false) 39 | --incremental 开启增量下载(初次下载加不加该参数没区别) (default: false) 40 | --convertMarkdownVideoLinks 转化markdown视频链接为video标签 (default: false) 41 | --hideFooter 是否禁用页脚显示(更新时间、原文地址...) (default: false) 42 | -h, --help Display this message 43 | -v, --version Display version number 44 | ``` 45 | 46 | ### Start 47 | 48 | ```bash 49 | # url 为对应需要的知识库地址 50 | yuque-dl "https://www.yuque.com/yuque/thyzgp" 51 | ``` 52 | 53 | ## Example 54 | 55 | ![demo](https://github.com/gxr404/yuque-dl/assets/17134256/98fbbc81-91d4-47f8-9316-eb0ef060d6be) 56 | 57 | ## 其他场景 58 | 59 | ### 私有知识库 60 | 61 | 通过别人私有知识库 分享的链接,需使用`-t`添加token才能下载 62 | 63 | ```bash 64 | yuque-dl "https://www.yuque.com/yuque/thyzgp" -t "abcd..." 65 | ``` 66 | 67 | [token的获取请看](./docs/GET_TOEKN.md) 68 | 69 | ### 企业私有服务 70 | 71 | 企业服务有自己的域名(黄色语雀logo),非`yuque.com`结尾, 如`https://yuque.antfin.com/r/zone` 72 | 73 | 这种情况 token的key不唯一, 不一定是为`_yuque_session` 需用户使用 `-k` 指定 token的key,`-t` 指定 token的值。 74 | 75 | 至于`key`具体是什么只能靠用户自己在 `浏览器Devtools-> Application -> Cookies` 里找了🤔 76 | 77 | ### 公开密码访问的知识库 78 | 79 | > [!WARNING] 80 | > 下载"公开密码访问的知识库" 前提是需要知道别人设置的密码,输入密码后拿cookie进行下载,**无法做到破解密码**, 请须知 81 | 82 | ![public_pwd](https://github.com/gxr404/yuque-dl/assets/17134256/b546a9a3-68f0-4f76-b450-6b16f464db5d) 83 | 84 | ⚠️ 公开密码访问的知识库两种情况: 85 | 86 | - 已经登录语雀,访问需要密码的知识库 输入密码后使用`_yuque_session`这个cookie 87 | 88 | ```bash 89 | yuque-dl "url" -t "_yuque_session的值" 90 | ``` 91 | 92 | - 未登录语雀,访问需要密码的知识库 输入密码后需要使用`verified_books`/`verified_docs`这个cookie 93 | 94 | ```bash 95 | yuque-dl "url" -k "verified_books" -t "verified_books的值" 96 | ``` 97 | 98 | ## 内置启动web服务可快速预览 99 | 100 | 使用[`vitepress`](https://vitepress.dev/)快速启动一个web服务提供可预览下载的内容 101 | 102 | ```bash 103 | yuque-dl server ./download/知识库/ 104 | 105 | ➜ Local: http://localhost:5173/ 106 | ➜ Network: use --host to expose 107 | ``` 108 | 109 | ![server](https://github.com/gxr404/yuque-dl/assets/17134256/6d3a06cd-20b1-4eca-ae75-d9a90614336f) 110 | 111 | ## Feature 112 | 113 | - [x] 支持下载中断继续 114 | - [x] 支持图片下载本地 115 | - [x] 支持下载分享私有的知识库 116 | - [x] 支持转换表格类型的文档 (ps: 表格内插入图表暂不支持) 117 | - [x] 添加toc目录功能 118 | - [x] 添加测试 119 | - [x] 添加附件下载 120 | - [ ] 支持其他文档类型?🤔 121 | - [ ] 直接打包成可执行文件 🤔 122 | 123 | ## 常见错误 124 | 125 | 1. 由于token可能含有 特殊字符导致参数识别错误 126 | 127 | ```bash 128 | yuque-dl "https://www.yuque.com/yuque/thyzgp" -t "-a123" 129 | yuque-dl [ERROR]: Unknown option `-1` 130 | ``` 131 | 132 | 解决方案 133 | 134 | ```bash 135 | yuque-dl "https://www.yuque.com/yuque/thyzgp" -t="-a123" 136 | ``` 137 | 138 | 2. 附件下载失败,需设置登录token 139 | 140 | 附件文件下载需要用户登录token,即使是完全公开的知识库,下载附件也可能需要 141 | 142 | 完全公开的知识库未登录的情况下查看附件: 143 | 144 | ![attachments](https://github.com/user-attachments/assets/6e764abf-0da6-4fb8-ab96-7d027830b291) 145 | 146 | ## Tips 147 | 148 | 由于网络波动下载失败的,重新运行即可,已下载的进度不会受到影响 149 | -------------------------------------------------------------------------------- /bin/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | function run() { 4 | return import('../dist/es/cli.js').catch((e) => { 5 | const lowVersionError = [ 6 | 'Not supported', 7 | 'No such built-in module: node:fs/promises', 8 | 'Only file and data URLs are supported by the default ESM loader', 9 | // at Loader.moduleStrategy (internal/modules/esm/translators.js:145:18) 10 | 'Unexpected token \'??=\'' 11 | ] 12 | if (lowVersionError.includes(e.message)) { 13 | console.error('\x1b[31m%s\x1b[0m', '✕ nodejs版本过低') 14 | return 15 | } 16 | throw e 17 | }) 18 | } 19 | run() -------------------------------------------------------------------------------- /docs/GET_TOEKN.md: -------------------------------------------------------------------------------- 1 | # 如何获取语雀的token 2 | 3 | > 以chrome为例其他浏览器也类似 4 | 5 | 1. 登录语雀,浏览器右击菜单"检查"或点击快捷键 F12(Mac是Option+Command+J) 6 | 2. 退出控制台后点击 `Application` 7 | 3. 点击左侧`Cookies` 下的 `https://www.yuque.com` 8 | 4. 右侧列表中找到 `Name`为 `_yuque_session` 双击`Value`列复制 **Value的值**(也就是下面图片中绿色部分) 9 | 10 | ![getoken](https://github.com/gxr404/yuque-dl/assets/17134256/cd28331a-5618-4c15-90de-6b914a0dd375) 11 | 12 | 之后就可以在终端执行 13 | 14 | ```bash 15 | yuque-dl "知识库的url" -t "复制的token" 16 | ``` 17 | 18 | > 🚨Tips: cookie为个人登录的信息,请勿泄露自己的cookie给其他人 19 | -------------------------------------------------------------------------------- /docs/assets/attachments.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gxr404/yuque-dl/91ab92ebb276e7520d83969157a7a5f7df3fc4d3/docs/assets/attachments.png -------------------------------------------------------------------------------- /docs/assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gxr404/yuque-dl/91ab92ebb276e7520d83969157a7a5f7df3fc4d3/docs/assets/demo.gif -------------------------------------------------------------------------------- /docs/assets/getoken.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gxr404/yuque-dl/91ab92ebb276e7520d83969157a7a5f7df3fc4d3/docs/assets/getoken.png -------------------------------------------------------------------------------- /docs/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gxr404/yuque-dl/91ab92ebb276e7520d83969157a7a5f7df3fc4d3/docs/assets/logo.png -------------------------------------------------------------------------------- /docs/assets/public_pwd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gxr404/yuque-dl/91ab92ebb276e7520d83969157a7a5f7df3fc4d3/docs/assets/public_pwd.png -------------------------------------------------------------------------------- /docs/assets/server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gxr404/yuque-dl/91ab92ebb276e7520d83969157a7a5f7df3fc4d3/docs/assets/server.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yuque-dl", 3 | "version": "1.0.79", 4 | "description": "yuque 知识库下载", 5 | "type": "module", 6 | "bin": { 7 | "yuque-dl": "bin/index.js" 8 | }, 9 | "scripts": { 10 | "dev": "pnpm run build:bundle -w", 11 | "build": "run-s build:**", 12 | "build:bundle": "rollup -c rollup.config.ts --configPlugin typescript", 13 | "build:types": "tsc --emitDeclarationOnly --outDir types -p tsconfig.base.json", 14 | "clean": "rm -rf dist types", 15 | "np": "np", 16 | "eslintLog": "eslint . > eslint.log", 17 | "eslintFix": "eslint --fix .", 18 | "changelog:gen": "conventional-changelog -p angular -i CHANGELOG.md -s -r 2", 19 | "changelog:commit": "git add CHANGELOG.md && git commit CHANGELOG.md -m 'chore: update changelog' && git push", 20 | "release": "run-s clean build np changelog:gen changelog:commit", 21 | "test": "vitest --run", 22 | "test:ui": "vitest --ui --coverage.enabled=true", 23 | "test:coverage": "vitest run --coverage" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/gxr404/yuque-dl.git" 28 | }, 29 | "keywords": [ 30 | "yuque", 31 | "nodejs", 32 | "download", 33 | "yuque-dl" 34 | ], 35 | "author": "gxr404", 36 | "license": "ISC", 37 | "bugs": { 38 | "url": "https://github.com/gxr404/yuque-dl/issues" 39 | }, 40 | "homepage": "https://github.com/gxr404/yuque-dl#readme", 41 | "devDependencies": { 42 | "@rollup/plugin-terser": "^0.4.3", 43 | "@rollup/plugin-typescript": "^11.1.2", 44 | "@types/cli-progress": "^3.11.0", 45 | "@types/mdast": "^4.0.4", 46 | "@types/node": "^20.11.26", 47 | "@types/progress": "^2.0.5", 48 | "@types/semver": "^7.5.8", 49 | "@types/web-bluetooth": "^0.0.20", 50 | "@typescript-eslint/eslint-plugin": "^6.3.0", 51 | "@typescript-eslint/parser": "^6.3.0", 52 | "@vitest/coverage-v8": "^1.6.0", 53 | "@vitest/ui": "^1.6.0", 54 | "conventional-changelog-cli": "^5.0.0", 55 | "msw": "^2.3.0", 56 | "np": "^10.0.7", 57 | "npm-run-all": "^4.1.5", 58 | "rollup": "^4.18.0", 59 | "tslib": "^2.6.1", 60 | "typescript": "^5.4.5", 61 | "vitest": "^1.6.0" 62 | }, 63 | "main": "dist/es/index.js", 64 | "types": "types/index.d.ts", 65 | "dependencies": { 66 | "axios": "^1.4.0", 67 | "cac": "^6.7.14", 68 | "cli-progress": "^3.12.0", 69 | "log4js": "^6.9.1", 70 | "markdown-toc": "^1.2.0", 71 | "mdast-util-from-markdown": "^2.0.1", 72 | "mdast-util-to-markdown": "^2.1.0", 73 | "ora": "^7.0.1", 74 | "pako": "1.0.11", 75 | "pull-md-img": "^0.0.69", 76 | "rand-user-agent": "1.0.109", 77 | "semver": "^7.6.0", 78 | "vitepress": "1.2.2" 79 | }, 80 | "engines": { 81 | "node": ">=18.4.0" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'rollup' 2 | import typescript from '@rollup/plugin-typescript' 3 | import terser from '@rollup/plugin-terser' 4 | 5 | export default defineConfig({ 6 | input: { 7 | index: 'src/index.ts', 8 | cli: 'src/cli.ts' 9 | }, 10 | output:[ 11 | { 12 | format: 'es', 13 | dir: 'dist/es', 14 | }, 15 | ], 16 | plugins: [ 17 | typescript(), 18 | terser() 19 | ] 20 | }) 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import { env } from 'node:process' 2 | import axios from 'axios' 3 | import { randUserAgent } from './utils' 4 | import { DEFAULT_COOKIE_KEY, DEFAULT_DOMAIN } from './constant' 5 | 6 | import type { 7 | ArticleResponse, 8 | KnowledgeBase, 9 | GetHeaderParams, 10 | IReqHeader, 11 | TGetKnowledgeBaseInfo, 12 | TGetMdData 13 | } from './types' 14 | import type { AxiosRequestConfig } from 'axios' 15 | 16 | function getHeaders(params: GetHeaderParams): IReqHeader { 17 | const { key = DEFAULT_COOKIE_KEY, token } = params 18 | const headers: IReqHeader = { 19 | 'user-agent': randUserAgent({ 20 | browser: 'chrome', 21 | device: 'desktop' 22 | }) 23 | } 24 | if (token) headers.cookie = `${key}=${token};` 25 | return headers 26 | } 27 | 28 | export function genCommonOptions(params: GetHeaderParams): AxiosRequestConfig { 29 | const config: AxiosRequestConfig = { 30 | headers: getHeaders(params), 31 | beforeRedirect: (options) => { 32 | // 语雀免费非企业空间会重定向如: www.yuque.com -> gxr404.yuque.com 33 | // 此时axios自动重定向并不会带上cookie 34 | options.headers = { 35 | ...(options?.headers || {}), 36 | ...getHeaders(params) 37 | } 38 | } 39 | } 40 | if (env.NODE_ENV === 'test') { 41 | config.proxy = false 42 | } 43 | return config 44 | } 45 | 46 | 47 | /** 获取知识库数据信息 */ 48 | export const getKnowledgeBaseInfo: TGetKnowledgeBaseInfo = (url, headerParams) => { 49 | const knowledgeBaseReg = /decodeURIComponent\("(.+)"\)\);/m 50 | return axios.get(url, genCommonOptions(headerParams)) 51 | .then(({data = '', status}) => { 52 | if (status === 200) return data 53 | return '' 54 | }) 55 | .then(html => { 56 | const data = knowledgeBaseReg.exec(html) ?? '' 57 | if (!data[1]) return {} 58 | const jsonData: KnowledgeBase.Response = JSON.parse(decodeURIComponent(data[1])) 59 | if (!jsonData.book) return {} 60 | const info = { 61 | bookId: jsonData.book.id, 62 | bookSlug: jsonData.book.slug, 63 | tocList: jsonData.book.toc || [], 64 | bookName: jsonData.book.name || '', 65 | bookDesc: jsonData.book.description || '', 66 | host: jsonData.space?.host || DEFAULT_DOMAIN, 67 | imageServiceDomains: jsonData.imageServiceDomains || [] 68 | } 69 | return info 70 | }).catch((e) => { 71 | // console.log(e.message) 72 | const errMsg = e?.message ?? '' 73 | if (!errMsg) throw new Error('unknown error') 74 | const netErrInfoList = [ 75 | 'getaddrinfo ENOTFOUND', 76 | 'read ECONNRESET', 77 | 'Client network socket disconnected before secure TLS connection was established' 78 | ] 79 | const isNetError = netErrInfoList.some(netErrMsg => errMsg.startsWith(netErrMsg)) 80 | if (isNetError) { 81 | throw new Error('请检查网络(是否正常联网/是否开启了代理软件)') 82 | } 83 | throw new Error(errMsg) 84 | }) 85 | } 86 | 87 | 88 | export const getDocsMdData: TGetMdData = (params, isMd = true) => { 89 | const { articleUrl, bookId, token, key, host = DEFAULT_DOMAIN } = params 90 | let apiUrl = `${host}/api/docs/${articleUrl}` 91 | const queryParams: any = { 92 | 'book_id': String(bookId), 93 | 'merge_dynamic_data': String(false) 94 | // plain=false 95 | // linebreak=true 96 | // anchor=true 97 | } 98 | if (isMd) queryParams.mode = 'markdown' 99 | const query = new URLSearchParams(queryParams).toString() 100 | apiUrl = `${apiUrl}?${query}` 101 | return axios.get(apiUrl, genCommonOptions({token, key})) 102 | .then(({data, status}) => { 103 | const res = { 104 | apiUrl, 105 | httpStatus: status, 106 | response: data 107 | } 108 | return res 109 | }) 110 | } 111 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | 2 | import { readFileSync } from 'node:fs' 3 | import { cac } from 'cac' 4 | import semver from 'semver' 5 | 6 | import { main } from './index' 7 | import { logger } from './utils' 8 | import { runServer } from './server' 9 | 10 | import type { ICliOptions, IServerCliOptions } from './types' 11 | 12 | const cli = cac('yuque-dl') 13 | 14 | // 不能直接使用 import {version} from '../package.json' 15 | // 否则declaration 生成的d.ts 会多一层src目录 16 | const { version, engines } = JSON.parse( 17 | readFileSync(new URL('../../package.json', import.meta.url)).toString(), 18 | ) 19 | 20 | function checkVersion() { 21 | const version = engines.node 22 | if (!semver.satisfies(process.version, version)) { 23 | logger.error(`✕ nodejs 版本需 ${version}, 当前版本为 ${process.version}`) 24 | process.exit(1) 25 | } 26 | } 27 | 28 | // 检查node版本 29 | checkVersion() 30 | 31 | cli 32 | .command('', '语雀知识库url') 33 | .option('-d, --dist-dir ', '下载的目录 eg: -d download', { 34 | default: 'download', 35 | }) 36 | .option('-i, --ignore-img', '忽略图片不下载', { 37 | default: false 38 | }) 39 | .option('-k, --key ', '语雀的cookie key, 默认是 "_yuque_session", 在某些企业版本中 key 不一样') 40 | .option('-t, --token ', '语雀的cookie key 对应的值') 41 | .option('--toc', '是否输出文档toc目录', { 42 | default: false 43 | }) 44 | .option('--incremental', '开启增量下载(初次下载加不加该参数没区别)', { 45 | default: false 46 | }) 47 | .option('--convertMarkdownVideoLinks', '转化markdown视频链接为video标签', { 48 | default: false 49 | }) 50 | .option('--hideFooter', '是否禁用页脚显示(更新时间、原文地址...)', { 51 | default: false 52 | }) 53 | .action(async (url: string, options: ICliOptions) => { 54 | try { 55 | await main(url, options) 56 | process.exit(0) 57 | } catch (err) { 58 | logger.error(err.message || 'unknown exception') 59 | process.exit(1) 60 | } 61 | }) 62 | 63 | cli 64 | .command('server ', '启动web服务') 65 | .option('-p, --port ', ' --port 1234', { 66 | default: 5173, 67 | }) 68 | .option('--host [host]', ' --host 0.0.0.0 或 --host', { 69 | default: 'localhost', 70 | }) 71 | .option('--force', '强制重新生成.vitepress', { 72 | default: false, 73 | }) 74 | .action(async (serverPath: string, options: IServerCliOptions) => { 75 | try { 76 | await runServer(serverPath, options) 77 | } catch (err) { 78 | logger.error(err.message || 'unknown exception') 79 | process.exit(1) 80 | } 81 | }) 82 | 83 | cli.help() 84 | cli.version(version) 85 | 86 | try { 87 | cli.parse() 88 | } catch (err) { 89 | logger.error(err.message || 'unknown exception') 90 | process.exit(1) 91 | } 92 | -------------------------------------------------------------------------------- /src/constant.ts: -------------------------------------------------------------------------------- 1 | /** 语雀toc菜单类型 */ 2 | enum ARTICLE_TOC_TYPE { 3 | // title目录类型 4 | TITLE = 'title', 5 | // link外链类型 6 | LINK = 'link', 7 | // 内部文档 8 | DOC = 'doc' 9 | } 10 | 11 | /** 语雀档类型 */ 12 | enum ARTICLE_CONTENT_TYPE { 13 | /** 画板 */ 14 | BOARD = 'board', 15 | /** 数据表 */ 16 | TABLE = 'table', 17 | /** 表格 */ 18 | SHEET = 'sheet', 19 | /** 文档 */ 20 | DOC = 'doc' 21 | } 22 | 23 | const ARTICLE_CONTENT_MAP = new Map([ 24 | [ARTICLE_CONTENT_TYPE.BOARD, '画板类型'], 25 | [ARTICLE_CONTENT_TYPE.TABLE, '数据表类型'], 26 | [ARTICLE_CONTENT_TYPE.SHEET, '表格类型'], 27 | [ARTICLE_CONTENT_TYPE.DOC, '文档类型'], 28 | ]) 29 | 30 | /** 默认语雀cookie KEY */ 31 | const DEFAULT_COOKIE_KEY = '_yuque_session' 32 | /** 默认语雀域名 */ 33 | const DEFAULT_DOMAIN = 'https://www.yuque.com' 34 | 35 | const IMAGE_SING_KEY = 'UXO91eVnUveQn8suOJaYMvBcWs9KptS8N5HoP8ezSeU4vqApZpy1CkPaTpkpQEx2W2mlhxL8zwS8UePwBgksUM0CTtAODbTTTDFD' 36 | 37 | export { 38 | ARTICLE_TOC_TYPE, 39 | ARTICLE_CONTENT_TYPE, 40 | ARTICLE_CONTENT_MAP, 41 | DEFAULT_COOKIE_KEY, 42 | DEFAULT_DOMAIN, 43 | IMAGE_SING_KEY 44 | } -------------------------------------------------------------------------------- /src/crypto/index.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'node:crypto' 2 | import { IMAGE_SING_KEY } from '../constant' 3 | 4 | function isCaptureImageURL(url: string, imageServiceDomains: string[]) { 5 | // try Invalid URL 6 | try { 7 | const {host, pathname} = new URL(url) 8 | if (imageServiceDomains.includes(host)) return false 9 | return Boolean(pathname) 10 | } catch(e) { 11 | return false 12 | } 13 | } 14 | 15 | export function genSign(url: string) { 16 | const hash = crypto.createHash('sha256') 17 | hash.update(`${IMAGE_SING_KEY}${url}`) 18 | return hash.digest('hex') 19 | } 20 | 21 | export function captureImageURL(url: string, imageServiceDomains: string[] = []) { 22 | if (!isCaptureImageURL(url, imageServiceDomains)) return url 23 | // try { 24 | // const {origin, pathname, hash} = new URL(targetURL) 25 | // // 存在多个 https://xxx/xxx#id=111&...#id=222&... 26 | // // 仅取一个则移除最后一个 27 | // const hastArr = hash.split('#') 28 | // hastArr.splice(hastArr.length-1, 1) 29 | // targetURL = `${origin}${pathname}${hastArr.join('#')}` 30 | 31 | // } catch (e) { 32 | // return url 33 | // } 34 | return `https://www.yuque.com/api/filetransfer/images?url=${encodeURIComponent(url)}&sign=${genSign(url)}` 35 | } -------------------------------------------------------------------------------- /src/download/article.ts: -------------------------------------------------------------------------------- 1 | import { writeFile } from 'node:fs/promises' 2 | import { stdout, env } from 'node:process' 3 | import ora, { type Ora } from 'ora' 4 | import mdImg from 'pull-md-img' 5 | import mdToc from 'markdown-toc' 6 | 7 | import { getDocsMdData } from '../api' 8 | import { ARTICLE_CONTENT_TYPE, ARTICLE_CONTENT_MAP } from '../constant' 9 | import { fixLatex, fixMarkdownImage, fixPath } from '../parse/fix' 10 | import { parseSheet } from '../parse/sheet' 11 | import { captureImageURL } from '../crypto' 12 | import { formateDate, getMarkdownImageList, isValidDate } from '../utils' 13 | 14 | import type { DownloadArticleParams, DownloadArticleRes, IHandleMdDataOptions, IProgressItem } from '../types' 15 | import { downloadAttachments } from './attachments' 16 | import { downloadVideo } from './video' 17 | 18 | 19 | /** 下载单篇文章 */ 20 | export async function downloadArticle(params: DownloadArticleParams): Promise { 21 | const { articleInfo, progressBar, options, progressItem, oldProgressItem } = params 22 | const { token, key, convertMarkdownVideoLinks, hideFooter } = options 23 | const { 24 | bookId, 25 | itemUrl, 26 | savePath, 27 | saveFilePath, 28 | uuid, 29 | articleUrl, 30 | articleTitle, 31 | ignoreImg, 32 | host, 33 | imageServiceDomains 34 | } = articleInfo 35 | const reqParams = { 36 | articleUrl: itemUrl, 37 | bookId, 38 | token, 39 | host, 40 | key, 41 | } 42 | const { httpStatus, apiUrl, response } = await getDocsMdData(reqParams) 43 | 44 | updateProgressItemTime() 45 | const { needDownload, isUpdateDownload } = checkProgressItemUpdate(progressItem, oldProgressItem) 46 | 47 | // console.log('需要下载??', needDownload, articleUrl) 48 | if (!needDownload) { 49 | return { 50 | needDownload, 51 | isUpdateDownload, 52 | isDownloadFinish: true 53 | } 54 | } 55 | 56 | const contentType = response?.data?.type?.toLocaleLowerCase() as ARTICLE_CONTENT_TYPE 57 | let mdData = '' 58 | /** 表格类型 */ 59 | if (contentType === ARTICLE_CONTENT_TYPE.SHEET) { 60 | const {response} = await getDocsMdData(reqParams, false) 61 | try { 62 | const rawContent = response?.data?.content 63 | const content = rawContent ? JSON.parse(rawContent) : {} 64 | const sheetData = content?.sheet 65 | mdData = sheetData ? parseSheet(sheetData) : '' 66 | // 表格类型默认忽略图片 67 | // ignoreImg = true 68 | // TODO 表格类型中插入图表 vessels字段 69 | } catch(e) { 70 | const notSupportType = ARTICLE_CONTENT_MAP.get(contentType) 71 | throw new Error(`download article Error: “${notSupportType}”解析错误 ${e}`) 72 | } 73 | } else if ([ 74 | ARTICLE_CONTENT_TYPE.BOARD, 75 | ARTICLE_CONTENT_TYPE.TABLE 76 | ].includes(contentType)) { 77 | // 暂时不支持的文档类型 78 | const notSupportType = ARTICLE_CONTENT_MAP.get(contentType) 79 | throw new Error(`download article Error: 暂不支持“${notSupportType}”的文档`) 80 | } else if (typeof response?.data?.sourcecode !== 'string') { 81 | throw new Error(`download article Error: ${apiUrl}, http status ${httpStatus}`) 82 | } else { 83 | mdData = response.data.sourcecode 84 | // fix latex 85 | mdData = fixLatex(mdData) 86 | } 87 | const imgList = getMarkdownImageList(mdData) 88 | // fix md image url 89 | 90 | // 获取浏览器直接访问的源数据,取出对应的html数据 对 md数据中的图片url修复 91 | const rawData = await getDocsMdData(reqParams, false) 92 | const htmlData = rawData.response?.data?.content ?? '' 93 | 94 | // TODO: 待定 需不需要区分文档类型呢? 95 | if (imgList.length && !ignoreImg) { 96 | // 没图片的话不需要修复图片url 且 没有忽略图片下载 97 | // console.log('old', mdData) 98 | mdData = fixMarkdownImage(imgList, mdData, htmlData) 99 | // console.log('new', mdData) 100 | } 101 | 102 | const handleMdDataOptions = { 103 | toc: options.toc, 104 | articleTitle, 105 | articleUrl, 106 | articleUpdateTime: formateDate(response?.data?.content_updated_at ?? ''), 107 | convertMarkdownVideoLinks, 108 | hideFooter 109 | } 110 | 111 | const attachmentsErrInfo = [] 112 | 113 | // 附件下载 114 | try { 115 | progressBar.pause() 116 | console.log('') 117 | const resData = await downloadAttachments({ 118 | mdData, 119 | savePath, 120 | attachmentsDir: `./attachments/${fixPath(uuid)}`, 121 | articleTitle, 122 | token, 123 | key 124 | }) 125 | mdData = resData.mdData 126 | } catch (e) { 127 | attachmentsErrInfo.push(`附件下载失败: ${e.message || 'unknown error'}`) 128 | } finally { 129 | progressBar.continue() 130 | } 131 | 132 | 133 | // 音、视频下载 134 | try { 135 | progressBar.pause() 136 | console.log('') 137 | const resData = await downloadVideo({ 138 | mdData, 139 | htmlData, 140 | savePath, 141 | attachmentsDir: `./attachments/${fixPath(uuid)}`, 142 | articleTitle, 143 | token, 144 | key 145 | }) 146 | mdData = resData.mdData 147 | } catch (e) { 148 | attachmentsErrInfo.push(`音视频下载失败: ${e.message || 'unknown error'}`) 149 | } finally { 150 | progressBar.continue() 151 | } 152 | 153 | // 有图片 且 未忽略图片 154 | if (imgList.length && !ignoreImg) { 155 | progressBar.pause() 156 | let spinnerDiscardingStdin: Ora 157 | console.log('') 158 | if (env.NODE_ENV !== 'test') { 159 | spinnerDiscardingStdin = ora({ 160 | text: `下载 "${articleTitle}" 的图片中...`, 161 | stream: stdout 162 | }) 163 | spinnerDiscardingStdin.start() 164 | } 165 | let errorInfo = [] 166 | let data = mdData 167 | try { 168 | const mdImgRes = await mdImg.run(mdData, { 169 | dist: savePath, 170 | imgDir: `./img/${uuid}`, 171 | isIgnoreConsole: true, 172 | errorStillReturn: true, 173 | referer: articleUrl || '', 174 | transform(url: string) { 175 | // 去除水印参数 176 | url = url.replace('x-oss-process=image%2Fwatermark%2C', '') 177 | return captureImageURL(url, imageServiceDomains) 178 | }, 179 | // 默认设置图片下载超时3分钟 180 | timeout: 3 * 60 * 1000 181 | }) 182 | errorInfo = mdImgRes.errorInfo 183 | data = mdImgRes.data 184 | } catch(e) { 185 | errorInfo = [e] 186 | } 187 | mdData = data 188 | const stopProgress = () => { 189 | if (spinnerDiscardingStdin) spinnerDiscardingStdin.stop() 190 | progressBar.continue() 191 | } 192 | 193 | if (errorInfo.length > 0) { 194 | // const errMessage = `图片下载失败(失败的以远程链接保存): \n` 195 | // let errMessageList = '' 196 | // errorInfo.forEach((e, index) => { 197 | // errMessageList = `${errMessageList} ———————— ${index+1}. ${e.error?.message}: ${e.url} \n` 198 | // }) 199 | const e = errorInfo[0] 200 | let errMessage = '图片下载失败(失败的以远程链接保存): ' 201 | errMessage = e.url ? `${errMessage}${e.error?.message} ${e.url.slice(0, 20)}...` : `${errMessage}${e.message}` 202 | // 图片下载 md文档按远程图片保存 203 | await writeFile(saveFilePath, handleMdData(mdData, handleMdDataOptions)) 204 | stopProgress() 205 | // throw new Error(`${errMessage}\n${errMessageList}`) 206 | throw new Error(`${errMessage}`) 207 | } 208 | stopProgress() 209 | } 210 | 211 | // 更新单篇文档进度时间相关信息 212 | function updateProgressItemTime() { 213 | const createAt = response?.data?.created_at || '' 214 | const contentUpdatedAt = response?.data?.content_updated_at || '' 215 | const publishedAt = response?.data?.published_at || '' 216 | const firstPublishedAt = response?.data?.first_published_at || '' 217 | progressItem.createAt = createAt 218 | progressItem.contentUpdatedAt = contentUpdatedAt 219 | progressItem.publishedAt = publishedAt 220 | progressItem.firstPublishedAt = firstPublishedAt 221 | } 222 | 223 | try { 224 | await writeFile(saveFilePath, handleMdData(mdData, handleMdDataOptions)) 225 | // 保存后检查附件是否下载失败, 优先图片下载错误显示 图片下载失败直接就 throw不会走到这里 226 | if (attachmentsErrInfo.length > 0) { 227 | throw new Error(attachmentsErrInfo[0]) 228 | } 229 | return { 230 | needDownload, 231 | isUpdateDownload, 232 | isDownloadFinish: true 233 | } 234 | } catch(e) { 235 | throw new Error(`${e.message}`) 236 | } 237 | } 238 | 239 | function handleMdData ( 240 | rawMdData: string, 241 | options: IHandleMdDataOptions, 242 | ): string { 243 | const {articleTitle, articleUrl, toc, convertMarkdownVideoLinks, hideFooter} = options 244 | let mdData = rawMdData 245 | 246 | /** 247 | * https://github.com/gxr404/yuque-dl/issues/46 ps: 还是不修改用户的源数据!
248 | // Close: https://github.com/gxr404/yuque-dl/issues/35 249 | // '|
- [x] xxx\n\n\nx|' ==> '| - [x] xxxx|' 250 | mdData = mdData.replace(/\|\s?
- \[(x|\s)\]([\s\S\n]*?)\|/gm, (text, $1, $2) => { 251 | return `|
- [${$1}]${$2}|`.replace(/\n/gm, '').replace(//gm, '') 252 | }) 253 | // '|
xxx
xxx
|' ==> '| xxx xxx |' 254 | // '|
|
|
|
|' ==> '| | | | |' 255 | mdData = mdData.replace(/(\|?)(.*?)\|/gm,(text, $1, $2) => { 256 | return `${$1}${$2.replace(//gm, '')}|` 257 | }) 258 | mdData = mdData.replace(//gm, '\n') 259 | */ 260 | 261 | mdData = mdData.replace(/(\s*?)<\/a>/gm, '') 262 | const header = articleTitle ? `# ${articleTitle}\n\n` : '' 263 | // toc 目录添加 264 | let tocData = toc ? mdToc(mdData).content : '' 265 | if (tocData) tocData = `${tocData}\n\n---\n\n` 266 | 267 | let footer = '' 268 | if (!hideFooter) { 269 | footer = '\n\n' 270 | if (options.articleUpdateTime) { 271 | footer += `> 更新: ${options.articleUpdateTime} \n` 272 | } 273 | if (articleUrl) { 274 | footer += `> 原文: <${articleUrl}>` 275 | } 276 | } 277 | 278 | mdData = `${header}${tocData}${mdData}${footer}` 279 | if (convertMarkdownVideoLinks) { 280 | mdData = handleVideoMd(mdData) 281 | } 282 | return mdData 283 | } 284 | 285 | /** 将video链接转化成html video */ 286 | function handleVideoMd(mdData: string) { 287 | return mdData.replace(/\[(.*?)\]\((.*?)\.(mp4|mp3)\)/gm, (match, alt, url, extType) => { 288 | return `` 289 | }) 290 | } 291 | 292 | /** 检查当前是否是 是否增量下载 或者是否初次下载 */ 293 | function checkProgressItemUpdate(progressItem: IProgressItem, oldProgressItem?: IProgressItem) { 294 | const defaultRes = { 295 | isFirstDownload: true, 296 | isUpdateDownload: false, 297 | needDownload: true 298 | } 299 | if (!progressItem.contentUpdatedAt || !oldProgressItem || !oldProgressItem?.contentUpdatedAt) { 300 | return defaultRes 301 | } 302 | const currentUpdateDate = new Date(progressItem.contentUpdatedAt) 303 | const preUpdateDate = new Date(oldProgressItem.contentUpdatedAt) 304 | 305 | if (!isValidDate(currentUpdateDate) || !isValidDate(preUpdateDate)) { 306 | return defaultRes 307 | } 308 | 309 | if (currentUpdateDate.getTime() > preUpdateDate.getTime()) { 310 | return { 311 | needDownload: true, 312 | isUpdateDownload: true, 313 | isFirstDownload: false 314 | } 315 | } else if (currentUpdateDate.getTime() === preUpdateDate.getTime()) { 316 | return { 317 | needDownload: false, 318 | isUpdateDownload: false, 319 | isFirstDownload: false 320 | } 321 | } 322 | return defaultRes 323 | } -------------------------------------------------------------------------------- /src/download/attachments.ts: -------------------------------------------------------------------------------- 1 | import { stdout, env } from 'node:process' 2 | import { mkdirSync } from 'node:fs' 3 | import path from 'node:path' 4 | import ora from 'ora' 5 | 6 | import { downloadFile } from './common' 7 | 8 | const mdUrlReg = /\[(.*?)\]\((.*?)\)/g 9 | const AttachmentsReg = /\[(.*?)\]\((.*?\.yuque\.com\/attachments.*?)\)/ 10 | 11 | interface IDownloadAttachments { 12 | mdData: string 13 | savePath: string 14 | attachmentsDir: string 15 | articleTitle: string 16 | token?: string 17 | key?: string 18 | } 19 | 20 | interface IAttachmentsItem { 21 | fileName: string 22 | url: string 23 | rawMd: string 24 | currentFilePath: string 25 | } 26 | 27 | 28 | export async function downloadAttachments(params: IDownloadAttachments) { 29 | const { 30 | mdData, 31 | savePath, 32 | attachmentsDir, 33 | articleTitle, 34 | token, 35 | key 36 | } = params 37 | 38 | const attachmentsList = (mdData.match(mdUrlReg) || []).filter(item => AttachmentsReg.test(item)) 39 | // 无附件 40 | if (attachmentsList.length === 0) { 41 | return { 42 | mdData 43 | } 44 | } 45 | 46 | const spinner = ora({ 47 | text: `下载 "${articleTitle}" 的附件中...`, 48 | stream: stdout 49 | }) 50 | 51 | if (env.NODE_ENV !== 'test') { 52 | spinner.start() 53 | } 54 | 55 | const attachmentsDirPath = path.resolve(savePath, attachmentsDir) 56 | 57 | const attachmentsDataList = attachmentsList 58 | .map(item => parseAttachments(item, attachmentsDirPath)) 59 | .filter(item => item !== false) as IAttachmentsItem[] 60 | 61 | // 创建文件夹 62 | mkdirSync(attachmentsDirPath, { recursive: true }) 63 | const promiseList = attachmentsDataList.map((item) => { 64 | return downloadFile({ 65 | fileUrl: item.url, 66 | savePath: item.currentFilePath, 67 | token, 68 | key, 69 | fileName: item.fileName 70 | }) 71 | }) 72 | const downloadFileInfo = await Promise.all(promiseList).finally(spinnerStop) 73 | 74 | let resMdData = mdData 75 | downloadFileInfo.forEach(info => { 76 | const replaceInfo = attachmentsDataList.find(item => item.url === info.fileUrl) 77 | if (replaceInfo) { 78 | const replaceData = `[附件: ${replaceInfo.fileName}](${attachmentsDir}/${replaceInfo.fileName})` 79 | resMdData = resMdData.replace(replaceInfo.rawMd, replaceData) 80 | } 81 | }) 82 | 83 | function spinnerStop() { 84 | if (spinner) spinner.stop() 85 | } 86 | return { 87 | mdData: resMdData 88 | } 89 | } 90 | 91 | function parseAttachments(mdData: string, attachmentsDirPath: string): IAttachmentsItem | false { 92 | const [, rawFileName, url] = AttachmentsReg.exec(mdData) || [] 93 | if (!url) return false 94 | const fileName = rawFileName || url.split('/').at(-1) 95 | if (!fileName) return false 96 | const currentFilePath = path.join(attachmentsDirPath, fileName) 97 | return { 98 | fileName, 99 | url, 100 | rawMd: mdData, 101 | currentFilePath 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/download/common.ts: -------------------------------------------------------------------------------- 1 | import * as stream from 'node:stream' 2 | import { promisify } from 'node:util' 3 | import { createWriteStream } from 'node:fs' 4 | import axios from 'axios' 5 | 6 | import { genCommonOptions } from '../api' 7 | 8 | interface IDownloadFileParams { 9 | fileUrl: string, 10 | savePath: string, 11 | token?: string 12 | key?: string, 13 | fileName: string 14 | } 15 | 16 | const finished = promisify(stream.finished) 17 | export async function downloadFile(params: IDownloadFileParams) { 18 | const {fileUrl, savePath, token, key, fileName} = params 19 | return axios.get(fileUrl, { 20 | ...genCommonOptions({token, key}), 21 | responseType: 'stream' 22 | }).then(async response => { 23 | if (response.request?.path?.startsWith('/login')) { 24 | throw new Error(`"${fileName}" need token`) 25 | } else if (response.status === 200) { 26 | const writer = createWriteStream(savePath) 27 | response.data?.pipe(writer) 28 | return finished(writer) 29 | .then(() => ({ 30 | fileUrl, 31 | savePath 32 | })) 33 | } 34 | throw new Error(`response status ${response.status}`) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /src/download/list.ts: -------------------------------------------------------------------------------- 1 | import { mkdir } from 'node:fs/promises' 2 | import path from 'node:path' 3 | 4 | import { ARTICLE_CONTENT_TYPE, ARTICLE_TOC_TYPE } from '../constant' 5 | import { logger } from '../utils' 6 | import { fixPath } from '../parse/fix' 7 | import { downloadArticle } from './article' 8 | 9 | import type { 10 | KnowledgeBase, 11 | IProgressItem, 12 | IErrArticleInfo, 13 | IDownloadArticleListParams, 14 | IUpdateDownloadItem 15 | } from '../types' 16 | 17 | 18 | export async function downloadArticleList(params: IDownloadArticleListParams) { 19 | const { 20 | articleUrlPrefix, 21 | total, 22 | uuidMap, 23 | tocList, 24 | bookPath, 25 | bookId, 26 | progressBar, 27 | host, 28 | options, 29 | imageServiceDomains = [] 30 | } = params 31 | let errArticleCount = 0 32 | let totalArticleCount = 0 33 | let warnArticleCount = 0 34 | const errArticleInfo: IErrArticleInfo[] = [] 35 | const warnArticleInfo = [] 36 | const updateDownloadList: IUpdateDownloadItem[] = [] 37 | for (let i = 0; i < total; i++) { 38 | const item = tocList[i] 39 | if (typeof item.type !== 'string') continue 40 | // if (uuidMap.get(item.uuid)) continue 41 | 42 | const itemType = item.type.toLocaleLowerCase() 43 | // title目录类型/link外链类型 44 | if (itemType === ARTICLE_TOC_TYPE.TITLE 45 | || item['child_uuid'] !== '' 46 | || itemType === ARTICLE_TOC_TYPE.LINK 47 | ) { 48 | let tempItem: KnowledgeBase.Toc | undefined = item 49 | const pathTitleList = [] 50 | const pathIdList = [] 51 | while (tempItem) { 52 | pathTitleList.unshift(fixPath(tempItem.title)) 53 | pathIdList.unshift(tempItem.uuid) 54 | if (uuidMap.get(tempItem['parent_uuid'])) { 55 | tempItem = uuidMap.get(tempItem['parent_uuid'])!.toc 56 | } else { 57 | tempItem = undefined 58 | } 59 | } 60 | const progressItem = { 61 | path: pathTitleList.map(fixPath).join('/'), 62 | pathTitleList, 63 | pathIdList, 64 | toc: item 65 | } 66 | // 外链类型不创建目录 67 | if (itemType === ARTICLE_TOC_TYPE.LINK) { 68 | warnArticleCount += 1 69 | warnArticleInfo.push(progressItem) 70 | } else { 71 | await mkdir(`${bookPath}/${pathTitleList.map(fixPath).join('/')}`, {recursive: true}) 72 | } 73 | uuidMap.set(item.uuid, progressItem) 74 | // 即是文档也是title则创建文件夹后不更新进度直接进行文档处理 75 | if (itemType === ARTICLE_CONTENT_TYPE.DOC) { 76 | await docHandle(item) 77 | } else { 78 | await progressBar.updateProgress(progressItem, itemType !== ARTICLE_TOC_TYPE.LINK) 79 | } 80 | } else if (item.url) { 81 | await docHandle(item) 82 | } 83 | } 84 | async function docHandle(item: KnowledgeBase.Toc) { 85 | totalArticleCount += 1 86 | let preItem: Omit = { 87 | path: '', 88 | pathTitleList: [], 89 | pathIdList: [] 90 | } 91 | const itemType = item.type.toLocaleLowerCase() 92 | if (uuidMap.get(item['parent_uuid'])) { 93 | preItem = uuidMap.get(item['parent_uuid'])! 94 | } 95 | const fileName = fixPath(item.title) 96 | const pathTitleList = [...preItem.pathTitleList, fileName] 97 | const pathIdList = [...preItem.pathIdList, item.uuid] 98 | let mdPath = [...preItem.pathTitleList, `${fileName}.md`].map(fixPath).join('/') 99 | let savePath = preItem.pathTitleList.map(fixPath).join('/') 100 | // 是标题也是文档 101 | if (itemType === ARTICLE_CONTENT_TYPE.DOC && item['child_uuid']) { 102 | mdPath = [...preItem.pathTitleList, fileName, 'index.md'].map(fixPath).join('/') 103 | savePath = pathTitleList.map(fixPath).join('/') 104 | } 105 | const progressItem = { 106 | path: mdPath, 107 | savePath, 108 | pathTitleList, 109 | pathIdList, 110 | toc: item 111 | } 112 | let isSuccess = true 113 | const articleUrl = `${articleUrlPrefix}/${item.url}` 114 | try { 115 | const articleInfo = { 116 | bookId, 117 | itemUrl: item.url, 118 | // savePath与saveFilePath区别在于 saveFilePath带有最后的 xx.md 119 | savePath: path.resolve(bookPath, progressItem.savePath), 120 | saveFilePath: path.resolve(bookPath, progressItem.path), 121 | uuid: item.uuid, 122 | articleUrl, 123 | articleTitle: item.title, 124 | ignoreImg: options.ignoreImg, 125 | host, 126 | imageServiceDomains 127 | } 128 | const { isUpdateDownload } = await downloadArticle({ 129 | articleInfo, 130 | progressBar, 131 | options, 132 | progressItem, 133 | oldProgressItem: uuidMap.get(item.uuid) 134 | }) 135 | if (isUpdateDownload) { 136 | updateDownloadList.push({ 137 | progressItem, 138 | articleInfo 139 | }) 140 | } 141 | } catch(e) { 142 | isSuccess = false 143 | errArticleCount += 1 144 | errArticleInfo.push({ 145 | articleUrl, 146 | errItem: progressItem, 147 | errMsg: e.message, 148 | err: e 149 | }) 150 | 151 | } 152 | uuidMap.set(item.uuid, progressItem) 153 | await progressBar.updateProgress(progressItem, isSuccess) 154 | } 155 | 156 | // 文章下载中警告打印 157 | if (warnArticleCount > 0) { 158 | logger.warn('该知识库存在以下外链文章') 159 | for (const warnInfo of warnArticleInfo) { 160 | logger.warn(`———— ✕ ${warnInfo.path} ${warnInfo.toc.url}`) 161 | } 162 | } 163 | 164 | // 文章下载中失败打印 165 | if (errArticleCount > 0) { 166 | logger.error(`本次执行总数${totalArticleCount}篇,✕ 失败${errArticleCount}篇`) 167 | for (const errInfo of errArticleInfo) { 168 | logger.error(`《${errInfo.errItem.path}》: ${errInfo.articleUrl}`) 169 | errInfo.errMsg.split('\n').forEach(errMsg => { 170 | logger.error(`———— ✕ ${errMsg}`) 171 | }) 172 | } 173 | logger.error('o(╥﹏╥)o 由于网络波动或链接失效以上下载失败,可重新执行命令重试(PS:不会影响已下载成功的数据)') 174 | } 175 | // 打印更新下载/增量下载 176 | if (updateDownloadList.length > 0) { 177 | logger.info('以下文档有更新: ') 178 | updateDownloadList.forEach(item => { 179 | logger.info(`———— √ ${item.articleInfo.saveFilePath}`) 180 | }) 181 | } 182 | } -------------------------------------------------------------------------------- /src/download/video.ts: -------------------------------------------------------------------------------- 1 | import { stdout, env } from 'node:process' 2 | import { mkdirSync } from 'node:fs' 3 | import path from 'node:path' 4 | import ora from 'ora' 5 | import axios from 'axios' 6 | 7 | import { genCommonOptions } from '../api' 8 | import { getAst, getLinkList, ILinkItem, toMd } from '../parse/ast' 9 | import { downloadFile } from './common' 10 | 11 | const audioReg = /name="audio" value="data:(.*?audioId.*?)".*?><\/card>/gm 12 | 13 | interface IDownloadVideo { 14 | mdData: string 15 | htmlData: string 16 | savePath: string 17 | attachmentsDir: string 18 | articleTitle: string 19 | token?: string 20 | key?: string 21 | } 22 | 23 | export async function downloadVideo(params: IDownloadVideo) { 24 | const { 25 | mdData, 26 | htmlData, 27 | savePath, 28 | attachmentsDir, 29 | articleTitle, 30 | token, 31 | key 32 | } = params 33 | 34 | const astTree = getAst(mdData) 35 | const linkList = getLinkList(astTree) 36 | const videoLinkList = linkList.filter(link => /_lake_card.*?videoId/.test(link.node.url)) 37 | const audioLinkList = getAudioList(htmlData) 38 | 39 | // 无音视频 40 | if (videoLinkList.length === 0 && audioLinkList.length === 0) { 41 | return { 42 | mdData 43 | } 44 | } 45 | 46 | const spinner = ora({ 47 | text: `下载 "${articleTitle}" 的音视频中...`, 48 | stream: stdout 49 | }) 50 | 51 | 52 | if (env.NODE_ENV !== 'test') { 53 | spinner.start() 54 | } 55 | 56 | // 创建文件夹 57 | const attachmentsDirPath = path.resolve(savePath, attachmentsDir) 58 | mkdirSync(attachmentsDirPath, { recursive: true }) 59 | 60 | let resMdData = mdData 61 | 62 | try { 63 | // 类型 视频 64 | if (videoLinkList.length > 0) { 65 | const realVideoList = await getRealVideoInfo(videoLinkList, params, attachmentsDirPath) 66 | const promiseList = realVideoList.map((item) => { 67 | const dlFileParams = { 68 | fileUrl: item.videoInfo.video, 69 | savePath: item.currentFilePath, 70 | token, 71 | key, 72 | fileName: item.videoInfo.name 73 | } 74 | return downloadFile(dlFileParams) 75 | }) 76 | const downloadFileInfo = await Promise.all(promiseList) 77 | 78 | downloadFileInfo.forEach(info => { 79 | const replaceInfo = realVideoList.find(item => item.videoInfo.video === info.fileUrl) 80 | if (replaceInfo) { 81 | // TODO: 这里直接更改了ast 还需考虑 82 | replaceInfo.astNode.node.url = `${attachmentsDir}${path.sep}${replaceInfo.fileName}` 83 | replaceInfo.astNode.node.children = [ 84 | { 85 | 'type': 'text', 86 | 'value': `音视频附件: ${replaceInfo.videoInfo.name}`, 87 | } 88 | ] 89 | } 90 | }) 91 | resMdData = toMd(astTree) 92 | } 93 | 94 | // 类型 音频 95 | if (audioLinkList.length > 0) { 96 | const realVideoList = await getRealAudioInfo(audioLinkList, params, attachmentsDirPath) 97 | const promiseList = realVideoList.map((item) => { 98 | const dlFileParams = { 99 | fileUrl: item.audioInfo.audio, 100 | savePath: item.currentFilePath, 101 | token, 102 | key, 103 | fileName: item.audioInfo.fileName 104 | } 105 | return downloadFile(dlFileParams) 106 | }) 107 | const downloadFileInfo = await Promise.all(promiseList) 108 | let audioMd = '\n\n> [yuque-dl warn]: 由于语雀markdown接口限制, 无法准确定位音频文件在文档中所在位置, 所以统一所有音频放到一起\n' 109 | downloadFileInfo.forEach(info => { 110 | const replaceInfo = realVideoList.find(item => item.audioInfo.audio === info.fileUrl) 111 | if (replaceInfo) { 112 | audioMd += `> - [音视频附件: ${replaceInfo.audioInfo.fileName}](${attachmentsDir}${path.sep}${replaceInfo.fileName})\n` 113 | } 114 | }) 115 | resMdData += audioMd 116 | } 117 | 118 | } finally { 119 | spinnerStop() 120 | } 121 | 122 | function spinnerStop() { 123 | if (spinner) spinner.stop() 124 | } 125 | return { 126 | mdData: resMdData 127 | } 128 | 129 | } 130 | 131 | // https://www.yuque.com/laoge776/ahq486/msa2ntf95o1646xw?_lake_card=%7B%22status%22%3A%22done%22%2C%22name%22%3A%22%E6%B5%8B%E8%AF%95%E7%94%A8%E8%A7%86%E9%A2%91.mp4%22%2C%22size%22%3A18058559%2C%22taskId%22%3A%22u0b9ed581-9b65-4f26-8a3d-6a583b056d3%22%2C%22taskType%22%3A%22upload%22%2C%22url%22%3Anull%2C%22cover%22%3Anull%2C%22videoId%22%3A%22inputs%2Fprod%2Fyuque%2F2024%2F43922322%2Fmp4%2F1723723211560-602e1f77-e869-4e89-9388-00d10e2fa782.mp4%22%2C%22download%22%3Afalse%2C%22__spacing%22%3A%22both%22%2C%22id%22%3A%22DuH55%22%2C%22margin%22%3A%7B%22top%22%3Atrue%2C%22bottom%22%3Atrue%7D%2C%22card%22%3A%22video%22%7D#DuH55 132 | // to 133 | // {"status":"done","name":"测试用视频.mp4","size":18058559,"taskId":"u0b9ed581-9b65-4f26-8a3d-6a583b056d3","taskType":"upload","url":null,"cover":null,"videoId":"inputs/prod/yuque/2024/43922322/mp4/1723723211560-602e1f77-e869-4e89-9388-00d10e2fa782.mp4","download":false,"__spacing":"both","id":"DuH55","margin":{"top":true,"bottom":true},"card":"video"} 134 | // { videoId: xxx } 135 | 136 | function perParseVideoInfo(url: string) { 137 | try { 138 | const urlObj = new URL(url) 139 | const encodeData = urlObj.searchParams.get('_lake_card') ?? '' 140 | const dataStr = decodeURIComponent(encodeData) 141 | const data = JSON.parse(dataStr) 142 | return { 143 | name: data?.name as string || '', 144 | videoId: data?.videoId as string || '' 145 | } 146 | } catch (e) { 147 | return false 148 | } 149 | } 150 | 151 | interface IGetVideoApiParams { 152 | videoId: string, 153 | token?: string, 154 | key?: string, 155 | } 156 | 157 | interface IGetVideoApiResponse { 158 | data: { 159 | status: string, 160 | info: IGetVideoApiInfo 161 | } 162 | } 163 | interface IGetVideoApiInfo { 164 | type: string, 165 | cover?: string, 166 | // video 特有 167 | video: string, 168 | // audio 特有 169 | audio: string, 170 | origin: string, 171 | state: number 172 | } 173 | 174 | function getVideoApi(params: IGetVideoApiParams) { 175 | let apiUrl = 'https://www.yuque.com/api/video' 176 | const { videoId, token, key } = params 177 | const searchParams = new URLSearchParams() 178 | searchParams.set('video_id', videoId) 179 | apiUrl = `${apiUrl}?${searchParams.toString()}` 180 | return axios 181 | .get(apiUrl, genCommonOptions({token, key})) 182 | .then(({data, status}) => { 183 | const res = data.data 184 | if (status === 200 && res.status === 'success') { 185 | return res.info 186 | } 187 | return false as const 188 | }).catch(() => { 189 | // console.log(e) 190 | return false as const 191 | }) 192 | } 193 | 194 | async function getRealVideoInfo( 195 | videoLinkList: ILinkItem[], 196 | downloadVideoParams: IDownloadVideo, 197 | attachmentsDirPath: string 198 | ) { 199 | const {key, token} = downloadVideoParams 200 | const parseVideoInfoPromiseList = videoLinkList.map(async link => { 201 | const videoInfo = perParseVideoInfo(link.node.url) 202 | if (!videoInfo) return false 203 | const res = await getVideoApi({ 204 | videoId: videoInfo.videoId, 205 | key, 206 | token 207 | }) 208 | if (!res) return false 209 | const fileName = videoInfo.name ?? videoInfo.videoId.split('/').at(-1) ?? videoInfo.videoId 210 | return { 211 | videoInfo: { 212 | ...videoInfo, 213 | ...res 214 | }, 215 | astNode: link, 216 | fileName, 217 | currentFilePath: path.join(attachmentsDirPath, fileName) 218 | } 219 | }) 220 | const parseVideoInfoList = await Promise.all(parseVideoInfoPromiseList) 221 | const realVideoInfoList = parseVideoInfoList.filter(truthy) 222 | return realVideoInfoList 223 | } 224 | 225 | type Truthy = T extends false | '' | 0 | null | undefined ? never : T; // from lodash 226 | 227 | function truthy(value: T): value is Truthy { 228 | return !!value 229 | } 230 | 231 | interface IGetAudioItem{ 232 | status: string, 233 | audioId: string, 234 | fileName: string, 235 | fileSize: number, 236 | id: string 237 | } 238 | 239 | function getAudioList(htmlData: string): IGetAudioItem[] { 240 | const list = htmlData.match(audioReg) || [] 241 | try { 242 | const audioList = list 243 | .map(item => item.replace(audioReg, '$1')) 244 | .map(item => JSON.parse(decodeURIComponent(item))) 245 | return audioList as IGetAudioItem[] 246 | } catch (e) { 247 | return [] 248 | } 249 | } 250 | 251 | async function getRealAudioInfo( 252 | audioLinkList: IGetAudioItem[], 253 | downloadVideoParams: IDownloadVideo, 254 | attachmentsDirPath: string 255 | ) { 256 | const {key, token} = downloadVideoParams 257 | const parseVideoInfoPromiseList = audioLinkList.map(async audioItem => { 258 | 259 | const res = await getVideoApi({ 260 | videoId: audioItem.audioId, 261 | key, 262 | token 263 | }) 264 | if (!res) return false 265 | const fileName = audioItem?.fileName ?? audioItem.id 266 | return { 267 | audioInfo: { 268 | ...audioItem, 269 | ...res 270 | }, 271 | fileName, 272 | currentFilePath: path.join(attachmentsDirPath, fileName) 273 | } 274 | }) 275 | const parseAudioInfoList = await Promise.all(parseVideoInfoPromiseList) 276 | const realAudioInfoList = parseAudioInfoList.filter(truthy) 277 | return realAudioInfoList 278 | } 279 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { mkdir } from 'node:fs/promises' 2 | import path from 'node:path' 3 | import Summary from './parse/Summary' 4 | import { getKnowledgeBaseInfo } from './api' 5 | import { fixPath } from './parse/fix' 6 | import { ProgressBar, isValidUrl, logger } from './utils' 7 | import { downloadArticleList } from './download/list' 8 | 9 | import type { ICliOptions, IProgressItem } from './types' 10 | 11 | export async function main(url: string, options: ICliOptions) { 12 | if (!isValidUrl(url)) { 13 | throw new Error('Please enter a valid URL') 14 | } 15 | const { 16 | bookId, 17 | tocList, 18 | bookName, 19 | bookDesc, 20 | bookSlug, 21 | host, 22 | imageServiceDomains 23 | } = await getKnowledgeBaseInfo(url, { 24 | token: options.token, 25 | key: options.key 26 | }) 27 | if (!bookId) throw new Error('No found book id') 28 | if (!tocList || tocList.length === 0) throw new Error('No found toc list') 29 | const bookPath = path.resolve(options.distDir, bookName ? fixPath(bookName) : String(bookId)) 30 | 31 | await mkdir(bookPath, {recursive: true}) 32 | 33 | const total = tocList.length 34 | const progressBar = new ProgressBar(bookPath, total, options.incremental) 35 | await progressBar.init() 36 | 37 | // 为了检查是否有增量数据 38 | // 即使已下载的与progress的数量一致也需继续进行 39 | if (!options.incremental && progressBar.curr == total) { 40 | if (progressBar.bar) progressBar.bar.stop() 41 | logger.info(`√ 已完成: ${bookPath}`) 42 | return 43 | } 44 | 45 | const uuidMap = new Map() 46 | // 下载中断 重新获取下载进度数据 或者 增量下载 也需获取旧的下载进度 47 | if (progressBar.isDownloadInterrupted || options.incremental) { 48 | progressBar.progressInfo.forEach(item => { 49 | uuidMap.set( 50 | item.toc.uuid, 51 | item 52 | ) 53 | }) 54 | } 55 | const articleUrlPrefix = url.replace(new RegExp(`(.*?/${bookSlug}).*`), '$1') 56 | // 下载文章列表 57 | await downloadArticleList({ 58 | articleUrlPrefix, 59 | total, 60 | uuidMap, 61 | tocList, 62 | bookPath, 63 | bookId, 64 | progressBar, 65 | host, 66 | options, 67 | imageServiceDomains 68 | }) 69 | 70 | // 生成目录 71 | const summary = new Summary({ 72 | bookPath, 73 | bookName, 74 | bookDesc, 75 | uuidMap 76 | }) 77 | await summary.genFile() 78 | logger.info(`√ 生成目录 ${path.resolve(bookPath, 'index.md')}`) 79 | 80 | if (progressBar.curr === total) { 81 | logger.info(`√ 已完成: ${bookPath}`) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/parse/Summary.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises' 2 | import { ARTICLE_TOC_TYPE } from '../constant' 3 | import { logger } from '../utils' 4 | 5 | import type { IGenSummaryFile, SummaryItem } from '../types' 6 | 7 | 8 | export default class Summary { 9 | summaryInfo: IGenSummaryFile 10 | constructor(summaryInfo: IGenSummaryFile) { 11 | this.summaryInfo = summaryInfo 12 | } 13 | 14 | async genFile() { 15 | const {bookName, bookDesc, bookPath, uuidMap} = this.summaryInfo 16 | let header = `# ${bookName}\n\n` 17 | if (bookDesc) header += `> ${bookDesc}\n\n` 18 | let mdContent = header 19 | const summary: SummaryItem[] = [] 20 | uuidMap.forEach(progressItem => { 21 | const toc = progressItem.toc 22 | const parentId = toc['parent_uuid'] 23 | const findRes = this.findTree(summary, parentId) 24 | const dirNameReg = /[\\/:*?"<>|\n\r]/g 25 | const tocText = toc.title.replace(dirNameReg, '_').replace(/\s/, '') 26 | const item: SummaryItem = { 27 | text: tocText, 28 | id: toc.uuid, 29 | level: 1, 30 | type: 'link' 31 | } 32 | const tocType = toc.type.toLocaleLowerCase() 33 | if (tocType === ARTICLE_TOC_TYPE.TITLE || toc['child_uuid'] !=='') { 34 | item.type = 'title' 35 | 36 | if (typeof findRes !== 'boolean') { 37 | if (!Array.isArray(findRes.children)) findRes.children = [] 38 | item.level = findRes.level + 1 39 | findRes.children.push(item) 40 | } else { 41 | item.level = 1 42 | summary.push(item) 43 | } 44 | // 如果是标题同时也是文档,标题加上链接 45 | if (tocType === ARTICLE_TOC_TYPE.DOC) { 46 | item.link = progressItem.path 47 | } 48 | // 如果是标题同时也是文档,标题文案下生成新链接 49 | // if (tocType === ARTICLE_TOC_TYPE.DOC) { 50 | // if (!Array.isArray(item.children)) item.children = [] 51 | // item.children.unshift({ 52 | // text: tocText, 53 | // id: toc.uuid, 54 | // level: item.level, 55 | // type: 'link', 56 | // link: progressItem.path 57 | // }) 58 | // } 59 | } else { 60 | item.type = 'link' 61 | // 外链类型直接 链接到url 62 | item.link = tocType=== ARTICLE_TOC_TYPE.LINK ? progressItem.toc.url : progressItem.path 63 | if (typeof findRes !== 'boolean') { 64 | if (!Array.isArray(findRes.children)) findRes.children = [] 65 | item.level = findRes.level + 1 66 | findRes.children.push(item) 67 | } else { 68 | item.level = 1 69 | summary.push(item) 70 | } 71 | } 72 | }) 73 | const summaryContent = this.genSummaryContent(summary, '') 74 | mdContent += summaryContent 75 | try { 76 | await fs.writeFile(`${bookPath}/index.md`, mdContent) 77 | } catch (err) { 78 | logger.error('Generate Summary Error') 79 | } 80 | return mdContent 81 | } 82 | 83 | genSummaryContent(summary: SummaryItem[], summaryContent: string): string { 84 | for (const item of summary) { 85 | if (item.type === ARTICLE_TOC_TYPE.TITLE) { 86 | // 是标题同时也是文档的情况 87 | if (item.link) { 88 | const link = item.link ? item.link.replace(/\s/g, '%20') : item.link 89 | summaryContent += `\n${''.padStart(item.level + 1, '#')} [${item.text}](${link})\n\n` 90 | } else { 91 | summaryContent += `\n${''.padStart(item.level + 1, '#')} ${item.text}\n\n` 92 | } 93 | } else if (item.type === ARTICLE_TOC_TYPE.LINK) { 94 | const link = item.link ? item.link.replace(/\s/g, '%20') : item.link 95 | summaryContent += `${item.level === 1 ? '\n##' : '-'} [${item.text}](${link})\n` 96 | } 97 | if (Array.isArray(item.children)) { 98 | summaryContent += this.genSummaryContent(item.children, '') 99 | } 100 | } 101 | return summaryContent 102 | } 103 | 104 | findIdItem(node: SummaryItem, id: string) { 105 | if (node.id === id) { 106 | return node 107 | } else if (node.children) { 108 | const findRes = this.findTree(node.children, id) 109 | if (findRes) return findRes 110 | } 111 | return false 112 | } 113 | 114 | findTree(tree: SummaryItem[], id: string): SummaryItem | boolean { 115 | if (!id) return false 116 | for (const item of tree) { 117 | const findRes = this.findIdItem(item, id) 118 | if (findRes) return findRes 119 | } 120 | return false 121 | } 122 | } -------------------------------------------------------------------------------- /src/parse/ast.ts: -------------------------------------------------------------------------------- 1 | import { fromMarkdown } from 'mdast-util-from-markdown' 2 | import { toMarkdown } from 'mdast-util-to-markdown' 3 | 4 | import type { Root, Parents, Nodes, Link } from 'mdast' 5 | 6 | export function getAst(mdData: string) { 7 | return fromMarkdown(mdData) 8 | } 9 | 10 | export function toMd(astTree: Root) { 11 | return toMarkdown(astTree) 12 | } 13 | 14 | export interface ILinkItem { 15 | node: Link, 16 | keyChain: string[] 17 | } 18 | 19 | export function getLinkList(curNode: Root) { 20 | const linkList: ILinkItem[] = [] 21 | eachNode(curNode, function(node, keyChain) { 22 | if (node.type === 'link') { 23 | linkList.push({ 24 | node, 25 | keyChain 26 | }) 27 | } 28 | }) 29 | return linkList 30 | } 31 | 32 | // const hasChildrenType = ['blockquote', 'code'] as const 33 | // type THasChildrenType = typeof hasChildrenType[number] 34 | // // type THasChildrenContent = Pick 35 | // type THasChildrenContent = RootContentMap[THasChildrenType]; 36 | function eachNode(node: Nodes, callback: (node: Nodes, keyChain: string[]) => void, keyChain: string[] = []) { 37 | callback(node, keyChain) 38 | if (Array.isArray((node as Parents).children)) { 39 | // node.children 40 | keyChain.push('children') 41 | 42 | ;(node as Parents).children.forEach((node, index) => { 43 | // callback(node) 44 | // keyChain.push(String(index)) 45 | eachNode(node, callback, [...keyChain, String(index)]) 46 | }) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/parse/fix.ts: -------------------------------------------------------------------------------- 1 | import { removeEmojis } from '../utils' 2 | 3 | // 现发现 latex svg格式可以正常下载 正常显示,非svg 不能 直接那search的文案替换掉 4 | // ![](https://cdn.nlark.com/yuque/__latex/a6cc75c5bd5731c6e361bbcaf18766e7.svg#card=math&code=999&id=JGAwA) 5 | // "https://g.yuque.com/gr/latex?options['where'] 是否是数组,#card=math&code=options['where'] 是否是数组," 6 | export function fixLatex(mdData: string) { 7 | const latexReg = /!\[(.*?)\]\((http.*?latex.*?)\)/gm 8 | const list = mdData.match(latexReg) 9 | let fixMaData = mdData 10 | const rawMaData = mdData 11 | try { 12 | list?.forEach(latexMd => { 13 | latexReg.lastIndex = 0 14 | const url = latexReg.exec(latexMd)?.[2] ?? '' 15 | const {pathname, search} = new URL(url) 16 | const isSvg = pathname.endsWith('.svg') 17 | // 非svg结尾的 latex链接 直接显示code内容 18 | if (!isSvg && search) { 19 | const data = decodeURIComponent(search) 20 | fixMaData = fixMaData.replace(latexMd, data.slice(1)) 21 | } 22 | }) 23 | } catch (e) { 24 | return rawMaData 25 | } 26 | 27 | return fixMaData 28 | } 29 | 30 | // 根据html接口返回的图片修复 md接口返回的图片 url 31 | export function fixMarkdownImage(imgList: string[], mdData: string, htmlData: string) { 32 | if (!htmlData) return mdData 33 | const htmlDataImgReg = /(.*?)<\/card>/gm 34 | const htmlImgDataList: string[] = [] 35 | let regExec 36 | let init = true 37 | while(init || Boolean(regExec)) { 38 | init = false 39 | regExec = htmlDataImgReg.exec(htmlData) 40 | if (regExec?.[1]) { 41 | try { 42 | const strData = decodeURIComponent(regExec[1]) 43 | const cardData = JSON.parse(strData) 44 | htmlImgDataList.push(cardData?.src || '') 45 | } catch(e) { 46 | htmlImgDataList.push('') 47 | } 48 | } 49 | } 50 | const replaceURLCountMap = new Map() 51 | imgList.forEach((imgUrl) => { 52 | const {origin, pathname} = new URL(imgUrl) 53 | const matchURL = `${origin}${pathname}` 54 | 55 | const targetURL = htmlImgDataList.find((item, index) => { 56 | const reg = new RegExp(`${matchURL}.*?`) 57 | const isFind = reg.test(item) 58 | if (isFind) htmlImgDataList.splice(index, 1) 59 | return isFind 60 | }) 61 | // console.log(imgUrl, ' -> ',targetURL) 62 | if (targetURL) { 63 | const reg = new RegExp(imgUrl, 'g') 64 | const count = replaceURLCountMap.get(imgUrl) || 0 65 | let temp = 0 66 | mdData = mdData.replace(reg, (match) => { 67 | let res = match 68 | if (temp === count) { 69 | res = targetURL 70 | } 71 | temp = temp + 1 72 | return res 73 | }) 74 | replaceURLCountMap.set(imgUrl, count + 1) 75 | } 76 | }) 77 | return mdData 78 | } 79 | 80 | 81 | export function fixPath(dirPath: string) { 82 | if (!dirPath) return '' 83 | const dirNameReg = /[\\/:*?"<>|\n\r]/g 84 | return removeEmojis(dirPath.replace(dirNameReg, '_').replace(/\s/g, '')) 85 | } 86 | -------------------------------------------------------------------------------- /src/parse/sheet.ts: -------------------------------------------------------------------------------- 1 | import pako from 'pako' 2 | import type { SheetItem, SheetItemData } from '../types' 3 | 4 | // 表格类型解析 5 | export const parseSheet = (sheetStr: string) => { 6 | if (!sheetStr) return '' 7 | const parseStr = pako.inflate(sheetStr, { 8 | to: 'string' 9 | }) 10 | const sheetList: SheetItem[] = JSON.parse(parseStr) 11 | let mdData = '' 12 | sheetList.forEach((item) => { 13 | const sheetTitle = `## ${item.name}\n` 14 | const table = genMarkdownTable(item.data) 15 | mdData = `${mdData}\n${sheetTitle}\n${table}` 16 | }) 17 | return mdData 18 | } 19 | 20 | export function genMarkdownTable(data: SheetItemData) { 21 | let rowList: string[] = Object.keys(data) 22 | // 过滤掉空白行 23 | rowList = rowList.filter(rowKey => { 24 | const colList = Object.keys(data[rowKey]) 25 | return colList.some(col => data?.[rowKey]?.[col]?.v) 26 | }) 27 | let colList: string[] = [] 28 | rowList.forEach(rowKey => { 29 | const cols = data[rowKey] 30 | if (cols) colList = colList.concat(Object.keys(cols)) 31 | }) 32 | 33 | const rowMax = Math.max(...rowList.map(row => Number(row))) 34 | const colMax = Math.max(...colList.map(col => Number(col))) 35 | if (rowMax < 0 || colMax < 0) return '' 36 | let tableMd = '' 37 | const TITLE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' 38 | let rowTitle = Array(colMax + 1).fill(' ').map((v, i) => { 39 | const index = i % (TITLE.length) 40 | return TITLE[index] 41 | }).join(' | ') 42 | rowTitle = `| |${rowTitle}|` 43 | let rowTitleLine = Array(colMax + 2).fill('---').join(' |' ) 44 | rowTitleLine = `|${rowTitleLine}|` 45 | tableMd = `${rowTitle}\n${rowTitleLine}\n` 46 | 47 | for (let row = 0; row < rowMax + 1; row++) { 48 | const colData = [] 49 | for (let col = 0; col < colMax + 1; col++) { 50 | const v: any = data?.[row]?.[col]?.v || null 51 | if (v && typeof v === 'string') { 52 | colData.push(v) 53 | } else if (v && typeof v === 'object') { 54 | // 单元格内含图片 55 | if (v?.class === 'image' && v?.src) { 56 | colData.push(`![${v?.name}'](${v?.src})`) 57 | } else if(v?.class === 'checkbox') { 58 | colData.push(v?.value ? '[x] ': '[ ] ') 59 | } else if(v?.class === 'link') { 60 | colData.push(`[${v?.text}](${v?.url})`) 61 | } else if(v?.class === 'select') { 62 | colData.push(v?.value?.join(',')) 63 | } 64 | } else { 65 | colData.push(null) 66 | } 67 | } 68 | const rowMd = `| ${row + 1} | ${colData.join(' | ')}|` 69 | tableMd = `${tableMd}${rowMd}\n` 70 | } 71 | return tableMd 72 | } -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { mkdir, writeFile, readFile, readdir, stat, access, copyFile } from 'node:fs/promises' 2 | import { dirname, join, resolve, sep } from 'node:path' 3 | import { fileURLToPath } from 'node:url' 4 | import { createServer } from 'vitepress' 5 | 6 | import type { IServerCliOptions, ISidebarItem } from './types' 7 | import { existsSync } from 'node:fs' 8 | 9 | let restartPromise: Promise | undefined 10 | 11 | export async function runServer(root: string, options: IServerCliOptions) { 12 | const rootPath = resolve(root) 13 | if (!await fileExists(rootPath)) { 14 | throw new Error('server root not found') 15 | } 16 | const vitepressPath = join(rootPath, '/.vitepress') 17 | // 不存在.vitepress 或者 强制重新生成.vitepress时 18 | if (!existsSync(vitepressPath) || options.force) await createVitePressConfig(rootPath) 19 | const createDevServer = async () => { 20 | const server = await createServer(root, { 21 | host: options.host, 22 | port: options.port 23 | }, async () => { 24 | if (!restartPromise) { 25 | restartPromise = (async () => { 26 | await server.close() 27 | await createDevServer() 28 | })().finally(() => { 29 | restartPromise = undefined 30 | }) 31 | } 32 | 33 | return restartPromise 34 | }) 35 | await server.listen() 36 | server.printUrls() 37 | // bindShortcuts(server, createDevServer) 38 | } 39 | 40 | await createDevServer().catch((err) => { 41 | console.error(err) 42 | process.exit(1) 43 | }) 44 | } 45 | 46 | async function createVitePressConfig(root: string) { 47 | const bookName = root.split(sep).filter(Boolean).at(-1) || 'yuque-dl' 48 | const vitepressPath = join(root, '/.vitepress') 49 | await mkdir(vitepressPath, {recursive: true}) 50 | const __dirname = dirname(fileURLToPath(import.meta.url)) 51 | // 相对于 package_dir + dist/es/xxxx.js 52 | const serverLibPath = join(__dirname, '../../server-lib/bundle.js') 53 | const vitePressServerLib = join(vitepressPath, 'bundle.mjs') 54 | await copyFile(serverLibPath, vitePressServerLib) 55 | const vitePressConfig = join(vitepressPath, 'config.mjs') 56 | const sidebar = await createSidebarMulti(root) 57 | // 根据summry排序 vitepress侧边栏 58 | const summaryStr = await readFile(resolve(root, 'index.md'), 'utf8') 59 | const summaryList = summaryStr.split('\n') 60 | setSidebarIndex(summaryList, sidebar) 61 | sortSidebar(sidebar) 62 | 63 | const config = ` 64 | import {fixHtmlTags} from './bundle.mjs' 65 | export default { 66 | title: "${bookName}", 67 | themeConfig: { 68 | search: { 69 | provider: 'local' 70 | }, 71 | sidebar: ${JSON.stringify(sidebar)} 72 | }, 73 | vite: { 74 | optimizeDeps: { 75 | include: [] 76 | } 77 | }, 78 | markdown: { 79 | html: true, 80 | breaks: true, 81 | config(md) { 82 | // 包装原始 render 方法,捕获解析异常 83 | const originalRender = md.render.bind(md); 84 | md.render = (src, env) => { 85 | try { 86 | let newMd = fixHtmlTags(originalRender(src, env)) 87 | newMd = newMd.replace('', '') 88 | newMd = newMd.replace('', '') 89 | return newMd 90 | } catch (error) { 91 | console.error("Markdown/HTML parsing error:", error); 92 | return src 93 | } 94 | } 95 | } 96 | } 97 | } 98 | ` 99 | await writeFile(vitePressConfig, config) 100 | } 101 | 102 | async function fileExists(filename: string) { 103 | try { 104 | await access(filename) 105 | return true 106 | } catch (err) { 107 | if (err.code === 'ENOENT') { 108 | return false 109 | } else { 110 | throw err 111 | } 112 | } 113 | } 114 | 115 | async function createSidebarMulti (path: string): Promise { 116 | const data = [] as any 117 | const ignoreList = ['.vitepress', 'img', 'index.md', 'progress.json', 'attachments'] 118 | let dirList = await readdir(path) 119 | dirList = dirList.filter(item => !ignoreList.includes(item)) 120 | for (const n of dirList) { 121 | const dirPath = join(path, n) 122 | const statRes = await stat(dirPath) 123 | if (statRes.isDirectory()) { 124 | const isHasIndex = await fileExists(join(dirPath,'index.md')) 125 | const item: any = { 126 | text: n, 127 | collapsed: true, 128 | items: await createSideBarItems(path, n) 129 | } 130 | if (isHasIndex) item.link = `/${n}/` 131 | data.push(item) 132 | } else { 133 | data.push({ 134 | text: n.slice(0, n.lastIndexOf('.')), 135 | link: `/${n}` 136 | }) 137 | } 138 | } 139 | return data 140 | } 141 | 142 | function setSidebarIndex(summaryList: string[], sidebar: ISidebarItem[]) { 143 | sidebar.map(item => { 144 | if ('items' in item) { 145 | setSidebarIndex(summaryList, item.items) 146 | } 147 | const itemIndex = summaryList.findIndex(str => { 148 | return str.includes(`[${item.text}]`) || str.includes(`# ${item.text}`) 149 | }) 150 | item.index = itemIndex 151 | }) 152 | } 153 | 154 | function sortSidebar(sidebar: ISidebarItem[]) { 155 | if (sidebar.length <=1) return 156 | sidebar.forEach((item) => { 157 | if ('items' in item) { 158 | sortSidebar(item.items) 159 | } 160 | }) 161 | sidebar.sort((item, nexItem) => { 162 | if (!item.index || !nexItem.index) return 0 163 | return item.index > nexItem.index ? 1 : -1 164 | }) 165 | } 166 | 167 | // 尝试从一个md文件中读取标题,读取到第一个 ‘# 标题内容’ 的时候返回这一行 168 | export async function getTitleFromFile (realFileName: string): Promise { 169 | const isExist = await fileExists(realFileName) 170 | if (!isExist) { 171 | return undefined 172 | } 173 | const fileExtension = realFileName.substring( 174 | realFileName.lastIndexOf('.') + 1 175 | ) 176 | if (fileExtension !== 'md' && fileExtension !== 'MD') { 177 | return undefined 178 | } 179 | // read contents of the file 180 | const data = await readFile(realFileName, { encoding: 'utf-8' }) 181 | // split the contents by new line 182 | const lines = data.split(/\r?\n/) 183 | // return title 184 | for (const line of lines) { 185 | if (line.startsWith('# ')) { 186 | return line.substring(2) 187 | } 188 | } 189 | return undefined 190 | } 191 | 192 | async function createSideBarItems ( 193 | targetPath: string, 194 | ...reset: string[] 195 | ) { 196 | const collapsed = false 197 | const node = await readdir(join(targetPath, ...reset)) 198 | 199 | const result = [] 200 | for (const fname of node) { 201 | const curPath = join(targetPath, ...reset, fname) 202 | const statRes = await stat(curPath) 203 | if (statRes.isDirectory()) { 204 | const items = await createSideBarItems(join(targetPath), ...reset, fname) 205 | if (items.length > 0) { 206 | const sidebarItem: any = { 207 | text: fname, 208 | items 209 | } 210 | // vitePress sidebar option collapsed 211 | sidebarItem.collapsed = collapsed 212 | const isHasIndex = await fileExists(join(curPath, 'index.md')) 213 | if (isHasIndex) { 214 | sidebarItem.link = '/' + [...reset, fname].map(decodeURIComponent).join('/') + '/' 215 | } 216 | result.push(sidebarItem) 217 | } 218 | } else { 219 | // is filed 220 | if ( 221 | fname === 'index.md' || 222 | /^-.*\.(md|MD)$/.test(fname) || 223 | !fname.endsWith('.md') 224 | ) { 225 | continue 226 | } 227 | const fileName = fname.replace(/\.md$/, '') 228 | const item = { 229 | text: fileName, 230 | link: '/' + [...reset.map(decodeURIComponent), `${encodeURI(fileName)}.html`].join('/') 231 | } 232 | result.push(item) 233 | } 234 | } 235 | return result 236 | } 237 | -------------------------------------------------------------------------------- /src/types/ArticleResponse.ts: -------------------------------------------------------------------------------- 1 | export declare namespace ArticleResponse { 2 | interface RootObject { 3 | meta: Meta; 4 | data: Data; 5 | } 6 | 7 | interface Data { 8 | id: number; 9 | space_id: number; 10 | type: string; 11 | sub_type?: any; 12 | format: string; 13 | title: string; 14 | slug: string; 15 | public: number; 16 | status: number; 17 | read_status: number; 18 | created_at: string; 19 | content_updated_at: string; 20 | published_at: string; 21 | first_published_at: string; 22 | sourcecode: string; 23 | last_editor?: any; 24 | _serializer: string; 25 | content?: string 26 | } 27 | 28 | interface Meta { 29 | abilities: Abilities; 30 | latestReviewStatus: number; 31 | } 32 | 33 | interface Abilities { 34 | create: boolean; 35 | destroy: boolean; 36 | update: boolean; 37 | read: boolean; 38 | export: boolean; 39 | manage: boolean; 40 | join: boolean; 41 | share: boolean; 42 | force_delete: boolean; 43 | create_collaborator: boolean; 44 | destroy_comment: boolean; 45 | } 46 | } 47 | 48 | -------------------------------------------------------------------------------- /src/types/KnowledgeBaseResponse.ts: -------------------------------------------------------------------------------- 1 | export declare namespace KnowledgeBase { 2 | interface Response { 3 | me: Me; 4 | notification: Notification; 5 | settings: Settings; 6 | env: string; 7 | space: Space; 8 | isYuque: boolean; 9 | isPublicCloud: boolean; 10 | isEnterprise: boolean; 11 | isUseAntLogin: boolean; 12 | defaultSpaceHost: string; 13 | timestamp: number; 14 | traceId: string; 15 | siteName: string; 16 | siteTip?: any; 17 | activityTip?: any; 18 | topTip?: any; 19 | readTip: Notification; 20 | questionRecommend?: any; 21 | dashboardBannerRecommend?: any; 22 | imageServiceDomains: string[]; 23 | sharePlatforms: string[]; 24 | locale: string; 25 | matchCondition: MatchCondition; 26 | empInfo: Notification; 27 | group: Group; 28 | book: Book; 29 | groupMemberInfo: GroupMemberInfo; 30 | userSettings: Notification; 31 | interest: Interest; 32 | canUseAiWriting: boolean; 33 | canUseAiLegal: boolean; 34 | canUseAiReading: boolean; 35 | aiWritingStreamType: any[]; 36 | legalAnimationTime: number; 37 | canUseAiTag: boolean; 38 | canUseAiTestCase: boolean; 39 | paymentInfo: PaymentInfo; 40 | login: Login; 41 | enableCoverageDeploy: boolean; 42 | isDesktopApp: boolean; 43 | isOnlineDesktopApp: boolean; 44 | isIsomorphicDesktopApp: boolean; 45 | isAssistant: boolean; 46 | isAlipayApp: boolean; 47 | isDingTalkApp: boolean; 48 | isDingTalkMiniApp: boolean; 49 | isDingTalkDesktopApp: boolean; 50 | isYuqueMobileApp: boolean; 51 | tracertConfig: TracertConfig; 52 | } 53 | 54 | interface TracertConfig { 55 | spmAPos: string; 56 | spmBPos?: any; 57 | } 58 | 59 | interface Login { 60 | loginType: string; 61 | enablePlatforms: string[]; 62 | isWechatMobileApp: boolean; 63 | } 64 | 65 | interface PaymentInfo { 66 | paymentBizInstId: string; 67 | } 68 | 69 | interface Interest { 70 | interests: Interests; 71 | limits: Limits; 72 | owner: Owner; 73 | limit: Member; 74 | } 75 | 76 | interface Owner { 77 | id: number; 78 | type: string; 79 | member_level: string; 80 | isTopLevel: boolean; 81 | isMemberTopLevel: boolean; 82 | isPaid: boolean; 83 | isExpired: boolean; 84 | } 85 | 86 | interface Limits { 87 | normal: Normal; 88 | member: Member; 89 | } 90 | 91 | interface Member { 92 | max_group_member_number: number; 93 | max_book_collaborator_number: number; 94 | max_book_number?: any; 95 | max_resource_total_size: number; 96 | max_single_file_size: number; 97 | max_single_image_size: number; 98 | max_single_video_size: number; 99 | max_doc_collaborator_number: number; 100 | max_doc_nologin_pv?: any; 101 | } 102 | 103 | interface Normal { 104 | max_group_member_number: number; 105 | max_book_collaborator_number: number; 106 | max_book_number: number; 107 | max_resource_total_size: number; 108 | max_single_image_size: number; 109 | max_single_video_size: number; 110 | max_single_file_size: number; 111 | max_doc_collaborator_number: number; 112 | max_doc_nologin_pv: number; 113 | } 114 | 115 | interface Interests { 116 | book_webhook: boolean; 117 | open_ocr: boolean; 118 | create_public_resource: boolean; 119 | book_statistics: boolean; 120 | book_security: boolean; 121 | } 122 | 123 | interface GroupMemberInfo { 124 | usage: Usage; 125 | expired_at: string; 126 | countDownDays: number; 127 | isAllowRenew: boolean; 128 | receipt?: any; 129 | groupOwners: GroupOwner[]; 130 | hasOrder: boolean; 131 | } 132 | 133 | interface GroupOwner { 134 | id: number; 135 | type: string; 136 | login: string; 137 | name: string; 138 | description?: string; 139 | avatar?: string; 140 | avatar_url: string; 141 | followers_count: number; 142 | following_count: number; 143 | status: number; 144 | public: number; 145 | scene?: any; 146 | created_at: string; 147 | updated_at: string; 148 | expired_at?: string; 149 | isPaid: boolean; 150 | member_level: number; 151 | memberLevelName?: string; 152 | hasMemberLevel: boolean; 153 | isTopLevel: boolean; 154 | isNewbie: boolean; 155 | profile?: any; 156 | organizationUser?: any; 157 | _serializer: string; 158 | } 159 | 160 | interface Usage { 161 | attachment_size: number; 162 | image_size: number; 163 | video_size: number; 164 | attachment_size_month: number; 165 | image_size_month: number; 166 | video_size_month: number; 167 | max_upload_size: number; 168 | _serializer: string; 169 | } 170 | 171 | interface Book { 172 | id: number; 173 | type: string; 174 | slug: string; 175 | name: string; 176 | toc: Toc[]; 177 | toc_updated_at: string; 178 | description: string; 179 | creator_id: number; 180 | menu_type: number; 181 | items_count: number; 182 | likes_count: number; 183 | watches_count: number; 184 | user_id: number; 185 | abilities: Abilities2; 186 | public: number; 187 | extend_private: number; 188 | scene?: any; 189 | source?: any; 190 | created_at: string; 191 | updated_at: string; 192 | pinned_at?: any; 193 | archived_at?: any; 194 | layout: string; 195 | doc_typography: string; 196 | doc_viewport: string; 197 | announcement?: any; 198 | should_manually_create_uid: boolean; 199 | catalog_tail_type: string; 200 | catalog_display_level: number; 201 | cover: string; 202 | comment_count?: any; 203 | organization_id: number; 204 | status: number; 205 | indexed_level: number; 206 | privacy_migrated: boolean; 207 | collaboration_count: number; 208 | content_updated_at: string; 209 | content_updated_at_ms: number; 210 | copyright_watermark: string; 211 | enable_announcement: boolean; 212 | enable_auto_publish: boolean; 213 | enable_comment: boolean; 214 | enable_document_copy: boolean; 215 | enable_export: boolean; 216 | enable_search_engine: boolean; 217 | enable_toc: boolean; 218 | enable_trash: boolean; 219 | enable_visitor_watermark: boolean; 220 | enable_webhook: boolean; 221 | image_copyright_watermark: string; 222 | original: number; 223 | resource_size: number; 224 | user?: any; 225 | contributors?: any; 226 | _serializer: string; 227 | } 228 | 229 | interface Abilities2 { 230 | create_doc: boolean; 231 | destroy: boolean; 232 | export: boolean; 233 | export_doc: boolean; 234 | read: boolean; 235 | read_private: boolean; 236 | update: boolean; 237 | create_collaborator: boolean; 238 | manage: boolean; 239 | share: boolean; 240 | modify_setting: boolean; 241 | } 242 | 243 | interface Toc { 244 | type: string; 245 | title: string; 246 | uuid: string; 247 | url: string; 248 | prev_uuid: string; 249 | sibling_uuid: string; 250 | child_uuid: string; 251 | parent_uuid: string; 252 | doc_id: number; 253 | level: number; 254 | id: number; 255 | open_window: number; 256 | visible: number; 257 | } 258 | 259 | interface Group { 260 | id: number; 261 | type: string; 262 | login: string; 263 | name: string; 264 | description: string; 265 | avatar: string; 266 | avatar_url: string; 267 | owner_id: number; 268 | books_count: number; 269 | public_books_count: number; 270 | topics_count: number; 271 | public_topics_count: number; 272 | members_count: number; 273 | abilities: Abilities; 274 | settings: Settings2; 275 | public: number; 276 | extend_private: number; 277 | scene?: any; 278 | created_at: string; 279 | updated_at: string; 280 | expired_at: string; 281 | deleted_at?: any; 282 | organization_id: number; 283 | isPaid: boolean; 284 | member_level: number; 285 | memberLevelName: string; 286 | hasMemberLevel: boolean; 287 | isTopLevel: boolean; 288 | grains_sum: number; 289 | status: number; 290 | source?: any; 291 | zone_id: number; 292 | isPermanentPunished: boolean; 293 | isWiki: boolean; 294 | isPublicPage: boolean; 295 | organization?: any; 296 | owners?: any; 297 | _serializer: string; 298 | } 299 | 300 | interface Settings2 { 301 | homepage: Homepage; 302 | navigation: string[]; 303 | group: Notification; 304 | id: number; 305 | created_at: string; 306 | updated_at: string; 307 | space_id: number; 308 | group_id: number; 309 | topic_enable: number; 310 | resource_enable: number; 311 | thread_enable: number; 312 | issue_enable: number; 313 | role_for_add_member: number; 314 | external_enable: number; 315 | permission: Permission; 316 | } 317 | 318 | interface Permission { 319 | create_member: boolean; 320 | create_book: boolean; 321 | create_book_collaborator: boolean; 322 | modify_book_setting: boolean; 323 | share_book: boolean; 324 | export_book: boolean; 325 | share_doc: boolean; 326 | export_doc: boolean; 327 | force_delete_doc: boolean; 328 | } 329 | 330 | interface Homepage { 331 | layout: Layout; 332 | version: number; 333 | } 334 | 335 | interface Layout { 336 | header: string[]; 337 | content: string[]; 338 | aside: string[]; 339 | } 340 | 341 | interface Abilities { 342 | create_book: boolean; 343 | create_member: boolean; 344 | destroy: boolean; 345 | read: boolean; 346 | read_private: boolean; 347 | update: boolean; 348 | manage: boolean; 349 | restore: boolean; 350 | } 351 | 352 | interface MatchCondition { 353 | page: string; 354 | } 355 | 356 | interface Space { 357 | id: number; 358 | login: string; 359 | name: string; 360 | short_name?: any; 361 | status: number; 362 | account_id: number; 363 | logo?: any; 364 | description: string; 365 | created_at?: any; 366 | updated_at?: any; 367 | host: string; 368 | displayName: string; 369 | logo_url: string; 370 | enable_password: boolean; 371 | enable_watermark: boolean; 372 | _serializer: string; 373 | } 374 | 375 | interface Settings { 376 | allowed_link_schema: string[]; 377 | enable_link_interception: boolean; 378 | enable_new_user_public_ability_forbid: boolean; 379 | user_registry_forbidden_level: string; 380 | watermark_enable: string; 381 | public_space_doc_search_enable: boolean; 382 | lake_enabled_groups: string; 383 | image_proxy_root: string; 384 | max_import_task_count: number; 385 | enable_search: boolean; 386 | enable_serviceworker: boolean; 387 | enable_lazyload_card: string; 388 | editor_canary: Editorcanary; 389 | enable_attachment_multipart: boolean; 390 | enable_custom_video_player: boolean; 391 | conference_gift_num: number; 392 | intranet_safe_tip: string[]; 393 | publication_enable_whitelist: any[]; 394 | foreign_phone_registry_enabled_organization_whitelist: string[]; 395 | disabled_login_modal_pop_default: boolean; 396 | enable_open_in_mobile_app: boolean; 397 | enable_wechat_guide_qrcode: boolean; 398 | enable_issue: boolean; 399 | enable_blank_page_detect: boolean; 400 | zone_ant_auth_keepalive_enabled_domains: any[]; 401 | enable_new_group_page_whitelist: any[]; 402 | enable_web_ocr: Enablewebocr; 403 | customer_staff_dingtalk_id: string; 404 | enable_desktop_force_local: boolean; 405 | support_extension_download_url: boolean; 406 | } 407 | 408 | interface Enablewebocr { 409 | enable: boolean; 410 | enableBrowsers: string[]; 411 | _users: number[]; 412 | percent: number; 413 | } 414 | 415 | interface Editorcanary { 416 | card_lazy_init: number; 417 | retryOriginImage: number; 418 | } 419 | 420 | interface Notification { 421 | } 422 | 423 | interface Me { 424 | avatar_url: string; 425 | avatar: string; 426 | language: string; 427 | is_admin: boolean; 428 | } 429 | } 430 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | import { ArticleResponse } from './ArticleResponse' 2 | import { KnowledgeBase } from './KnowledgeBaseResponse' 3 | import { ProgressBar } from '../utils/ProgressBar' 4 | 5 | export interface ICliOptions { 6 | /** 目标目录 */ 7 | distDir: string 8 | /** 是否忽略图片 */ 9 | ignoreImg: boolean 10 | /** 私有知识库 token */ 11 | token?: string 12 | /** 自定义token key(企业所有部署) */ 13 | key?: string 14 | /** 是否忽略markdown中toc的生成 */ 15 | toc: boolean 16 | /** 是否增量下载 */ 17 | incremental: boolean 18 | /** 转化markdown视频链接为video标签 */ 19 | convertMarkdownVideoLinks: boolean 20 | /** 是否禁用页脚 */ 21 | hideFooter: boolean 22 | } 23 | 24 | export interface IServerCliOptions { 25 | host: boolean | string 26 | port: number 27 | force: boolean 28 | } 29 | 30 | export interface ISidebarItemDir { 31 | text: string, 32 | index?: number, 33 | collapsed: string 34 | items: ISidebarItem[] 35 | } 36 | 37 | export interface ISidebarItemLink { 38 | text: string 39 | index?: number, 40 | link: string, 41 | } 42 | 43 | export type ISidebarItem = ISidebarItemDir | ISidebarItemLink 44 | 45 | // ---------------- index 46 | 47 | export interface ArticleInfo { 48 | bookId: number, 49 | itemUrl: string, 50 | savePath: string, 51 | saveFilePath: string, 52 | uuid: string, 53 | articleTitle: string, 54 | articleUrl: string, 55 | ignoreImg: boolean, 56 | host?: string, 57 | imageServiceDomains: string[] 58 | } 59 | export interface DownloadArticleParams { 60 | /** 文章信息 */ 61 | articleInfo: ArticleInfo, 62 | /** 进度条实例 */ 63 | progressBar: ProgressBar, 64 | /** cli options */ 65 | options: ICliOptions, 66 | /** 单篇文档进度信息 */ 67 | progressItem: IProgressItem, 68 | /** 第二次下载时前一次的单篇文档进度信息 */ 69 | oldProgressItem?: IProgressItem 70 | } 71 | 72 | export interface DownloadArticleRes { 73 | needDownload: boolean, 74 | isUpdateDownload: boolean, 75 | isDownloadFinish: boolean 76 | } 77 | 78 | export interface IHandleMdDataOptions { 79 | articleUrl: string 80 | articleTitle: string 81 | toc: boolean 82 | articleUpdateTime: string 83 | convertMarkdownVideoLinks: boolean 84 | hideFooter: boolean 85 | } 86 | 87 | 88 | export interface IErrArticleInfo { 89 | articleUrl: string, 90 | errItem: IProgressItem, 91 | errMsg: string, 92 | err: any 93 | } 94 | 95 | export interface IUpdateDownloadItem { 96 | progressItem: IProgressItem, 97 | articleInfo: ArticleInfo 98 | } 99 | 100 | export interface IDownloadArticleListParams { 101 | articleUrlPrefix: string, 102 | total: number, 103 | uuidMap: Map, 104 | tocList: KnowledgeBase.Toc[], 105 | bookPath: string, 106 | bookId: number, 107 | progressBar: ProgressBar, 108 | host?: string 109 | options: ICliOptions, 110 | imageServiceDomains?: string[] 111 | } 112 | 113 | // ---------------- ProgressBar 114 | export interface IProgressItem { 115 | path: string, 116 | toc: KnowledgeBase.Toc, 117 | pathIdList: string[], 118 | pathTitleList: string[], 119 | createAt?: string, 120 | contentUpdatedAt?: string, 121 | publishedAt?: string, 122 | firstPublishedAt?: string 123 | } 124 | export type IProgress = IProgressItem[] 125 | 126 | 127 | // ---------------- Summary 128 | export interface IGenSummaryFile { 129 | bookPath: string, 130 | bookName?: string, 131 | bookDesc?: string, 132 | uuidMap: Map 133 | } 134 | export interface SummaryItem { 135 | id: string, 136 | children?: SummaryItem[], 137 | type: 'link' | 'title', 138 | text: string, 139 | level: number, 140 | link?: string 141 | } 142 | 143 | 144 | // ---------------- parseSheet 145 | 146 | export interface SheetItemData { 147 | [key: string]: { 148 | [key: string]: { 149 | v: string 150 | } 151 | } 152 | } 153 | 154 | export interface SheetItem { 155 | name: string, 156 | rowCount: number, 157 | selections: { 158 | row: number, 159 | col: number, 160 | rowCount: number, 161 | colCount: number, 162 | activeCol: number, 163 | activeRow: number 164 | }, 165 | rows: any, 166 | columns: any, 167 | filter: any, 168 | index: 0, 169 | colCount: 26, 170 | mergeCells: any, 171 | id: string, 172 | data: SheetItemData, 173 | vStore: any 174 | } 175 | 176 | // ---------------- api 177 | export interface IKnowledgeBaseInfo { 178 | bookId?: number 179 | bookSlug?: string 180 | tocList?: KnowledgeBase.Toc[], 181 | bookName?: string, 182 | bookDesc?: string, 183 | host?: string, 184 | imageServiceDomains?: string[] 185 | } 186 | export interface IReqHeader { 187 | [key: string]: string 188 | } 189 | export interface GetHeaderParams { 190 | /** token key */ 191 | key?:string, 192 | /** token value */ 193 | token?: string 194 | } 195 | export type TGetKnowledgeBaseInfo = (url: string, headerParams: GetHeaderParams) => Promise 196 | 197 | export interface GetMdDataParams { 198 | articleUrl: string, 199 | bookId: number, 200 | host?: string 201 | token?: string, 202 | key?: string 203 | } 204 | export interface IGetDocsMdDataRes { 205 | apiUrl: string, 206 | httpStatus: number, 207 | response?: ArticleResponse.RootObject 208 | } 209 | export type TGetMdData = (params: GetMdDataParams, isMd?: boolean) => Promise 210 | 211 | export * from './ArticleResponse' 212 | export * from './KnowledgeBaseResponse' -------------------------------------------------------------------------------- /src/types/lib.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'rand-user-agent' 2 | declare module 'pako' 3 | declare module 'markdown-toc' -------------------------------------------------------------------------------- /src/utils/ProgressBar.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises' 2 | import cliProgress from 'cli-progress' 3 | import { logger } from './log' 4 | 5 | import type { IProgress, IProgressItem } from '../types' 6 | 7 | export class ProgressBar { 8 | bookPath: string = '' 9 | progressFilePath: string = '' 10 | progressInfo: IProgress = [] 11 | curr: number = 0 12 | total: number = 0 13 | isDownloadInterrupted: boolean = false 14 | bar: cliProgress.SingleBar | null = null 15 | completePromise: Promise | null = null 16 | incremental: boolean 17 | 18 | constructor (bookPath: string, total: number, incremental = false) { 19 | this.bookPath = bookPath 20 | this.progressFilePath = `${bookPath}/progress.json` 21 | this.total = total 22 | this.incremental = incremental 23 | } 24 | 25 | async init() { 26 | this.progressInfo = await this.getProgress() 27 | // 增量下载需把进度重置为0 然后每一篇文档重新检查一遍 update时间 28 | this.curr = this.incremental ? 0 : this.progressInfo.length 29 | // 可能出现增量下载 30 | if (this.curr === this.total) return 31 | if (this.curr > 0 && this.curr !== this.total && !this.incremental) { 32 | this.isDownloadInterrupted = true 33 | logger.info('根据上次数据继续断点下载') 34 | } 35 | 36 | this.bar = new cliProgress.SingleBar({ 37 | format: 'Download [{bar}] {percentage}% | {value}/{total}', 38 | // hideCursor: true 39 | }, cliProgress.Presets.legacy) 40 | this.bar.start(this.total, this.curr) 41 | } 42 | 43 | async getProgress(): Promise { 44 | let progressInfo = [] 45 | try { 46 | const progressInfoStr = await fs.readFile(this.progressFilePath, {encoding: 'utf8'}) 47 | progressInfo = JSON.parse(progressInfoStr) 48 | } catch (err) { 49 | if (err && err.code === 'ENOENT') { 50 | await fs.writeFile( 51 | this.progressFilePath, 52 | JSON.stringify(progressInfo), 53 | {encoding: 'utf8'} 54 | ) 55 | } 56 | } 57 | return progressInfo 58 | } 59 | 60 | async updateProgress(progressItem: IProgressItem, isSuccess: boolean) { 61 | if (this.curr === this.total) return 62 | this.curr = this.curr + 1 63 | // 成功才写入 progress.json 以便重新执行时重新下载 64 | if (isSuccess) { 65 | const uuid = progressItem.toc.uuid 66 | // 查找到已有数据则可能是更新文档的内容 67 | const findProgressItem = this.progressInfo.find(item => item.toc.uuid === uuid) 68 | if (findProgressItem) { 69 | // 非深赋值 主要是 时间等字段更新 70 | Object.assign(findProgressItem, progressItem) 71 | } else { 72 | this.progressInfo.push(progressItem) 73 | } 74 | 75 | await fs.writeFile( 76 | this.progressFilePath, 77 | JSON.stringify(this.progressInfo), 78 | {encoding: 'utf8'} 79 | ) 80 | } 81 | if (this.bar) { 82 | this.bar.update(this.curr) 83 | if (this.curr >= this.total) { 84 | this.bar.stop() 85 | console.log('') 86 | } 87 | } 88 | } 89 | // 暂停进度条的打印 90 | pause () { 91 | if (this.bar) this.bar.stop() 92 | } 93 | // 继续进度条的打印 94 | continue() { 95 | this.clearLine(2) 96 | this.bar?.start(this.total, this.curr) 97 | } 98 | // 清理n行终端显示 99 | clearLine(line: number) { 100 | if (line <= 0) return 101 | if (typeof process?.stderr?.cursorTo !== 'function') return 102 | process.stderr.cursorTo(0) 103 | for (let i = 0; i< line;i++){ 104 | process.stderr.moveCursor(0, -1) 105 | process.stderr.clearLine(1) 106 | } 107 | } 108 | } -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import randUserAgentLib from 'rand-user-agent' 2 | 3 | /** 随机生成UA */ 4 | export function randUserAgent({ browser = 'chrome', os = 'mac os', device = 'desktop' }) { 5 | device = device.toLowerCase() 6 | browser = browser.toLowerCase() 7 | os = os.toLowerCase() 8 | let UA = randUserAgentLib(device, browser, os) 9 | 10 | if (browser === 'chrome') { 11 | while (UA.includes('Chrome-Lighthouse') 12 | || UA.includes('Gener8') 13 | || UA.includes('HeadlessChrome') 14 | || UA.includes('SMTBot')) { 15 | UA = randUserAgentLib(device, browser, os) 16 | } 17 | } 18 | if (browser === 'safari') { 19 | while (UA.includes('Applebot')) { 20 | UA = randUserAgentLib(device, browser, os) 21 | } 22 | } 23 | return UA 24 | } 25 | 26 | /** 27 | * 获取md中的img url 28 | */ 29 | export function getMarkdownImageList(mdStr: string) { 30 | if (!mdStr) return [] 31 | const mdImgReg = /!\[(.*?)\]\((.*?)\)/gm 32 | let list = Array.from(mdStr.match(mdImgReg) || []) 33 | list = list 34 | .map((itemUrl) => { 35 | itemUrl = itemUrl.replace(mdImgReg, '$2') 36 | // 如果出现非http开头的图片 如 "./xx.png" 则跳过 37 | if (!/^http.*/g.test(itemUrl)) return '' 38 | return itemUrl 39 | }) 40 | .filter((url) => Boolean(url)) 41 | return list 42 | } 43 | 44 | export function removeEmojis(dirName: string) { 45 | return dirName.replace(/[\ud800-\udbff][\udc00-\udfff]/g, '') 46 | } 47 | 48 | export function isValidUrl(url: string): boolean { 49 | if (typeof URL.canParse === 'function') { 50 | return URL.canParse(url) 51 | } 52 | try { 53 | new URL(url) 54 | return true 55 | } catch (e) { 56 | return false 57 | } 58 | } 59 | 60 | function pad(num: number) { 61 | return num.toString().padStart(2, '0') 62 | } 63 | 64 | export function formateDate(d: string) { 65 | const date = new Date(d) 66 | if (isNaN(date.getTime())) return '' 67 | const year = date.getFullYear() 68 | const month = date.getMonth() + 1 69 | const day = date.getDate() 70 | const hour = date.getHours() 71 | const minute = date.getMinutes() 72 | const second = date.getSeconds() 73 | return `${year}-${pad(month)}-${pad(day)} ${pad(hour)}:${pad(minute)}:${pad(second)}` 74 | } 75 | 76 | export function isValidDate(date: Date) { 77 | return date instanceof Date && !isNaN(date.getTime()) 78 | } 79 | 80 | export * from './log' 81 | export * from './ProgressBar' 82 | -------------------------------------------------------------------------------- /src/utils/log.ts: -------------------------------------------------------------------------------- 1 | import log4js from 'log4js' 2 | 3 | const getLogger = () => { 4 | log4js.configure({ 5 | appenders: { 6 | cheese: { 7 | type: 'console', 8 | layout: { 9 | type: 'pattern', 10 | pattern: '%[%c [%p]:%] %m%n' 11 | } 12 | } 13 | }, 14 | categories: { default: { appenders: ['cheese'], level: 'trace' } } 15 | }) 16 | return log4js.getLogger('yuque-dl') 17 | } 18 | 19 | export const logger = getLogger() 20 | -------------------------------------------------------------------------------- /test/__snapshots__/cli.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`yuque-dl CLI > generate toc list should work 1`] = ` 4 | "# 🎁 语雀 · 大学生公益计划 5 | 6 | - [👉🏻 常见问题 👈🏻](#-%F0%9F%91%89%F0%9F%8F%BB-%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98-%F0%9F%91%88%F0%9F%8F%BB-) 7 | - [公益计划介绍](#%E5%85%AC%E7%9B%8A%E8%AE%A1%E5%88%92%E4%BB%8B%E7%BB%8D) 8 | * [规则](#%E8%A7%84%E5%88%99) 9 | * [操作指引](#%E6%93%8D%E4%BD%9C%E6%8C%87%E5%BC%95) 10 | + [适合新用户:语雀官网公益计划页认证](#%E9%80%82%E5%90%88%E6%96%B0%E7%94%A8%E6%88%B7%E8%AF%AD%E9%9B%80%E5%AE%98%E7%BD%91%E5%85%AC%E7%9B%8A%E8%AE%A1%E5%88%92%E9%A1%B5%E8%AE%A4%E8%AF%81) 11 | + [适合老用户:会员信息页直接认证](#%E9%80%82%E5%90%88%E8%80%81%E7%94%A8%E6%88%B7%E4%BC%9A%E5%91%98%E4%BF%A1%E6%81%AF%E9%A1%B5%E7%9B%B4%E6%8E%A5%E8%AE%A4%E8%AF%81) 12 | 13 | --- 14 | 15 | ## 👉🏻 常见问题 👈🏻 16 | [认证常见问题解决](https://www.yuque.com/yuque/welfare/faq) 17 | 18 | 19 | 20 | ## 公益计划介绍 21 | 助力梦想,相伴成长。 22 | 23 | 即日起,语雀面向**中国大陆各大学的大学生、教师**发布语雀公益计划-个人版。认证教育邮箱,即可免费享有语雀会员权益。 24 | 25 | 26 | 27 | [语雀写给大学生的一封信](https://www.yuque.com/yuque/blog/welfare-edu) 28 | 29 | ### 规则 30 | 认证学校提供的 **edu.cn 结尾的教育邮箱**,即可免费获得 **1 年语雀会员**。 31 | 32 | + 若你之前并非会员/已是专业会员,则获得 1 年专业会员; 33 | + 若你已是超级会员,则获得 1 年超级会员。 34 | + 1 年后,可在原入口进行**续期**,只要你还在校期间,就可以一直免费使用。 35 | 36 | 37 | 38 | ### 操作指引 39 | #### 适合新用户:语雀官网公益计划页认证 40 | + 打开 [语雀官网公益计划页](https://www.yuque.com/about/welfare) 41 | 42 | ![](https://cdn.nlark.com/yuque/0/2023/png/103125/1694522534230-9d9d42f9-bbba-46d3-8e9a-773f2951089d.png) 43 | 44 | + 在「个人版公益计划」下,点击【**立即认证**】 45 | 46 | ![](https://cdn.nlark.com/yuque/0/2023/png/103125/1694522561681-3a568a52-7ef3-43ae-9067-d852f5556cf7.png) 47 | 48 | + 完成注册或登录后,来到「**账户设置-会员信息**」页面,输入教育邮箱,点击发送认证邮件 49 | 50 | ![](https://cdn.nlark.com/yuque/0/2023/png/103125/1694513696571-0458a2d9-9dd8-4eab-a04b-671e4fd66e09.png) 51 | 52 | + 在教育邮箱中查收该邮件,点击「**立即验证**」 53 | 54 | ![](https://cdn.nlark.com/yuque/0/2023/png/103125/1694513756376-8dc9a3bd-ec24-43b6-9a2f-5f7760fc421b.png) 55 | 56 | + 完成认证,直接获得会员 ✅ 57 | 58 | #### 适合老用户:会员信息页直接认证 59 | + 来到「账户设置-会员信息」页面,点击【**立即认证**】 60 | 61 | ![](https://cdn.nlark.com/yuque/0/2023/png/103125/1694511270833-fc586c50-cc11-45ef-8fae-96961ec92150.png) 62 | 63 | ![](https://cdn.nlark.com/yuque/0/2023/png/103125/1694513621870-1e0659bf-0bd5-4f55-9519-a9dc663f5111.png) 64 | 65 | 66 | 67 | + 输入教育邮箱,点击发送认证邮件 68 | 69 | ![](https://cdn.nlark.com/yuque/0/2023/png/103125/1694513696571-0458a2d9-9dd8-4eab-a04b-671e4fd66e09.png) 70 | 71 | + 在教育邮箱中查收该邮件,点击「立即验证」 72 | 73 | ![](https://cdn.nlark.com/yuque/0/2023/png/103125/1694513756376-8dc9a3bd-ec24-43b6-9a2f-5f7760fc421b.png) 74 | 75 | + 完成认证,直接获得会员 ✅ 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | > 更新: 2023-10-07 14:12:28 84 | > 原文: " 85 | `; 86 | 87 | exports[`yuque-dl CLI > ignore img should work 1`] = ` 88 | "# 🎁 语雀 · 大学生公益计划 89 | 90 | ## 👉🏻 常见问题 👈🏻 91 | [认证常见问题解决](https://www.yuque.com/yuque/welfare/faq) 92 | 93 | 94 | 95 | ## 公益计划介绍 96 | 助力梦想,相伴成长。 97 | 98 | 即日起,语雀面向**中国大陆各大学的大学生、教师**发布语雀公益计划-个人版。认证教育邮箱,即可免费享有语雀会员权益。 99 | 100 | 101 | 102 | [语雀写给大学生的一封信](https://www.yuque.com/yuque/blog/welfare-edu) 103 | 104 | ### 规则 105 | 认证学校提供的 **edu.cn 结尾的教育邮箱**,即可免费获得 **1 年语雀会员**。 106 | 107 | + 若你之前并非会员/已是专业会员,则获得 1 年专业会员; 108 | + 若你已是超级会员,则获得 1 年超级会员。 109 | + 1 年后,可在原入口进行**续期**,只要你还在校期间,就可以一直免费使用。 110 | 111 | 112 | 113 | ### 操作指引 114 | #### 适合新用户:语雀官网公益计划页认证 115 | + 打开 [语雀官网公益计划页](https://www.yuque.com/about/welfare) 116 | 117 | ![](https://cdn.nlark.com/yuque/0/2023/png/103125/1694522534230-9d9d42f9-bbba-46d3-8e9a-773f2951089d.png) 118 | 119 | + 在「个人版公益计划」下,点击【**立即认证**】 120 | 121 | ![](https://cdn.nlark.com/yuque/0/2023/png/103125/1694522561681-3a568a52-7ef3-43ae-9067-d852f5556cf7.png) 122 | 123 | + 完成注册或登录后,来到「**账户设置-会员信息**」页面,输入教育邮箱,点击发送认证邮件 124 | 125 | ![](https://cdn.nlark.com/yuque/0/2023/png/103125/1694513696571-0458a2d9-9dd8-4eab-a04b-671e4fd66e09.png) 126 | 127 | + 在教育邮箱中查收该邮件,点击「**立即验证**」 128 | 129 | ![](https://cdn.nlark.com/yuque/0/2023/png/103125/1694513756376-8dc9a3bd-ec24-43b6-9a2f-5f7760fc421b.png) 130 | 131 | + 完成认证,直接获得会员 ✅ 132 | 133 | #### 适合老用户:会员信息页直接认证 134 | + 来到「账户设置-会员信息」页面,点击【**立即认证**】 135 | 136 | ![](https://cdn.nlark.com/yuque/0/2023/png/103125/1694511270833-fc586c50-cc11-45ef-8fae-96961ec92150.png) 137 | 138 | ![](https://cdn.nlark.com/yuque/0/2023/png/103125/1694513621870-1e0659bf-0bd5-4f55-9519-a9dc663f5111.png) 139 | 140 | 141 | 142 | + 输入教育邮箱,点击发送认证邮件 143 | 144 | ![](https://cdn.nlark.com/yuque/0/2023/png/103125/1694513696571-0458a2d9-9dd8-4eab-a04b-671e4fd66e09.png) 145 | 146 | + 在教育邮箱中查收该邮件,点击「立即验证」 147 | 148 | ![](https://cdn.nlark.com/yuque/0/2023/png/103125/1694513756376-8dc9a3bd-ec24-43b6-9a2f-5f7760fc421b.png) 149 | 150 | + 完成认证,直接获得会员 ✅ 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | > 更新: 2023-10-07 14:12:28 159 | > 原文: " 160 | `; 161 | -------------------------------------------------------------------------------- /test/__snapshots__/index.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`main > should work 1`] = ` 4 | "# 知识库TEST1 5 | 6 | > 知识库 test desc 7 | 8 | 9 | ## Title1 10 | 11 | - [文档1](Title1/文档1.md) 12 | 13 | ## Title2 14 | 15 | - [文档2](Title2/文档2.md) 16 | " 17 | `; 18 | 19 | exports[`main > should work 2`] = ` 20 | "# 文档2 21 | 22 | # DOC2 23 | ## Title 24 | text... 25 | ### Title2 26 | text... 27 | 28 | 29 | > 原文: " 30 | `; 31 | 32 | exports[`main > should work 3`] = `"[{"path":"Title1","pathTitleList":["Title1"],"pathIdList":["001"],"toc":{"type":"TITLE","title":"Title1","uuid":"001","child_uuid":"002","parent_uuid":""}},{"path":"Title1/文档1.md","savePath":"Title1","pathTitleList":["Title1","文档1"],"pathIdList":["001","002"],"toc":{"type":"DOC","title":"文档1","uuid":"002","url":"one","child_uuid":"","parent_uuid":"001"},"createAt":"2025-03-12T12:59:22.000Z","contentUpdatedAt":"2025-03-12T12:59:27.000Z","publishedAt":"2025-03-12T12:59:27.000Z","firstPublishedAt":"2025-03-12T12:59:26.630Z"},{"path":"Title2","pathTitleList":["Title2"],"pathIdList":["003"],"toc":{"type":"TITLE","title":"Title2","uuid":"003","child_uuid":"004","parent_uuid":""}},{"path":"Title2/文档2.md","savePath":"Title2","pathTitleList":["Title2","文档2"],"pathIdList":["003","004"],"toc":{"type":"DOC","title":"文档2","uuid":"004","url":"two","child_uuid":"","parent_uuid":"003"},"createAt":"","contentUpdatedAt":"","publishedAt":"","firstPublishedAt":""}]"`; 33 | 34 | exports[`main > should work 4`] = ` 35 | "# 文档1 36 | 37 | # DOC1 38 | ![1.jpeg](./img/002/1-123456.jpeg) 39 | ## SubTitle 40 | ![2.jpeg](./img/002/2-123456.jpeg) 41 | 42 | > 更新: 2025-03-12 20:59:27 43 | > 原文: " 44 | `; 45 | -------------------------------------------------------------------------------- /test/api.test.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' 2 | import { TestTools } from './helpers/TestTools' 3 | import { server } from './mocks/server' 4 | import { getDocsMdData, getKnowledgeBaseInfo, genCommonOptions } from '../src/api' 5 | 6 | let testTools: TestTools 7 | 8 | describe('api', () => { 9 | beforeAll(() => { 10 | server.listen({ onUnhandledRequest: 'error' }) 11 | }) 12 | afterAll(() => server.close()) 13 | 14 | beforeEach(() => { 15 | testTools = new TestTools() 16 | }) 17 | 18 | afterEach(() => { 19 | testTools.cleanup() 20 | server.resetHandlers() 21 | }) 22 | 23 | describe('getKnowledgeBaseInfo', () => { 24 | it('should work', async () => { 25 | const data = await getKnowledgeBaseInfo('https://www.yuque.com/yuque/welfare', { 26 | token: 'token', 27 | key: 'key' 28 | }) 29 | expect(data.bookId).toBe(41966892) 30 | expect(data.bookSlug).toBe('welfare') 31 | expect(data.tocList?.length).toBe(2) 32 | expect(data.bookName).toBe('🤗 语雀公益计划') 33 | expect(data.bookDesc).toBe('') 34 | expect(data.imageServiceDomains?.length).toBe(70) 35 | }) 36 | 37 | it('404 should throw Error', async () => { 38 | const requestPromise = getKnowledgeBaseInfo('http://localhost/404', {}) 39 | await expect(requestPromise).rejects.toThrow('Request failed with status code 404') 40 | }) 41 | }) 42 | 43 | describe('getDocsMdData', () => { 44 | it('should work', async () => { 45 | const params = { 46 | articleUrl: 'edu', 47 | bookId: 41966892, 48 | } 49 | const data = await getDocsMdData(params) 50 | expect(data.apiUrl).toBe('https://www.yuque.com/api/docs/edu?book_id=41966892&merge_dynamic_data=false&mode=markdown') 51 | expect(data.httpStatus).toBe(200) 52 | expect(data.response?.data.sourcecode).toBeTruthy() 53 | }) 54 | }) 55 | 56 | it('genCommonOptions should work', async () => { 57 | const data = genCommonOptions({ 58 | key: 'test_key', 59 | token: 'test_token' 60 | }) 61 | expect(data.headers?.cookie).toMatchObject('test_key=test_token;') 62 | const redirectObj = {} as any 63 | if (data.beforeRedirect) { 64 | data.beforeRedirect(redirectObj, null as any) 65 | } 66 | expect(redirectObj?.headers?.cookie).toMatchObject('test_key=test_token;') 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /test/cli.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import fs from 'node:fs' 3 | import { fileURLToPath } from 'node:url' 4 | import { afterEach, beforeEach, describe, expect, it } from 'vitest' 5 | import { TestTools } from './helpers/TestTools' 6 | 7 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 8 | const cliPath = path.join(__dirname, '../bin/index.js') 9 | 10 | let testTools: TestTools 11 | 12 | const mdImgReg = /!\[.*?\]\(https*.*?\)/g 13 | 14 | describe('yuque-dl CLI', () => { 15 | 16 | beforeEach(() => { 17 | testTools = new TestTools() 18 | }) 19 | 20 | afterEach(() => { 21 | testTools.cleanup() 22 | }) 23 | 24 | it('should work', async () => { 25 | const { stdout, exitCode, stderr } = await testTools.fork(cliPath, [ 26 | 'https://www.yuque.com/yuque/welfare', 27 | '-d', '.' 28 | ]) 29 | expect(exitCode).toBe(0) 30 | expect(stdout).toContain('√ 已完成') 31 | const imgDir = path.join(testTools.cwd, '语雀公益计划/语雀·大学生公益计划/img') 32 | const indexMdPath = path.join(testTools.cwd, '语雀公益计划/语雀·大学生公益计划/index.md') 33 | expect(fs.existsSync(imgDir)).toBeTruthy() 34 | const data = fs.readFileSync(indexMdPath).toString() 35 | expect(data.match(mdImgReg)).toBeFalsy() 36 | expect(stderr).toBeFalsy() 37 | }) 38 | 39 | it('ignore img should work ', async () => { 40 | const { stdout, exitCode } = await testTools.fork(cliPath, [ 41 | 'https://www.yuque.com/yuque/welfare', 42 | '-d', '.', 43 | '-i' 44 | ]) 45 | expect(exitCode).toBe(0) 46 | expect(stdout).toContain('√ 已完成') 47 | const imgDir = path.join(testTools.cwd, '语雀公益计划/语雀·大学生公益计划/img') 48 | expect(fs.existsSync(imgDir)).toBeFalsy() 49 | const indexMdPath = path.join(testTools.cwd, '语雀公益计划/语雀·大学生公益计划/index.md') 50 | const data = fs.readFileSync(indexMdPath).toString() 51 | expect(data).toMatchSnapshot() 52 | }) 53 | 54 | it('generate toc list should work ', async () => { 55 | const { stdout, exitCode } = await testTools.fork(cliPath, [ 56 | 'https://www.yuque.com/yuque/welfare', 57 | '-d', '.', 58 | '--toc', 59 | '-i' 60 | ]) 61 | expect(exitCode).toBe(0) 62 | expect(stdout).toContain('√ 已完成') 63 | const imgDir = path.join(testTools.cwd, '语雀公益计划/语雀·大学生公益计划/img') 64 | expect(fs.existsSync(imgDir)).toBeFalsy() 65 | const indexMdPath = path.join(testTools.cwd, '语雀公益计划/语雀·大学生公益计划/index.md') 66 | const data = fs.readFileSync(indexMdPath).toString() 67 | expect(data).toMatchSnapshot() 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /test/crypto.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { captureImageURL, genSign } from '../src/crypto' 3 | 4 | 5 | describe('captureImageURL', () => { 6 | const imageServiceDomains = ['www.abc.com', 'www.efg.com'] 7 | it('Ignored within the image server', () => { 8 | const imgUrl = 'https://www.abc.com/1.jpg' 9 | const data = captureImageURL(imgUrl, imageServiceDomains) 10 | expect(data).toBe(imgUrl) 11 | const imgUrl2 = 'https://www.efg.com/1.jpg' 12 | const data2 = captureImageURL(imgUrl2, imageServiceDomains) 13 | expect(data2).toBe(imgUrl2) 14 | }) 15 | // 不在图片服务器的域名需做转发到filetransfer 16 | it('should work', () => { 17 | const imgUrl = 'https://www.baidu2.com/logo.jpg' 18 | const data = captureImageURL(imgUrl, imageServiceDomains) 19 | expect(data).toBe(`https://www.yuque.com/api/filetransfer/images?url=${encodeURIComponent(imgUrl)}&sign=${genSign(imgUrl)}`) 20 | }) 21 | 22 | it('An invalid URL can work properly', () => { 23 | const imgUrl = '123' 24 | const data = captureImageURL(imgUrl, imageServiceDomains) 25 | expect(data).toBe(imgUrl) 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /test/download/__snapshots__/article.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`downloadArticle > custom key token 1`] = ` 4 | "# downloadArticle Title 5 | 6 | MyKey=MyToken; 7 | 8 | > 原文: " 9 | `; 10 | 11 | exports[`downloadArticle > sheet type 1`] = ` 12 | "# downloadArticle Title 13 | 14 | 15 | ## Sheet1 16 | 17 | | |A | B | C | D | E | F | G | H | I| 18 | |--- |--- |--- |--- |--- |--- |--- |--- |--- |---| 19 | | 1 | 1 | | | | | | | | | 20 | | 2 | | | | GSSSAPPAP | | | | | | 21 | | 3 | | | | | | | | | | 22 | | 4 | | 12 | | | | | | | | 23 | | 5 | | | | 123 | | | | | | 24 | | 6 | | | | | | | | | | 25 | | 7 | | | | | | | | | | 26 | | 8 | | | | | | | | | | 27 | | 9 | | | | | | | | | | 28 | | 10 | | | | | | | | | | 29 | | 11 | | | | | | | | | | 30 | | 12 | | | | | | | | | | 31 | | 13 | | | | | | | 234 | | | 32 | | 14 | | | | | | | | | 32423| 33 | 34 | ## Sheet2 35 | 36 | | |A | B | C | D | E | F | G | H| 37 | |--- |--- |--- |--- |--- |--- |--- |--- |---| 38 | | 1 | s | | | [x] | 1 | | | | 39 | | 2 | | | | [baidu](https://www.baidu.com) | 13,21321 | | | | 40 | | 3 | | 34 | | ![2x-00037-3212833155.png'](./img/img_dir_uuid/images-123456.jpeg) | | | | | 41 | | 4 | | 34 | | ![2x-00037-3212833155.png'](./img/img_dir_uuid/images-123456.jpeg) | | | | | 42 | 43 | ## Sheet3 44 | 45 | 46 | 47 | > 原文: " 48 | `; 49 | 50 | exports[`downloadArticle > should work 1`] = ` 51 | "# downloadArticle Title 52 | 53 | # DOC1 54 | ![1.jpeg](./img/img_dir_uuid/1-123456.jpeg) 55 | ## SubTitle 56 | ![2.jpeg](./img/img_dir_uuid/2-123456.jpeg) 57 | 58 | > 更新: 2025-03-12 20:59:27 59 | > 原文: " 60 | `; 61 | 62 | exports[`downloadArticle > uuid contains special characters 1`] = ` 63 | "# downloadArticle Title 64 | 65 | # DOC1 66 | ![1.jpeg](./img/img_dir/uuid/1-123456.jpeg) 67 | ## SubTitle 68 | [附件: test.pdf](./attachments/img_dir_uuid/test.pdf) 69 | 70 | > 原文: " 71 | `; 72 | -------------------------------------------------------------------------------- /test/download/__snapshots__/attachments.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`downloadAttachments > should work 1`] = ` 4 | "# test 5 | 6 | [附件: test.pdf](./attachments/123456789/test.pdf) 7 | " 8 | `; 9 | -------------------------------------------------------------------------------- /test/download/__snapshots__/list.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`downloadArticle > should work 1`] = ` 4 | "# 文档1 5 | 6 | # DOC1 7 | ![1.jpeg](./img/002/1-123456.jpeg) 8 | ## SubTitle 9 | ![2.jpeg](./img/002/2-123456.jpeg) 10 | 11 | > 更新: 2025-03-12 20:59:27 12 | > 原文: " 13 | `; 14 | 15 | exports[`downloadArticle > should work 2`] = ` 16 | "# 文档2 17 | 18 | # DOC2 19 | ## Title 20 | text... 21 | ### Title2 22 | text... 23 | 24 | 25 | > 原文: " 26 | `; 27 | 28 | exports[`downloadArticle > the title is also a doc 1`] = ` 29 | "# Title1_文档 30 | 31 | # DOC1 32 | ![1.jpeg](./img/001/1-123456.jpeg) 33 | ## SubTitle 34 | ![2.jpeg](./img/001/2-123456.jpeg) 35 | 36 | > 更新: 2025-03-12 20:59:27 37 | > 原文: " 38 | `; 39 | 40 | exports[`downloadArticle > the title is also a doc 2`] = ` 41 | "# 文档1 42 | 43 | # DOC2 44 | ## Title 45 | text... 46 | ### Title2 47 | text... 48 | 49 | 50 | > 原文: " 51 | `; 52 | -------------------------------------------------------------------------------- /test/download/article.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { readdirSync, readFileSync } from 'node:fs' 3 | import { vi, afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' 4 | import { TestTools } from '../helpers/TestTools' 5 | import { server } from '../mocks/server' 6 | import { downloadArticle } from '../../src/download/article' 7 | 8 | let testTools: TestTools 9 | 10 | describe('downloadArticle', () => { 11 | beforeAll(() => { 12 | server.listen({ onUnhandledRequest: 'error' }) 13 | }) 14 | afterAll(() => server.close()) 15 | 16 | beforeEach(() => { 17 | testTools = new TestTools() 18 | }) 19 | 20 | afterEach(() => { 21 | testTools.cleanup() 22 | server.resetHandlers() 23 | }) 24 | it('should work', async () => { 25 | const articleInfo = { 26 | savePath: testTools.cwd, 27 | saveFilePath: path.join(testTools.cwd, 'test.md'), 28 | host: 'https://www.yuque.com', 29 | ignoreImg: false, 30 | uuid: 'img_dir_uuid', 31 | articleTitle: 'downloadArticle Title', 32 | articleUrl: 'https://www.yuque.com/yuque/base1/one', 33 | itemUrl: 'one', 34 | bookId: 1111, 35 | imageServiceDomains: ['gxr404.com'] 36 | } 37 | const progressItem = {} as any 38 | await downloadArticle({ 39 | articleInfo, 40 | progressBar: { 41 | pause: vi.fn(), 42 | continue: vi.fn() 43 | } as any, 44 | options: { 45 | token: 'options token', 46 | key: 'options key' 47 | } as any, 48 | progressItem 49 | }) 50 | 51 | let doc1Data = readFileSync(articleInfo.saveFilePath).toString() 52 | doc1Data = doc1Data.replace(/\.\/img.*?-(\d{6})\./g, (match, random) => { 53 | return match.replace(random, '123456') 54 | }) 55 | expect(doc1Data).toMatchSnapshot() 56 | const imgList = readdirSync(`${articleInfo.savePath}/img/${articleInfo.uuid}`) 57 | expect(readFileSync(`${articleInfo.savePath}/img/${articleInfo.uuid}/${imgList[0]}`).length).toBe(99892) 58 | expect(readFileSync(`${articleInfo.savePath}/img/${articleInfo.uuid}/${imgList[1]}`).length).toBe(81011) 59 | expect(progressItem.createAt).toBe('2025-03-12T12:59:22.000Z') 60 | expect(progressItem.contentUpdatedAt).toBe('2025-03-12T12:59:27.000Z') 61 | expect(progressItem.publishedAt).toBe('2025-03-12T12:59:27.000Z') 62 | expect(progressItem.firstPublishedAt).toBe('2025-03-12T12:59:26.630Z') 63 | }) 64 | 65 | it('sourcecode null should work', async () => { 66 | const articleInfo = { 67 | savePath: testTools.cwd, 68 | saveFilePath: path.join(testTools.cwd, 'test.md'), 69 | host: 'https://www.yuque.com', 70 | ignoreImg: false, 71 | uuid: 'img_dir_uuid', 72 | articleTitle: 'downloadArticle Title', 73 | articleUrl: 'https://www.yuque.com/yuque/base1/one', 74 | itemUrl: 'sourcecodeNull', 75 | bookId: 1111, 76 | imageServiceDomains: ['gxr404.com'] 77 | } 78 | 79 | const requestPromise = downloadArticle({ 80 | articleInfo, 81 | progressBar: { 82 | pause: vi.fn(), 83 | continue: vi.fn() 84 | } as any, 85 | options: { 86 | token: 'options token', 87 | key: 'options key' 88 | } as any, 89 | progressItem: {} as any 90 | }) 91 | await expect(requestPromise).rejects.toThrow(/download article Error: .*?, http status 200/g) 92 | }) 93 | 94 | 95 | it('board type', async () => { 96 | const articleInfo = { 97 | savePath: testTools.cwd, 98 | saveFilePath: path.join(testTools.cwd, 'test.md'), 99 | host: 'https://www.yuque.com', 100 | ignoreImg: false, 101 | uuid: 'img_dir_uuid', 102 | articleTitle: 'downloadArticle Title', 103 | articleUrl: 'https://www.yuque.com/yuque/base1/one', 104 | itemUrl: 'board', 105 | bookId: 1111, 106 | imageServiceDomains: ['gxr404.com'] 107 | } 108 | const requestPromise = downloadArticle({ 109 | articleInfo, 110 | progressBar: { 111 | pause: vi.fn(), 112 | continue: vi.fn() 113 | } as any, 114 | options: { 115 | token: 'options token', 116 | key: 'options key' 117 | } as any, 118 | progressItem: {} as any 119 | }) 120 | await expect(requestPromise).rejects.toThrow('download article Error: 暂不支持“画板类型”的文档') 121 | }) 122 | 123 | it('table type', async () => { 124 | const articleInfo = { 125 | savePath: testTools.cwd, 126 | saveFilePath: path.join(testTools.cwd, 'test.md'), 127 | host: 'https://www.yuque.com', 128 | ignoreImg: false, 129 | uuid: 'img_dir_uuid', 130 | articleTitle: 'downloadArticle Title', 131 | articleUrl: 'https://www.yuque.com/yuque/base1/one', 132 | itemUrl: 'table', 133 | bookId: 1111, 134 | imageServiceDomains: ['gxr404.com'] 135 | } 136 | const requestPromise = downloadArticle({ 137 | articleInfo, 138 | progressBar: { 139 | pause: vi.fn(), 140 | continue: vi.fn() 141 | } as any, 142 | options: { 143 | token: 'options token', 144 | key: 'options key' 145 | } as any, 146 | progressItem: {} as any 147 | }) 148 | await expect(requestPromise).rejects.toThrow('download article Error: 暂不支持“数据表类型”的文档') 149 | }) 150 | 151 | it('sheet type', async () => { 152 | const articleInfo = { 153 | savePath: testTools.cwd, 154 | saveFilePath: path.join(testTools.cwd, 'test.md'), 155 | host: 'https://www.yuque.com', 156 | ignoreImg: false, 157 | uuid: 'img_dir_uuid', 158 | articleTitle: 'downloadArticle Title', 159 | articleUrl: 'https://www.yuque.com/yuque/base1/one', 160 | itemUrl: 'sheet', 161 | bookId: 1111, 162 | imageServiceDomains: ['gxr404.com'] 163 | } 164 | await downloadArticle({ 165 | articleInfo, 166 | progressBar: { 167 | pause: vi.fn(), 168 | continue: vi.fn() 169 | } as any, 170 | options: { 171 | token: 'options token', 172 | key: 'options key' 173 | } as any, 174 | progressItem: {} as any 175 | }) 176 | 177 | let doc1Data = readFileSync(articleInfo.saveFilePath).toString() 178 | doc1Data = doc1Data.replace(/\.\/img.*?-(\d{6})\./g, (match, random) => { 179 | return match.replace(random, '123456') 180 | }) 181 | expect(doc1Data).toMatchSnapshot() 182 | const imgList = readdirSync(`${articleInfo.savePath}/img/${articleInfo.uuid}`) 183 | expect(readFileSync(`${articleInfo.savePath}/img/${articleInfo.uuid}/${imgList[0]}`).length).toBe(99892) 184 | }) 185 | 186 | it('sheet type parse error', async () => { 187 | const articleInfo = { 188 | savePath: testTools.cwd, 189 | saveFilePath: path.join(testTools.cwd, 'test.md'), 190 | host: 'https://www.yuque.com', 191 | ignoreImg: false, 192 | uuid: 'img_dir_uuid', 193 | articleTitle: 'downloadArticle Title', 194 | articleUrl: 'https://www.yuque.com/yuque/base1/one', 195 | itemUrl: 'sheetError', 196 | bookId: 1111, 197 | imageServiceDomains: ['gxr404.com'] 198 | } 199 | const requestPromise = downloadArticle({ 200 | articleInfo, 201 | progressBar: { 202 | pause: vi.fn(), 203 | continue: vi.fn() 204 | } as any, 205 | options: { 206 | token: 'options token', 207 | key: 'options key' 208 | } as any, 209 | progressItem: {} as any 210 | }) 211 | await expect(requestPromise).rejects.toThrow(/download article Error: “表格类型”解析错误 SyntaxError: Unexpected token/) 212 | }) 213 | 214 | it('custom key token', async () => { 215 | const articleInfo = { 216 | savePath: testTools.cwd, 217 | saveFilePath: path.join(testTools.cwd, 'test.md'), 218 | host: 'https://www.yuque.com', 219 | ignoreImg: false, 220 | uuid: 'img_dir_uuid', 221 | articleTitle: 'downloadArticle Title', 222 | articleUrl: 'https://www.yuque.com/yuque/base1/one', 223 | itemUrl: 'tokenAndKey', 224 | bookId: 1111, 225 | imageServiceDomains: ['gxr404.com'] 226 | } 227 | await downloadArticle({ 228 | articleInfo, 229 | progressBar: { 230 | pause: vi.fn(), 231 | continue: vi.fn() 232 | } as any, 233 | options: { 234 | token: 'MyToken', 235 | key: 'MyKey' 236 | } as any, 237 | progressItem: {} as any 238 | }) 239 | const docData = readFileSync(articleInfo.saveFilePath).toString() 240 | expect(docData).toMatchSnapshot() 241 | }) 242 | 243 | it('uuid contains special characters', async () => { 244 | const articleInfo = { 245 | savePath: testTools.cwd, 246 | saveFilePath: path.join(testTools.cwd, 'test.md'), 247 | host: 'https://www.yuque.com', 248 | ignoreImg: false, 249 | uuid: 'img:dir/uuid', 250 | articleTitle: 'downloadArticle Title', 251 | articleUrl: 'https://www.yuque.com/api/docs/attachments', 252 | itemUrl: 'attachments', 253 | bookId: 1111, 254 | imageServiceDomains: ['gxr404.com'] 255 | } 256 | await downloadArticle({ 257 | articleInfo, 258 | progressBar: { 259 | pause: vi.fn(), 260 | continue: vi.fn() 261 | } as any, 262 | options: { 263 | token: 'MyToken', 264 | key: 'MyKey' 265 | } as any, 266 | progressItem: {} as any 267 | }) 268 | let doc1Data = readFileSync(articleInfo.saveFilePath).toString() 269 | doc1Data = doc1Data.replace(/\.\/img.*?-(\d{6})\./g, (match, random) => { 270 | return match.replace(random, '123456') 271 | }) 272 | expect(doc1Data).toMatchSnapshot() 273 | }) 274 | }) 275 | -------------------------------------------------------------------------------- /test/download/attachments.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { readFileSync } from 'node:fs' 3 | import { describe, expect, it, beforeAll, afterAll, beforeEach, afterEach } from 'vitest' 4 | import { TestTools } from '../helpers/TestTools' 5 | import { downloadAttachments } from '../../src/download/attachments' 6 | import { server } from '../mocks/server' 7 | 8 | let testTools: TestTools 9 | 10 | describe('downloadAttachments', () => { 11 | beforeAll(() => { 12 | server.listen({ onUnhandledRequest: 'error' }) 13 | }) 14 | afterAll(() => server.close()) 15 | 16 | beforeEach(() => { 17 | testTools = new TestTools() 18 | }) 19 | 20 | afterEach(() => { 21 | server.resetHandlers() 22 | testTools.cleanup() 23 | }) 24 | 25 | it('should work', async () => { 26 | const mdData = '# test \n\n[test.pdf](https://www.yuque.com/attachments/test.pdf)\n' 27 | const params = { 28 | mdData, 29 | savePath: testTools.cwd, 30 | attachmentsDir: './attachments/123456789', 31 | articleTitle: 'test' 32 | } 33 | const resData = await downloadAttachments(params) 34 | expect(readFileSync(path.join(testTools.cwd, params.attachmentsDir, 'test.pdf')).length).toBe(46219) 35 | expect(resData.mdData).toMatchSnapshot() 36 | }) 37 | 38 | it('attachments download error', async () => { 39 | const mdData = '# test \n\n[error.pdf](https://www.yuque.com/attachments/error.pdf)\n' 40 | const params = { 41 | mdData, 42 | savePath: testTools.cwd, 43 | attachmentsDir: './attachments/123456789', 44 | articleTitle: 'test' 45 | } 46 | const requestPromise = downloadAttachments(params) 47 | await expect(requestPromise).rejects.toThrow(/Request failed with status code 404/g) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /test/download/list.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { readdirSync, readFileSync } from 'node:fs' 3 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' 4 | import { TestTools } from '../helpers/TestTools' 5 | import { server } from '../mocks/server' 6 | import { downloadArticleList } from '../../src/download/list' 7 | import { ProgressBar } from '../../src/utils' 8 | 9 | 10 | let testTools: TestTools 11 | 12 | describe('downloadArticle', () => { 13 | beforeAll(() => { 14 | server.listen({ onUnhandledRequest: 'error' }) 15 | }) 16 | afterAll(() => server.close()) 17 | 18 | beforeEach(() => { 19 | testTools = new TestTools() 20 | }) 21 | 22 | afterEach(() => { 23 | testTools.cleanup() 24 | server.resetHandlers() 25 | }) 26 | it('should work', async () => { 27 | const tocList = [ 28 | { 29 | type: 'TITLE', 30 | title: 'Title1', 31 | uuid: '001', 32 | child_uuid: '002', 33 | parent_uuid: '' 34 | }, 35 | { 36 | type: 'DOC', 37 | title: '文档1', 38 | uuid: '002', 39 | url: 'one', 40 | child_uuid: '', 41 | parent_uuid: '001' 42 | }, 43 | { 44 | type: 'TITLE', 45 | title: 'Title2', 46 | uuid: '003', 47 | child_uuid: '004', 48 | parent_uuid: '' 49 | }, 50 | { 51 | type: 'DOC', 52 | title: '文档2', 53 | uuid: '004', 54 | url: 'two', 55 | child_uuid: '', 56 | parent_uuid: '003' 57 | } 58 | ] 59 | const uuidMap = new Map() 60 | const pr = new ProgressBar(testTools.cwd, tocList.length) 61 | await pr.init() 62 | 63 | await downloadArticleList({ 64 | articleUrlPrefix: 'https://www.yuque.com/yuque/base1', 65 | total: tocList.length, 66 | uuidMap, 67 | tocList: tocList as any, 68 | bookPath: testTools.cwd, 69 | bookId: 1111, 70 | host: 'https://www.yuque.com', 71 | imageServiceDomains: ['gxr404.com'], 72 | progressBar: pr, 73 | options: { 74 | ignoreImg: false 75 | } as any, 76 | }) 77 | expect(pr.curr).toBe(tocList.length) 78 | let doc1Data = readFileSync(path.join(testTools.cwd, tocList[0].title, `${tocList[1].title}.md`)).toString() 79 | doc1Data = doc1Data.replace(/\.\/img.*?-(\d{6})\./g, (match, random) => { 80 | return match.replace(random, '123456') 81 | }) 82 | expect(doc1Data).toMatchSnapshot() 83 | const imgPath = path.join(testTools.cwd, tocList[0].title, 'img', tocList[1].uuid) 84 | const imgList = readdirSync(imgPath) 85 | expect(readFileSync(path.join(imgPath, imgList[0])).length).toBe(99892) 86 | expect(readFileSync(path.join(imgPath, imgList[1])).length).toBe(81011) 87 | 88 | const doc2Data = readFileSync(path.join(testTools.cwd, tocList[2].title, `${tocList[3].title}.md`)).toString() 89 | expect(doc2Data).toMatchSnapshot() 90 | }) 91 | it('the title is also a doc', async () => { 92 | const tocList = [ 93 | { 94 | type: 'DOC', 95 | title: 'Title1_文档', 96 | uuid: '001', 97 | url: 'one', 98 | child_uuid: '002', 99 | parent_uuid: '' 100 | }, 101 | { 102 | type: 'DOC', 103 | title: '文档1', 104 | uuid: '002', 105 | url: 'two', 106 | child_uuid: '', 107 | parent_uuid: '001' 108 | } 109 | ] 110 | const uuidMap = new Map() 111 | const pr = new ProgressBar(testTools.cwd, tocList.length) 112 | await pr.init() 113 | 114 | await downloadArticleList({ 115 | articleUrlPrefix: 'https://www.yuque.com/yuque/base1', 116 | total: tocList.length, 117 | uuidMap, 118 | tocList: tocList as any, 119 | bookPath: testTools.cwd, 120 | bookId: 1111, 121 | host: 'https://www.yuque.com', 122 | imageServiceDomains: ['gxr404.com'], 123 | progressBar: pr, 124 | options: { 125 | ignoreImg: false 126 | } as any, 127 | }) 128 | expect(pr.curr).toBe(tocList.length) 129 | 130 | let doc1Data = readFileSync(path.join(testTools.cwd, tocList[0].title, 'index.md')).toString() 131 | doc1Data = doc1Data.replace(/\.\/img.*?-(\d{6})\./g, (match, random) => { 132 | return match.replace(random, '123456') 133 | }) 134 | expect(doc1Data).toMatchSnapshot() 135 | const imgPath = path.join(testTools.cwd, tocList[0].title, 'img', tocList[0].uuid) 136 | const imgList = readdirSync(imgPath) 137 | expect(readFileSync(path.join(imgPath, imgList[0])).length).toBe(99892) 138 | expect(readFileSync(path.join(imgPath, imgList[1])).length).toBe(81011) 139 | 140 | const doc2Data = readFileSync(path.join(testTools.cwd, tocList[0].title, `${tocList[1].title}.md`)).toString() 141 | expect(doc2Data).toMatchSnapshot() 142 | }) 143 | 144 | }) 145 | -------------------------------------------------------------------------------- /test/helpers/TestTools.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'node:child_process' 2 | import { fileURLToPath } from 'node:url' 3 | import path from 'node:path' 4 | import fs from 'node:fs' 5 | import { randomUUID } from 'node:crypto' 6 | 7 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 8 | const tmpDir = path.join(__dirname, '../temp') 9 | 10 | export function createTmpDirectory(rootDir: string) { 11 | const id = randomUUID() 12 | const dirname = path.join(rootDir, id) 13 | fs.mkdirSync(dirname, { 14 | recursive: true 15 | }) 16 | 17 | return dirname 18 | } 19 | 20 | interface ITestToolsForkRes { 21 | stdout: string, 22 | stderr: string, 23 | exitCode: number | null 24 | } 25 | 26 | export class TestTools { 27 | cwd: string 28 | private readonly shouldCleanup: boolean 29 | 30 | constructor(cwd?: string) { 31 | if (cwd) { 32 | this.cwd = cwd 33 | this.shouldCleanup = false 34 | } else { 35 | this.cwd = createTmpDirectory(tmpDir) 36 | this.shouldCleanup = true 37 | } 38 | } 39 | 40 | cleanup() { 41 | if (!this.shouldCleanup) { 42 | return 43 | } 44 | 45 | try { 46 | this.rmSync(this.cwd, { 47 | recursive: true 48 | }) 49 | 50 | const otherDirs = fs.readdirSync(tmpDir) 51 | 52 | if (!otherDirs.length) { 53 | fs.rmdirSync(tmpDir) 54 | } 55 | } catch (err) { 56 | // ignore 57 | } 58 | } 59 | 60 | mkdirSync(dir: string, options?: Parameters[1]) { 61 | return fs.mkdirSync(path.resolve(this.cwd, dir), options) 62 | } 63 | 64 | // writeFileSync(file: string, content: string) { 65 | // return fs.writeFileSync(path.resolve(this.cwd, file), content) 66 | // } 67 | 68 | // readFileSync(file: string, options: Parameters[1]) { 69 | // return fs.readFileSync(path.resolve(this.cwd, file), options) 70 | // } 71 | 72 | rmSync(target: string, options?: Parameters[1]) { 73 | return fs.rmSync(path.resolve(this.cwd, target), options) 74 | } 75 | 76 | // exec(command: string) { 77 | // return execSync(command, { 78 | // cwd: this.cwd, 79 | // stdio: 'pipe', 80 | // encoding: 'utf-8' 81 | // }) 82 | // } 83 | 84 | fork(script: string, args: string[] = [], options: Parameters[2] = {}) { 85 | return new Promise((resolve, reject) => { 86 | const finalOptions = { 87 | cwd: this.cwd, 88 | stdio: [ 89 | null, 90 | null, 91 | null 92 | ], 93 | ...options 94 | } 95 | const nodeArgs = [ 96 | '--no-warnings', 97 | // '--loader', 98 | // pathToFileURL(path.resolve(__dirname, '..', 'node_modules', 'tsm', 'loader.mjs')).toString() 99 | ] 100 | const child = spawn(process.execPath, [ 101 | ...nodeArgs, 102 | script, 103 | ...args 104 | ], finalOptions) 105 | let stdout = '' 106 | let stderr = '' 107 | let exitCode 108 | 109 | child.stdout?.on('data', (data: Buffer) => { 110 | stdout += data.toString() 111 | }) 112 | child.stderr?.on('data', (data: Buffer) => { 113 | stderr += data.toString() 114 | }) 115 | child.on('close', (code) => { 116 | exitCode = code 117 | resolve({ 118 | stdout, 119 | stderr, 120 | exitCode 121 | }) 122 | }) 123 | child.on('error', reject) 124 | }) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' 3 | import { TestTools } from './helpers/TestTools' 4 | import { server } from './mocks/server' 5 | import { main } from '../src/index' 6 | import { readdirSync, readFileSync } from 'node:fs' 7 | 8 | 9 | let testTools: TestTools 10 | 11 | describe('main', () => { 12 | beforeAll(() => { 13 | server.listen({ onUnhandledRequest: 'error' }) 14 | }) 15 | afterAll(() => server.close()) 16 | 17 | beforeEach(() => { 18 | testTools = new TestTools() 19 | }) 20 | 21 | afterEach(() => { 22 | testTools.cleanup() 23 | server.resetHandlers() 24 | }) 25 | it('should work', async () => { 26 | await main('https://www.yuque.com/yuque/base1', { 27 | distDir: testTools.cwd, 28 | ignoreImg: false, 29 | toc: false 30 | }) 31 | const summaryPath = path.join(testTools.cwd, '知识库TEST1/index.md') 32 | expect(readFileSync(summaryPath).toString()).toMatchSnapshot() 33 | const doc2 = path.join(testTools.cwd, '知识库TEST1/Title2/文档2.md') 34 | expect(readFileSync(doc2).toString()).toMatchSnapshot() 35 | const progressJSON = path.join(testTools.cwd, '知识库TEST1/progress.json') 36 | expect(readFileSync(progressJSON).toString()).toMatchSnapshot() 37 | let doc1Data = readFileSync(path.join(testTools.cwd, '知识库TEST1/Title1/文档1.md')).toString() 38 | doc1Data = doc1Data.replace(/\.\/img.*?-(\d{6})\./g, (match, random) => { 39 | return match.replace(random, '123456') 40 | }) 41 | expect(doc1Data).toMatchSnapshot() 42 | const imgDir = path.join(testTools.cwd, '知识库TEST1/Title1/img/002') 43 | const imgList = readdirSync(imgDir) 44 | const img1 = path.join(testTools.cwd, `知识库TEST1/Title1/img/002/${imgList[0]}`) 45 | expect(readFileSync(img1).length).toBe(99892) 46 | const img2 = path.join(testTools.cwd, `知识库TEST1/Title1/img/002/${imgList[1]}`) 47 | expect(readFileSync(img2).length).toBe(81011) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /test/mocks/assets/1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gxr404/yuque-dl/91ab92ebb276e7520d83969157a7a5f7df3fc4d3/test/mocks/assets/1.jpeg -------------------------------------------------------------------------------- /test/mocks/assets/2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gxr404/yuque-dl/91ab92ebb276e7520d83969157a7a5f7df3fc4d3/test/mocks/assets/2.jpeg -------------------------------------------------------------------------------- /test/mocks/assets/test.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gxr404/yuque-dl/91ab92ebb276e7520d83969157a7a5f7df3fc4d3/test/mocks/assets/test.pdf -------------------------------------------------------------------------------- /test/mocks/data/appData.json: -------------------------------------------------------------------------------- 1 | { 2 | "space": { 3 | "host": "https://www.yuque.com" 4 | }, 5 | "book": { 6 | "id": 41966892, 7 | "slug": "welfare", 8 | "name": "知识库TEST1", 9 | "description": "知识库 test desc", 10 | "toc": [ 11 | { 12 | "type": "TITLE", 13 | "title": "Title1", 14 | "uuid": "001", 15 | "child_uuid": "002", 16 | "parent_uuid": "" 17 | }, 18 | { 19 | "type": "DOC", 20 | "title": "文档1", 21 | "uuid": "002", 22 | "url": "one", 23 | "child_uuid": "", 24 | "parent_uuid": "001" 25 | }, 26 | { 27 | "type": "TITLE", 28 | "title": "Title2", 29 | "uuid": "003", 30 | "child_uuid": "004", 31 | "parent_uuid": "" 32 | }, 33 | { 34 | "type": "DOC", 35 | "title": "文档2", 36 | "uuid": "004", 37 | "url": "two", 38 | "child_uuid": "", 39 | "parent_uuid": "003" 40 | } 41 | ] 42 | }, 43 | "imageServiceDomains": [ 44 | "gxr404.com" 45 | ] 46 | } -------------------------------------------------------------------------------- /test/mocks/data/attachments.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "type": "Doc", 4 | "sourcecode": "# DOC1\n![](https://gxr404.com/1.jpeg)\n## SubTitle\n[test.pdf](https://www.yuque.com/attachments/test.pdf)" 5 | } 6 | } -------------------------------------------------------------------------------- /test/mocks/data/boardData.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "type": "Board" 4 | } 5 | } -------------------------------------------------------------------------------- /test/mocks/data/docMd.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "type": "Doc", 4 | "sourcecode": "# DOC1\n![](https://gxr404.com/1.jpeg)\n## SubTitle\n![](https://gxr404.com/2.jpeg)", 5 | "created_at": "2025-03-12T12:59:22.000Z", 6 | "content_updated_at": "2025-03-12T12:59:27.000Z", 7 | "published_at": "2025-03-12T12:59:27.000Z", 8 | "first_published_at": "2025-03-12T12:59:26.630Z" 9 | } 10 | } -------------------------------------------------------------------------------- /test/mocks/data/docMd2.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "type": "Doc", 4 | "sourcecode": "# DOC2\n## Title\ntext...\n### Title2\ntext...\n" 5 | } 6 | } -------------------------------------------------------------------------------- /test/mocks/data/sheetData.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "type": "Sheet", 4 | "content": "{\"format\":\"lakesheet\",\"version\":\"3.5.5\",\"larkJson\":true,\"sheet\":\"xœÕ—]oâ8\\u0014@ÿ‹Ÿ¡øÚÎ\\u0017o£®´O+u\\u0006i´«j\\u001eÒඨ!aBh»ªøïë\\u0004(9i;ê<.\\u000fˆkû:Ç×É\\t¾~QU¾öj®\\u0016÷Þ·¢&ª©Ÿ.ë]Õª¹Ñz¢¶¾ôE»ª«­š¿tj\\u001eOTQ—j¥o<\\u0007yHzô—Çq‡è[Ÿ½ïÓºéö}În]\\u001dƒÛUÙúæð{U-ý³šëá´&\\\\yí›;éËò˜³Z\\u0006ø¿¿ý# /ó6ï(õë×cè\\u0014µ\\u000fã¤\\u000bí©íÏÅbñåêêËUß×7ËëxÓ7:$ˆ±‡iL×\\u0012Ÿšu‡æ~`zj¶ÆõãCÏ㢭\\u001bß±î',¶A±%qï\\u0016[ô±Úæ“Õ6¨¶èß*7æ5É\\u0007åþ+ߔ\\u0001~ÓÔmÀ]\\f ¯|´\\u000b[\\u0015²Ía’SU_TQæÛ¦Š{_<ÜÔÏaÖǼ܅\\u001aµÍοn\\u0003Æ\\u001eŠt\\u001ey\\u001d¶.\\\\¶Þœ\\u0018^N=ÝÖOΑA\\u0014ö'd­we»RóÛ¼Üú~íusŒöoîš3B¹ª\\u001e\\u0002@ëŸC™ÔM¾ZîB¸kBùÕ}Ûn¶óÙìééé¢ï¹(êµúôRlˆŒXóñš,\\u0017Տ\\u001d¶Èpa]\\u0019ßY—Á\\u001dßÝÄïíÊjßùÀ³mŠÁŠeuQ•yóÐ-löïîçÎÏôÌhãf›ênfÂ'³3Itjc›Ù8ü˜&7K\\u001fé<šJ¶Œ¦®pv𯑛Ú\\\"ÊoŒR/ñEÈ\\u000e\\u0017;>\\\"æyªµ¶É4,ϤÖJ\\u0014õ\\u0003öo\\u001fØÿ\\u0017þD%\\u0007\\u001bœ\\rÓ\\u0005Ñ0ˆ‡A2\\fÒa\\r\\u0003ш\\u0004‘Ad\\u0011\\u0001CÀ!\\u0000\\u0011\\bP\\u0004,\\u0006,\\u0006,\\u0006,\\u0006,\\u0006,\\u0006,\\u0006,\\u0006,\\u0006,\\u0006,\\u0016,\\u0016,\\u0016,\\u0016,\\u0016,\\u0016,\\u0016,\\u0016,\\u0016,\\u0016,\\u000e,\\u000e,\\u000e,\\u000e,\\u000e,\\u000e,\\u000e,\\u000e,\\u000e,\\u000e,\\u0011X\\\"°D`‰À\\u0012ñ¶\\u0005K\\u0004–\\b,\\u0011X\\\"°Ä`‰Á\\u0012ƒ%\\u0006K\\f–˜Ï\\u0010Xb°Ä`‰Á’€%\\u0001K\\u0002–\\u0004,\\tX\\u0012°$| Á’€%\\u0001K\\n–\\u0014,)XR°¤`IÁ’‚%¥]À’‚%\\u0003K\\u0006–\\f,\\u0019X2°d`ÉÀ’%£êF®£ì4m§©;Mßi\\nOÓxšÊÓtž¦ô4©Æ\\n&ÕHÂ#\\u000b4<òðHÄ#\\u0013TL\\u0017\\u000be,fôf \\u0015},\\u0014²ÐÈB%\\u000b,”²ÐÊB-\\u000b½,vôÂ\\\"\\u0015Õ,t³PÎB;\\u000bõ,ô³PÐBC\\u000b\\u0015-t´PÒBK\\u000b5-ô´PÔBS\\u000bU-tµPÖB[K4z½“ŠÂ\\u0016\\u001a[¨l¡³…Ò\\u0016Z[¨m¡·…â–xô¯ƒTt·PÞB{\\u000bõ-ô·Pà24øþWg5û©ƒ±~{D\\u001b`yTÓ8ªýÞIÍ|ê`\\\\­nÿ9\\u001fŒ¹Ä\\u001fÿ\\u0001?€sf\",\"calcChain\":[],\"vessels\":{},\"useUTC\":true,\"useIndex\":true,\"customColors\":[],\"meta\":{\"sort\":0,\"shareFilter\":0},\"formulaCalclated\":true,\"versionId\":\"IiuRGaFWFAvDDESW\"}" 5 | } 6 | } -------------------------------------------------------------------------------- /test/mocks/data/tableData.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "type": "Table" 4 | } 5 | } -------------------------------------------------------------------------------- /test/mocks/data/welfare/appData.json: -------------------------------------------------------------------------------- 1 | { 2 | "space": { 3 | "id": 0, 4 | "login": "", 5 | "name": "语雀", 6 | "short_name": null, 7 | "status": 0, 8 | "account_id": 0, 9 | "logo": null, 10 | "description": "", 11 | "created_at": null, 12 | "updated_at": null, 13 | "host": "https://www.yuque.com", 14 | "displayName": "语雀", 15 | "logo_url": "https://cdn.nlark.com/yuque/0/2022/png/303152/1665994257081-avatar/dcb74862-1409-4691-b9ce-8173b4f6e037.png", 16 | "enable_password": true, 17 | "enable_watermark": false, 18 | "_serializer": "web.space" 19 | }, 20 | "imageServiceDomains": [ 21 | "cdn.yuque.com", 22 | "cdn.nlark.com", 23 | "img.shields.io", 24 | "travis-ci.org", 25 | "api.travis-ci.org", 26 | "npm.packagequality.com", 27 | "snyk.io", 28 | "coveralls.io", 29 | "badgen.now.sh", 30 | "badgen.net", 31 | "packagephobia.now.sh", 32 | "duing.alibaba-inc.com", 33 | "npm.alibaba-inc.com", 34 | "web.npm.alibaba-inc.com", 35 | "npmjs.com", 36 | "npmjs.org", 37 | "npg.dockerlab.alipay.net", 38 | "private-alipayobjects.alipay.com", 39 | "googleusercontent.com", 40 | "blogspot.com", 41 | "cdn.hk01.com", 42 | "camo.githubusercontent.com", 43 | "gw.daily.taobaocdn.net", 44 | "cdn-images-1.medium.com", 45 | "medium.com", 46 | "i.stack.imgur.com", 47 | "imgur.com", 48 | "doc.ucweb.local", 49 | "lh6.googleusercontent.com", 50 | "4.bp.blogspot.com", 51 | "bp.blogspot.com", 52 | "blogspot.com", 53 | "1.bp.blogspot.com", 54 | "2.bp.blogspot.com", 55 | "3.bp.blogspot.com", 56 | "aliwork-files.oss-accelerate.aliyuncs.com", 57 | "oss-accelerate.aliyuncs.com", 58 | "work.alibaba.net", 59 | "work.alibaba-inc.com", 60 | "work-file.alibaba.net", 61 | "work-file.alibaba-inc.com", 62 | "pre-work-file.alibaba-inc.com", 63 | "yuque.antfin.com", 64 | "yuque.antfin-inc.com", 65 | "intranetproxy.alipay.com", 66 | "lark-assets-prod-aliyun.oss-accelerate.aliyuncs.com", 67 | "lh1.googleusercontent.com", 68 | "lh2.googleusercontent.com", 69 | "lh3.googleusercontent.com", 70 | "lh4.googleusercontent.com", 71 | "lh5.googleusercontent.com", 72 | "lh6.googleusercontent.com", 73 | "lh7.googleusercontent.com", 74 | "lh8.googleusercontent.com", 75 | "lh9.googleusercontent.com", 76 | "raw.githubusercontent.com", 77 | "github.com", 78 | "en.wikipedia.org", 79 | "rawcdn.githack.com", 80 | "pre-work-file.alibaba-inc.com", 81 | "alipay-rmsdeploy-image.cn-hangzhou.alipay.aliyun-inc.com", 82 | "antsys-align-files-management.cn-hangzhou.alipay.aliyun-inc.com", 83 | "baiyan-pre.antfin.com", 84 | "baiyan.antfin.com", 85 | "baiyan.dev.alipay.net", 86 | "marketing.aliyun-inc.com", 87 | "lark-temp.oss-cn-hangzhou.aliyuncs.com", 88 | "www.yuque.com", 89 | "yuque.com", 90 | "cdn.nlark.com" 91 | ], 92 | "book": { 93 | "id": 41966892, 94 | "type": "Book", 95 | "slug": "welfare", 96 | "name": "🤗 语雀公益计划", 97 | "toc": [ 98 | { 99 | "type": "DOC", 100 | "title": "🎁 语雀 · 大学生公益计划", 101 | "uuid": "0lIISK25SDu8ZWPh", 102 | "url": "edu", 103 | "prev_uuid": "", 104 | "sibling_uuid": "", 105 | "child_uuid": "K_sWbStNMXRXhnPT", 106 | "parent_uuid": "", 107 | "doc_id": 139665854, 108 | "level": 0, 109 | "id": 139665854, 110 | "open_window": 1, 111 | "visible": 1 112 | }, 113 | { 114 | "type": "DOC", 115 | "title": "教育邮箱认证常见问题解决", 116 | "uuid": "K_sWbStNMXRXhnPT", 117 | "url": "faq", 118 | "prev_uuid": "0lIISK25SDu8ZWPh", 119 | "sibling_uuid": "", 120 | "child_uuid": "", 121 | "parent_uuid": "0lIISK25SDu8ZWPh", 122 | "doc_id": 141554154, 123 | "level": 1, 124 | "id": 141554154, 125 | "open_window": 1, 126 | "visible": 1 127 | } 128 | ], 129 | "toc_updated_at": "2023-10-17T07:57:51.000Z", 130 | "description": "", 131 | "creator_id": 103125, 132 | "menu_type": 0, 133 | "items_count": 2, 134 | "likes_count": 0, 135 | "watches_count": 0, 136 | "user_id": 84140, 137 | "abilities": { 138 | "create_doc": false, 139 | "destroy": false, 140 | "export": false, 141 | "export_doc": false, 142 | "read": true, 143 | "read_private": false, 144 | "update": false, 145 | "create_collaborator": false, 146 | "manage": false, 147 | "share": false, 148 | "modify_setting": false 149 | }, 150 | "public": 1, 151 | "extend_private": 0, 152 | "scene": null, 153 | "source": null, 154 | "created_at": "2023-09-12T09:16:00.000Z", 155 | "updated_at": "2023-11-07T08:42:07.000Z", 156 | "pinned_at": null, 157 | "archived_at": null, 158 | "layout": "Book", 159 | "doc_typography": "classic_all", 160 | "doc_viewport": "fixed", 161 | "announcement": null, 162 | "should_manually_create_uid": false, 163 | "catalog_tail_type": "UPDATED_AT", 164 | "catalog_display_level": 1, 165 | "book_icon": { 166 | "type": "url", 167 | "symbol": "book-type-notes" 168 | }, 169 | "cover": "https://cdn.nlark.com/yuque/0/2023/png/95294/1694522544691-c36c9b44-83c0-4307-a1b3-e8a6931587f1.png", 170 | "cover_color": "purpleLight", 171 | "comment_count": null, 172 | "organization_id": 0, 173 | "status": 0, 174 | "indexed_level": 0, 175 | "privacy_migrated": true, 176 | "collaboration_count": 1, 177 | "content_updated_at": "2023-11-07T08:42:06.832Z", 178 | "content_updated_at_ms": 1699346526832, 179 | "copyright_watermark": "", 180 | "enable_announcement": true, 181 | "enable_auto_publish": false, 182 | "enable_automation": false, 183 | "enable_comment": true, 184 | "enable_document_copy": true, 185 | "enable_export": true, 186 | "enable_search_engine": true, 187 | "enable_toc": true, 188 | "enable_trash": true, 189 | "enable_visitor_watermark": false, 190 | "enable_webhook": true, 191 | "image_copyright_watermark": "", 192 | "original": 0, 193 | "resource_size": 0, 194 | "user": null, 195 | "contributors": null, 196 | "_serializer": "web.book_detail" 197 | } 198 | } -------------------------------------------------------------------------------- /test/mocks/data/welfare/docMd.json: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "abilities": { 4 | "create": false, 5 | "destroy": false, 6 | "update": false, 7 | "read": true, 8 | "export": false, 9 | "manage": false, 10 | "join": true, 11 | "share": false, 12 | "force_delete": false, 13 | "create_collaborator": false, 14 | "destroy_comment": false 15 | }, 16 | "latestReviewStatus": -1 17 | }, 18 | "data": { 19 | "id": 139665854, 20 | "space_id": 0, 21 | "type": "Doc", 22 | "sub_type": null, 23 | "format": "lake", 24 | "title": "🎁 语雀 · 大学生公益计划", 25 | "slug": "edu", 26 | "public": 1, 27 | "status": 1, 28 | "read_status": 1, 29 | "created_at": "2023-09-12T08:52:47.000Z", 30 | "content_updated_at": "2023-10-07T06:12:28.000Z", 31 | "published_at": "2023-10-07T06:12:28.000Z", 32 | "first_published_at": "2023-09-12T08:54:07.455Z", 33 | "sourcecode": "\n## 👉🏻 常见问题 👈🏻 \n[认证常见问题解决](https://www.yuque.com/yuque/welfare/faq?view=doc_embed)\n\n\n## 公益计划介绍\n助力梦想,相伴成长。
即日起,语雀面向**中国大陆各大学的大学生、教师**发布语雀公益计划-个人版。认证教育邮箱,即可免费享有语雀会员权益。\n\n[语雀写给大学生的一封信](https://www.yuque.com/yuque/blog/welfare-edu?view=doc_embed)\n\n### 规则\n认证学校提供的 **edu.cn 结尾的教育邮箱**,即可免费获得 **1 年语雀会员**。\n\n- 若你之前并非会员/已是专业会员,则获得 1 年专业会员;\n- 若你已是超级会员,则获得 1 年超级会员。\n- 1 年后,可在原入口进行**续期**,只要你还在校期间,就可以一直免费使用。\n\n\n### 操作指引\n\n#### 适合新用户:语雀官网公益计划页认证\n\n- 打开 [语雀官网公益计划页](https://www.yuque.com/about/welfare) \n\n![image.png](https://cdn.nlark.com/yuque/0/2023/png/103125/1694522534230-9d9d42f9-bbba-46d3-8e9a-773f2951089d.png#averageHue=%23f9f7f4&clientId=u4fcd7a23-d992-4&from=paste&height=181&id=uf4e32c00&originHeight=1352&originWidth=2304&originalType=binary&ratio=2&rotation=0&showTitle=false&size=1111025&status=done&style=stroke&taskId=u586690d0-4f43-44da-97d0-96d9f9c74ba&title=&width=309)\n\n- 在「个人版公益计划」下,点击【**立即认证**】\n\n![image.png](https://cdn.nlark.com/yuque/0/2023/png/103125/1694522561681-3a568a52-7ef3-43ae-9067-d852f5556cf7.png#averageHue=%23fbfbfa&clientId=u4fcd7a23-d992-4&from=paste&height=199&id=uc86615fc&originHeight=1002&originWidth=1542&originalType=binary&ratio=2&rotation=0&showTitle=false&size=280905&status=done&style=stroke&taskId=u382422b9-1d6c-4483-b415-fdca20000b4&title=&width=307)\n\n- 完成注册或登录后,来到「**账户设置-会员信息**」页面,输入教育邮箱,点击发送认证邮件\n\n![image.png](https://cdn.nlark.com/yuque/0/2023/png/103125/1694513696571-0458a2d9-9dd8-4eab-a04b-671e4fd66e09.png#averageHue=%23fafaf9&clientId=u332e1e90-72b8-4&from=paste&height=205&id=yNmiq&originHeight=510&originWidth=798&originalType=binary&ratio=2&rotation=0&showTitle=false&size=115244&status=done&style=stroke&taskId=uc713068b-6030-4d7a-afcd-320c5c41050&title=&width=320)\n\n- 在教育邮箱中查收该邮件,点击「**立即验证**」\n\n![image.png](https://cdn.nlark.com/yuque/0/2023/png/103125/1694513756376-8dc9a3bd-ec24-43b6-9a2f-5f7760fc421b.png#averageHue=%23eff1ec&clientId=u332e1e90-72b8-4&from=paste&height=395&id=KCBN7&originHeight=1318&originWidth=800&originalType=binary&ratio=2&rotation=0&showTitle=false&size=333950&status=done&style=stroke&taskId=u7cdd9fb7-3d23-4f44-8367-fe4adbfec15&title=&width=240)\n\n- 完成认证,直接获得会员 ✅\n\n#### 适合老用户:会员信息页直接认证\n\n- 来到「账户设置-会员信息」页面,点击【**立即认证**】\n\n![image.png](https://cdn.nlark.com/yuque/0/2023/png/103125/1694511270833-fc586c50-cc11-45ef-8fae-96961ec92150.png#averageHue=%23f2f2ea&clientId=u07c6fafd-7c7b-4&from=paste&height=229&id=dgJI8&originHeight=710&originWidth=1028&originalType=binary&ratio=2&rotation=0&showTitle=false&size=255499&status=done&style=stroke&taskId=ucca249a2-c403-4d7b-a3e6-549bae9d135&title=&width=331.5)
![image.png](https://cdn.nlark.com/yuque/0/2023/png/103125/1694513621870-1e0659bf-0bd5-4f55-9519-a9dc663f5111.png#averageHue=%23f6f5f1&clientId=u332e1e90-72b8-4&from=paste&height=218&id=uRJCj&originHeight=1376&originWidth=1954&originalType=binary&ratio=2&rotation=0&showTitle=false&size=641657&status=done&style=stroke&taskId=u6ff96fc8-7863-432f-b0f4-d983259e98c&title=&width=310)\n\n- 输入教育邮箱,点击发送认证邮件\n\n![image.png](https://cdn.nlark.com/yuque/0/2023/png/103125/1694513696571-0458a2d9-9dd8-4eab-a04b-671e4fd66e09.png#averageHue=%23fafaf9&clientId=u332e1e90-72b8-4&from=paste&height=205&id=u9301cf88&originHeight=510&originWidth=798&originalType=binary&ratio=2&rotation=0&showTitle=false&size=115244&status=done&style=stroke&taskId=uc713068b-6030-4d7a-afcd-320c5c41050&title=&width=320)\n\n- 在教育邮箱中查收该邮件,点击「立即验证」\n\n![image.png](https://cdn.nlark.com/yuque/0/2023/png/103125/1694513756376-8dc9a3bd-ec24-43b6-9a2f-5f7760fc421b.png#averageHue=%23eff1ec&clientId=u332e1e90-72b8-4&from=paste&height=395&id=u79d7417f&originHeight=1318&originWidth=800&originalType=binary&ratio=2&rotation=0&showTitle=false&size=333950&status=done&style=stroke&taskId=u7cdd9fb7-3d23-4f44-8367-fe4adbfec15&title=&width=240)\n\n- 完成认证,直接获得会员 ✅\n\n\n", 34 | "last_editor": null, 35 | "_serializer": "web.doc_code_mode" 36 | } 37 | } -------------------------------------------------------------------------------- /test/mocks/data/welfare/index.md: -------------------------------------------------------------------------------- 1 | 语雀公益计划完整数据: https://www.yuque.com/yuque/welfare -------------------------------------------------------------------------------- /test/mocks/handlers.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { fileURLToPath } from 'node:url' 3 | import { readFileSync } from 'node:fs' 4 | import { http, HttpResponse } from 'msw' 5 | import { toArrayBuffer } from './utils' 6 | 7 | // import data 8 | import appData from './data/appData.json' assert { type: 'json' } 9 | import docMdData from './data/docMd.json' assert { type: 'json' } 10 | import docMdData2 from './data/docMd2.json' assert { type: 'json' } 11 | import boardData from './data/boardData.json' assert { type: 'json' } 12 | import tableData from './data/tableData.json' assert { type: 'json' } 13 | import sheetData from './data/sheetData.json' assert { type: 'json' } 14 | import attachmentsDocMdData from './data/attachments.json' assert { type: 'json' } 15 | import yuqueWelfareAppData from './data/welfare/appData.json' assert { type: 'json' } 16 | import yuqueWelfareDocMdData from './data/welfare/docMd.json' assert { type: 'json' } 17 | 18 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 19 | const img1buffer = readFileSync(path.join(__dirname, './assets/1.jpeg')) 20 | const img2buffer = readFileSync(path.join(__dirname, './assets/2.jpeg')) 21 | const pdfBuffer = readFileSync(path.join(__dirname, './assets/test.pdf')) 22 | 23 | const header = { 24 | img: { 25 | 'Content-Type': 'image/jpeg', 26 | }, 27 | pdf: { 28 | 'Content-Type': 'application/pdf', 29 | }, 30 | plain: { 31 | 'Content-Type': 'text/plain', 32 | }, 33 | text: { 34 | 'Content-Type': 'text/html' 35 | } 36 | } 37 | 38 | const NotFoundRes = { 39 | status: 404, 40 | headers: header.plain 41 | } 42 | 43 | export const handlers = [ 44 | http.get('http://localhost/404', () => new HttpResponse('Not found', NotFoundRes)), 45 | http.get('https://www.yuque.com/attachments/test.pdf', () => { 46 | return HttpResponse.arrayBuffer(toArrayBuffer(pdfBuffer), { headers: header.pdf }) 47 | }), 48 | http.get('https://www.yuque.com/attachments/error.pdf', () => new HttpResponse('Not found', NotFoundRes)), 49 | http.get('https://gxr404.com/1.jpeg', () => HttpResponse.arrayBuffer(toArrayBuffer(img1buffer), { headers: header.img })), 50 | http.get('https://gxr404.com/2.jpeg', () => HttpResponse.arrayBuffer(toArrayBuffer(img2buffer), { headers: header.img })), 51 | http.get('https://www.yuque.com/yuque/base1', () => { 52 | const jsonData = appData 53 | const resData = encodeURIComponent(JSON.stringify(jsonData)) 54 | return new HttpResponse(`decodeURIComponent("${resData}"));`, { 55 | status: 200, 56 | headers: header.text 57 | }) 58 | }), 59 | http.get('https://www.yuque.com/api/docs/one', ({ request }) => { 60 | const url = new URL(request.url) 61 | const mode = url.searchParams.get('mode') 62 | const res = mode ? docMdData : {} 63 | return HttpResponse.json(res) 64 | }), 65 | http.get('https://www.yuque.com/api/docs/two', ({ request }) => { 66 | const url = new URL(request.url) 67 | const mode = url.searchParams.get('mode') 68 | // https://www.yuque.com/api/docs/edu?book_id=41966892&mode=markdown&merge_dynamic_data=false 69 | const res = mode ? docMdData2 : {} 70 | return HttpResponse.json(res) 71 | }), 72 | http.get('https://www.yuque.com/api/docs/board', () => HttpResponse.json(boardData)), 73 | http.get('https://www.yuque.com/api/docs/table', () => HttpResponse.json(tableData)), 74 | http.get('https://www.yuque.com/api/docs/sheet', () => HttpResponse.json(sheetData)), 75 | http.get('https://www.yuque.com/api/docs/sheetError', () => { 76 | const temp = structuredClone(sheetData) 77 | temp.data.content = 'error' 78 | return HttpResponse.json(temp) 79 | }), 80 | http.get('https://www.yuque.com/api/filetransfer/images', () => { 81 | return HttpResponse.arrayBuffer(toArrayBuffer(img1buffer), { headers: header.img }) 82 | }), 83 | http.get('https://www.yuque.com/api/docs/tokenAndKey', ({request}) => { 84 | return HttpResponse.json({ 85 | 'data': { 86 | 'type': 'Doc', 87 | 'sourcecode': request.headers.get('cookie') 88 | } 89 | }) 90 | }), 91 | http.get('https://www.yuque.com/api/docs/sourcecodeNull', () => { 92 | return HttpResponse.json({ 93 | 'data': { 94 | 'type': 'Doc', 95 | } 96 | }) 97 | }), 98 | http.get('https://www.yuque.com/api/docs/attachments', () => HttpResponse.json(attachmentsDocMdData)), 99 | 100 | // ====================== welfareHandlers ====================== 101 | http.get('https://www.yuque.com/yuque/welfare', () => { 102 | const jsonData = yuqueWelfareAppData 103 | const resData = encodeURIComponent(JSON.stringify(jsonData)) 104 | return new HttpResponse(`decodeURIComponent("${resData}"));`, { 105 | status: 200, 106 | headers: header.text 107 | }) 108 | }), 109 | http.get('https://www.yuque.com/api/docs/edu', ({ request }) => { 110 | const url = new URL(request.url) 111 | const mode = url.searchParams.get('mode') 112 | const jsonData = { 113 | md: yuqueWelfareDocMdData 114 | } 115 | // https://www.yuque.com/api/docs/edu?book_id=41966892&mode=markdown&merge_dynamic_data=false 116 | const res = mode ? jsonData.md : {} 117 | return HttpResponse.json(res) 118 | }), 119 | ] 120 | -------------------------------------------------------------------------------- /test/mocks/server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from 'msw/node' 2 | import { handlers } from './handlers' 3 | 4 | export const server = setupServer(...handlers) 5 | -------------------------------------------------------------------------------- /test/mocks/utils.ts: -------------------------------------------------------------------------------- 1 | export function toArrayBuffer(buffer) { 2 | const arrayBuffer = new ArrayBuffer(buffer.length) 3 | const view = new Uint8Array(arrayBuffer) 4 | for (let i = 0; i < buffer.length; ++i) { 5 | view[i] = buffer[i] 6 | } 7 | return arrayBuffer 8 | } 9 | -------------------------------------------------------------------------------- /test/parse/__snapshots__/fix.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`fixMarkdownImage > should work 1`] = ` 4 | " 5 | # Test 6 | ![](http://www.abc.com/3.jpg?a=3&b=c#33) 7 | ![](./test.jpg) 8 | ![](http://www.abc.com/1.jpg?a=1&b=c#11) 9 | ![](http://www.abc.com/2.jpg) 10 | ![](http://www.abc.com/1.jpg?a=2&b=c#22) 11 | ![](http://www.abc.com/1.jpg) 12 | ![](http://www.abc.com/3.jpg) 13 | " 14 | `; 15 | -------------------------------------------------------------------------------- /test/parse/__snapshots__/sheet.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`genMarkdownTable > should work 1`] = ` 4 | "| |A | B | C | D | E| 5 | |--- |--- |--- |--- |--- |---| 6 | | 1 | 单元格(0,0) | | | | | 7 | | 2 | | | | | | 8 | | 3 | | | 单元格(2,2) | | | 9 | | 4 | | | | | | 10 | | 5 | | | | | 单元格(4,4)| 11 | " 12 | `; 13 | 14 | exports[`parseSheet > should work 1`] = ` 15 | " 16 | ## Sheet1 17 | 18 | | |A | B | C | D | E | F | G | H | I| 19 | |--- |--- |--- |--- |--- |--- |--- |--- |--- |---| 20 | | 1 | 1 | | | | | | | | | 21 | | 2 | | | | GSSSAPPAP | | | | | | 22 | | 3 | | | | | | | | | | 23 | | 4 | | 12 | | | | | | | | 24 | | 5 | | | | 123 | | | | | | 25 | | 6 | | | | | | | | | | 26 | | 7 | | | | | | | | | | 27 | | 8 | | | | | | | | | | 28 | | 9 | | | | | | | | | | 29 | | 10 | | | | | | | | | | 30 | | 11 | | | | | | | | | | 31 | | 12 | | | | | | | | | | 32 | | 13 | | | | | | | 234 | | | 33 | | 14 | | | | | | | | | 32423| 34 | 35 | ## Sheet2 36 | 37 | | |A | B | C | D | E | F | G | H| 38 | |--- |--- |--- |--- |--- |--- |--- |--- |---| 39 | | 1 | s | | | [x] | 1 | | | | 40 | | 2 | | | | [baidu](https://www.baidu.com) | 13,21321 | | | | 41 | | 3 | | 34 | | ![2x-00037-3212833155.png'](https://cdn.nlark.com/yuque/0/2024/png/222293/1708363936708-7bde50a5-19d5-4c43-8654-3c5ab2e58e16.png) | | | | | 42 | | 4 | | 34 | | ![2x-00037-3212833155.png'](https://cdn.nlark.com/yuque/0/2024/png/222293/1708363936708-7bde50a5-19d5-4c43-8654-3c5ab2e58e16.png) | | | | | 43 | 44 | ## Sheet3 45 | 46 | " 47 | `; 48 | -------------------------------------------------------------------------------- /test/parse/__snapshots__/summary.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`summary > should work 1`] = ` 4 | "# Test Book 5 | 6 | > This is a test book 7 | 8 | 9 | ## Title1 10 | 11 | - [DOC1](002/doc.md) 12 | 13 | ## Title2 14 | 15 | - [DOC2](004/doc.md) 16 | 17 | ### Title2-1 18 | 19 | 20 | ## [DOC3](006/doc.md) 21 | " 22 | `; 23 | 24 | exports[`summary > the title is also a doc 1`] = ` 25 | "# Test Book 26 | 27 | > This is a test book 28 | 29 | 30 | ## [Title1](001/doc.md) 31 | 32 | - [DOC1](002/doc.md) 33 | " 34 | `; 35 | -------------------------------------------------------------------------------- /test/parse/fix.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { fixLatex, fixMarkdownImage, fixPath } from '../../src/parse/fix' 3 | import { getMarkdownImageList } from '../../src/utils' 4 | 5 | 6 | describe('fixLatex', () => { 7 | it('should work', () => { 8 | const searchStr = 'options[\'where\'] 是否是数组,' 9 | const hashStr = 'card=math&code=options[\'where\'] 是否是数组,' 10 | const latexMd = `![](https://g.yuque.com/gr/latex?${encodeURIComponent(searchStr)}#${encodeURIComponent(hashStr)})` 11 | expect(fixLatex(latexMd)).toBe(searchStr) 12 | }) 13 | it('svg suffix does not require fix', () => { 14 | const latexMd = '![](https://cdn.nlark.com/yuque/__latex/a6cc75c5bd5731c6e361bbcaf18766e7.svg#card=math&code=999&id=JGAwA)' 15 | expect(fixLatex(latexMd)).toBe(latexMd) 16 | }) 17 | }) 18 | 19 | describe('fixMarkdownImage', () => { 20 | it('should work', () => { 21 | const mdData = ` 22 | # Test 23 | ![](http://www.abc.com/3.jpg#123) 24 | ![](./test.jpg) 25 | ![](http://www.abc.com/1.jpg#123) 26 | ![](http://www.abc.com/2.jpg) 27 | ![](http://www.abc.com/1.jpg#456) 28 | ![](http://www.abc.com/1.jpg) 29 | ![](http://www.abc.com/3.jpg) 30 | ` 31 | const imgInfo = { 32 | 'src': 'http://www.abc.com/1.jpg?a=1&b=c#11', 33 | 'alt':'image-20210325085833220' 34 | } 35 | const imgInfo2 = { 36 | 'src': 'http://www.abc.com/1.jpg?a=2&b=c#22', 37 | 'alt':'image-20210325085833220' 38 | } 39 | const imgInfo3 = { 40 | 'src': 'http://www.abc.com/3.jpg?a=3&b=c#33', 41 | 'alt':'image-20210325085833220' 42 | } 43 | const imgInfoStr = encodeURIComponent(JSON.stringify(imgInfo)) 44 | const imgInfoStr2 = encodeURIComponent(JSON.stringify(imgInfo2)) 45 | const imgInfoStr3 = encodeURIComponent(JSON.stringify(imgInfo3)) 46 | const htmlData = ` 47 |

Test

48 | 49 | 50 | 51 | ` 52 | const imgList = getMarkdownImageList(mdData) 53 | const data = fixMarkdownImage(imgList, mdData, htmlData) 54 | // console.log(data) 55 | expect(data).toMatchSnapshot() 56 | 57 | }) 58 | }) 59 | 60 | describe('fixPath', () => { 61 | it('should work', () => { 62 | expect(fixPath('/xxa.12~*#)$$M/13')).toBe('_xxa.12~_#)$$M_13') 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /test/parse/sheet.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { parseSheet, genMarkdownTable } from '../../src/parse/sheet' 3 | 4 | const sheetData = 'x\x9CÕ\x97]oâ8\x14@ÿ\x8B\x9F¡øÚÎ\x17o£®´O+u\x06i´«j\x1EÒඨ!aBh»ªøïë\x04(9i;ê<.\x0F\x88kû:Ç×É\t¾~QU¾öj®\x16÷Þ·¢&ª©\x9F.ë]Õª¹Ñz¢¶¾ôE»ª«­\x9A¿t\x9Dj\x1EOTQ\x97jî\x86\x83¥o<\x07yHzô\x97Çq\x87è[\x9F½ïÓºéö}În]\x1D\x83ÛUÙúæð{U-ý³\x9Aëá´&\\yí\x9B;\x7FéËò\x98³Z\x06ø¿¿\x7Fý# /ó6ï(õë×cè\x14µ\x0Fã¤\ví©íÏÅbñåêêËUß×7ËëxÓ7:$\x88±\x87iL×\x12\x9F\x9A\x8Du\x87æ~`zj¶ÆõãCÏ㢭\x1Bß±î\',¶A±%qï\x16[ô±Úæ\x93Õ6¨¶èß*7æ5É\x07åþ+ß\x94\x01~ÓÔmÀ]\f ¯\x7F|´\v[\x15²Ía\x92SU_TQæÛ\x90¦\x8A{_<ÜÔÏaÖǼÜ\x85\x1AµÍοn\x03Æ\x1E\x8At\x1Ey\x1D¶.\\¶Þ\x9C\x18^N=ÝÖOÎ\x91A\x14ö\'d­we»RóÛ¼Üú~íus\x8Cöoî\x9A3B¹ª\x1E\x02@ë\x9FC\x99ÔM¾ZîB¸kBùÕ}Ûn¶óÙìééé¢ï¹(êµúôRl\x88\x8CXóñ\x9A,\x17Õ\x8F\x1D¶Èpa]\x19ßY\x97Á\x1DßÝÄïíÊj\x9DßùÀ³m\x8AÁÂ\x8AeuQ\x95yóÐ-löïîçÎÏôÌhãf\x9BênfÂ\'³3Itjc\x9BÙ8ü\x98&7K\x1Fé<\x9AJ¶\x8C¦®pv\x9AÆ\x91\x9BÚ"Êo\x8C\x8FR/ñEÈ\x0E\x17;>"æyªµ¶É4,ϤÖJ\x14õ\x03öo\x1FØÿ\x17þD%\x07\x1B\x9C\rÓ\x05Ñ0\x88\x87A2\fÒa\x90\r\x03Ñ\x88\x04\x91Ad\x11\x01CÀ!\x00\x11\x90\bP\x04,\x06,\x06,\x06,\x06,\x06,\x06,\x06,\x06,\x06,\x06,\x16,\x16,\x16,\x16,\x16,\x16,\x16,\x16,\x16,\x16,\x0E,\x0E,\x0E,\x0E,\x0E,\x0E,\x0E,\x0E,\x0E,\x0E,\x11X"°D`\x89À\x12ñ¶\x05K\x04\x96\b,\x11X"°Ä`\x89Á\x12\x83%\x06K\f\x96\x98Ï\x10Xb°Ä`\x89Á\x92\x80%\x01K\x02\x96\x04,\tX\x12°$| Á\x92\x80%\x01K\n\x96\x14,)XR°¤`IÁ\x92\x82%¥]À\x92\x82%\x03K\x06\x96\f,\x19X2°d`ÉÀ\x92\x81%£êF®£ì4m§©;Mßi\nOÓx\x9AÊÓt\x9E¦ô4©Æ\n&ÕHÂ#\v\x8F4<òðHÄ#\x13\x8FTL\x17\ve,fôf \x15},\x14²ÐÈB%\v\x9D,\x94²ÐÊB-\v½,vôÂ"\x15Õ,t³PÎB;\võ,ô³PÐBC\v\x15-t´PÒBK\v5-ô´PÔBS\vU-tµPÖB[K4z½\x93\x8AÂ\x16\x1A[¨l¡³\x85Ò\x16Z[¨m¡·\x85â\x96xô¯\x83Tt·PÞB{\võ-ô·Pà24øþWg5û©\x83±~{D\x1B\x9D`yTÓ8ªýÞIÍ|ê`\\­nÿ9\x1F\x8C¹Ä\x1Fÿ\x01?\x80sf' 5 | 6 | describe('parseSheet', () => { 7 | it('should work', () => { 8 | const data = parseSheet(sheetData) 9 | expect(data).toMatchSnapshot() 10 | }) 11 | }) 12 | 13 | describe('genMarkdownTable', () => { 14 | it('should work', () => { 15 | const data = genMarkdownTable({ 16 | 0: { 0: {v: '单元格(0,0)'}}, 17 | 2: { 2: {v: '单元格(2,2)'}}, 18 | 4: { 4: {v: '单元格(4,4)'}} 19 | }) 20 | expect(data).toMatchSnapshot() 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /test/parse/summary.test.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'node:fs' 2 | import path from 'node:path' 3 | import { afterEach, beforeEach, describe, expect, it } from 'vitest' 4 | import { TestTools } from '../helpers/TestTools' 5 | import Summary from '../../src/parse/Summary' 6 | 7 | let testTools: TestTools 8 | 9 | describe('summary', () => { 10 | beforeEach(() => { 11 | testTools = new TestTools() 12 | }) 13 | 14 | afterEach(() => { 15 | testTools.cleanup() 16 | }) 17 | it('should work', async () => { 18 | const summary = new Summary({ 19 | bookPath: testTools.cwd, 20 | bookName: 'Test Book', 21 | bookDesc: 'This is a test book', 22 | uuidMap: new Map([ 23 | ['001', { 24 | toc: { 25 | title: 'Title1', 26 | type: 'TITLE', 27 | uuid: '001', 28 | parent_uuid: '000', 29 | child_uuid: '' 30 | } 31 | } as any], 32 | ['002', { 33 | path: '002/doc.md', 34 | toc: { 35 | title: 'DOC1', 36 | type: 'DOC', 37 | uuid: '002', 38 | parent_uuid: '001', 39 | child_uuid: '' 40 | } 41 | } as any], 42 | ['003', { 43 | toc: { 44 | title: 'Title2', 45 | type: 'TITLE', 46 | uuid: '003', 47 | parent_uuid: '000', 48 | child_uuid: '' 49 | } 50 | } as any], 51 | ['004', { 52 | path: '004/doc.md', 53 | toc: { 54 | title: 'DOC2', 55 | type: 'DOC', 56 | uuid: '004', 57 | parent_uuid: '003', 58 | child_uuid: '' 59 | } 60 | } as any], 61 | ['006', { 62 | path: '006/doc.md', 63 | toc: { 64 | title: 'DOC3', 65 | type: 'DOC', 66 | uuid: '006', 67 | parent_uuid: '005', 68 | child_uuid: '' 69 | } 70 | } as any], 71 | ['005', { 72 | toc: { 73 | title: 'Title2-1', 74 | type: 'TITLE', 75 | uuid: '005', 76 | parent_uuid: '003', 77 | child_uuid: '' 78 | } 79 | } as any], 80 | ]) 81 | }) 82 | await summary.genFile() 83 | const data = readFileSync(path.join(testTools.cwd, 'index.md')) 84 | expect(data.toString()).toMatchSnapshot() 85 | }) 86 | 87 | // 标题也是文档 88 | it('the title is also a doc', async () => { 89 | const summary = new Summary({ 90 | bookPath: testTools.cwd, 91 | bookName: 'Test Book', 92 | bookDesc: 'This is a test book', 93 | uuidMap: new Map([ 94 | ['001', { 95 | path: '001/doc.md', 96 | toc: { 97 | title: 'Title1', 98 | type: 'DOC', 99 | uuid: '001', 100 | parent_uuid: '000', 101 | child_uuid: '002' 102 | } 103 | } as any], 104 | ['002', { 105 | path: '002/doc.md', 106 | toc: { 107 | title: 'DOC1', 108 | type: 'DOC', 109 | uuid: '002', 110 | parent_uuid: '001', 111 | child_uuid: '' 112 | } 113 | } as any], 114 | ]) 115 | }) 116 | await summary.genFile() 117 | const data = readFileSync(path.join(testTools.cwd, 'index.md')) 118 | expect(data.toString()).toMatchSnapshot() 119 | }) 120 | }) -------------------------------------------------------------------------------- /test/realRequest.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { fileURLToPath } from 'node:url' 3 | import { afterEach, beforeEach, describe, expect, it } from 'vitest' 4 | import { TestTools } from './helpers/TestTools' 5 | 6 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 7 | const cliPath = path.join(__dirname, '../bin/index.js') 8 | 9 | let testTools: TestTools 10 | 11 | // const mdImgReg = /!\[.*?\]\(https*.*?\)/g 12 | // const problematicList = [] 13 | 14 | describe.skip('real request', () => { 15 | 16 | beforeEach(() => { 17 | testTools = new TestTools() 18 | }) 19 | 20 | afterEach(() => { 21 | // testTools.cleanup() 22 | }) 23 | 24 | it('should work', async () => { 25 | const url = '' 26 | const { stdout, exitCode, stderr } = await testTools.fork(cliPath, [ 27 | url, 28 | '-d', '.' 29 | ]) 30 | expect(exitCode).toBe(0) 31 | expect(stdout).toContain('√ 已完成') 32 | console.log(stderr) 33 | // const imgDir = path.join(testTools.cwd, '语雀公益计划/语雀·大学生公益计划/img') 34 | // const indexMdPath = path.join(testTools.cwd, '语雀公益计划/语雀·大学生公益计划/index.md') 35 | // expect(fs.existsSync(imgDir)).toBeTruthy() 36 | // const data = fs.readFileSync(indexMdPath).toString() 37 | // expect(data.match(mdImgReg)).toBeFalsy() 38 | // expect(stderr).toBeFalsy() 39 | }) 40 | 41 | }) 42 | -------------------------------------------------------------------------------- /test/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it } from 'vitest' 2 | import { getMarkdownImageList, removeEmojis, ProgressBar, formateDate, isValidUrl, randUserAgent } from '../src/utils' 3 | import { TestTools } from './helpers/TestTools' 4 | 5 | let testTools: TestTools 6 | 7 | describe('utils', () => { 8 | it('getMarkdownImageList', () => { 9 | const data = getMarkdownImageList('# test\n![](http://x.jpg)\n![](./x2.jpg)\n![](http://x3.jpg)') 10 | expect(data).toMatchObject(['http://x.jpg', 'http://x3.jpg']) 11 | }) 12 | it('getMarkdownImageList param null', () => { 13 | const data = getMarkdownImageList(null as any) 14 | expect(data).toMatchObject([]) 15 | }) 16 | it('removeEmojis', () => { 17 | const data = removeEmojis('🤣t😅e😁s😂t😅') 18 | expect(data).toBe('test') 19 | }) 20 | 21 | it('randUserAgent', () => { 22 | expect(randUserAgent({})).toBeTruthy() 23 | expect(randUserAgent({browser: 'safari'}).includes('Applebot')).toBeFalsy() 24 | }) 25 | 26 | describe('isValidUrl',() => { 27 | it('should work', () => { 28 | expect(isValidUrl('http://localhost:51204/')).toBe(true) 29 | expect(isValidUrl('asdfsadf')).toBe(false) 30 | }) 31 | it('set URL.canParse null',() => { 32 | const rawFn = URL.canParse 33 | ;(URL.canParse as any) = null 34 | expect(isValidUrl('http://localhost:51204/')).toBe(true) 35 | expect(isValidUrl('asdfsadf')).toBe(false) 36 | URL.canParse = rawFn 37 | }) 38 | }) 39 | 40 | describe('formateDate', () => { 41 | it('should work', () => { 42 | const date = formateDate('2023-10-07T06:12:28.000Z') 43 | expect(date).toBe('2023-10-07 14:12:28') 44 | }) 45 | it('empty string', () => { 46 | const date = formateDate('') 47 | expect(date).toBe('') 48 | }) 49 | it('Invalid Date', () => { 50 | const date = formateDate('abcde') 51 | expect(date).toBe('') 52 | }) 53 | it('only Date', () => { 54 | const date = formateDate('2023-1-2') 55 | expect(date).toBe('2023-01-02 00:00:00') 56 | }) 57 | it('only Time', () => { 58 | const date = formateDate('09:0:10') 59 | expect(date).toBe('') 60 | }) 61 | }) 62 | }) 63 | 64 | describe('ProgressBar', () => { 65 | beforeEach(() => { 66 | testTools = new TestTools() 67 | }) 68 | 69 | afterEach(() => { 70 | testTools.cleanup() 71 | }) 72 | const updateItem = { 73 | path: '语雀知识库1', 74 | toc: { 75 | 'type': 'TITLE', 76 | 'title': '语雀知识库1', 77 | 'uuid': '6e3OzZQk2SHqApWA', 78 | 'url': '', 79 | 'prev_uuid': '', 80 | 'sibling_uuid': 'LhaQ85mI3D03Y4Zy', 81 | 'child_uuid': 'XZBg8vA4yir0loRp', 82 | 'parent_uuid': '', 83 | 'doc_id': 0, 84 | 'level': 0, 85 | 'id': 0, 86 | 'open_window': 1, 87 | 'visible': 1 88 | }, 89 | pathIdList: ['6e3OzZQk2SHqApWA'], 90 | pathTitleList: ['语雀知识库1'] 91 | } 92 | const updateItem2 = { 93 | path: '语雀知识库2', 94 | toc: { 95 | 'type': 'TITLE', 96 | 'title': '语雀知识库2', 97 | 'uuid': '6e3OzZQk2SHqApWA-2', 98 | 'url': '', 99 | 'prev_uuid': '', 100 | 'sibling_uuid': 'LhaQ85mI3D03Y4Zy', 101 | 'child_uuid': 'XZBg8vA4yir0loRp', 102 | 'parent_uuid': '', 103 | 'doc_id': 0, 104 | 'level': 0, 105 | 'id': 0, 106 | 'open_window': 1, 107 | 'visible': 1 108 | }, 109 | pathIdList: ['6e3OzZQk2SHqApWA'], 110 | pathTitleList: ['语雀知识库2'] 111 | } 112 | const updateItem3 = { 113 | path: '语雀知识库3', 114 | toc: { 115 | 'type': 'TITLE', 116 | 'title': '语雀知识库3', 117 | 'uuid': '6e3OzZQk2SHqApWA-3', 118 | 'url': '', 119 | 'prev_uuid': '', 120 | 'sibling_uuid': 'LhaQ85mI3D03Y4Zy', 121 | 'child_uuid': 'XZBg8vA4yir0loRp', 122 | 'parent_uuid': '', 123 | 'doc_id': 0, 124 | 'level': 0, 125 | 'id': 0, 126 | 'open_window': 1, 127 | 'visible': 1 128 | }, 129 | pathIdList: ['6e3OzZQk2SHqApWA'], 130 | pathTitleList: ['语雀知识库3'] 131 | } 132 | it('should work', async () => { 133 | let pr = new ProgressBar(testTools.cwd, 3) 134 | await pr.init() 135 | 136 | await pr.updateProgress(updateItem, true) 137 | let prInfo = await pr.getProgress() 138 | // pr.updateProgress() 139 | expect(prInfo).toMatchObject([updateItem]) 140 | expect(pr.curr).toBe(1) 141 | 142 | // 下载中断 143 | pr = new ProgressBar(testTools.cwd, 3) 144 | await pr.init() 145 | expect(pr.isDownloadInterrupted).toBe(true) 146 | expect(pr.curr).toBe(1) 147 | await pr.updateProgress(updateItem2, true) 148 | prInfo = await pr.getProgress() 149 | expect(prInfo).toMatchObject([updateItem, updateItem2]) 150 | expect(pr.curr).toBe(2) 151 | 152 | await pr.updateProgress(updateItem3, true) 153 | prInfo = await pr.getProgress() 154 | expect(prInfo).toMatchObject([updateItem, updateItem2, updateItem3]) 155 | expect(pr.curr).toBe(3) 156 | 157 | // 进度完成 再更新进度无效也不属于中断下载了 158 | pr = new ProgressBar(testTools.cwd, 3) 159 | await pr.init() 160 | expect(pr.isDownloadInterrupted).toBe(false) 161 | await pr.updateProgress(updateItem3, true) 162 | prInfo = await pr.getProgress() 163 | expect(prInfo).toMatchObject([updateItem, updateItem2, updateItem3]) 164 | expect(pr.curr).toBe(3) 165 | }) 166 | 167 | it('incremental progress item', async() => { 168 | let pr = new ProgressBar(testTools.cwd, 3) 169 | await pr.init() 170 | 171 | await pr.updateProgress(updateItem, true) 172 | let prInfo = await pr.getProgress() 173 | // pr.updateProgress() 174 | expect(prInfo).toMatchObject([updateItem]) 175 | expect(pr.curr).toBe(1) 176 | const newItem = { 177 | ...updateItem, 178 | createAt: 'xxxx' 179 | } 180 | pr = new ProgressBar(testTools.cwd, 3, true) 181 | await pr.init() 182 | await pr.updateProgress(newItem, true) 183 | prInfo = await pr.getProgress() 184 | expect(prInfo).toMatchObject([newItem]) 185 | // toc.uuid一致 则是 更新 而非 push 186 | expect(pr.curr).toBe(1) 187 | }) 188 | }) -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "strict": true, 7 | "declaration": true, 8 | "noImplicitOverride": true, 9 | "noUnusedLocals": true, 10 | "esModuleInterop": true, 11 | "useUnknownInCatchVariables": false, 12 | "resolveJsonModule": true, 13 | "paths": { 14 | "@/*": ["./*"], 15 | }, 16 | "types": [ 17 | "web-bluetooth" 18 | ] 19 | }, 20 | "include": [ 21 | "./src/**/*" 22 | ], 23 | 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "declaration": false 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | 5 | test: { 6 | testTimeout: 60 * 1000, 7 | onConsoleLog (log) { 8 | if (log.includes('yuque-dl [INFO]')) return false 9 | // if (/下载 "(.*?)" 的图片中/gm.test(log)) return false 10 | if (/^\s+$/gm.test(log)) return false 11 | }, 12 | coverage: { 13 | include: ['src/**/*'], 14 | exclude: ['src/types/**/*'], 15 | } 16 | }, 17 | }) 18 | --------------------------------------------------------------------------------