├── .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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | // 
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(``)
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 | 
43 |
44 | + 在「个人版公益计划」下,点击【**立即认证**】
45 |
46 | 
47 |
48 | + 完成注册或登录后,来到「**账户设置-会员信息**」页面,输入教育邮箱,点击发送认证邮件
49 |
50 | 
51 |
52 | + 在教育邮箱中查收该邮件,点击「**立即验证**」
53 |
54 | 
55 |
56 | + 完成认证,直接获得会员 ✅
57 |
58 | #### 适合老用户:会员信息页直接认证
59 | + 来到「账户设置-会员信息」页面,点击【**立即认证**】
60 |
61 | 
62 |
63 | 
64 |
65 |
66 |
67 | + 输入教育邮箱,点击发送认证邮件
68 |
69 | 
70 |
71 | + 在教育邮箱中查收该邮件,点击「立即验证」
72 |
73 | 
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 | 
118 |
119 | + 在「个人版公益计划」下,点击【**立即认证**】
120 |
121 | 
122 |
123 | + 完成注册或登录后,来到「**账户设置-会员信息**」页面,输入教育邮箱,点击发送认证邮件
124 |
125 | 
126 |
127 | + 在教育邮箱中查收该邮件,点击「**立即验证**」
128 |
129 | 
130 |
131 | + 完成认证,直接获得会员 ✅
132 |
133 | #### 适合老用户:会员信息页直接认证
134 | + 来到「账户设置-会员信息」页面,点击【**立即认证**】
135 |
136 | 
137 |
138 | 
139 |
140 |
141 |
142 | + 输入教育邮箱,点击发送认证邮件
143 |
144 | 
145 |
146 | + 在教育邮箱中查收该邮件,点击「立即验证」
147 |
148 | 
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 | 
39 | ## SubTitle
40 | 
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 | |  | | | | |
41 | | 4 | | 34 | |  | | | | |
42 |
43 | ## Sheet3
44 |
45 |
46 |
47 | > 原文: "
48 | `;
49 |
50 | exports[`downloadArticle > should work 1`] = `
51 | "# downloadArticle Title
52 |
53 | # DOC1
54 | 
55 | ## SubTitle
56 | 
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 | 
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 | 
8 | ## SubTitle
9 | 
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 | 
33 | ## SubTitle
34 | 
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\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\n## SubTitle\n",
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;ê<.\\u000fkû:Ç×É\\t¾~QU¾öj®\\u0016÷Þ·¢&ª©.ë]Õª¹Ñz¢¶¾ôE»ª«¿tj\\u001eOTQjî¥o<\\u0007yHzôÇqè[½ïÓºéö}În]\\u001dÛUÙúæð{U-ý³ëá´&\\\\yí;éËò³Z\\u0006ø¿¿ý# /ó6ï(õë×cè\\u0014µ\\u000fã¤\\u000bí©íÏÅbñåêêËUß×7ËëxÓ7:$±iL×\\u0012uæ~`zj¶ÆõãCÏã¢\\u001bß±î',¶A±%qï\\u0016[ô±ÚæÕ6¨¶èß*7æ5É\\u0007åþ+ß\\u0001~ÓÔmÀ]\\f ¯|´\\u000b[\\u0015²ÍaSU_TQæÛ¦{_<ÜÔÏaÖǼÜ
\\u001aµÍοn\\u0003Æ\\u001et\\u001ey\\u001d¶.\\\\¶Þ\\u0018^N=ÝÖOÎA\\u0014ö'dwe»RóÛ¼Üú~íusöoî3B¹ª\\u001e\\u0002@ëCÔM¾ZîB¸kBùÕ}Ûn¶óÙìééé¢ï¹(êµúôRlXóñ,\\u0017Õ\\u001d¶Èpa]\\u0019ßYÁ\\u001dßÝÄïíÊjßùÀ³mÁÂeuQyóÐ-löïîçÎÏôÌhãfênfÂ'³3ItjcÙ8ü&7K\\u001fé<J¶¦®pvÆÚ\\\"ÊoR/ñEÈ\\u000e\\u0017;>\\\"æyªµ¶É4,ϤÖJ\\u0014õ\\u0003öo\\u001fØÿ\\u0017þD%\\u0007\\u001b\\rÓ\\u0005Ñ0A2\\fÒa\\r\\u0003Ñ\\u0004Ad\\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Â#\\u000b4<òðHÄ#\\u0013TL\\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\n\n- 在「个人版公益计划」下,点击【**立即认证**】\n\n\n\n- 完成注册或登录后,来到「**账户设置-会员信息**」页面,输入教育邮箱,点击发送认证邮件\n\n\n\n- 在教育邮箱中查收该邮件,点击「**立即验证**」\n\n\n\n- 完成认证,直接获得会员 ✅\n\n#### 适合老用户:会员信息页直接认证\n\n- 来到「账户设置-会员信息」页面,点击【**立即认证**】\n\n
\n\n- 输入教育邮箱,点击发送认证邮件\n\n\n\n- 在教育邮箱中查收该邮件,点击「立即验证」\n\n\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 | 
7 | 
8 | 
9 | 
10 | 
11 | 
12 | 
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 | |  | | | | |
42 | | 4 | | 34 | |  | | | | |
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 = `}#${encodeURIComponent(hashStr)})`
11 | expect(fixLatex(latexMd)).toBe(searchStr)
12 | })
13 | it('svg suffix does not require fix', () => {
14 | const latexMd = ''
15 | expect(fixLatex(latexMd)).toBe(latexMd)
16 | })
17 | })
18 |
19 | describe('fixMarkdownImage', () => {
20 | it('should work', () => {
21 | const mdData = `
22 | # Test
23 | 
24 | 
25 | 
26 | 
27 | 
28 | 
29 | 
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ö\'dwe»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\n\n')
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 |
--------------------------------------------------------------------------------