这一节,先去掉 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>