petty-spider设计思路和使用说明

在18年的时候断断续续开始写petty-spider这个爬虫工具,是时候整理一下开发思路和相关实现了~(再不整理就快忘记了...

<!--more-->

本文主要说明当初设计这个框架的一些思路。

1. 将网络请求交给用户

爬虫框架最先需要考虑的是获取资源的内容,即如何发送网络请求并处理响应。实际上的抓取请求有各种情况,除了最基础的get请求url,还包括

  • get、post请求混用,通过js触发表单post提交页面
  • 各种请求返回各不相同,比如页面编码,在框架代码中封装不太合适
  • 有时候希望在请求前后插入一些特定的逻辑,以及用户鉴权等信息,如果通过配置项添加,框架使用成本较高

所以为了减少使用成本,将整个请求的逻辑交给用户,框架本身只约定了一个request接口,该方法返回对应资源的内容

interface request {
    ({url}: { url: string }): Promise<string>;
}

这样就需要由用户自己实现网一些特定逻辑,如

  • 对需要登录访问限制的资源实现鉴权,如手动设置请求header头
  • 对响应接口进行初步处理,如返回非utf-8的内容进行解码等
function request({url}) {
    return http.get(url, {
        headers: {
            'User-Agent': `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.89 Safari/537.36`,
            'Authorization': 'Bearer xxdsfdsafdas' // 需要用户自己实现获取登录token相关逻辑
        },
    }).then(res => res.data)
}

2. 将保存逻辑交给用户

数据提取完毕之后,需要考虑的第二个问题就是如何保存数据。常见的方式有

  • 将数据保存到本地文件,如txt、json文件中
  • 将数据保存到数据库,如mongo、mysql中
  • 将数据直接提交到某些后台服务中,一般用于已经存在的、需要处理特殊逻辑的接口

目前框架封装上述三种保存数据的方法,通过在初始化应用时通过配置参数saveConfig传入

文件类型

{
    type: 'file',
    config: {
        dist: path.resolve(__dirname, `./tmp/${count}.json`),
        format(data) {
            return JSON.stringify(data)
        },
    },
}

mongodb类型

{
    type: 'mongo',
    config: {
        host: 'mongodb://localhost/data_source',
        document: 'news',
        schema: {
            "name": String,
            // ... 其他字段
        },
    }
}

接口上传类型

{
    type: 'upload',
    config: {
        // 将上传数据的方法通过配置参数传入
        request: function(data){
            return axios.post(`http://127.0.0.1:7001/upload/news`, { data })
        }
    },
}

在完成数据的抓取和解析之后,框架将根据saveConfig将数据保存到指定位置。

3. 多种开发模式

在进行爬虫开发时,往往需要经历各种调试和测试。一个比较常见的场景是,

  • 在正式服务中,我们将直接请求资源url,解析页面并将数据保存到数据库中
  • 为了避免在开发期间对源网站频繁访问,在开发期间我们会把需要抓取的html文件下载到本地,然后通过读取本地文件的方式完成后续解析的流程,并将数据保存到txt中进行调试

由于框架暴露的request接口并不要求真实访问网络资源,所以上面的request方法可以修改为

// 这也是将request接口暴露给用户的好处之一,可以单独进行mock或测试,而无需修改框架的配置
function request(url) {
    let html = fs.readFileSync(path.resolve(__dirname, './tmp/1.html'), 'utf-8')
    return Promise.resolve(html)
}

同时saveConfig也可以根据环境变量动态控制是开发环境还是线上环境,从而传入不同的配置。

总体来说对我而言,在各种环境切换是一个比较常规的需求,因此框架引入了mode概念,目前每个mode包含了requestsaveConfig两个配置项

let config = {
    test: {
        request({url}) {
            let html = fs.readFileSync(path.resolve(__dirname, './tmp/test.html'), 'utf-8')
            return Promise.resolve(html)
        },
        saveConfig: {
            type: 'file',
            config: {
                dist: path.resolve(__dirname, `./tmp/test.json`),
                format(data) {
                    return JSON.stringify(data)
                },
            },
        },
    },
    prod: {
        request({url}) {
            return http.get(url).then(res => res.data)
        },
        saveConfig:{
            type: 'mongo',
            config: {
                host: 'mongodb://localhost/data_source',
                document: 'news',
                schema: {
                    "name": String,
                },
            }
        }
    }
}

可以通过app.addConfig传入各种模式的配置

4. 解析策略

一般来说我们需要处理两种响应:JSON和HTML

  • 对于JSON响应的处理非常简单,我们只需要保存部分或全部字段即可
  • 对于HTML响应而言,我们就需要解析相关标签,从页面上提取各种需要的数据

根据http响应,解析html文档,获取对应的dom节点数据,对于框架而言这一块应该是非常灵活的,框架内部使用cheerio解析HTML文档,同时也支持直接抓取JSON接口

设计的解析策略大致数据结构为

let strategy1 = {
    rtype: /www.a.com\/1.html/,
    // strategy配置项是一个数组,支持从页面上多个不同的区域提取数据,一种常见的场景是:从ul列表提取数据,从分页器提取下一页的链接加入爬取池
    strategy: [{
        selector: '#list .td a', // 页面上包含所需数据的选择器,与jQuery保持一致
        parse($dom) {
            return $dom.text().trim()
        }
    }]
}
let strategy2 = {
    rtype: /www.a.com\/2.json/,
    strategy: [{
        json: true,
        selector: '#pl_top_realtimehot .td-02 a',
        parse(data) {
            // 或者对data数据进一步过滤
            return data
        }
    }]
}

通过rtype确定某个或者某类页面的解析策略,页面的strategy配置项是一个数组,包含选择器和对应选择器匹配节点的解析模式,同一个页面可以配置多个策略,最后会将收集到的数据汇总到一起,进行保存。

此外应用可以通过addStrategy添加多个解析策略,然后根据当前请求的url与所有策略的rtype进行匹配,找到该url对应的strategy,然后执行数据解析或过滤等逻辑

5. IP代理池

爬虫的访问频率一般比较高,除了控制每次请求的间隔之外,还可以使用ip代理进行访问,抹除原始访问记录

参考

目前这里并没有单独实现,利用提供的request配置项,接入代理也比较方便

6. 一个简单的例子

下面是一个抓取微博热搜的例子

let path = require('path')

let {http, default: App} = require('../lib')

function initApp() {
    let app = new App()
    let strategy = {
        rtype: /weibo.com/,
        strategy: [{
            selector: '#pl_top_realtimehot .td-02 a',
            parse($dom) {
                return $dom.text().trim()
            }
        }]
    }
    let config = {
        test: {
            request({url}) {
                return http.get(url, {
                    headers: {
                        'User-Agent': `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.89 Safari/537.36`
                    },
                }).then(res => {
                    return res.data
                })
            },
            saveConfig: {
                type: 'file',
                config: {
                    dist: path.resolve(__dirname, `./tmp/weibo_hot.json`),
                    format(data) {
                        return JSON.stringify(data)
                    },
                },
            },
        }

    }

    let startTask = app.createTask(`https://s.weibo.com/top/summary?Refer=top_hot&topnav=1&wvr=6`)
    app.addStrategy(strategy)
    app.addConfig(config)
    app.addTask(startTask)

    return app
}

let app = initApp()
// 运行test类型的mode
app.start('test').then(() => {
    console.log('抓取完毕')
})

在实际的开发阶段,只需要传入

  • strategy解析策略,如何从页面上提取数据
  • config.test配置至少一种mode类型,
    • 实现request接口,主要配置header及一些前置后置处理逻辑
    • 配置saveConfig,指定保存方式
  • createTask创建任务

这样就可以满足大部分需求场景了。对于需要连续抓取的分页数据,可以提前addTask添加多个任务,也可以在抓取过程中动态添加任务。

7. 小结

本文主要记录了petty-spider的一些设计思路,由于三天打鱼两天晒网,加之我对爬虫的需求也不是特别大,偶尔抓抓表情包和段子啥的,因此一直没有正儿八经的写一个使用文档,本文就算做是一个使用说明吧。

整个项目都放在github上面了,现在回头看代码,有一些写的很烂的地方,甚至忘记了自己当时为何要如此实现,总之如果后续有需求,我再进行重构吧。