章节内容
历史介绍
开发环境
代码封装
函数式
获取小说命令行版(上)
获取小说命令行版(中)
获取小说命令行版(下)
嵌入到应用中
插件机制
文字转语音
设置与信号中断
插件商店
下载与删除(上)
下载与删除(下)
通知栏程序
播放功能
美化界面
数据可视化(上)
数据可视化(中)
数据可视化(下)
嵌入到应用中
打包应用

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

# 安装依赖

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

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

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

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

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

# 定义接口

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

import { app } from 'electron'
import { resolve } from 'path'

export interface downloadOptions {
  // 下载选项
  path?: string // ? 表示可选属性,路径
  concurrence?: number // 并发量
  waitTime?: number // 等待事件
  charset?: string // 字符集
}

const defaultOptions: downloadOptions = {
  // 设置一个默认值
  path: resolve(app.getPath('home'), 'xiaoshuo'),
  concurrence: 4,
  waitTime: 500,
  charset: 'utf-8'
}

export { defaultOptions }

export interface Chatper {
  // 章节的数据
  title: string
  url: string
}

export interface Crawl {
  // 爬取规则需要提供的选项
  opts?: downloadOptions // 爬取选项。
  text(select: any): Chatper[] // 章节内容爬取规则
  chapter(select: any, url: string): Chatper[] // 爬取章节的规则
}

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

# 状态通信

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

封装成 subject 有啥好处?

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

import { mainWindow } from './'
import { Subject } from 'rxjs'

interface Log {
  type: string // 类型
  step?: string // 第几步
  percent?: number // 百分比
  message?: string // 消息
  [index: string]: any // 其他属性,只要属性名为 string 类型,值为 any 任意类型都可以
}

// 发送状态显示给用户
const createLog = () => {
  const log$ = new Subject<Log>()
  log$.subscribe(
    log => mainWindow && mainWindow.webContents.send('download-status', log) // 主线程发送到渲染进程通过 webContents 上下文发送。
  )
  return log$.next.bind(log$)
}
// (log: Log) => void 表示匿名函数的类型, void 表示空
const log: (log: Log) => void = createLog()

export default log

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

# 下载

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

  • 导入依赖与接口
import { promisified } from 'phin'
import { ServerRequest } from 'http'
import { decode } from 'iconv-lite'
import { load as cheerio } from 'cheerio'
import { writeJSON, ensureDir, readJson } from 'fs-extra'
import { resolve } from 'path'

import { from, of, timer, throwError } from 'rxjs'
import { pluck, map, concatAll } from 'rxjs/operators'

import log from './statusLog'
import { downloadOptions, defaultOptions, Crawl, Chatper } from './crawl'
  • 定义一些初始函数
// 请求
const request = (url: string) => from(promisified(url)) // promise 化

// cheerio 载入
const toSelector = (text: string) => {
  if (text) {
    return cheerio(text)
  }
  return throwError('没有抓取到内容')
}

// 处理 301、302
const handleFollowRedirect = (res: ServerRequest) => {
  const { headers } = res
  if (headers['location']) {
    return request(headers['location']!) // 301 是 location 跳转
  }
  return of(res) // 再次通过 rxjs 实例包裹
}

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

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

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

# 构建选择器

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

// 解码
const decodeCharset = (charset: string = 'utf-8') => (text: Buffer) =>
  decode(text, charset)

// 获取选择器
const getSelector = (url: string, charset?: string) =>
  request(url).pipe(
    map(handleFollowRedirect), // 对结果处理 301
    concatAll(), // 假如处理了 301 , 是 2 层的 Observable 实例 ,铺平它
    pluck('body'), // 拿到 res.body
    map(decodeCharset(charset)), // 转码
    map(toSelector), // 构造选择器
    catchError(err => {
      // 捕获错误
      log({ type: 'crawl', step: 'error', message: err.message })
      return of(err)
    })
  )

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

# 下载章节

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

async function downloadChapter(
  url: string,
  crawl: Crawl,
  opts: Required<downloadOptions>
) {
  log({ type: 'crawl', step: 'chapter', percent: 0 })
  const { path, charset } = opts
  const selector = await getSelector(url, charset).toPromise()
  log({ type: 'crawl', step: 'chapter', percent: 50 })
  const chatpers = crawl.chapter(selector, url)
  const savePath = resolve(path, `chapters.json`)
  await writeJSON(savePath, chatpers)
  log({ type: 'crawl', step: 'chapter', percent: 100 })
}

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

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

async function downloadChapter(
  url: string,
  crawl: Crawl,
  opts: Required<downloadOptions>
) {
  const { path, charset } = opts
  const savePath = resolve(path, `chapters.json`)
  return getSelector(url, charset)
    .pipe(
      tap(() => log({ type: 'crawl', step: 'chapter', percent: 30 })), // tap 表示处理的时候顺带执行以下这里面的内容,但是不修改原来的数据
      map($ => crawl.chapter($, url)),
      tap(() => log({ type: 'crawl', step: 'chapter', percent: 60 })),
      map(chatpers => writeJSON(savePath, chatpers)),
      tap(() => log({ type: 'crawl', step: 'chapter', percent: 100 })),
      catchError(err => {
        log({ type: 'crawl', step: 'error', message: err.message })
        return of(err)
      })
    )
    .toPromise()
}

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

# 下载单个内容

async function downloadText(
  chapter: Chatper,
  crawl: Crawl,
  index: number,
  opts: Required<downloadOptions>
) {
  const { path, charset } = opts
  const savePath = resolve(path, `text/${index}-${chapter.title}.json`) // 存储路径
  const selector = await getSelector(chapter.url, charset).toPromise() // $ 选择器
  const text = crawl.text(selector)
  await writeJSON(savePath, text)
}

# 并发控制下载所有内容

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

const chunk = (array: any[], chunkSize: number) => {
  let index = 0
  let retArr = []
  while (index <= array.length) {
    retArr.push(array.slice(index, index + chunkSize))
    index += chunkSize
  }
  return retArr
}

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

const invoke = (fn: Function) => fn() // 调用传递进来的函数

async function downloadAllText(crawl: Crawl, opts: Required<downloadOptions>) {
  const { path, concurrence, waitTime } = opts
  const chaptersPath = resolve(path, 'chapters.json')
  let chapters = await readJson(chaptersPath)

  const needInvoke = chapters.map((chapter: Chatper, i: number) => () =>
    downloadText(chapter, crawl, i, opts)
  ) // 需要被触发的函数数组

  let chaptersChunk = chunk(needInvoke, concurrence) // 分割

  for (let index = 0; index < chaptersChunk.length; index++) {
    const promies: Promise<void>[] = chaptersChunk[index].map(invoke) // 构建 promise 数组
    await Promise.all(promies) // 调用
    const percent = Math.ceil((index / chaptersChunk.length) * 100)
    const first =
      index * concurrence <= chapters.length - 1
        ? index * concurrence
        : chapters.length - 1 // 当前块第一个序号的索引
    log({
      type: 'crawl',
      step: 'text',
      percent,
      title: chapters[first].title
    })
    waitTime && (await timer(waitTime).toPromise())
  }
}

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

# 导出函数

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

export default download

将函数导出供 index.ts 使用

# 测试功能

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

import { app, BrowserWindow, ipcMain as ipc } from 'electron'
import { fromEvent, Subject } from 'rxjs'
import download from './download'

interface CombineEvent {
  // 将原来的两个参数转成对象的接口
  event: any
  args: any
}

function on(channel: string): Subject<CombineEvent> {
  const eventListner = new Subject<CombineEvent>() // 通过 next 可以派发时间的订阅者模式,里面每次派发的内容都是 CombineEvent
  ipc.on(channel, (event: any, args: any) => eventListner.next({ event, args }))
  return eventListner
}

function ready(): void {
  mainWindow = createMainWindow() // 创建主窗口
  on('download').subscribe(({ event, args }) => {
    // 接受到下载事件的时候
    const crawl = require('./test.js') // 命令行版本的源文件
    download(args.url, crawl, { path: './' }).catch(console.log)
  })
}

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

<div>
    <p>{JSON.stringify(status)}</p>
    <p>{JSON.stringify(errors)}</p>
    <button on:click="download()">xiazai</button>
</div>

<script>
    import {
        ipcRenderer
    } from "electron";
    export default {
        data() {
            return {
                status: null, // 当前状态
                errors: [] // 存储错误
            };
        },
        oncreate() { // 创建的时候调用的生命周期函数
            ipcRenderer.on("download-status", (event, args) => { // 接受下载状态
                if (args.step == "error") {
                    console.log(args);
                    const {
                        errors
                    } = this.get() // get 获取 data ,set 设置 data
                    console.log(errors);
                    errors.push(args)
                    return this.set({
                        errors
                    })
                }
                this.set({
                    status: args
                });
            });
        },
        methods: {
            download() {
                console.log("download");
                ipcRenderer.send("download", { // 发送下载信号,与下载的地址
                    url: "https://www.ybdu.com/xiaoshuo/0/910"
                });
            }
        }
    };
</script>

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

<h1>Hello {name}!</h1>
<Status/>

<style>
    h1 {
        color: purple;
    }
</style>

<script>
    import Status from "./Status.svelte";

    export default {
        components: {
            Status
        }
    };
</script>

download