持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第20天,点击查看活动详情
爬虫原理
爬取数据其实就是向对应的服务器发送请求,相当于用浏览器访问页面,这样会得到一个html的字符串,然后对字符串进行分析,获取我们想要的数据。
对于nodejs编写的爬虫来说,主要用的两个的库是superagent和cheerio。
- superagent:是用nodejs的API封装的http请求库,即可以用在服务端也可以用在浏览器端。
- cheerio: 是jquery核心功能的一个快速灵活而又简洁的实现,主要是为了用在服务器端需要对DOM进行操作的地方,让你在服务器端和html愉快的玩耍。
关于JSDOM
JSDOM是一个纯粹由 javascript 实现的一系列 web 标准,专门在 nodejs 中使用,目标是模拟一个浏览器环境,以便用于测试和挖掘真实世界的 web 应用程序。也就是说 JSDOM 是一个nodejs版的浏览器。
cheerio 产生的原因是出于对JSDOM的失望:主要体现在以下几点:
- JSDOM太过于重量级:JSDOM的目标是提供与浏览器一样的DOM环境,但是我们往往不需要这样。我们需要的只是一种简单,熟悉的方式来操作我们的HTML。
- JSDOM太慢了:用JSDOM解析大的网站甚至可以产生可察觉的延迟
cheerio并非万能,当你需要一个浏览器一样的环境时,你最好还是用JSDOM,尤其是你需要进行自动化的功能测试时。
第一版爬虫
写爬虫分为两步:一是获取网站元素html信息;二是对信息进行分析。
前面的文章详细学习了设计模式,下面遵照单一职责原则来写爬虫功能,即一个函数只做一件事事情:
- 利用superagent库发送请求,获取网页html信息
- 利用cheerio来提取html中有用的信息
- 把获取的信息存储在一个文件中,分为两步:首先按格式要求生成存储的信息,然后写入到对应的文件中
import superagent from 'superagent'
import cheerio from 'cheerio'
import path from 'path'
import fs from 'fs'
interface ICourse {
title: string
count: number
}
interface ICourseResult {
time: number
data: ICourse[]
}
interface IContent {
[prop: number]: ICourse[]
}
class Crowller {
private url = 'http://localhost:3004'
private filePath = path.resolve(__dirname, '../data/course.json')
constructor() {
this.init()
}
async init() {
// 利用superagent库发送请求,获取网页html信息
const html = await this.getRawHtml()
// 利用cheerio来提取html中有用的信息
const courseResult = this.getCourseInfo(html)
// 把获取的信息存储在一个文件中,分为两步:首先生成存储的信息,然后写入到对应的文件中
const content = this.genCourseContent(courseResult)
this.writeFile(JSON.stringify(content))
}
async getRawHtml() {
const result = await superagent.get(this.url)
return result.text
}
getCourseInfo(html: string): ICourseResult {
const $ = cheerio.load(html)
const courseItems = $('li')
const courseInfos: ICourse[] = []
courseItems.map((index, element) => {
const title = $(element).find('.title').text()
const count = parseInt($(element).find('.study').text().split(':')[1])
courseInfos.push({
title,
count
})
})
return {
time: Date.now(),
data: courseInfos
}
}
genCourseContent(courseInfo: ICourseResult): IContent {
let fileContent: IContent = {}
if (fs.existsSync(this.filePath)) {
fileContent = JSON.parse(fs.readFileSync(this.filePath, 'utf-8'))
}
fileContent[courseInfo.time] = courseInfo.data
return fileContent
}
writeFile(content: string) {
fs.writeFileSync(this.filePath, content, 'utf-8')
}
}
复制代码
第二版爬虫
第一版爬虫存在如下问题:
- 爬取的url写死了,我们需要外部传入。
- 获取html的信息,以及把信息写入到文件中是爬虫公共的方法,但是提取有用的信息和生成特殊格式的存储信息每个用户的需求不一样,所以要把这两个功能单独提取出来。
crowller.ts
import superagent from 'superagent'
import path from 'path'
import fs from 'fs'
import CourseAnalyzer from './courseAnalyzer'
export interface IAnalyzer {
analyze: (html: string, filePath: string) => string
}
class Crowller {
private filePath = path.resolve(__dirname, '../data/course.json')
constructor(private url: string, private analyzer: IAnalyzer) {
this.init()
}
async init() {
const html = await this.getRawHtml()
const content = this.analyzer.analyze(html, this.filePath)
this.writeFile(content)
}
async getRawHtml() {
const result = await superagent.get(this.url)
return result.text
}
writeFile(content: string) {
fs.writeFileSync(this.filePath, content, 'utf-8')
}
}
// 爬取的url由外面传入
const url = 'http://localhost:3004'
const analyzer = new CourseAnalyzer()
new Crowller(url, analyzer)
复制代码
courseAnalyzer.ts
import cheerio from 'cheerio'
import fs from 'fs'
import { IAnalyzer } from './crowller'
interface ICourse {
title: string
count: number
}
interface ICourseResult {
time: number
data: ICourse[]
}
interface IContent {
[prop: number]: ICourse[]
}
class CourseAnalyzer implements IAnalyzer {
private getCourseInfo(html: string): ICourseResult {
const $ = cheerio.load(html)
const courseItems = $('li')
const courseInfos: ICourse[] = []
courseItems.map((index, element) => {
const title = $(element).find('.title').text()
const count = parseInt($(element).find('.study').text().split(':')[1])
courseInfos.push({
title,
count
})
})
return {
time: Date.now(),
data: courseInfos
}
}
private genCourseContent(
courseInfo: ICourseResult,
filePath: string
): IContent {
let fileContent: IContent = {}
if (fs.existsSync(filePath)) {
fileContent = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
}
fileContent[courseInfo.time] = courseInfo.data
return fileContent
}
analyze(html: string, filePath: string) {
const result = this.getCourseInfo(html)
const content = this.genCourseContent(result, filePath)
return JSON.stringify(content)
}
}
export default CourseAnalyzer
复制代码
改进点:
- 爬取的url由用户自己传入
- 把公共方法
getRawHtml
,writeFile
仍然写在crowller类中,把需求不固定的方法getCourseInfo
,genCourseContent
单独拆分出来放在CourseAnalyzer
类中,同时对外暴露analyze
方法 - 在使用的时候,实例化一个CourseAnalyzer类,然后把它传入到crowller的实例当中,然后调用CourseAnalyzer.analyze方法获取爬取的有用信息
const url = 'http://localhost:3004'
const analyzer = new CourseAnalyzer()
new Crowller(url, analyzer)
复制代码
如果这个时候你想爬取另外一个网站,这个时候你只需要重新写一个类似CourseAnalyzer类即可,比如:
import { IAnalyzer } from './crowller'
class FoodAnalyzer implements IAnalyzer {
analyze(html: string, filePath: string) {
// 写你需要的逻辑即可
return ''
}
}
export default FoodAnalyzer
const url = 'http://localhost:3005'
const analyzer = new FoodAnalyzer()
new Crowller(url, analyzer)
复制代码
这种模式叫做组合设计模式。
上面有几个点需要注意:
- 我们使用一个接口代替类作为类型,这样更加通用。如果你引入了一个类的实例作为变量,当给这个变量指定类型的时候,怎么办呢?我们就可以使用接口interface来进行,具体见代码:
export interface IAnalyzer {
analyze: (html: string, filePath: string) => string
}
constructor(private url: string, private analyzer: IAnalyzer) {
this.init()
}
复制代码
- 组合设计模式:我们把爬虫的实现和数据的分析分成两个类来使用,这样就是实现了一定程度的解耦,当后期修改分析方法的时候,就可以不同改动爬虫里面的代码,只需要再写一个分析方法即可。
第三版爬虫
我们把CourseAnalyzer
称之为分析器,在使用的时候我们可以多次 new 很多实例的,但是这是没有必要的,所以我们需要把CourseAnalyzer
改造成单例模式。
class CourseAnalyzer implements IAnalyzer {
private static instance: CourseAnalyzer
static getInstance() {
if (!this.instance) {
this.instance = new CourseAnalyzer()
}
return this.instance
}
private constructor() {}
}
const analyzer = CourseAnalyzer.getInstance()
复制代码