├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── nodejs.yml ├── .gitignore ├── .prettierrc.yaml ├── .travis.yml ├── LICENSE ├── README.md ├── adapter ├── hexo.js └── markdown.js ├── appveyor.yml ├── bin └── yuque-hexo.js ├── command ├── clean.js └── sync.js ├── config.js ├── index.js ├── lib ├── Downloader.js ├── cleaner.js ├── out.js ├── qetag.js └── yuque.js ├── package.json ├── test ├── custom-adapter-project │ ├── custom.adapter.js │ ├── package.json │ └── yuque-hexo-last-generate-timestamp.txt ├── hexo-project │ └── package.json ├── markdown-project │ ├── package.json │ └── yuque-hexo-last-generate-timestamp.txt └── yuque.test.js └── util ├── imageBeds ├── cos.js ├── github.js ├── index.js ├── oss.js ├── qiniu.js └── upyun.js ├── img2cdn.js └── index.js /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-egg", 3 | "parserOptions": { 4 | "ecmaVersion": 2018 5 | }, 6 | "rules": { 7 | "linebreak-style": 0 8 | } 9 | } -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | schedule: 12 | - cron: '0 2 * * *' 13 | 14 | jobs: 15 | build: 16 | runs-on: ${{ matrix.os }} 17 | 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | node-version: [12] 22 | os: [windows-latest, macos-latest] 23 | 24 | steps: 25 | - name: Checkout Git Source 26 | uses: actions/checkout@v2 27 | 28 | - name: Use Node.js ${{ matrix.node-version }} 29 | uses: actions/setup-node@v1 30 | with: 31 | node-version: ${{ matrix.node-version }} 32 | 33 | - name: Install Dependencies 34 | run: npm i 35 | 36 | - name: Continuous Integration 37 | run: npm run ci 38 | env: 39 | YUQUE_TOKEN: ${{secrets.YUQUE_TOKEN}} 40 | 41 | - name: Code Coverage 42 | uses: codecov/codecov-action@v1 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | package-lock.json 64 | 65 | yarn.lock 66 | .idea 67 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | tabWidth: 2 2 | semi: true 3 | singleQuote: true 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | 2 | language: node_js 3 | node_js: 4 | - '8' 5 | - '10' 6 | - '12' 7 | before_install: 8 | - npm i npminstall -g 9 | install: 10 | - npminstall 11 | script: 12 | - npm run ci 13 | after_script: 14 | - npminstall codecov && codecov 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 xcold 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # yuque-hexo 2 | 3 | [![NPM version][npm-image]][npm-url] 4 | [![build status][gitflow-image]][gitflow-url] 5 | [![Test coverage][codecov-image]][codecov-url] 6 | [![David deps][david-image]][david-url] 7 | [![npm download][download-image]][download-url] 8 | 9 | [npm-image]: https://img.shields.io/npm/v/yuque-hexo.svg?style=flat-square 10 | [npm-url]: https://npmjs.org/package/yuque-hexo 11 | [gitflow-image]: https://github.com/x-cold/yuque-hexo/actions/workflows/nodejs.yml/badge.svg?branch=master 12 | [gitflow-url]: https://github.com/x-cold/yuque-hexo/actions/workflows/nodejs.yml 13 | [codecov-image]: https://codecov.io/gh/x-cold/yuque-hexo/branch/master/graph/badge.svg 14 | [codecov-url]: https://codecov.io/gh/x-cold/yuque-hexo 15 | [david-image]: https://img.shields.io/david/x-cold/yuque-hexo.svg?style=flat-square 16 | [david-url]: https://david-dm.org/x-cold/yuque-hexo 17 | [download-image]: https://badgen.net/npm/dt/yuque-hexo 18 | [download-url]: https://npmjs.org/package/yuque-hexo 19 | 20 | A downloader for articles from yuque(语雀知识库同步工具) 21 | 22 | # Usage 23 | 24 | ## Premise 25 | 26 | > 建议使用 Node.js >= 12 27 | 28 | 事先拥有一个 [hexo](https://github.com/hexojs/hexo) 项目,并在 `package.json` 中配置相关信息,可参考 [例子](#Example)。 29 | 30 | ## Config 31 | 32 | ### 配置 YUQUE_TOKEN 33 | 34 | 出于对知识库安全性的调整,使用第三方 API 访问知识库,需要传入环境变量 YUQUE_TOKEN,在语雀上点击 个人头像 -> 设置 -> Token 即可获取。传入 YUQUE_TOKEN 到 yuque-hexo 的进程有两种方式: 35 | 36 | - 设置全局的环境变量 YUQUE_TOKEN 37 | - 命令执行时传入环境变量 38 | - mac / linux: `YUQUE_TOKEN=xxx yuque-hexo sync` 39 | - windows: `set YUQUE_TOKEN=xxx && yuque-hexo sync` 40 | 41 | ### 配置 图床TOKEN(可选) 42 | 语雀的url存在防盗链的问题,直接部署可能导致图片无法加载。 43 | 如果需要语雀URL上传到图床中并替换原链接,就需要配置上传密钥。 44 | 45 | 访问图床的密钥管理获取密钥,然后传入密钥到yuque-hexo 46 | - 腾讯云[API密钥管理](https://console.cloud.tencent.com/cam/capi) 47 | - 阿里云[API密钥管理](https://ram.console.aliyun.com/manage/ak) 48 | - 七牛云[API密钥管理](https://portal.qiniu.com/user/key) 49 | - 又拍云[操作员管理](https://console.upyun.com/account/operators/) 50 | - GitHub图床[生成Github Token](https://github.com/settings/tokens) 51 | > 又拍云的SECRET_ID=操作员账号,SECRET_KEY=操作员密码 52 | > 53 | > Github图床的`SECRET_ID=用户名`,`SECRET_KEY=Github Token`。 54 | > 注意在生成`token`时,记得勾选上读写权限,即 `write:packages`和`read:packages` 55 | 56 | - 在设置YUQUE_TOKEN的基础上配置SECRET_ID和SECRET_KEY 57 | - 命令执行时传入环境变量 58 | - mac / linux: `YUQUE_TOKEN=xxx SECRET_ID=xxx SECRET_KEY=xxx yuque-hexo sync` 59 | - windows: `set YUQUE_TOKEN=xxx SECRET_ID=xxx SECRET_KEY=xxx && yuque-hexo sync` 60 | 61 | 62 | ### 配置知识库 63 | 64 | > package.json 65 | 66 | ```json 67 | { 68 | "name": "your hexo project", 69 | "yuqueConfig": { 70 | "postPath": "source/_posts/yuque", 71 | "cachePath": "yuque.json", 72 | "mdNameFormat": "title", 73 | "adapter": "hexo", 74 | "concurrency": 5, 75 | "baseUrl": "https://www.yuque.com/api/v2", 76 | "login": "yinzhi", 77 | "repo": "blog", 78 | "onlyPublished": false, 79 | "onlyPublic": false, 80 | "lastGeneratePath": "lastGeneratePath.log", 81 | "imgCdn": { 82 | "enabled": false, 83 | "concurrency": 0, 84 | "imageBed": "qiniu", 85 | "host": "", 86 | "bucket": "", 87 | "region": "", 88 | "prefixKey": "" 89 | } 90 | } 91 | } 92 | ``` 93 | 94 | | 参数名 | 含义 | 默认值 | 95 | | ------------- | ------------------------------------ | -------------------- | 96 | | postPath | 文档同步后生成的路径 | source/\_posts/yuque | 97 | | cachePath | 文档下载缓存文件 | yuque.json | 98 | | lastGeneratePath | 上一次同步结束的时间戳的文件 | | 99 | | mdNameFormat | 文件名命名方式 (title / slug) | title | 100 | | adapter | 文档生成格式 (hexo/markdown) | hexo | 101 | | concurrency | 下载文章并发数 | 5 | 102 | | baseUrl | 语雀 API 地址 | - | 103 | | login | 语雀 login (group), 也称为个人路径 | - | 104 | | repo | 语雀仓库短名称,也称为语雀知识库路径 | - | 105 | | onlyPublished | 只展示已经发布的文章 | false | 106 | | onlyPublic | 只展示公开文章 | false | 107 | | imgCdn | 语雀图片转CDN配置 | | 108 | > slug 是语雀的永久链接名,一般是几个随机字母。 109 | 110 | imgCdn 语雀图片转图床配置说明 111 | 112 | 注意:开启后会将匹配到的所有的图片都上传到图床 113 | 114 | | 参数名 | 含义 | 默认值 | 115 | |-----------|-----------------------------------------------------------------------------------------|---------| 116 | | enabled | 是否开启 | false | 117 | | concurrency | 上传图片并发数, 0代表无限制,使用github图床时,并发问题严重,建议设置为1 | 0 | 118 | | imageBed | 选择将图片上传的图床
目前支持腾讯云(cos)、阿里云(oss)和七牛云(qiniu),又拍云(upyun),Github图床(github)
默认使用七牛云 | 'qiniu' | 119 | | host | 使用七牛云/又拍云图床时,需要指定CDN域名前缀 | | 120 | | bucket | 图床的bucket名称 | - | 121 | | region | 图床的的region | - | 122 | | prefixKey | 文件前缀 | - | 123 | 124 | > host 说明 125 | > 126 | > 由于七牛云默认使用CND进行图片外链访问(默认提供30天的临时域名或者添加自定义CDN域名),所以需要指定访问的域名前缀 127 | > 例如:'host': `http://image.1874.cool`,域名后面不需要加斜杠 128 | 129 | > 又拍云和七牛云有点类似,默认使用临时域名访问,但不同的是又拍云的临时域名暂时是由`服务名.test.upcdn.net`组成,默认为http访问 130 | > 131 | > 如果使用临时域名,host可不填。使用自定义域名,默认为http访问,如果需要指定协议为https,则需要填写完整的域名 132 | > 133 | > 例如 'host': `upyun.1874.cool`或 `https://upyun.1874.cool ` 134 | 135 | > Github图床的默认域名是`raw.githubusercontent.com`,如果要使用jsdelivr进行加速,可以配置`host`为`cdn.jsdelivr.net`, 136 | > 137 | > 例如 'host': `cdn.jsdelivr.net` 138 | 139 | > bucket和region说明 140 | > 141 | > [获取腾讯云的bucket和region](https://console.cloud.tencent.com/cos/bucket),示例:{ bucket: "blog", region: "ap-guangzhou" } 142 | > 143 | > [获取阿里云的bucket和region](https://oss.console.aliyun.com/bucket),示例:{ bucket: "blog", region: "oss-cn-shenzhen" } 144 | > 145 | > [获取七牛云的bucket(空间)和region(机房)](https://portal.qiniu.com/kodo/overview),示例:{ bucket: "blog", region: "Zone_z2" } 146 | > 147 | > 七牛云机房取值: 华东(Zone_z0)华北(Zone_z1)华南(Zone_z2) 148 | > 149 | > 又拍云没有bucket和region的概念,只有服务名。所以这里的bucket=服务名,region暂时保留不需要填写 150 | > 151 | > Github图床也没有bucket和region的概念。所以bucket=仓库名,region暂时保留不需要填写 152 | 153 | > prefixKey 说明 154 | > 155 | > 如果需要将图片上传到bucket的根目录,那么prefixKey不用配置。 156 | > 157 | > 如果想上传到指定目录blog/image下,则需要配置prefixKey为"prefixKey": "blog/image"。 158 | > 159 | > 目录名前后都不需要加斜杠 160 | 161 | > 配置示例: [yuque-hexo配置示例](https://github.com/LetTTGACO/yuque-hexo-example) 162 | 163 | 164 | ## Install 165 | 166 | ```bash 167 | npm i -g yuque-hexo 168 | # or 169 | npm i --save-dev yuque-hexo 170 | ``` 171 | 172 | ## Sync 173 | 174 | ``` 175 | yuque-hexo sync 176 | ``` 177 | 178 | ## Clean 179 | 180 | ``` 181 | yuque-hexo clean 182 | ``` 183 | 184 | ## Npm Scripts 185 | 186 | ```json 187 | { 188 | "sync": "yuque-hexo sync", 189 | "clean:yuque": "yuque-hexo clean" 190 | } 191 | ``` 192 | 193 | ## Debug 194 | 195 | ``` 196 | DEBUG=yuque-hexo.* yuque-hexo sync 197 | ``` 198 | 199 | ## Best practice 200 | - [语雀云端写作Hexo+Github Actions+COS持续集成](https://www.yuque.com/1874w/1874.cool/roeayv) 201 | - [Hexo 博客终极玩法:云端写作,自动部署](https://www.yuque.com/u46795/blog/dlloc7) 202 | - [Hexo:语雀云端写作 Github Actions 持续集成](https://www.zhwei.cn/hexo-github-actions-yuque/) 203 | 204 | > 另外 x-cold 本人提供了一个触发 Travis CI 构建的 HTTP API 接口,详情请查看[文档](https://github.com/x-cold/aliyun-function/tree/master/travis_ci) (请勿恶意使用) 205 | 206 | # Notice 207 | 208 | - 语雀同步过来的文章会生成两部分文件; 209 | 210 | - yuque.json: 从语雀 API 拉取的数据 211 | - source/\_posts/yuque/\*.md: 生成的 md 文件 212 | 213 | - 支持配置 front-matter, 语雀编辑器编写示例如下: 214 | 215 | - 语雀编辑器示例,可参考[原文](https://www.yuque.com/u46795/blog/dlloc7) 216 | 217 | ```markdown 218 | tags: [hexo, node] 219 | categories: [fe] 220 | cover: https://cdn.nlark.com/yuque/0/2019/jpeg/155457/1546857679810-d82e3d46-e960-419c-a715-0a82c48a2fd6.jpeg#align=left&display=inline&height=225&name=image.jpeg&originHeight=225&originWidth=225&size=6267&width=225 221 | 222 | --- 223 | 224 | some description 225 | 226 | 227 | 228 | more detail 229 | ``` 230 | 231 | - - 如果遇到上传到语雀的图片无法加载的问题, 可以考虑开启imgCdn配置或者参考这个处理方式[yuque-hexo插件语雀图片防盗链限制的解决方案](https://1874.cool/osar7h/)或者参考这个处理方式 [#41](https://github.com/x-cold/yuque-hexo/issues/41) 232 | 233 | # Example 234 | 235 | - yuque to hexo: [x-cold/blog](https://github.com/x-cold/blog/blob/master/package.json) 236 | - yuque to github repo: [txd-team/monthly](https://github.com/txd-team/monthly/blob/master/package.json) 237 | - [yuque-hexo配置示例](https://github.com/LetTTGACO/yuque-hexo-example) 238 | 239 | # Changelog 240 | 241 | ### v1.9.5 242 | - 修复腾讯云图床/Github图床上传问题 243 | - 图片上传失败时,取消停止进程 244 | - 新增图片上传时的并发数concurrency,使用Github图床时,并发问题严重,建议设置为1 245 | 246 | ### v1.9.4 247 | - 🔥 新增GitHub图床和又拍云图床 248 | 249 | ### v1.9.2 250 | - 修复上传图片到图床时可能由于用户代理缺失导致403问题 251 | 252 | ### v1.9.1 253 | - 修复不使用图床配置时报错的问题 254 | 255 | ### v1.9.0 256 | - 🔥 支持腾讯云/阿里云/七牛云图床链接替换语雀链接 257 | 258 | ### v1.8.0 259 | - 🔥 支持自定义的适配器 adapter,具体查看 [配置示例](https://github.com/x-cold/yuque-hexo/tree/master/test/custom-adapter-project) 260 | 261 | ### v1.7.0 262 | 263 | - 🔥 支持配置 lastGeneratePath,同步文章后会记录一个时间戳,下一次同步文档时不再清空全部文档,只同步修改时间大于这个时间戳的文档 264 | - 🔥 支持语雀提示区块语法 265 | - 🐸 修复 front-matter 中 “:” 等特殊字符会导致文章无法正常生成 266 | - 🐸 由于 [prettier 不再支持 Node 8](https://github.com/prettier/eslint-config-prettier/issues/140),markdown 格式化仅在 node 版本 >= 10 生效 267 | - 🐸 现在必须配置 YUQUE_TOKEN 工具才能正常工作 268 | 269 | ### v1.6.5 270 | 271 | - 🔥 支持过滤 public 文章 272 | - 🔥 生成的 markdown 自动格式化 273 | - 🔥 移除去除语雀的锚点 274 | 275 | ### v1.6.4 276 | 277 | - 🐸 修复多行
的[问题](https://github.com/x-cold/yuque-hexo/pull/59) 278 | 279 | ### v1.6.3 280 | 281 | - 🔥 支持嵌套的 categories 解析 #56 282 | - 🐸 使用 [filenamify](https://github.com/sindresorhus/filenamify) 修复因为特殊字符的标题,生成非法的文件名导致的程序错误 283 | 284 | ### v1.6.2 285 | 286 | - 🔥 使用 slug 自定义 [urlname](https://github.com/x-cold/yuque-hexo/pull/37) 287 | 288 | ### v1.6.1 289 | 290 | - 🐸 修复 tags 格式化[问题](https://github.com/x-cold/yuque-hexo/issues/31) 291 | 292 | ### v1.6.0 293 | 294 | - 🐸 修复 descrption 导致的 front-matter 解析错误[问题](https://github.com/x-cold/yuque-hexo/issues/27#issuecomment-490138318) 295 | - 🔥 支持私有仓库同步 296 | - 🔥 使用语雀官方的 SDK,支持 YUQUE_TOKEN,可以解除 API 调用次数限制 297 | 298 | ### v1.5.0 299 | 300 | - 支持自定义 front-matter 301 | 302 | ### v1.4.3 303 | 304 | - 支持过滤未发布文章 `onlyPublished` 305 | 306 | ### v1.4.2 307 | 308 | - 支持纯 markdown 导出 309 | - 支持请求并发数量参数 `concurrency` 310 | 311 | ### v1.4.0 312 | 313 | - 升级项目架构,增强扩展性,支持自定义 adpter 314 | 315 | ### v1.3.1 316 | 317 | - 修复 front-matter 处理格式问题 318 | 319 | ### v1.2.1 320 | 321 | - 修复 windows 环境下命令行报错的问题 322 | - 支持自定义文件夹和博客文件命名 323 | 324 | ### v1.1.1 325 | 326 | - 支持 hexo-front-matter,可以在文章中编辑 tags / date 等属性 327 | -------------------------------------------------------------------------------- /adapter/hexo.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ejs = require('ejs'); 4 | const Entities = require('html-entities').AllHtmlEntities; 5 | const FrontMatter = require('hexo-front-matter'); 6 | const { formatDate, formatRaw } = require('../util'); 7 | const img2Cdn = require('../util/img2cdn'); 8 | const config = require('../config'); 9 | 10 | 11 | const entities = new Entities(); 12 | // 背景色区块支持 13 | const colorBlocks = { 14 | ':::tips\n': '
', 15 | ':::danger\n': '
', 16 | ':::info\n': '
', 17 | '\\s+:::': '
', 18 | }; 19 | 20 | // 文章模板 21 | const template = `--- 22 | <%- matter -%> 23 | 24 | <%- raw -%>`; 25 | 26 | /** 27 | * front matter 反序列化 28 | * @description 29 | * docs: https://www.npmjs.com/package/hexo-front-matter 30 | * 31 | * @param {String} body md 文档 32 | * @return {String} result 33 | */ 34 | function parseMatter(body) { 35 | body = entities.decode(body); 36 | try { 37 | // front matter信息的
换成 \n 38 | const regex = /(title:|layout:|tags:|date:|categories:){1}(\S|\s)+?---/gi; 39 | body = body.replace(regex, a => 40 | a.replace(/(
|
|)/gi, '\n') 41 | ); 42 | // 支持提示区块语法 43 | for (const key in colorBlocks) { 44 | body = body.replace(new RegExp(key, 'igm'), colorBlocks[key]); 45 | } 46 | const result = FrontMatter.parse(body); 47 | result.body = result._content; 48 | if (result.date) { 49 | result.date = formatDate(result.date); 50 | } 51 | delete result._content; 52 | return result; 53 | } catch (error) { 54 | return { 55 | body, 56 | }; 57 | } 58 | } 59 | 60 | /** 61 | * hexo 文章生产适配器 62 | * 63 | * @param {Object} post 文章 64 | * @return {String} text 65 | */ 66 | module.exports = async function(post) { 67 | // 语雀img转成自己的cdn图片 68 | if (config.imgCdn.enabled) { 69 | post = await img2Cdn(post); 70 | } 71 | // matter 解析 72 | const parseRet = parseMatter(post.body); 73 | const { body, ...data } = parseRet; 74 | const { title, slug: urlname, created_at } = post; 75 | const raw = formatRaw(body); 76 | const date = data.date || formatDate(created_at); 77 | const tags = data.tags || []; 78 | const categories = data.categories || []; 79 | const props = { 80 | title: title.replace(/"/g, ''), // 临时去掉标题中的引号,至少保证文章页面是正常可访问的 81 | urlname, 82 | date, 83 | ...data, 84 | tags, 85 | categories, 86 | }; 87 | const text = ejs.render(template, { 88 | raw, 89 | matter: FrontMatter.stringify(props), 90 | }); 91 | return text; 92 | }; 93 | -------------------------------------------------------------------------------- /adapter/markdown.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { formatRaw } = require('../util'); 4 | const img2Cdn = require('../util/img2cdn'); 5 | const config = require('../config'); 6 | 7 | /** 8 | * markdown 文章生产适配器 9 | * 10 | * @param {Object} post 文章 11 | * @return {String} text 12 | */ 13 | module.exports = async function(post) { 14 | // 语雀img转成自己的cdn图片 15 | if (config.imgCdn.enabled) { 16 | post = await img2Cdn(post); 17 | } 18 | const { body } = post; 19 | const raw = formatRaw(body); 20 | return raw; 21 | }; 22 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | matrix: 3 | - nodejs_version: '8, 10, 12' 4 | 5 | install: 6 | - ps: Install-Product node $env:nodejs_version 7 | - npm i npminstall && node_modules\.bin\npminstall 8 | 9 | test_script: 10 | - node --version 11 | - npm --version 12 | - npm run test 13 | 14 | build: off 15 | -------------------------------------------------------------------------------- /bin/yuque-hexo.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | const checkForUpdate = require('update-check'); 6 | const chalk = require('chalk'); 7 | const pkg = require('../package'); 8 | const out = require('../lib/out'); 9 | const Command = require('..'); 10 | 11 | (async function() { 12 | let update = null; 13 | 14 | try { 15 | update = await checkForUpdate(pkg, { 16 | interval: 3600000, // For how long to cache latest version (default: 1 day) 17 | }); 18 | } catch (err) { 19 | out.warn(`Failed to check for updates: ${err}`); 20 | } 21 | 22 | if (update) { 23 | out.info(`Current yuque-hexo version is ${chalk.yellow(pkg.version)}, and the latest version is ${chalk.green(update.latest)}. Please update!`); 24 | out.info('View more detail: https://github.com/x-cold/yuque-hexo#changelog'); 25 | } 26 | 27 | new Command().start(); 28 | })(); 29 | -------------------------------------------------------------------------------- /command/clean.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Command = require('common-bin'); 4 | const initConfig = require('../config'); // 初始化 config 5 | const cleaner = require('../lib/cleaner'); 6 | const out = require('../lib/out'); 7 | 8 | class CleanCommand extends Command { 9 | constructor(rawArgv) { 10 | super(rawArgv); 11 | this.usage = 'Usage: yuque-hexo clean'; 12 | } 13 | 14 | async run() { 15 | if (!initConfig) { 16 | process.exit(0); 17 | } 18 | cleaner.cleanPosts(); 19 | cleaner.clearCache(); 20 | cleaner.clearLastGenerate(); 21 | out.info('yuque-hexo clean done!'); 22 | } 23 | } 24 | 25 | module.exports = CleanCommand; 26 | -------------------------------------------------------------------------------- /command/sync.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Command = require('common-bin'); 4 | const initConfig = require('../config'); // 初始化 config 5 | const cleaner = require('../lib/cleaner'); 6 | const Downloader = require('../lib/Downloader'); 7 | const out = require('../lib/out'); 8 | 9 | class SyncCommand extends Command { 10 | constructor(rawArgv) { 11 | super(rawArgv); 12 | this.usage = 'Usage: yuque-hexo sync'; 13 | } 14 | 15 | async run() { 16 | if (!initConfig) { 17 | process.exit(0); 18 | } 19 | 20 | // clear previous directory. 21 | if (initConfig.lastGeneratePath === '') { 22 | out.info('clear previous directory.'); 23 | cleaner.cleanPosts(); 24 | } 25 | 26 | // get articles from yuque or cache 27 | const downloader = new Downloader(initConfig); 28 | await downloader.autoUpdate(); 29 | out.info('yuque-hexo sync done!'); 30 | } 31 | } 32 | 33 | module.exports = SyncCommand; 34 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const lodash = require('lodash'); 5 | const out = require('./lib/out'); 6 | 7 | const cwd = process.cwd(); 8 | const token = process.env.YUQUE_TOKEN; 9 | const defaultConfig = { 10 | postPath: 'source/_posts/yuque', 11 | cachePath: 'yuque.json', 12 | lastGeneratePath: '', 13 | mdNameFormat: 'title', 14 | baseUrl: 'https://www.yuque.com/api/v2/', 15 | token, 16 | login: '', 17 | repo: '', 18 | adapter: 'hexo', 19 | concurrency: 5, 20 | onlyPublished: false, 21 | onlyPublic: false, 22 | imgCdn: { 23 | concurrency: 0, 24 | enabled: false, 25 | imageBed: 'qiniu', 26 | host: '', 27 | bucket: '', 28 | region: '', 29 | prefixKey: '', 30 | }, 31 | }; 32 | 33 | function loadConfig() { 34 | const pkg = loadJson() || loadYaml(); 35 | if (!pkg) { 36 | out.error('current directory should have a package.json'); 37 | return null; 38 | } 39 | const { yuqueConfig } = pkg; 40 | if (!lodash.isObject(yuqueConfig)) { 41 | out.error('package.yueConfig should be an object.'); 42 | return null; 43 | } 44 | const config = Object.assign({}, defaultConfig, yuqueConfig); 45 | return config; 46 | } 47 | 48 | function loadJson() { 49 | const pkgPath = path.join(cwd, 'package.json'); 50 | // out.info(`loading config: ${pkgPath}`); 51 | try { 52 | const pkg = require(pkgPath); 53 | return pkg; 54 | } catch (error) { 55 | // do nothing 56 | } 57 | } 58 | 59 | function loadYaml() { 60 | // TODO 61 | } 62 | 63 | module.exports = loadConfig(); 64 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const Command = require('common-bin'); 5 | 6 | class MainCommand extends Command { 7 | constructor(rawArgv) { 8 | super(rawArgv); 9 | this.usage = 'Usage: yuque-hexo '; 10 | 11 | // load sub command 12 | this.load(path.join(__dirname, 'command')); 13 | } 14 | } 15 | 16 | module.exports = MainCommand; 17 | -------------------------------------------------------------------------------- /lib/Downloader.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const mkdirp = require('mkdirp'); 6 | const lodash = require('lodash'); 7 | const Queue = require('queue'); 8 | const filenamify = require('filenamify'); 9 | const YuqueClient = require('./yuque'); 10 | const { isPost } = require('../util'); 11 | const out = require('./out'); 12 | const rimraf = require('rimraf'); 13 | 14 | const cwd = process.cwd(); 15 | 16 | // 需要提取的文章属性字段 17 | const PICK_PROPERTY = [ 18 | 'title', 19 | 'description', 20 | 'created_at', 21 | 'updated_at', 22 | 'published_at', 23 | 'format', 24 | 'slug', 25 | 'last_editor', 26 | ]; 27 | 28 | /** 29 | * Constructor 下载器 30 | * 31 | * @prop {Object} client 语雀 client 32 | * @prop {Object} config 知识库配置 33 | * @prop {String} cachePath 下载的文章缓存的 JSON 文件 34 | * @prop {String} postBasicPath 下载的文章最终生成 markdown 的目录 35 | * @prop {Array} _cachedArticles 文章列表 36 | * 37 | */ 38 | class Downloader { 39 | constructor(config) { 40 | this.client = new YuqueClient(config); 41 | this.config = config; 42 | this.cachePath = path.join(cwd, config.cachePath); 43 | this.postBasicPath = path.join(cwd, config.postPath); 44 | this.lastGeneratePath = config.lastGeneratePath ? path.join(cwd, config.lastGeneratePath) : ''; 45 | this._cachedArticles = []; 46 | this.fetchArticle = this.fetchArticle.bind(this); 47 | this.generatePost = this.generatePost.bind(this); 48 | this.lastGenerate = 0; 49 | if (this.lastGeneratePath !== '') { 50 | try { 51 | this.lastGenerate = Number( 52 | fs.readFileSync(this.lastGeneratePath).toString() 53 | ); 54 | } catch (error) { 55 | out.warn(`get last generate time err: ${error}`); 56 | } 57 | } 58 | } 59 | 60 | /** 61 | * 下载文章详情 62 | * 63 | * @param {Object} item 文章概要 64 | * @param {Number} index 所在缓存数组的下标 65 | * 66 | * @return {Promise} data 67 | */ 68 | fetchArticle(item, index) { 69 | const { client, _cachedArticles } = this; 70 | return function() { 71 | out.info(`download article body: ${item.title}`); 72 | return client.getArticle(item.slug).then(({ data: article }) => { 73 | _cachedArticles[index] = article; 74 | }); 75 | }; 76 | } 77 | 78 | /** 79 | * 下载所有文章 80 | * 并根据文章是否有更新来决定是否需要重新下载文章详情 81 | * 82 | * @return {Promise} queue 83 | */ 84 | async fetchArticles() { 85 | const { client, config, postBasicPath } = this; 86 | const articles = await client.getArticles(); 87 | if (!Array.isArray(articles.data)) { 88 | throw new Error( 89 | `fail to fetch article list, response: ${JSON.stringify(articles)}` 90 | ); 91 | } 92 | out.info(`article amount: ${articles.data.length}`); 93 | const realArticles = articles.data 94 | .filter(article => 95 | (config.onlyPublished ? !!article.published_at : true) 96 | ) 97 | .filter(article => (config.onlyPublic ? !!article.public : true)) 98 | .map(article => lodash.pick(article, PICK_PROPERTY)); 99 | 100 | const deletedArticles = this._cachedArticles.filter(cache => realArticles.findIndex(item => item.slug === cache.slug) === -1); 101 | 102 | // 删除本地已存在的但是语雀上面被删除的文章 103 | for (const article of deletedArticles) { 104 | const fileName = filenamify(article[config.mdNameFormat]); 105 | const postPath = path.join(postBasicPath, `${fileName}.md`); 106 | rimraf.sync(postPath); 107 | } 108 | 109 | this._cachedArticles = this._cachedArticles.filter(cache => realArticles.findIndex(item => item.slug === cache.slug) !== -1); 110 | 111 | const queue = new Queue({ concurrency: config.concurrency }); 112 | 113 | let article; 114 | let cacheIndex; 115 | let cacheArticle; 116 | let cacheAvaliable; 117 | 118 | const findIndexFn = function(item) { 119 | return item.slug === article.slug; 120 | }; 121 | 122 | const { _cachedArticles } = this; 123 | 124 | for (let i = 0; i < realArticles.length; i++) { 125 | article = realArticles[i]; 126 | cacheIndex = _cachedArticles.findIndex(findIndexFn); 127 | if (cacheIndex < 0) { 128 | // 未命中缓存,新增一条 129 | cacheIndex = _cachedArticles.length; 130 | _cachedArticles.push(article); 131 | queue.push(this.fetchArticle(article, cacheIndex)); 132 | } else { 133 | cacheArticle = _cachedArticles[cacheIndex]; 134 | cacheAvaliable = 135 | +new Date(article.updated_at) === +new Date(cacheArticle.updated_at); 136 | // 文章有变更,更新缓存 137 | if (!cacheAvaliable) { 138 | queue.push(this.fetchArticle(article, cacheIndex)); 139 | } 140 | } 141 | } 142 | 143 | return new Promise((resolve, reject) => { 144 | queue.start(function(err) { 145 | if (err) return reject(err); 146 | out.info('download articles done!'); 147 | resolve(); 148 | }); 149 | }); 150 | } 151 | 152 | /** 153 | * 读取语雀的文章缓存 json 文件 154 | */ 155 | readYuqueCache() { 156 | const { cachePath } = this; 157 | out.info(`reading from yuque.json: ${cachePath}`); 158 | try { 159 | const articles = require(cachePath); 160 | if (Array.isArray(articles)) { 161 | this._cachedArticles = articles; 162 | return; 163 | } 164 | } catch (error) { 165 | out.warn(error.message); 166 | // Do noting 167 | } 168 | this._cachedArticles = []; 169 | } 170 | 171 | /** 172 | * 写入语雀的文章缓存 json 文件 173 | */ 174 | writeYuqueCache() { 175 | const { cachePath, _cachedArticles } = this; 176 | out.info(`writing to local file: ${cachePath}`); 177 | fs.writeFileSync(cachePath, JSON.stringify(_cachedArticles, null, 2), { 178 | encoding: 'UTF8', 179 | }); 180 | } 181 | 182 | /** 183 | * 生成一篇 markdown 文章 184 | * 185 | * @param {Object} post 文章详情 186 | */ 187 | async generatePost(post) { 188 | if (!isPost(post)) { 189 | out.error(`invalid post: ${post}`); 190 | return; 191 | } 192 | 193 | if (new Date(post.published_at).getTime() < this.lastGenerate) { 194 | out.info(`post not updated skip: ${post.title}`); 195 | return; 196 | } 197 | 198 | const { postBasicPath } = this; 199 | const { mdNameFormat, adapter } = this.config; 200 | const fileName = filenamify(post[mdNameFormat]); 201 | const postPath = path.join(postBasicPath, `${fileName}.md`); 202 | const internalAdapters = [ 'markdown', 'hexo' ]; 203 | const adpaterPath = internalAdapters.includes(adapter) 204 | ? path.join(__dirname, '../adapter', adapter) 205 | : path.join(process.cwd(), adapter); 206 | 207 | let transform; 208 | try { 209 | transform = require(adpaterPath); 210 | } catch (error) { 211 | out.error(`adpater (${adapter}) is invalid.`); 212 | process.exit(-1); 213 | } 214 | out.info(`generate post file: ${postPath}`); 215 | const text = await transform(post); 216 | fs.writeFileSync(postPath, text, { 217 | encoding: 'UTF8', 218 | }); 219 | } 220 | 221 | /** 222 | * 全量生成所有 markdown 文章 223 | */ 224 | async generatePosts() { 225 | const { _cachedArticles, postBasicPath } = this; 226 | mkdirp.sync(postBasicPath); 227 | out.info(`create posts directory (if it not exists): ${postBasicPath}`); 228 | const promiseList = _cachedArticles.map(item => { 229 | return async () => { 230 | return await this.generatePost(item); 231 | }; 232 | }); 233 | // 并发数 234 | const concurrency = this.config.imgCdn.concurrency || promiseList.length; 235 | const queue = new Queue({ concurrency }); 236 | queue.push(...promiseList); 237 | await new Promise((resolve, reject) => { 238 | queue.start(function(err) { 239 | if (err) return reject(err); 240 | resolve(); 241 | }); 242 | }); 243 | } 244 | 245 | // 文章下载 => 增量更新文章到缓存 json 文件 => 全量生成 markdown 文章 246 | async autoUpdate() { 247 | this.readYuqueCache(); 248 | await this.fetchArticles(); 249 | this.writeYuqueCache(); 250 | await this.generatePosts(); 251 | if (this.lastGeneratePath) { 252 | fs.writeFileSync(this.lastGeneratePath, new Date().getTime().toString()); 253 | } 254 | } 255 | } 256 | 257 | module.exports = Downloader; 258 | -------------------------------------------------------------------------------- /lib/cleaner.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const rimraf = require('rimraf'); 6 | const config = require('../config'); 7 | const out = require('./out'); 8 | 9 | const cwd = process.cwd(); 10 | 11 | module.exports = { 12 | // clear directory of generated posts 13 | cleanPosts() { 14 | const { postPath } = config; 15 | const dist = path.join(cwd, postPath); 16 | out.info(`remove yuque posts: ${dist}`); 17 | rimraf.sync(dist); 18 | }, 19 | 20 | // clear cache of posts' data 21 | clearCache() { 22 | const cachePath = path.join(cwd, 'yuque.json'); 23 | try { 24 | out.info(`remove yuque.json: ${cachePath}`); 25 | fs.unlinkSync(cachePath); 26 | } catch (error) { 27 | out.warn(`remove empty yuque.json: ${error.message}`); 28 | } 29 | }, 30 | 31 | // clear last generated timestamp file 32 | clearLastGenerate() { 33 | const { lastGeneratePath } = config; 34 | if (!lastGeneratePath) { 35 | return; 36 | } 37 | const dist = path.join(cwd, lastGeneratePath); 38 | out.info(`remove last generated timestamp: ${dist}`); 39 | rimraf.sync(dist); 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /lib/out.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chalk = require('chalk'); 4 | 5 | module.exports = { 6 | info(...args) { 7 | const prefix = chalk.green('[INFO]'); 8 | args.unshift(prefix); 9 | console.log.apply(console, args); 10 | }, 11 | warn(...args) { 12 | const prefix = chalk.yellow('[WARNING]'); 13 | args.unshift(prefix); 14 | console.log.apply(console, args); 15 | }, 16 | error(...args) { 17 | const prefix = chalk.red('[ERROR]'); 18 | args.unshift(prefix); 19 | console.log.apply(console, args); 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /lib/qetag.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // 计算文件的eTag,参数为buffer或者readableStream或者文件路径 4 | function getEtag(buffer, callback) { 5 | 6 | // 判断传入的参数是buffer还是stream还是filepath 7 | let mode = 'buffer'; 8 | 9 | if (typeof buffer === 'string') { 10 | buffer = require('fs').createReadStream(buffer); 11 | mode = 'stream'; 12 | } else if (buffer instanceof require('stream')) { 13 | mode = 'stream'; 14 | } 15 | 16 | // sha1算法 17 | const sha1 = function(content) { 18 | const crypto = require('crypto'); 19 | const sha1 = crypto.createHash('sha1'); 20 | sha1.update(content); 21 | return sha1.digest(); 22 | }; 23 | 24 | // 以4M为单位分割 25 | const blockSize = 4 * 1024 * 1024; 26 | const sha1String = []; 27 | let prefix = 0x16; 28 | let blockCount = 0; 29 | 30 | // eslint-disable-next-line default-case 31 | switch (mode) { 32 | case 'buffer': 33 | // eslint-disable-next-line 34 | const bufferSize = buffer.length; 35 | blockCount = Math.ceil(bufferSize / blockSize); 36 | 37 | for (let i = 0; i < blockCount; i++) { 38 | sha1String.push(sha1(buffer.slice(i * blockSize, (i + 1) * blockSize))); 39 | } 40 | process.nextTick(function() { 41 | callback(calcEtag()); 42 | }); 43 | break; 44 | case 'stream': 45 | // eslint-disable-next-line no-case-declarations 46 | const stream = buffer; 47 | stream.on('readable', function() { 48 | let chunk; 49 | // eslint-disable-next-line no-cond-assign 50 | while (chunk = stream.read(blockSize)) { 51 | sha1String.push(sha1(chunk)); 52 | blockCount++; 53 | } 54 | }); 55 | stream.on('end', function() { 56 | callback(calcEtag()); 57 | }); 58 | break; 59 | } 60 | 61 | function calcEtag() { 62 | if (!sha1String.length) { 63 | return 'Fto5o-5ea0sNMlW_75VgGJCv2AcJ'; 64 | } 65 | let sha1Buffer = Buffer.concat(sha1String, blockCount * 20); 66 | 67 | // 如果大于4M,则对各个块的sha1结果再次sha1 68 | if (blockCount > 1) { 69 | prefix = 0x96; 70 | sha1Buffer = sha1(sha1Buffer); 71 | } 72 | 73 | sha1Buffer = Buffer.concat( 74 | [ new Buffer([ prefix ]), sha1Buffer ], 75 | sha1Buffer.length + 1 76 | ); 77 | 78 | return sha1Buffer.toString('base64') 79 | .replace(/\//g, '_').replace(/\+/g, '-'); 80 | 81 | } 82 | 83 | } 84 | 85 | module.exports = getEtag; 86 | -------------------------------------------------------------------------------- /lib/yuque.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const urllib = require('urllib'); 4 | const debug = require('debug')('yuque-hexo:client'); 5 | 6 | class YuqueClient { 7 | constructor(config) { 8 | const { baseUrl, login, repo, token } = config; 9 | this.config = Object.assign({}, config); 10 | this.token = token; 11 | this.config.namespace = `${login}/${repo}`; 12 | debug(`create client: baseUrl: ${baseUrl}, login: ${login}, repo: ${repo}`); 13 | } 14 | 15 | async _fetch(method, api, data) { 16 | const { baseUrl, namespace, timeout = 10000 } = this.config; 17 | const path = `${baseUrl}/repos/${namespace}${api}`; 18 | debug(`request data: api: ${path}, data: ${data}`); 19 | try { 20 | const result = await urllib.request(path, { 21 | dataType: 'json', 22 | method, 23 | data, 24 | timeout, 25 | headers: { 26 | 'User-Agent': 'yuque-hexo', 27 | 'X-Auth-Token': this.token, 28 | }, 29 | }); 30 | return result.data; 31 | } catch (error) { 32 | throw new Error(`请求数据失败: ${error.message}`); 33 | } 34 | } 35 | 36 | async getArticles() { 37 | const api = '/docs'; 38 | const result = await this._fetch('GET', api); 39 | return result; 40 | } 41 | 42 | async getArticle(slug) { 43 | const api = `/docs/${slug}?raw=1`; 44 | const result = await this._fetch('GET', api); 45 | return result; 46 | } 47 | 48 | // async getToc() { 49 | // const api = '/toc'; 50 | // const result = await this._fetch('GET', api); 51 | // return result; 52 | // } 53 | } 54 | 55 | module.exports = YuqueClient; 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yuque-hexo", 3 | "version": "1.9.5", 4 | "description": "A downloader for articles from yuque", 5 | "main": "index.js", 6 | "scripts": { 7 | "clean": "rimraf coverage", 8 | "lint": "eslint .", 9 | "test": "npm run lint -- --fix && npm run test-local", 10 | "test-local": "egg-bin test", 11 | "cov": "egg-bin cov", 12 | "ci": "npm run clean && npm run lint && egg-bin cov" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/x-cold/yuque-hexo.git" 17 | }, 18 | "bin": { 19 | "yuque-hexo": "bin/yuque-hexo.js" 20 | }, 21 | "author": "x-cold ", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/x-cold/yuque-hexo/issues" 25 | }, 26 | "homepage": "https://github.com/x-cold/yuque-hexo#readme", 27 | "dependencies": { 28 | "@yuque/sdk": "^1.1.1", 29 | "ali-oss": "6.17.0", 30 | "chalk": "^2.4.1", 31 | "common-bin": "^2.7.3", 32 | "cos-nodejs-sdk-v5": "^2.11.6", 33 | "debug": "^3.1.0", 34 | "depd": "^2.0.0", 35 | "ejs": "^3.1.6", 36 | "filenamify": "^4.1.0", 37 | "hexo-front-matter": "^0.2.3", 38 | "html-entities": "^1.2.1", 39 | "lodash": "^4.17.10", 40 | "mkdirp": "^1.0.0", 41 | "moment": "^2.22.2", 42 | "prettier": "^2.0.4", 43 | "qiniu": "^7.4.0", 44 | "queue": "^4.5.0", 45 | "rimraf": "^2.6.2", 46 | "superagent": "^7.0.2", 47 | "update-check": "^1.5.3", 48 | "upyun": "^3.4.6", 49 | "urllib": "^3.0.0" 50 | }, 51 | "devDependencies": { 52 | "coffee": "^5.1.0", 53 | "egg-bin": "^4.8.3", 54 | "egg-ci": "^1.8.0", 55 | "eslint": "^5.4.0", 56 | "eslint-config-egg": "^7.1.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /test/custom-adapter-project/custom.adapter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * 格式化 markdown 内容 5 | * 6 | * @param {String} body md 文档 7 | * @return {String} body 8 | */ 9 | function formatRaw(body) { 10 | const multiBr = /(
[\s\n]){2}/gi; 11 | const multiBrEnd = /(
[\n]?){2}/gi; 12 | const brBug = /
/g; 13 | const hiddenContent = /
[\s\S]*?<\/div>/gi; 14 | // 删除语雀特有的锚点 15 | const emptyAnchor = /<\/a>/g; 16 | body = body 17 | .replace(hiddenContent, '') 18 | .replace(multiBr, '
') 19 | .replace(multiBrEnd, '
\n') 20 | .replace(brBug, '\n') 21 | .replace(emptyAnchor, ''); 22 | return body; 23 | } 24 | 25 | /** 26 | * markdown 文章生产适配器 27 | * 28 | * @param {Object} post 文章 29 | * @return {String} text 30 | */ 31 | module.exports = function(post) { 32 | const { body } = post; 33 | const raw = formatRaw(body); 34 | return raw; 35 | }; 36 | -------------------------------------------------------------------------------- /test/custom-adapter-project/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "your-hexo-project", 3 | "yuqueConfig": { 4 | "baseUrl": "https://www.yuque.com/api/v2", 5 | "login": "yinzhi", 6 | "repo": "yuque-hexo-demo", 7 | "postPath": "report", 8 | "mdNameFormat": "title", 9 | "adapter": "custom.adapter.js", 10 | "lastGeneratePath": "./yuque-hexo-last-generate-timestamp.txt" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/custom-adapter-project/yuque-hexo-last-generate-timestamp.txt: -------------------------------------------------------------------------------- 1 | 1640966400000 2 | -------------------------------------------------------------------------------- /test/hexo-project/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "your-hexo-project", 3 | "yuqueConfig": { 4 | "baseUrl": "https://www.yuque.com/api/v2", 5 | "login": "yinzhi", 6 | "repo": "yuque-hexo-demo", 7 | "mdNameFormat": "title", 8 | "postPath": "source/_posts/yuque" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/markdown-project/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "your-hexo-project", 3 | "yuqueConfig": { 4 | "baseUrl": "https://www.yuque.com/api/v2", 5 | "login": "yinzhi", 6 | "repo": "yuque-hexo-demo", 7 | "postPath": "report", 8 | "mdNameFormat": "title", 9 | "adapter": "markdown", 10 | "lastGeneratePath": "./yuque-hexo-last-generate-timestamp.txt" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/markdown-project/yuque-hexo-last-generate-timestamp.txt: -------------------------------------------------------------------------------- 1 | 1640966400000 2 | -------------------------------------------------------------------------------- /test/yuque.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const coffee = require('coffee'); 5 | const assert = require('assert'); 6 | 7 | describe('hexo project test', () => { 8 | const myBin = require.resolve('../bin/yuque-hexo'); 9 | const cwd = path.join(__dirname, 'hexo-project'); 10 | it('yuque-hexo --help', async () => { 11 | const { stdout, code } = await coffee 12 | .fork(myBin, [ '--help' ], { cwd }) 13 | .end(); 14 | console.log(stdout); 15 | assert(stdout.includes('Usage: yuque-hexo ')); 16 | assert(code === 0); 17 | }); 18 | 19 | it('yuque-hexo clean with warning', async () => { 20 | const { stdout, code } = await coffee 21 | .fork(myBin, [ 'clean' ], { cwd }) 22 | .end(); 23 | console.log(stdout); 24 | assert(stdout.includes('yuque-hexo clean done!')); 25 | assert(stdout.includes('remove empty yuque.json')); 26 | assert(code === 0); 27 | }); 28 | 29 | it('yuque-hexo sync without cache', async () => { 30 | const { stdout, code } = await coffee 31 | .fork(myBin, [ 'sync' ], { cwd }) 32 | .end(); 33 | console.log(stdout); 34 | assert(stdout.includes('download article body')); 35 | assert(stdout.includes('yuque-hexo sync done!')); 36 | assert(code === 0); 37 | }); 38 | 39 | it('yuque-hexo sync use cache', async () => { 40 | const { stdout, code } = await coffee 41 | .fork(myBin, [ 'sync' ], { cwd }) 42 | .end(); 43 | console.log(stdout); 44 | assert(!stdout.includes('download article body')); 45 | assert(stdout.includes('yuque-hexo sync done!')); 46 | assert(code === 0); 47 | }); 48 | 49 | it('yuque-hexo clean', async () => { 50 | const { stdout, code } = await coffee 51 | .fork(myBin, [ 'clean' ], { cwd }) 52 | .end(); 53 | console.log(stdout); 54 | assert(stdout.includes('yuque-hexo clean done!')); 55 | assert(!stdout.includes('remove empty yuque.json')); 56 | assert(code === 0); 57 | }); 58 | }); 59 | 60 | describe('markdown project test', () => { 61 | const myBin = require.resolve('../bin/yuque-hexo'); 62 | const cwd = path.join(__dirname, 'markdown-project'); 63 | it('yuque-hexo --help', async () => { 64 | const { stdout, code } = await coffee 65 | .fork(myBin, [ '--help' ], { cwd }) 66 | .end(); 67 | console.log(stdout); 68 | assert(stdout.includes('Usage: yuque-hexo ')); 69 | assert(code === 0); 70 | }); 71 | 72 | it('yuque-hexo clean with warning', async () => { 73 | const { stdout, code } = await coffee 74 | .fork(myBin, [ 'clean' ], { cwd }) 75 | .end(); 76 | console.log(stdout); 77 | assert(stdout.includes('yuque-hexo clean done!')); 78 | assert(stdout.includes('remove empty yuque.json')); 79 | assert(code === 0); 80 | }); 81 | 82 | it('yuque-hexo sync without cache', async () => { 83 | const { stdout, code } = await coffee 84 | .fork(myBin, [ 'sync' ], { cwd }) 85 | .end(); 86 | console.log(stdout); 87 | assert(stdout.includes('download article body')); 88 | assert(stdout.includes('yuque-hexo sync done!')); 89 | assert(code === 0); 90 | }); 91 | 92 | it('yuque-hexo sync use cache', async () => { 93 | const { stdout, code } = await coffee 94 | .fork(myBin, [ 'sync' ], { cwd }) 95 | .end(); 96 | console.log(stdout); 97 | assert(!stdout.includes('download article body')); 98 | assert(stdout.includes('yuque-hexo sync done!')); 99 | assert(code === 0); 100 | }); 101 | 102 | it('yuque-hexo clean', async () => { 103 | const { stdout, code } = await coffee 104 | .fork(myBin, [ 'clean' ], { cwd }) 105 | .end(); 106 | console.log(stdout); 107 | assert(stdout.includes('yuque-hexo clean done!')); 108 | assert(!stdout.includes('remove empty yuque.json')); 109 | assert(code === 0); 110 | }); 111 | }); 112 | 113 | describe('custom adapter test', () => { 114 | const myBin = require.resolve('../bin/yuque-hexo'); 115 | const cwd = path.join(__dirname, 'custom-adapter-project'); 116 | 117 | it('yuque-hexo clean with warning', async () => { 118 | const { stdout, code } = await coffee 119 | .fork(myBin, [ 'clean' ], { cwd }) 120 | .end(); 121 | console.log(stdout); 122 | assert(stdout.includes('yuque-hexo clean done!')); 123 | assert(stdout.includes('remove empty yuque.json')); 124 | assert(code === 0); 125 | }); 126 | 127 | it('yuque-hexo sync without cache', async () => { 128 | const { stdout, code } = await coffee 129 | .fork(myBin, [ 'sync' ], { cwd }) 130 | .end(); 131 | console.log(stdout); 132 | assert(stdout.includes('download article body')); 133 | assert(stdout.includes('yuque-hexo sync done!')); 134 | assert(code === 0); 135 | }); 136 | 137 | it('yuque-hexo sync use cache', async () => { 138 | const { stdout, code } = await coffee 139 | .fork(myBin, [ 'sync' ], { cwd }) 140 | .end(); 141 | console.log(stdout); 142 | assert(!stdout.includes('download article body')); 143 | assert(stdout.includes('yuque-hexo sync done!')); 144 | assert(code === 0); 145 | }); 146 | 147 | it('yuque-hexo clean', async () => { 148 | const { stdout, code } = await coffee 149 | .fork(myBin, [ 'clean' ], { cwd }) 150 | .end(); 151 | console.log(stdout); 152 | assert(stdout.includes('yuque-hexo clean done!')); 153 | assert(!stdout.includes('remove empty yuque.json')); 154 | assert(code === 0); 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /util/imageBeds/cos.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // 腾讯云图床 4 | const COS = require('cos-nodejs-sdk-v5'); 5 | const out = require('../../lib/out'); 6 | 7 | const secretId = process.env.SECRET_ID; 8 | const secretKey = process.env.SECRET_KEY; 9 | 10 | 11 | class CosClient { 12 | constructor(config) { 13 | this.config = config; 14 | this.imageBedInstance = new COS({ 15 | SecretId: secretId, // 身份识别ID 16 | SecretKey: secretKey, // 身份秘钥 17 | }); 18 | } 19 | 20 | static getInstance(config) { 21 | if (!this.instance) { 22 | this.instance = new CosClient(config); 23 | } 24 | return this.instance; 25 | } 26 | 27 | /** 28 | * 检查图床是否已经存在图片,存在则返回url,不存在返回空 29 | * 30 | * @param {string} fileName 文件名 31 | * @return {Promise} 图片url 32 | */ 33 | async hasImage(fileName) { 34 | try { 35 | await this.imageBedInstance.headObject({ 36 | Bucket: this.config.bucket, // 存储桶名字(必须) 37 | Region: this.config.region, // 存储桶所在地域,必须字段 38 | Key: `${this.config.prefixKey}/${fileName}`, // 文件名 必须 39 | }); 40 | return `https://${this.config.bucket}.cos.${this.config.region}.myqcloud.com/${this.config.prefixKey}/${fileName}`; 41 | } catch (e) { 42 | return ''; 43 | } 44 | } 45 | 46 | /** 47 | * 上传图片到图床 48 | * 49 | * @param {Buffer} imgBuffer 文件buffer 50 | * @param {string} fileName 文件名 51 | * @return {Promise} 图床的图片url 52 | */ 53 | async uploadImg(imgBuffer, fileName) { 54 | try { 55 | const res = await this.imageBedInstance.putObject({ 56 | Bucket: this.config.bucket, // 存储桶名字(必须) 57 | Region: this.config.region, // 存储桶所在地域,必须字段 58 | Key: `${this.config.prefixKey}/${fileName}`, // 文件名 必须 59 | StorageClass: 'STANDARD', // 上传模式(标准模式) 60 | Body: imgBuffer, // 上传文件对象 61 | }); 62 | return `https://${res.Location}`; 63 | } catch (e) { 64 | out.warn(`上传图片失败,请检查: ${e}`); 65 | return ''; 66 | } 67 | } 68 | } 69 | 70 | module.exports = CosClient; 71 | -------------------------------------------------------------------------------- /util/imageBeds/github.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Github图床 4 | const urllib = require('urllib'); 5 | const out = require('../../lib/out'); 6 | const { transformRes } = require('../index'); 7 | 8 | const secretId = process.env.SECRET_ID; 9 | const secretKey = process.env.SECRET_KEY; 10 | 11 | 12 | class GithubClient { 13 | constructor(config) { 14 | this.config = config; 15 | this.init(); 16 | } 17 | 18 | init() { 19 | if (!this.config.host) { 20 | out.warn('未指定加速域名,将使用默认域名:https://raw.githubusercontent.com'); 21 | } 22 | // 如果指定了加速域名 23 | if (this.config.host && this.config.host.includes('cdn.jsdelivr.net')) { 24 | this.config.host = 'https://cdn.jsdelivr.net'; 25 | out.info(`图床域名:${this.config.host}`); 26 | } 27 | } 28 | 29 | static getInstance(config) { 30 | if (!this.instance) { 31 | this.instance = new GithubClient(config); 32 | } 33 | return this.instance; 34 | } 35 | 36 | async _fetch(method, fileName, base64File) { 37 | const path = `https://api.github.com/repos/${secretId}/${this.config.bucket}/contents/${this.config.prefixKey}/${fileName}`; 38 | const data = method === 'PUT' ? { 39 | message: 'yuque-hexo upload images', 40 | content: base64File, 41 | } : null; 42 | try { 43 | const result = await urllib.request(path, { 44 | dataType: 'json', 45 | method, 46 | data, 47 | timeout: 60000, 48 | headers: { 49 | 'Content-Type': 'application/json', 50 | 'User-Agent': 'yuque-hexo', 51 | Authorization: `token ${secretKey}`, 52 | }, 53 | }); 54 | if (result.status === 409) { 55 | out.warn('由于github并发问题,图片上传失败'); 56 | return ''; 57 | } 58 | if (result.status === 200 || result.status === 201) { 59 | if (this.config.host) { 60 | return `${this.config.host}/gh/${secretId}/${this.config.bucket}/${this.config.prefixKey}/${fileName}`; 61 | } 62 | if (method === 'GET') { 63 | return result.data.download_url; 64 | } 65 | return result.data.content.download_url; 66 | } 67 | method === 'PUT' && out.warn(`请求图片失败,请检查: ${transformRes(result)}`); 68 | return ''; 69 | } catch (error) { 70 | out.warn(`请求图片失败,请检查: ${transformRes(error)}`); 71 | return ''; 72 | } 73 | } 74 | 75 | 76 | /** 77 | * 检查图床是否已经存在图片,存在则返回url,不存在返回空 78 | * 79 | * @param {string} fileName 文件名 80 | * @return {Promise} 图片url 81 | */ 82 | async hasImage(fileName) { 83 | try { 84 | return await this._fetch('GET', fileName); 85 | } catch (e) { 86 | out.warn(`检查图片信息时出错: ${transformRes(e)}`); 87 | return ''; 88 | } 89 | } 90 | 91 | /** 92 | * 上传图片到图床 93 | * 94 | * @param {Buffer} imgBuffer 文件buffer 95 | * @param {string} fileName 文件名 96 | * @return {Promise} 图床的图片url 97 | */ 98 | async uploadImg(imgBuffer, fileName) { 99 | try { 100 | const base64File = imgBuffer.toString('base64'); 101 | const imgUrl = await this._fetch('PUT', fileName, base64File); 102 | if (imgUrl) return imgUrl; 103 | } catch (e) { 104 | out.warn(`上传图片失败,请检查: ${e}`); 105 | } 106 | } 107 | } 108 | 109 | module.exports = GithubClient; 110 | -------------------------------------------------------------------------------- /util/imageBeds/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const CosClient = require('./cos'); 4 | const OssClient = require('./oss'); 5 | const QiniuClient = require('./qiniu'); 6 | const UPClient = require('./upyun'); 7 | const GithubClient = require('./github'); 8 | const out = require('../../lib/out'); 9 | 10 | // 目前已适配图床列表 11 | const imageBedList = [ 'qiniu', 'cos', 'oss', 'upyun', 'github' ]; 12 | 13 | class ImageBeds { 14 | constructor(config) { 15 | this.config = config; 16 | this.imageBedInstance = this.getImageBedInstance(config.imageBed); 17 | } 18 | 19 | static getInstance(config) { 20 | if (!this.instance) { 21 | this.instance = new ImageBeds(config); 22 | } 23 | return this.instance; 24 | } 25 | 26 | /** 27 | * 获取图床对象的实例 28 | * 29 | * @param {string} imageBed 图床类型: cos | oss 30 | * @return {any} 图床实例 31 | */ 32 | getImageBedInstance(imageBed) { 33 | if (!imageBedList.includes(imageBed)) { 34 | out.error(`imageBed配置错误,目前只支持${imageBedList.toString()}`); 35 | process.exit(-1); 36 | } 37 | switch (imageBed) { 38 | case 'cos': 39 | return CosClient.getInstance(this.config); 40 | case 'oss': 41 | return OssClient.getInstance(this.config); 42 | case 'qiniu': 43 | return QiniuClient.getInstance(this.config); 44 | case 'upyun': 45 | return UPClient.getInstance(this.config); 46 | case 'github': 47 | return GithubClient.getInstance(this.config); 48 | default: 49 | return QiniuClient.getInstance(this.config); 50 | } 51 | } 52 | 53 | /** 54 | * 检查图床是否已经存在图片,存在则返回url 55 | * 56 | * @param {string} fileName 文件名 57 | * @return {Promise} 图片url 58 | */ 59 | async hasImage(fileName) { 60 | return await this.imageBedInstance.hasImage(fileName); 61 | } 62 | 63 | /** 64 | * 上传图片到图床 65 | * 66 | * @param {Buffer} imgBuffer 文件buffer 67 | * @param {string} fileName 文件名 68 | * @return {Promise} 图床的图片url 69 | */ 70 | async uploadImg(imgBuffer, fileName) { 71 | return await this.imageBedInstance.uploadImg(imgBuffer, fileName); 72 | } 73 | 74 | } 75 | 76 | module.exports = ImageBeds; 77 | 78 | -------------------------------------------------------------------------------- /util/imageBeds/oss.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // 阿里云图床 4 | const OSS = require('ali-oss'); 5 | const out = require('../../lib/out'); 6 | const { transformRes } = require('../index'); 7 | 8 | const secretId = process.env.SECRET_ID; 9 | const secretKey = process.env.SECRET_KEY; 10 | 11 | 12 | class OssClient { 13 | constructor(config) { 14 | this.config = config; 15 | this.imageBedInstance = new OSS({ 16 | bucket: config.bucket, 17 | // yourRegion填写Bucket所在地域。以华东1(杭州)为例,Region填写为oss-cn-hangzhou。 18 | region: config.region, 19 | // 阿里云账号AccessKey拥有所有API的访问权限,风险很高。强烈建议您创建并使用RAM用户进行API访问或日常运维,请登录RAM控制台创建RAM用户。 20 | accessKeyId: secretId, 21 | accessKeySecret: secretKey, 22 | }); 23 | } 24 | 25 | static getInstance(config) { 26 | if (!this.instance) { 27 | this.instance = new OssClient(config); 28 | } 29 | return this.instance; 30 | } 31 | 32 | /** 33 | * 检查图床是否已经存在图片,存在则返回url,不存在返回空 34 | * 35 | * @param {string} fileName 文件名 36 | * @return {Promise} 图片url 37 | */ 38 | async hasImage(fileName) { 39 | try { 40 | await this.imageBedInstance.head(`${this.config.prefixKey}/${fileName}`); 41 | return `https://${this.config.bucket}.${this.config.region}.aliyuncs.com/${this.config.prefixKey}/${fileName}`; 42 | } catch (e) { 43 | out.warn(`检查图片信息时出错: ${transformRes(e)}`); 44 | return ''; 45 | } 46 | } 47 | 48 | /** 49 | * 上传图片到图床 50 | * 51 | * @param {Buffer} imgBuffer 文件buffer 52 | * @param {string} fileName 文件名 53 | * @return {Promise} 图床的图片url 54 | */ 55 | async uploadImg(imgBuffer, fileName) { 56 | try { 57 | const res = await this.imageBedInstance.put(`${this.config.prefixKey}/${fileName}`, imgBuffer); 58 | return res.url; 59 | } catch (e) { 60 | out.warn(`上传图片失败,请检查: ${e}`); 61 | return ''; 62 | } 63 | } 64 | } 65 | 66 | module.exports = OssClient; 67 | -------------------------------------------------------------------------------- /util/imageBeds/qiniu.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // 七牛云图床 4 | const qiniu = require('qiniu'); 5 | const out = require('../../lib/out'); 6 | const { transformRes } = require('../index'); 7 | 8 | const secretId = process.env.SECRET_ID; 9 | const secretKey = process.env.SECRET_KEY; 10 | 11 | class QiniuClient { 12 | constructor(config) { 13 | this.config = config; 14 | this.init(); 15 | } 16 | 17 | init() { 18 | if (!this.config.host) { 19 | out.error('使用七牛云时,需要在imgCdn中指定域名host'); 20 | process.exit(-1); 21 | } 22 | out.info(`图床域名:${this.config.host}`); 23 | const mac = new qiniu.auth.digest.Mac(secretId, secretKey); 24 | const putPolicy = new qiniu.rs.PutPolicy({ scope: this.config.bucket }); // 配置 25 | this.uploadToken = putPolicy.uploadToken(mac); // 获取上传凭证 26 | const config = new qiniu.conf.Config(); 27 | // 空间对应的机房 28 | config.zone = qiniu.zone[this.config.region]; 29 | this.formUploader = new qiniu.form_up.FormUploader(config); 30 | this.bucketManager = new qiniu.rs.BucketManager(mac, config); 31 | this.putExtra = new qiniu.form_up.PutExtra(); 32 | } 33 | 34 | static getInstance(config) { 35 | if (!this.instance) { 36 | this.instance = new QiniuClient(config); 37 | } 38 | return this.instance; 39 | } 40 | 41 | /** 42 | * 检查图床是否已经存在图片,存在则返回url,不存在返回空 43 | * 44 | * @param {string} fileName 文件名 45 | * @return {Promise} 图片url 46 | */ 47 | async hasImage(fileName) { 48 | return await new Promise(resolve => { 49 | this.bucketManager.stat(this.config.bucket, `${this.config.prefixKey}/${fileName}`, (err, respBody, respInfo) => { 50 | if (err) { 51 | out.warn(`检查图片信息时出错: ${transformRes(err)}`); 52 | } else { 53 | if (respInfo.statusCode === 200) { 54 | resolve(`${this.config.host}/${this.config.prefixKey}/${fileName}`); 55 | } else { 56 | resolve(''); 57 | } 58 | } 59 | }); 60 | }); 61 | } 62 | 63 | /** 64 | * 上传图片到图床 65 | * 66 | * @param {Buffer} imgBuffer 文件buffer 67 | * @param {string} fileName 文件名 68 | * @return {Promise} 图床的图片url 69 | */ 70 | async uploadImg(imgBuffer, fileName) { 71 | return await new Promise(resolve => { 72 | this.formUploader.put(this.uploadToken, `${this.config.prefixKey}/${fileName}`, imgBuffer, this.putExtra, (respErr, 73 | respBody, respInfo) => { 74 | if (respErr) { 75 | out.warn(`上传图片失败,请检查: ${transformRes(respErr)}`); 76 | resolve(''); 77 | } 78 | if (respInfo.statusCode === 200) { 79 | resolve(`${this.config.host}/${this.config.prefixKey}/${fileName}`); 80 | } else { 81 | out.warn(`上传图片失败,请检查: ${transformRes(respInfo)}`); 82 | resolve(''); 83 | } 84 | }); 85 | }); 86 | 87 | } 88 | } 89 | 90 | module.exports = QiniuClient; 91 | -------------------------------------------------------------------------------- /util/imageBeds/upyun.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // 又拍云图床 4 | const upyun = require('upyun'); 5 | const out = require('../../lib/out'); 6 | 7 | const secretId = process.env.SECRET_ID; 8 | const secretKey = process.env.SECRET_KEY; 9 | 10 | class UPClient { 11 | constructor(config) { 12 | this.config = config; 13 | this.init(); 14 | } 15 | init() { 16 | if (!this.config.host) { 17 | out.warn(`未指定域名host,将使用测试域名:http://${this.config.bucket}.test.upcdn.net`); 18 | this.config.host = `http://${this.config.bucket}.test.upcdn.net`; 19 | } 20 | // 如果不指定协议,默认使用http 21 | if (!this.config.host.startsWith('http')) { 22 | this.config.host = `http://${this.config.bucket}`; 23 | out.info(`图床域名:${this.config.host}`); 24 | } 25 | this.imageBedInstance = new upyun.Client(new upyun.Service(this.config.bucket, secretId, secretKey)); 26 | } 27 | 28 | static getInstance(config) { 29 | if (!this.instance) { 30 | this.instance = new UPClient(config); 31 | } 32 | return this.instance; 33 | } 34 | 35 | /** 36 | * 检查图床是否已经存在图片,存在则返回url,不存在返回空 37 | * 38 | * @param {string} fileName 文件名 39 | * @return {Promise} 图片url 40 | */ 41 | async hasImage(fileName) { 42 | try { 43 | const res = await this.imageBedInstance.headFile(`${this.config.prefixKey}/${fileName}`); 44 | if (res) { 45 | return `${this.config.host}/${this.config.prefixKey}/${fileName}`; 46 | } 47 | return ''; 48 | } catch (e) { 49 | out.warn(`上传图片失败,请检查: ${e}`); 50 | return ''; 51 | } 52 | } 53 | 54 | /** 55 | * 上传图片到图床 56 | * 57 | * @param {Buffer} imgBuffer 文件buffer 58 | * @param {string} fileName 文件名 59 | * @return {Promise} 图床的图片url 60 | */ 61 | async uploadImg(imgBuffer, fileName) { 62 | try { 63 | const res = await this.imageBedInstance.putFile(`${this.config.prefixKey}/${fileName}`, imgBuffer); 64 | if (res) { 65 | return `${this.config.host}/${this.config.prefixKey}/${fileName}`; 66 | } 67 | out.warn('上传图片失败,请检查又拍云配置'); 68 | return ''; 69 | } catch (e) { 70 | out.warn(`上传图片失败,请检查: ${e}`); 71 | return ''; 72 | } 73 | } 74 | } 75 | 76 | module.exports = UPClient; 77 | 78 | -------------------------------------------------------------------------------- /util/img2cdn.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const superagent = require('superagent'); 4 | const getEtag = require('../lib/qetag'); 5 | const config = require('../config'); 6 | const out = require('../lib/out'); 7 | const ImageBed = require('./imageBeds'); 8 | const Queue = require('queue'); 9 | const lodash = require('lodash'); 10 | 11 | const imageBed = config.imgCdn.enabled ? ImageBed.getInstance(config.imgCdn) : null; 12 | 13 | // 获取语雀的图片链接的正则表达式 14 | const imageUrlRegExp = /!\[(.*?)]\((.*?)\)/mg; 15 | 16 | /** 17 | * 将图片转成buffer 18 | * 19 | * @param {string} yuqueImgUrl 语雀图片url 20 | * @return {Promise} 文件buffer 21 | */ 22 | async function img2Buffer(yuqueImgUrl) { 23 | return await new Promise(async function(resolve) { 24 | try { 25 | await superagent 26 | .get(yuqueImgUrl) 27 | .set('User-Agent', 'Mozilla/5.0') 28 | .buffer(true) 29 | .parse(res => { 30 | const buffer = []; 31 | res.on('data', chunk => { 32 | buffer.push(chunk); 33 | }); 34 | res.on('end', () => { 35 | const data = Buffer.concat(buffer); 36 | resolve(data); 37 | }); 38 | }); 39 | } catch (e) { 40 | out.warn(`invalid img: ${yuqueImgUrl}`); 41 | resolve(null); 42 | } 43 | }); 44 | } 45 | 46 | /** 47 | * 从markdown格式的url中获取url 48 | * 49 | * @param {string} markdownImgUrl markdown语法图片 50 | * @return {string} 图片url 51 | */ 52 | function getImgUrl(markdownImgUrl) { 53 | const _temp = markdownImgUrl.replace(/\!\[(.*?)]\(/, ''); 54 | const _temp_index = _temp.indexOf(')'); 55 | // 得到真正的语雀的url 56 | return _temp.substring(0, _temp_index) 57 | .split('#')[0]; 58 | } 59 | 60 | /** 61 | * 根据文件内容获取唯一文件名 62 | * 63 | * @param {Buffer} imgBuffer 文件buffer 64 | * @param {string} yuqueImgUrl 语雀图片url 65 | * @return {Promise} 图片文件名称 66 | */ 67 | async function getFileName(imgBuffer, yuqueImgUrl) { 68 | return new Promise(resolve => { 69 | getEtag(imgBuffer, hash => { 70 | const imgName = hash; 71 | const imgSuffix = yuqueImgUrl.substring(yuqueImgUrl.lastIndexOf('.')); 72 | const fileName = `${imgName}${imgSuffix}`; 73 | resolve(fileName); 74 | }); 75 | }); 76 | } 77 | 78 | 79 | /** 80 | * 将article中body中的语雀url进行替换 81 | * @param {*} article 文章 82 | * @return {*} 文章 83 | */ 84 | async function img2Cdn(article) { 85 | // 1。从文章中获取语雀的图片URL列表 86 | const matchYuqueImgUrlList = article.body.match(imageUrlRegExp); 87 | if (!matchYuqueImgUrlList) return article; 88 | const promiseList = matchYuqueImgUrlList.map(matchYuqueImgUrl => { 89 | return async () => { 90 | // 获取真正的图片url 91 | const yuqueImgUrl = getImgUrl(matchYuqueImgUrl); 92 | // 2。将图片转成buffer 93 | const imgBuffer = await img2Buffer(yuqueImgUrl); 94 | if (!imgBuffer) { 95 | return { 96 | originalUrl: matchYuqueImgUrl, 97 | yuqueRealImgUrl: yuqueImgUrl, 98 | url: yuqueImgUrl, 99 | }; 100 | } 101 | // 3。根据buffer文件生成唯一的hash文件名 102 | const fileName = await getFileName(imgBuffer, yuqueImgUrl); 103 | try { 104 | // 4。检查图床是否存在该文件 105 | let url = await imageBed.hasImage(fileName); 106 | let exists = true; 107 | // 5。如果图床已经存在,直接替换;如果图床不存在,则先上传到图床,再将原本的语雀url进行替换 108 | if (!url) { 109 | url = await imageBed.uploadImg(imgBuffer, fileName); 110 | exists = false; 111 | } 112 | return { 113 | originalUrl: matchYuqueImgUrl, 114 | yuqueRealImgUrl: yuqueImgUrl, 115 | url, 116 | exists, 117 | }; 118 | } catch (e) { 119 | out.error(`访问图床出错,请检查配置: ${e}`); 120 | return { 121 | yuqueRealImgUrl: yuqueImgUrl, 122 | url: '', 123 | }; 124 | } 125 | }; 126 | }); 127 | // 并发数 128 | const concurrency = config.imgCdn.concurrency || promiseList.length; 129 | const queue = new Queue({ concurrency, results: [] }); 130 | queue.push(...promiseList); 131 | await new Promise(resolve => { 132 | queue.start(() => { 133 | resolve(); 134 | }); 135 | }); 136 | const _urlList = queue.results; 137 | const urlList = lodash.flatten(_urlList); 138 | 139 | urlList.forEach(function(url) { 140 | if (url.url) { 141 | article.body = article.body.replace(url.originalUrl, `![](${url.url})`); 142 | if (url.exists) { 143 | out.info(`图片已存在 skip: ${url.url}`); 144 | } else { 145 | out.info(`replace ${url.yuqueRealImgUrl} to ${url.url}`); 146 | } 147 | } else { 148 | out.warn(`图片替换失败,将使用原url: ${url.yuqueRealImgUrl}`); 149 | } 150 | }); 151 | return article; 152 | } 153 | 154 | module.exports = img2Cdn; 155 | 156 | -------------------------------------------------------------------------------- /util/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const moment = require('moment'); 4 | const lodash = require('lodash'); 5 | const out = require('../lib/out'); 6 | 7 | const formatMarkdown = (() => { 8 | let prettier; 9 | try { 10 | prettier = require('prettier'); 11 | return body => prettier.format(body, { parser: 'markdown' }); 12 | } catch (error) { 13 | out.warn('Node 8 doesn\'t support prettier@latest (see: https://github.com/prettier/eslint-config-prettier/issues/140), the markdown will not be formated.'); 14 | return body => body; 15 | } 16 | })(); 17 | 18 | /** 19 | * 格式化 markdown 中的 tags 20 | * 21 | * @param {Array} tags tags 22 | * @return {String} body 23 | */ 24 | function formatTags(tags) { 25 | tags = Array.isArray(tags) ? tags : []; 26 | return `[${tags.join(',')}]`; 27 | } 28 | 29 | exports.formatTags = formatTags; 30 | 31 | /** 32 | * 格式化 front matter 中的可嵌套数组 33 | * 34 | * @param {Array} list list 35 | * @return {String} body 36 | */ 37 | function formatList(list = []) { 38 | const result = []; 39 | for (const item of list) { 40 | if (Array.isArray(item)) { 41 | result.push(formatList(item)); 42 | } else { 43 | result.push(item); 44 | } 45 | } 46 | return `[${result.join(',')}]`; 47 | } 48 | 49 | exports.formatList = formatList; 50 | 51 | /** 52 | * 格式化 markdown 内容 53 | * 54 | * @param {String} body md 文档 55 | * @return {String} body 56 | */ 57 | function formatRaw(body) { 58 | const multiBr = /(
[\s\n]){2}/gi; 59 | const multiBrEnd = /(
[\n]?){2}/gi; 60 | const brBug = /
/g; 61 | const hiddenContent = /
[\s\S]*?<\/div>/gi; 62 | // 删除语雀特有的锚点 63 | const emptyAnchor = /<\/a>/g; 64 | body = body 65 | .replace(hiddenContent, '') 66 | .replace(multiBr, '
') 67 | .replace(multiBrEnd, '
\n') 68 | .replace(brBug, '\n') 69 | .replace(emptyAnchor, ''); 70 | return formatMarkdown(body); 71 | } 72 | 73 | exports.formatRaw = formatRaw; 74 | 75 | /** 76 | * 判断是否为 post 77 | * 78 | * @param {*} post 文章 79 | * @return {Boolean} isPost 80 | */ 81 | function isPost(post) { 82 | return lodash.isObject(post) && post.body && post.title; 83 | } 84 | 85 | exports.isPost = isPost; 86 | 87 | function doubleDigit(num) { 88 | return num < 10 ? '0' + num : num; 89 | } 90 | 91 | exports.doubleDigit = doubleDigit; 92 | 93 | function formatDate(date) { 94 | return moment(new Date(date).toISOString()).format('YYYY-MM-DD HH:mm:ss ZZ'); 95 | } 96 | 97 | exports.formatDate = formatDate; 98 | 99 | function transformRes(res) { 100 | try { 101 | if (lodash.isString(res)) return res; 102 | return JSON.stringify(res); 103 | } catch (e) { 104 | return res; 105 | } 106 | } 107 | 108 | exports.transformRes = transformRes; 109 | --------------------------------------------------------------------------------