TypeScript Node实现下载简书文章图片工具

写在前面

经常的写作的人都有备份的好习惯,为了防止自己的文章丢失,简书提供了下载所有文章功能,可以让作者将文章下载到本地保存,或者上传到自己的站点。


但是简书的图片是放在专门的图片服务器上的,下载所有文章并不包含文章中的所有图片。所以我们现在写个小工具,通过命令行的方式将文章中的所有图片下载本地保存。

需求实现步骤

  • 下载简书文章,解压到 A 目录;
  • 建一个 TypeScript + Node 项目,读取 A 目录中的所有 .md 文件;
  • 提取文件内容中的图片链接,下载下来;
  • 把下载的图片放到 B 目录/当前文章/ 中,用来分类;
  • 重构优化代码;

下面按照这几个步骤一步步完成简书下载图片工具。

下载简书文章

进入我的简书 ->账号管理 打包下载全部的简书文章即可,我是下载到了这个目录 D:\jianshu_article\user-5541401-1565071963,这个目录下的所有文件都是文集/文章的格式。接下来开始搭建项目结构。

TypeScript Node 搭建项目

先在 github 上新建一个仓库,然后 clone 下来。开发工作一直在 master 分支上,然后每完成一步需求,新建一个分支用来保留记录,以后看的时候更清晰。

新建一个仓库然后 clone 下来:
git clone git@github.com:mxcz213/download-jianshu-images.git

开始项目搭建:
  • 生成 package.json 文件;
npm init -f
  • 下载项目依赖 :typescript node 的ts 版本,download下载文件包,runscript 用来执行 shell 命令,ts-node 用来开发调试;
npm install @types/node download runscript ts-node typescript --save-dev
  • 配置 tsconfig 文件,用来按照这个规则编译 ts 文件为 js 文件。执行命令 tsc --init,自动生成 tsconfig.json 文件;
//tsconfig.json
{
  "compilerOptions": {
    "target": "es5",  
    "module": "commonjs",
    "outDir": "./dist/", 
    "strict": true,
    "esModuleInterop": true                  
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
  • 配置 package.json 文件的 scripts 字段,启动项目和编译命令
{
  "name": "download-jianshu-images",
  "version": "1.0.0",
  "description": "Node + typescript 实现下载简书文章中所有的图片链接",
  "main": "dist/index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "tsc",
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/mxcz213/download-jianshu-images.git"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "bugs": {
    "url": "https://github.com/mxcz213/download-jianshu-images/issues"
  },
  "homepage": "https://github.com/mxcz213/download-jianshu-images#readme",
  "devDependencies": {
    "@types/node": "^12.6.9",
    "download": "^7.1.0",
    "runscript": "^1.4.0",
    "ts-node": "^8.3.0",
    "typescript": "^3.5.3"
  }
}
  • 配置 vscode 的调试脚本 launch.json
//.vscode/launch.json
{
    "version": "0.2.0",
    "configurations": [

        {
            "name": "Current TS File",
            "type": "node",
            "request": "launch",
            "program": "${workspaceRoot}/node_modules/ts-node/dist/bin.js",
            // "program": "${workspaceRoot}/test.js",
            "args": [
                "${relativeFile}"
            ],
            "cwd": "${workspaceRoot}",
            "protocol": "inspector"
        }
    ]
}
  • 添加 .gitignore 文件配置忽略提交的目录
//.gitignore
/node_modules
  • 新建 dist 目录用来放编译之后的 js 文件
  • 新建 src 源代码文件目录

具体代码实现,新建 src/index.ts 文件

//src index.ts
const fs = require('fs')
const path = require('path')
const runScript = require('runscript')
const download = require('download')

//windows中用户复制的目录
let originDir: string = 'D:\\jianshu_article\\user-5541401-1565071963\\'
let targetDir: string = 'E:\\workCode\\download-jianshu-images\\jianshu_article\\'

const readmeUrlReg: RegExp = /\s!\[\]\(https:\/\/\upload-images.jianshu.io\/upload_images\/[a-zA-Z0-9-_?%./]+\)\s/g
const imageUrlReg: RegExp = /https:\/\/\upload-images.jianshu.io\/upload_images\/[a-zA-Z0-9-_?%./]+/g

//用户通过命令行工具输入命令比如:node dist/index.js 简书解压目录 目标存储图片目录
process.argv.forEach((val, index) => {
    console.log(`${index}: ${val}`)
});

try {
    originDir = process.argv[2] ? process.argv[2] : originDir
    targetDir = process.argv[3] ? process.argv[3] : targetDir
} catch(e) {
    console.log('获取命令参数错误', e)
}

const downloadImages = (imgurl: string[], path: string) => {   
    let newUrlArr: any[] = []
    imgurl.forEach((item: any) => {
        if(item.match(imageUrlReg)){
            newUrlArr.push(item.match(imageUrlReg)[0])
        }
    })
    console.log(newUrlArr)
    Promise.all(newUrlArr.map((url: string) => {
        download(url, path)
    })).then(() => {
       console.log('all files downloaded')
    })
}

const runFunction = async () => {
    //shell ls拿到所有的.md文章
    const { stdout } = await runScript('ls **/*.md', {
        cwd: originDir,
        stdio: 'pipe'
    })
    let files: string[] = stdout.toString().split('\n')
    let num: number = 0
    try {
        files.forEach((fileitem: any, index: number) => {
            if(fileitem){
                let filepath: string = fileitem.split('.md')[0].split('/').join('\\')
                let dirStr: string = `${targetDir}\\${filepath}`                
                runScript(`mkdir ${dirStr}`, { stdio: 'pipe' })
                .then((stdio: any) => {
                    let fileContent = fs.readFileSync(path.join(originDir, fileitem.split('/').join('\\')), { encoding: 'utf8'})
                    let urlList: any = fileContent.match(readmeUrlReg)
                    if(urlList && urlList.length > 0){
                        downloadImages(urlList, dirStr)
                    }
                })
            }
        })
    } catch(e) {
        console.log(e)
    }
}
runFunction()
  • 执行命令 npm run build 编译 ts 文件
  • 执行命令node . D:\jianshu_article\user-5541401-1565071963 D:\jianshu_article\article_img,下载图片
    node . 命令会到 package.json 文件中找到 main 字段执行入口文件。
    process.argv 会获取到命令行参数。

接下来提交文件到 master 分支:

git add .
git commit -m "download jianshu images"
git push

然后根据 master 新建一个分支,用来保存这次的提交历史:

git checkout -b node_tool
git pull origin master
git push

实现工具命令,如 jianshu ...

配置命令行,通过 package.json 文件的 bin 字段,然后新建 bin 目录,在 bin 目录下新建 jianshu 文件;

//package.json
{
  ...
  "bin": {
    "jianshu": "bin/jianshu"
  }
  ...
}
//bin/jianshu
#!/usr/bin/env node

require('../dist/index');

配置完就可以通过命令 jianshu D:\jianshu_article\user-5541401-1565071963 D:\jianshu_article\article_img 实现下载图片。

通过const [, , sourceDir, targetDir] = process.argv;来获取命令行参数。

提交代码之后,这一步同样新建 node_cli 分支用来保存历史:

git checkout -b node_cli
git pull origin master
git pull

代码重构优化

上面的代码只是实现的简单的功能,流程并不清晰,现在来重构代码,使主流程变的清晰。

代码重构的原则:主流程要清晰

每个函数只做一件事,有两个以上的函数,有内部函数式,就要考虑把这每个函数放到单独的文件里,然后用模块导入的方式。

以上代码展现的问题:
    1. handleDir getArticleContent 重复判断平台和路径,没有把判断平台提出来
    1. getMarkdownImageUrls getRealImageUrl 重复使用相似的正则,没使用exec和正则的捕获组
    1. 小函数嵌套太严重,一个函数能搞定的
    1. 没有异常判断,没有log
    1. 逻辑层次不清晰,分了好多层
    1. 关键注释缺失,例如files这个是相对路径的列表,不注明的话,以后肯定不知道

所以接下来就要重构这些代码,主要根据以下分类原则来实现模块的拆分:

分类原则

哪些是项目独有的逻辑(业务逻辑),
哪些是通用逻辑(可复用的),
哪些是模板代码(没啥用但是要写的)

根据以上原则,拆分出来工具函数 log,文件操作;核心函数 libs。

//src/utils/log.ts
const log = (str: string) => {
    console.log(str);
}
 const error = (str: string) => {
    console.error(str);
}
const warn = (str: string) => {
    console.warn(str);
}
export {
    log,
    error,
    warn
}
//src/utils/fs.ts
const fs = require('fs');
const runScript = require('runscript');
const download = require('download');

const read = (path: string, options?: {}) => {
    let fileContent = fs.readFileSync(path, options);
    return fileContent;
}
const createDir = async (targetDir: string) => {
    await runScript(`mkdir ${targetDir}`);
}
const deleteDir = async (targetDir: string) => {
    await runScript(`rd /s/q ${targetDir}`);
}
const isExistDir = (targetDir: string): boolean => {
    return fs.existsSync(targetDir);
}
const downloadFile = async(url: string, targetDir: string) => {
    await download(url, targetDir);
}

export {
    read,
    createDir,
    deleteDir,
    isExistDir,
    downloadFile
}
//src/libs/lib.ts
const runScript = require('runscript');
import { log } from '../utils/log';

//sourceDir:简书文章目录
export const getAllMarkdownFiles = async (sourceDir: string) => {
    //ls **/*.md 查询二级目录下的所有.md后缀的文件
    //stdio: pipe 在父进程和子进程之间建立管道
    const { stdout } = await runScript('ls **/*.md', {
        cwd: sourceDir,
        stdio: 'pipe'
    });
    const files: string[] = stdout.toString().split('\n');

    //去掉ls命令产生的尾部空行
    files.pop();
    log('获取所有的简书文章列表;');
    return files;
}

//获取图片url的markdown写法![](https://....)
export const getMarkdownImageUrls = (fileContent: string) => {
    const urlRegExp = /\!\[.*\]\((https?:\/\/.+?)\)/g;

    const imageUrls: string[] = [];
    while(true) {
        const match = urlRegExp.exec(fileContent);
        if(match === null) {
            break;
        }
        
        const [, url] = match;
        imageUrls.push(url);
    }
    return imageUrls;
}

主入口函数:

//src/index.ts
import path from 'path';
import { log } from './utils/log';
import { read, createDir, deleteDir, isExistDir, downloadFile } from './utils/fs';
import { getAllMarkdownFiles, getMarkdownImageUrls } from './libs/lib';

//入口函数
const main = async () => {
    //平台判断
    const { platform } = process;
    const isWindows: boolean = platform === 'win32';

    //获取命令行参数
    const [, , sourceDir, targetDir] = process.argv;

    //获取markdown文件列表
    const files: string[] = await getAllMarkdownFiles(sourceDir);

    //下载文件列表中每个文章的图片
    for(const file of files){
        // file 是相对路径 例如:"2017-2018/前端模块化总结.md"

        // 兼容 windows 系统路径规则
        let platFile: string = isWindows ? `${file.split('.md')[0].split('/').join('\\')}.md` : file;
        const filepath: string = platFile.split('.md')[0];

        //读取文件内容
        const filecontent = read(path.join(sourceDir, platFile), { encoding: 'utf8'});
        
        //根据 md 文件名,创建目标文件夹,如果目标文件夹存在,则删除重建
        const newTargetDir: string = path.join(targetDir, filepath);
        if(isExistDir(newTargetDir)){
            await deleteDir(newTargetDir);
        }
        await createDir(newTargetDir);

        //找出图片,下载图片到目标目录
        const urlList: string[] = getMarkdownImageUrls(filecontent);
        for(const url of urlList){
            await downloadFile(url, newTargetDir);
        }
    }

    log('所有文章中的图片已下载成功!');
}
main();

提交代码到 master 分支,然后新建 node-cli-refactory 分支用来保存重构历史。

git checkout -b node-cli-refactory
git pull origin master
git push

最后这个下载图片的小工具就做好。

总结:在写代码的过程中,一定要分析什么是通用工具类,什么是独有的业务逻辑类,该模块化的模块化,目的只有一个就是:主流程要清晰

项目地址:https://github.com/mxcz213/download-jianshu-images

参考:
https://www.npmjs.com/package/runscript
https://www.npmjs.com/package/download
https://www.npmjs.cn/files/package.json/

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,830评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,992评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,875评论 0 331
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,837评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,734评论 5 360
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,091评论 1 277
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,550评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,217评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,368评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,298评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,350评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,027评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,623评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,706评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,940评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,349评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,936评论 2 341

推荐阅读更多精彩内容

  • 概要 64学时 3.5学分 章节安排 电子商务网站概况 HTML5+CSS3 JavaScript Node 电子...
    阿啊阿吖丁阅读 9,039评论 0 3
  • 1 Node.js模块的实现 之前在网上查阅了许多介绍Node.js的文章,可惜对于Node.js的模块机制大都着...
    zlx_2017阅读 1,215评论 0 1
  • NPM NPM 是随同 Node 一起安装的包管理工具,能解决 Node 代码部署上的很多问题,常见的使用场景有以...
    heyi_let阅读 2,566评论 0 2
  • Swift1> Swift和OC的区别1.1> Swift没有地址/指针的概念1.2> 泛型1.3> 类型严谨 对...
    cosWriter阅读 11,076评论 1 32
  • 张小姐年方二八,正青春,一头乱糟糟的长发。一大早,闹钟一响,瞌睡魔把她的头裹进被子里,死守着她的睡眠。直到手机里第...
    9dc50ca4b68f阅读 338评论 0 2