这一节,先去掉 url-join ,在 ts 环境下,似乎没法把 require 传递进去,所以我们只能暂时先抛弃这个功能。

安装依赖

  1. npm install iconv-lite cheerio fs-extra --save
  • 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

封装成 subject 有啥好处?

单个来看,其实没啥好处。只不过比较 Rxjs,容易跟其他操作符结合。

  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 的指向,要不然会报错。

下载

现在我们将上一节的内容改成 TypeScript 的版本,一些检测函数我用 fs-extra 进行了代替,这样看起来会更简洁些。

  • 导入依赖与接口
  1. import { promisified } from 'phin'
  2. import { ServerRequest } from 'http'
  3. import { decode } from 'iconv-lite'
  4. import { load as cheerio } from 'cheerio'
  5. import { writeJSON, ensureDir, readJson } from 'fs-extra'
  6. import { resolve } from 'path'
  7. import { from, of, timer, throwError } from 'rxjs'
  8. import { pluck, map, concatAll } from 'rxjs/operators'
  9. import log from './statusLog'
  10. import { downloadOptions, defaultOptions, Crawl, Chatper } from './crawl'
  • 定义一些初始函数
  1. // 请求
  2. const request = (url: string) => from(promisified(url)) // promise 化
  3. // cheerio 载入
  4. const toSelector = (text: string) => {
  5. if (text) {
  6. return cheerio(text)
  7. }
  8. return throwError('没有抓取到内容')
  9. }
  10. // 处理 301、302
  11. const handleFollowRedirect = (res: ServerRequest) => {
  12. const { headers } = res
  13. if (headers['location']) {
  14. return request(headers['location']!) // 301 是 location 跳转
  15. }
  16. return of(res) // 再次通过 rxjs 实例包裹
  17. }

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嵌入到应用中 - 图1 可以把它解出来,并连接起来。这样我们就可以得到响应的结果,通过 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. }

tap 是按照原样输出,就像 1 乘以任何数都等于它原来的数,只不过执行一下里面的方法而已。

下载单个内容

  1. async function downloadText(
  2. chapter: Chatper,
  3. crawl: Crawl,
  4. index: number,
  5. opts: Required<downloadOptions>
  6. ) {
  7. const { path, charset } = opts
  8. const savePath = resolve(path, `text/${index}-${chapter.title}.json`) // 存储路径
  9. const selector = await getSelector(chapter.url, charset).toPromise() // $ 选择器
  10. const text = crawl.text(selector)
  11. await writeJSON(savePath, text)
  12. }

并发控制下载所有内容

这次我们换一种方式来实现,先分割数组,然后通过闭包构建一个懒函数,在下载的时候再获取 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
  9. }

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

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

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, 我们在这个里面发送下载事件,到主进程里面

  1. <div>
  2. <p>{JSON.stringify(status)}</p>
  3. <p>{JSON.stringify(errors)}</p>
  4. <button on:click="download()">xiazai</button>
  5. </div>
  6. <script>
  7. import {
  8. ipcRenderer
  9. } from "electron";
  10. export default {
  11. data() {
  12. return {
  13. status: null, // 当前状态
  14. errors: [] // 存储错误
  15. };
  16. },
  17. oncreate() { // 创建的时候调用的生命周期函数
  18. ipcRenderer.on("download-status", (event, args) => { // 接受下载状态
  19. if (args.step == "error") {
  20. console.log(args);
  21. const {
  22. errors
  23. } = this.get() // get 获取 data ,set 设置 data
  24. console.log(errors);
  25. errors.push(args)
  26. return this.set({
  27. errors
  28. })
  29. }
  30. this.set({
  31. status: args
  32. });
  33. });
  34. },
  35. methods: {
  36. download() {
  37. console.log("download");
  38. ipcRenderer.send("download", { // 发送下载信号,与下载的地址
  39. url: "https://www.ybdu.com/xiaoshuo/0/910"
  40. });
  41. }
  42. }
  43. };
  44. </script>

修改 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. };
  15. </script>

download