• iconv-lite 是转码 gbk 工具,避免乱码
  • cheerio 是服务端的 jquery 解析器
  • fs-extra 文件增强模块
  1. npm install @types/cheerio @types/iconv-lite @types/fs-extra --save-dev

TypeScript 只有在有定义文件的前提下,才能提供代码提示。

  • 添加定义文件 src/main/phin.d.ts

并非所有模块都有定义文件的,没有定义文件就只能自己写定义文件了。

  1. declare module 'phin' {
  2. export function promisified(url: string, opts?: any): Promise<string>
  3. }

定义接口

为了使程序更加的 TypeScript,所以我们需要定义一些接口,来约定开发规范,新建 src/main/crawl.ts

  1. import { app } from 'electron'
  2. import { resolve } from 'path'
  3. export interface downloadOptions {
  4. // 下载选项
  5. path?: string // ? 表示可选属性,路径
  6. concurrence?: number // 并发量
  7. waitTime?: number // 等待事件
  8. charset?: string // 字符集
  9. }
  10. const defaultOptions: downloadOptions = {
  11. // 设置一个默认值
  12. path: resolve(app.getPath('home'), 'xiaoshuo'),
  13. concurrence: 4,
  14. waitTime: 500,
  15. charset: 'utf-8'
  16. }
  17. export { defaultOptions }
  18. export interface Chatper {
  19. // 章节的数据
  20. title: string
  21. url: string
  22. }
  23. export interface Crawl {
  24. // 爬取规则需要提供的选项
  25. opts?: downloadOptions // 爬取选项。
  26. text(select: any): Chatper[] // 章节内容爬取规则
  27. chapter(select: any, url: string): Chatper[] // 爬取章节的规则
  28. }

这个文件里面真正有作用的只有 defaultOptionsinterface 在编译的时候会被删除掉。也就是说,静态类型只会停留咋 ts 层面。

状态通信

为了让下载状态可以传输到渲染进程,我们需要创建 IPC 通信的信道,我们可以用 Subject 封装一下,Subject 的实例可以调用 next 方法,传递进去的参数,会原样传递给 subscribe 里面的回调。 新建 statusLog.ts

  1. import { mainWindow } from './'
  2. import { Subject } from 'rxjs'
  3. interface Log {
  4. type: string // 类型
  5. step?: string // 第几步
  6. percent?: number // 百分比
  7. message?: string // 消息
  8. [index: string]: any // 其他属性,只要属性名为 string 类型,值为 any 任意类型都可以
  9. }
  10. // 发送状态显示给用户
  11. const createLog = () => {
  12. const log$ = new Subject<Log>()
  13. log$.subscribe(
  14. log => mainWindow && mainWindow.webContents.send('download-status', log) // 主线程发送到渲染进程通过 webContents 上下文发送。
  15. )
  16. return log$.next.bind(log$)
  17. }
  18. // (log: Log) => void 表示匿名函数的类型, void 表示空
  19. const log: (log: Log) => void = createLog()
  20. export default log

这里我们还定义了一下通信的格式接口,传递信息给渲染进程,必须要拿到窗口实例,我们可以在 index.ts 里面导出一下这个实例。这里一定要绑定一下 this 的指向,要不然会报错。

  • 导入依赖与接口
  • 定义一些初始函数
  1. // 请求
  2. const request = (url: string) => from(promisified(url)) // promise 化
  3. // cheerio 载入
  4. if (text) {
  5. return cheerio(text)
  6. }
  7. return throwError('没有抓取到内容')
  8. }
  9. // 处理 301、302
  10. const handleFollowRedirect = (res: ServerRequest) => {
  11. const { headers } = res
  12. return request(headers['location']!) // 301 是 location 跳转
  13. }
  14. return of(res) // 再次通过 rxjs 实例包裹
  15. }

from 会把 event 事件、Promise 、数组转换成 Rxjs 的 Observable,这样我们就可以使用 Rxjs 的操作符了,对于 Observable 可以理解为 Promise 多次触发版本,或者数组,亦或者 stream,且用 subscribe 代替了 thenof 其实就是类似与数组的 Array.of 表示用 Observable 包裹一下这个变量。

对于 headers 里面有 location 的,表示有重定向,再次请求这个地址即可,不过为了统一,都输出 Observable 的实例,所有用 of 包裹了一下原来的响应数据。

在后面加一个 ! 叹号表示,它一定不为空。

构建选择器

这两段代码可能有些小难,可以参考源码里面完成的内容进行阅读。

  1. // 解码
  2. const decodeCharset = (charset: string = 'utf-8') => (text: Buffer) =>
  3. decode(text, charset)
  4. // 获取选择器
  5. const getSelector = (url: string, charset?: string) =>
  6. request(url).pipe(
  7. map(handleFollowRedirect), // 对结果处理 301
  8. concatAll(), // 假如处理了 301 , 是 2 层的 Observable 实例 ,铺平它
  9. pluck('body'), // 拿到 res.body
  10. map(decodeCharset(charset)), // 转码
  11. map(toSelector), // 构造选择器
  12. catchError(err => {
  13. // 捕获错误
  14. log({ type: 'crawl', step: 'error', message: err.message })
  15. return of(err)
  16. })
  17. )

首先对解码进行柯里化,map 就像数组 [].map 对数组里面的每一个元素进行操作,这不过我们这里面只有一个数据,即请求 url 的结果。为了处理跳转,我们又将它的返回值变成了 Observable 的实例,也就是说需要 subscribe 才能拿到里面的结果。他就像一个高阶的 ObservableObservable 里面还有一个 Observable ,使用 concatAll 可以把它解出来,并连接起来。这样我们就可以得到响应的结果,通过 pluck('body') 拿到 body 属性,然后解码,装载 cherrio ,最后有一个捕获错误的回调,捕获错误的回调还是要有返回一个 Observable 的。

下载章节

 执行下载章节逻辑,这里我们通过 log 把消息发送到渲染进程,这样就可以显示百分比。

  1. async function downloadChapter(
  2. url: string,
  3. crawl: Crawl,
  4. opts: Required<downloadOptions>
  5. ) {
  6. log({ type: 'crawl', step: 'chapter', percent: 0 })
  7. const { path, charset } = opts
  8. const selector = await getSelector(url, charset).toPromise()
  9. log({ type: 'crawl', step: 'chapter', percent: 50 })
  10. const chatpers = crawl.chapter(selector, url)
  11. const savePath = resolve(path, `chapters.json`)
  12. await writeJSON(savePath, chatpers)
  13. log({ type: 'crawl', step: 'chapter', percent: 100 })
  14. }

Required<T> 可以将属性都变成必须存在的。toPromise() 可以将 Observable 转换为 Promise 对象处理。

其实也可以用 Rxjs 来改造一下

  1. async function downloadChapter(
  2. url: string,
  3. crawl: Crawl,
  4. opts: Required<downloadOptions>
  5. ) {
  6. const { path, charset } = opts
  7. const savePath = resolve(path, `chapters.json`)
  8. return getSelector(url, charset)
  9. .pipe(
  10. tap(() => log({ type: 'crawl', step: 'chapter', percent: 30 })), // tap 表示处理的时候顺带执行以下这里面的内容,但是不修改原来的数据
  11. map($ => crawl.chapter($, url)),
  12. tap(() => log({ type: 'crawl', step: 'chapter', percent: 60 })),
  13. map(chatpers => writeJSON(savePath, chatpers)),
  14. tap(() => log({ type: 'crawl', step: 'chapter', percent: 100 })),
  15. catchError(err => {
  16. log({ type: 'crawl', step: 'error', message: err.message })
  17. return of(err)
  18. })
  19. )
  20. .toPromise()
  21. }

并发控制下载所有内容

这次我们换一种方式来实现,先分割数组,然后通过闭包构建一个懒函数,在下载的时候再获取 Promise 数组。

  1. const chunk = (array: any[], chunkSize: number) => {
  2. let index = 0
  3. let retArr = []
  4. while (index <= array.length) {
  5. retArr.push(array.slice(index, index + chunkSize))
  6. index += chunkSize
  7. }
  8. return retArr

chunk 最主要用来分割,分割成二维数组。

  1. const invoke = (fn: Function) => fn() // 调用传递进来的函数
  2. async function downloadAllText(crawl: Crawl, opts: Required<downloadOptions>) {
  3. const chaptersPath = resolve(path, 'chapters.json')
  4. let chapters = await readJson(chaptersPath)
  5. const needInvoke = chapters.map((chapter: Chatper, i: number) => () =>
  6. downloadText(chapter, crawl, i, opts)
  7. ) // 需要被触发的函数数组
  8. let chaptersChunk = chunk(needInvoke, concurrence) // 分割
  9. for (let index = 0; index < chaptersChunk.length; index++) {
  10. const promies: Promise<void>[] = chaptersChunk[index].map(invoke) // 构建 promise 数组
  11. await Promise.all(promies) // 调用
  12. const percent = Math.ceil((index / chaptersChunk.length) * 100)
  13. const first =
  14. index * concurrence <= chapters.length - 1
  15. ? index * concurrence
  16. : chapters.length - 1 // 当前块第一个序号的索引
  17. log({
  18. type: 'crawl',
  19. step: 'text',
  20. percent,
  21. title: chapters[first].title
  22. })
  23. waitTime && (await timer(waitTime).toPromise())
  24. }
  25. }

timer 可以用来暂停执行,等待一小会,太快了容易报错。为了取到文章的标题,需要计算一下序号 first

导出函数

  1. async function download(url: string, crawl: Crawl, opts: downloadOptions) {
  2. opts = Object.assign({}, defaultOptions, crawl.opts, opts)
  3. try {
  4. await ensureDir(resolve(opts.path!, 'text')) // 确保文件存在
  5. await downloadChapter(url, crawl, opts as Required<downloadOptions>) // 下载章节
  6. await downloadAllText(crawl, opts as Required<downloadOptions>) // 下载内容
  7. } catch (e) {
  8. log({ type: 'crawl', step: 'error', message: e.message })
  9. }
  10. }
  11. export default download

将函数导出供 index.ts 使用

修改 index.ts , test.js 是跟上一节的类似,不过去掉了 require ,导出的就是一个对象。

  1. import { app, BrowserWindow, ipcMain as ipc } from 'electron'
  2. import { fromEvent, Subject } from 'rxjs'
  3. import download from './download'
  4. interface CombineEvent {
  5. // 将原来的两个参数转成对象的接口
  6. event: any
  7. args: any
  8. }
  9. function on(channel: string): Subject<CombineEvent> {
  10. const eventListner = new Subject<CombineEvent>() // 通过 next 可以派发时间的订阅者模式,里面每次派发的内容都是 CombineEvent
  11. ipc.on(channel, (event: any, args: any) => eventListner.next({ event, args }))
  12. return eventListner
  13. }
  14. function ready(): void {
  15. mainWindow = createMainWindow() // 创建主窗口
  16. on('download').subscribe(({ event, args }) => {
  17. // 接受到下载事件的时候
  18. const crawl = require('./test.js') // 命令行版本的源文件
  19. download(args.url, crawl, { path: './' }).catch(console.log)
  20. })
  21. }

新建 renderer/Status.svelte, 我们在这个里面发送下载事件,到主进程里面

修改 App.svelte,载入这个刚刚写好的组件。

  1. <h1>Hello {name}!</h1>
  2. <Status/>
  3. <style>
  4. h1 {
  5. color: purple;
  6. }
  7. </style>
  8. <script>
  9. import Status from "./Status.svelte";
  10. export default {
  11. components: {
  12. Status
  13. }
  14. };

download