这一节,先去掉 url-join
,在 ts
环境下,似乎没法把 require
传递进去,所以我们只能暂时先抛弃这个功能。
npm install iconv-lite cheerio fs-extra --save
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>