Serverless实战——函数计算+Puppeteer爬虫实践

1,844 阅读9分钟

前言

本文目标:爬取百度搜索引擎的关键词搜索结果,并部署到阿里云的函数计算中。

开始前请先简单看看下面函数计算和Puppeteer的概念,方便接下来的实战。

函数计算

函数计算是什么

函数计算(Function Compute):函数计算是以事件为驱动的全托管计算服务,使用函数计算,你只用专注编写代码,无需关注服务器相关基础设施。当触发事件后,函数计算会在云服务中弹性地、可靠的运行任务,并且支持日志查询,性能监控和报警等功能。

使用函数计算的优势

  • 您无需采购和管理服务器等基础设施,运维成本低。
  • 您只需专注业务逻辑的开发,使用函数计算支持的开发语言设计、优化、测试、审核以及上传自己的应用代码。
  • 以事件驱动的方式触发应用响应用户请求。与阿里云对象存储 OSS、API 网关、日志服务和表格存储等服务无缝对接,帮助您快速构建应用。
  • 提供日志查询、性能监控和报警等功能快速排查故障。
  • 毫秒级别弹性伸缩,快速实现底层扩容以应对峰值压力。
  • 按需付费,支持百毫秒级别收费。只需为实际使用的计算资源付费,适合有明显波峰波谷的用户访问场景。

Puppeteer

Puppeteer是什么

Puppeteer是一个Node库,它提供了一个高级API来通过DevTools协议控制无头 Chrome或Chromium ,它也可以配置为使用完整(非无头)Chrome或Chromium。

你可以通过Puppeteer的提供的api直接控制Chrome模拟大部分用户操作来进行UI Test或者作为爬虫访问页面来收集数据。

Puppeteer能做什么

  • 生成页面的截图和PDF

  • 可以抓取SPA或SSR网站

  • 自动化测试,模拟表单提交,键盘输入,鼠标事件等行为

  • 捕获网站的时间线,帮助诊断性能问题

  • 创建一个最新的自动化测试环境。使用最新的JavaScript和浏览器功能,直接在最新版本的Chrome中运行测试。

Puppeteer官方帮助文档

1. 前期准备

开始之前请确保如下工具已经正确的安装,更新到最新版本,并进行正确的配置。

Funcraft

Funcraft 是函数计算提供的一种命令行工具,通过该工具,您可以便捷地管理函数计算、API 网关、日志服务等资源。通过一个资源配置文件 template.yml,Funcraft 即可协助您进行开发、构建、部署操作。本文提供安装 Funcraft 的三种方式。

  1. 安装

    • 执行以下命令安装Funcraft

      npm install @alicloud/fun -g

    • 安装完成之后,在控制终端执行fun命令查看版本信息。

      fun --version

  2. 配置Funcraft

    • 执行以下命令

      fun config

    • 按照提示依次配置 Account ID、AccessKeyId、AccessKeySecret、Default Region Name。

Docker

Funcraft 进行依赖编译、安装、本地运行调试等,需要依赖于Docker来模拟本地环境。

Windows

  1. 安装

    根据系统下载并安装 Docker Desktop

  2. 配置国内镜像

    {
      "registry-mirrors": [
        "https://docker.mirrors.ustc.edu.cn",
        "https://registry.docker-cn.com",
        "http://hub-mirror.c.163.com"
      ]
    }
    

    在桌面右下角状态栏中右键 docker 图标,修改在 Docker Daemon 标签页中的 json ,把 上面的镜像地址加到"registry-mirrors"的数组里,保存即可。

    Tips: 推荐使用阿里云Docker镜像。

2.实践

初始化项目

  1. 执行以下命令,并选择http-trigger-nodejs10模板

    fun init -n xxx

    • -n, --name 选项要作为文件夹生成的项目名称。默认值是 fun-app

  1. 创建存放百度爬虫函数的文件夹

    cd fun-puppetter && mkdir baiduKeywordResult

  2. 生成package.json文件

    npm init -y

  3. 生成Funcraft的配置文件

    fun config

    按照提示依次配置 Account ID、AccessKeyId、AccessKeySecret、Default Region Name。

  4. 替换template.yml文件中的内容,内容为:

    ROSTemplateFormatVersion: '2015-09-01'
    Transform: 'Aliyun::Serverless-2018-04-03'
    Resources:
      FunPuppetter:
        Type: 'Aliyun::Serverless::Service'
        Properties:
          Description: 'Puppetter服务, 一个服务下可以创建多个函数'
        baiduKeywordResult:
          Type: 'Aliyun::Serverless::Function'
          Properties:
            Handler: index.handler
            Runtime: nodejs10
            CodeUri: './baiduKeywordResult'
            Timeout: 600
            MemorySize: 1024
            InstanceConcurrency: 3
          Events:
            httpTrigger:
              Type: HTTP
              Properties:
                AuthType: ANONYMOUS
                Methods: ['POST', 'GET']
    

    这个 template.yml 的含义如下:声明一个名为 FunPuppetter的 服务。并在这个服务下,再声明一个名为 baiduKeywordResult的 函数,配置函数触发方式为httpTrigger,入口为 index.handler,以及函数的 runtime 为 nodejs10。并且,我们指定Timeout 处理函数可以运行的最长时间为 600 秒,指定MemorySize函数执行分配的内存大小为1024 MB。指定InstanceConcurrency为函数设置一个实例并发度 ,表示单个函数实例可以同时处理多少个请求。指定CodeUri 为当前目录。在部署时,Fun 会将 CodeUri 指定的目录打包上传。更多的配置规则 请参考

  5. 把/index.js文件移动到/baiduKeywordResult目录下

    index.js

    var getRawBody = require('raw-body');
    var getFormBody = require('body/form');
    var body = require('body');
    
    module.exports.handler = function(req, resp, context) {
        console.log('hello world');
    
        var params = {
            path: req.path,
            queries: req.queries,
            headers: req.headers,
            method : req.method,
            requestURI : req.url,
            clientIP : req.clientIP,
        }
            
        getRawBody(req, function(err, body) {
            resp.setHeader('content-type', 'text/plain');
    
            for (var key in req.queries) {
              var value = req.queries[key];
              resp.setHeader(key, value);
            }
            params.body = body.toString();
            resp.send(JSON.stringify(params, null, '    '));
        }); 
          
    }
    

    此时目录的内容应该是这样:

    初始化后的项目结构

执行fun local start进行调试,此时Funcraft响应如下:

打开生成Url,如果响应如下,就可以开始Coding了

Coding

以下为我们要实现的接口:

百度关键词搜索结果

// request
{
    url: 'http://localhost:8000/2016-08-15/proxy/FunPuppetter/baiduKeywordResult',
    params: {
        keyword, // 要搜索的关键词
        page, // 要爬取多少页
    },
}
// response
{
   msg: 'success',
   code: 2000,
   data: [
       {
           title,
           abstract,
           redirectUrl,
           url,
           domain,
           keyword,
           pageNum,
       }
   ]
}

/baiduKeywordResult/index.js

/baiduKeywordResult/index.js文件内容如下:

依赖的package

const puppeteer = require('puppeteer');
const _ = require('lodash');
const async = require('async');
const axios = require('axios');
const cheerio = require('cheerio');
const nodeUrl = require('url');
  • lodash

    高性能的 JavaScript 实用工具库

  • async

    async库是一个非常出色的异步控制库,除了 函数外,还提供了大量的其他工具函数,在当年没有async/await的时候,async库的作用尤为突出。

  • axios

    Axios 是一个基于 promise 的 HTTP 库,可以用在浏览器和 node.js 中。

  • cheerio

    cheerio是jquery核心功能的一个快速灵活而又简洁的实现,主要是为了用在服务器端需要对DOM进行操作的地方

  • url

    用于处理与解析 URL

handle函数

module.exports.handler = async function(req, resp, context) {

    // 接收参数
    let { keyword, page } = req.queries;

    if (_.isEmpty(keyword) || _.isEmpty(page)) {
        resp.send(JSON.stringify({
            msg: '参数不正确!',
            code: 4005,
            data: null
        }))
    }

    try {
        // 百度搜索结果最多只有76页
        page = Math.min(page, 76);

        const task = new Task({ keyword, page })
        const result = await task.start();
        console.log('response result', result)
        resp.send(JSON.stringify(result))
    } catch(e) {
        console.log(e)
    }

}

Task Class

class Task {

    // 构造函数, 创建示例时会调用
    constructor(task) {
        this._result = {
            msg: 'success',
            data: {},
            code: 5000
        };
        this._browser = null;
        this._task = task;
    }

    async start() {
        try {
            await this.initialize();
            await this.execute();
            this._result.code = 2000;
        } catch(e) {
            console.log(e.stack);
            this._result.msg = e.stack;
            this._result.code = 5000;
        } finally {
            await this.destroy();
        }

        return this._result;
    }

    async initialize() {
        // 打开一个浏览器实例
        this._browser = await puppeteer.launch({
            headless: true,
            ignoreDefaultArgs: ['--disable-extensions'],
            args: [
                '--no-sandbox',
                '--disable-setuid-sandbox',
            ]
        });
    }

    async execute() {
        const { keyword, page } = this._task;
        const pageRange = _.range(0, page * 10, 10);
        
        let results = [];
        
        // 并发获取每页搜索结果
        results = await async.mapLimit(
            pageRange,
            50,
            async (offset) => {
                
                // 失败重试机制
                let retry = 0;
                let success = false;
                
                do {
                    // 打开一个Tab页
                    let entryPage = await this._browser.newPage();
                    try {
                        const url = `https://baidu.com/s?wd=${keyword}&pn=${offset}`;
                        console.log('Crawler url:', url)
                        await entryPage.goto(url,{
                            waitUntil: 'load',
                            timeout: 1000 * 30,
                        });
                        
                        let pageData = [];
                        if(this.isLastPage(entryPage)) {
                            pageData = await this.structureData(offset, entryPage);
                        }
                        
                        success = true;
                        return pageData;
                    } catch(e) {
                        console.log('error', e);
                        retry++;
                        
                        // 重试6次后依旧失败则抛出异常,由handler函数内的catch捕捉
                        if (retry >= 6) {
                            throw e;
                        }
                    } finally {
                        await entryPage.close()
                    }
                } while(!success && retry < 6)

            }
        );
        
        results = _.flatMapDepth(results).map((item, index)=>{
            item.rank = index + 1;
            return item;
        })
        console.log(results);
        this._result.data = results;

    }
    
    async structureData(offset = 0, entryPage) {

        const htmlContent = await entryPage.content();
        let htmlData = await this.htmlParse(htmlContent);
        
        // 遍历解析后的数据,增加page和keyword字段
        htmlData = _.map(htmlData, (data) => {
            data.keyword = this._task.keyword;
            data.pageNum = Math.max(1, offset / 10);
            return data;
        });
    
        return htmlData;
    }
    
    async htmlParse(html) {
        // 解析html获取数据
        const $ = cheerio.load(html);
        let pageItems = [];
        $(".result.c-container").each(function (i, el) {
            const that = $(el);
            const item = {
                title: _.trim(that.find("h3 > a").text()),
                abstract: _.trim(that.find(".c-abstract").text()),
                redirectUrl: _.trim(that.find("h3 > a").attr("href")),
                url: "",
            };
            pageItems.push(item);
        });

        // 并发请求url, 获取百度重定向后的真实url
        pageItems = await new Promise((resolve, reject) => {
            async.mapLimit(
            pageItems,
            50,
            async (item) => {
                const redirectResponse = await axios.head(item.redirectUrl, {
                timeout: 1000 * 10, // 10秒
                maxRedirects: 0,
                validateStatus: function (status) {
                    return status >= 200 && status < 400;
                },
                });
                item.url = redirectResponse.headers.location || item.redirectUrl;
                item.domain = nodeUrl.parse(item.url).host;
                return item;
            },
            (err, results) => {
                if (err) {
                    reject(err);
                } else {
                    resolve(results);
                }
            }
            );
        });
        return pageItems;
    }

    async isLastPage(entryPage) {
        const htmlContent = await entryPage.content();
        // 解析html获取数据
        const $ = cheerio.load(htmlContent);
        
        return $("#page").length && $("#page .n").length
    }

    async destroy() {
        await this._browser.close();
    }
}

/baiduKeywordResult/package.json

"dependencies": {
    "async": "^3.2.0",
    "axios": "^0.19.2",
    "cheerio": "^1.0.0-rc.3",
    "lodash": "^4.17.15",
    "puppeteer": "^2.0.0",
    "url": "^0.11.0"
  },

安装依赖

$ fun install -d

如果直接使用npm install安装依赖,那么运行时puppeteer则会报错。这里的问题在于,puppeteer 依赖了 chromium,而 chromium 又依赖一些系统库。所以 npm install 后,还会触发下载 chromium 的操作。这里用户经常遇到的问题,主要是:

  1. 由于 chromium 的体积比较大,所以经常遇到网络问题导致下载失败。
  2. npm 仅仅只下载 chromium,chromium 依赖的系统库并不会自动安装。用户还需要自行查找缺失的依赖进行安装。

好在函数计算命令行工具Funcraft 已经集成了 Puppeteer 的解决方案,只要 package.json 中包含了 puppeteer 依赖,然后使用 fun install -d 即可一键安装所有系统依赖。

3. 本地调试函数

在本地调试代码,可以使用如下命令:

$ fun local start
using template: template.yml
HttpTrigger httpTrigger of FunPuppetter/baiduKeywordResult was registered
        url: http://localhost:8000/2016-08-15/proxy/FunPuppetter/baiduKeywordResult
        methods: [ 'POST', 'GET' ]
        authType: ANONYMOUS

浏览器打开http://localhost:8000/2016-08-15/proxy/FunPuppetter/baiduKeywordResult,会自动下载Response

Response:

{"msg":"参数不正确!","code":4005,"data":null}

携带keyword和page参数后Response:

http://localhost:8000/2016-08-15/proxy/FunPuppetter/baiduKeywordResult?keyword=vue&page=3

{
	"msg": "success",
	"data": [
        {
            "title": "vue.js官网",
            "abstract": "Vue.js - The Progressive JavaScript Framework... 订阅我们的周刊 (英文) 你可以在 news.vuejs.org 翻阅往期的 issue,也可以收听 podcast。",
            "redirectUrl": "http://www.baidu.com/link?url=Men7IMCzaXf2qP148hYmJKK54l5fL03Wbya_S4L25_i",
            "url": "https://cn.vuejs.org/",
            "domain": "cn.vuejs.org",
            "keyword": "vue",
            "pageNum": 1,
            "rank": 1
		}, 
        {
            "title": "Vue.js 教程 | 菜鸟教程",
            "abstract": "Vue.js 教程 Vue.js(读音 /vjuː/, 类似于 view) 是一套构建用户界面的渐进式框架。 Vue 只关注视图层, 采用自底向上增量开发的设计。 Vue 的目标是通过...",
            "redirectUrl": "http://www.baidu.com/link?url=WXIdaqC4EhUmm3Vdis5p0BCM3vUo139WwLQCB28LV8p5epqoiZMceQ1AWV_HpjKAb2jaqVpsXyWytUzPrnDqt_",
            "url": "https://www.runoob.com/vue2/vue-tutorial.html",
            "domain": "www.runoob.com",
            "keyword": "vue",
            "pageNum": 1,
            "rank": 2
		},
        {
            "title": "介绍— Vue.js",
            "abstract": "Vue.js - The Progressive JavaScript Framework... Vue (读音 /vjuː/,类似于 view) 是一套用于构建用户界面的渐进式框架。与其它大型框架不同的是,Vue 被设...",
            "redirectUrl": "http://www.baidu.com/link?url=RjryFjnGxvreIzhFX1iicF8hHcRbNhkoTTTrFLjsLk4EmqM5ydhCbTR2vye8NBUv",
            "url": "https://cn.vuejs.org/v2/guide/",
            "domain": "cn.vuejs.org",
            "keyword": "vue",
            "pageNum": 1,
            "rank": 3
		}
    	.......
    ],
	"code": 2000
}

4. 一键部署服务

在本地调试代码,可以使用如下命令:

  • 确认yml文件中的配置,选择Y就可以了,使用fun deploy -y部署时可跳过确认

  • 使用nas服务管理依赖

FunPuppetter/baiduKeywordResult函数大小超过50M,需要使用Nas服务来管理依赖。

  • ? Do you want to let fun to help you automate the configuration?

    询问是否使用 Fun 来自动化的配置 NAS 管理依赖,选择Yes

  • ? We recommend using the 'NasConfig: Auto' configuration to manage your function dependencies.

    是否使用NasConfig: Auto配置来管理函数依赖关系, 选择Yes。

    Tips: 可以选择手动配置。函数计算挂载NAS访问。如果你已经手动配置,这里则提示用户选择已经配置的 NAS 存储函数依赖

看到这里就表示部署成功了。

为什么Response会强制下载

因为服务端会为response header中强制添加content-disposition: attachment字段,此字段会使得返回结果在浏览器中以附件的方式打开。此字段无法覆盖,使用自定义域名将不受影响。

配置自定义域名

接下来我们给函数服务配置一个自定义域名,这样Http trigger触发的函数响应就不会再强制下载了。

  1. 登录阿里云函数计算控制台

  2. 打开自定义域名,创建域名

    fun.root2.cn换成你们的域名地址

  3. 解析域名到函数计算的Endpoint

    Endpoint在函数计算控制台/概览的右上角获取。

    打开云解析DNS控制台,选中域名,添加记录

    记录类型选择CNAME,记录值为函数计算的Endpoint

  4. 测试解析是否生效

    如下图则解析成功

添加了新依赖,如何更新?

如果添加了新依赖,只需要重新执行fun nas sync进行同步即可。

如果修改了代码,只需要重新执行fun deploy重新部署即可。

项目代码

github.com/ITHcc/fun-p…