V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
推荐学习书目
Learn Python the Hard Way
Python Sites
PyPI - Python Package Index
http://diveintopython.org/toc/index.html
Pocoo
值得关注的项目
PyPy
Celery
Jinja2
Read the Docs
gevent
pyenv
virtualenv
Stackless Python
Beautiful Soup
结巴中文分词
Green Unicorn
Sentry
Shovel
Pyflakes
pytest
Python 编程
pep8 Checker
Styles
PEP 8
Google Python Style Guide
Code Style from The Hitchhiker's Guide
tikazyq
V2EX  ›  Python

手把手教你如何用 Crawlab 构建技术文章聚合平台(一)

  •  
  •   tikazyq ·
    tikazyq · 2019-03-15 21:15:21 +08:00 · 2763 次点击
    这是一个创建于 2083 天前的主题,其中的信息可能已经有所发展或是发生改变。

    背景

    说到爬虫,大多数程序员想到的是 scrapy 这样受人欢迎的框架。scrapy 的确不错,而且有很强大的生态圈,有 gerapy 等优秀的可视化界面。但是,它还是有一些不能做到的事情,例如在页面上做翻页点击操作、移动端抓取等等。对于这些新的需求,可以用 Selenium、Puppeteer、Appium 这些自动化测试框架绕开繁琐的动态内容,直接模拟用户操作进行抓取。可惜的是,这些框架不是专门的爬虫框架,不能对爬虫进行集中管理,因此对于一个多达数十个爬虫的大型项目来说有些棘手。

    Crawlab 是一个基于 Celery 的分布式通用爬虫管理平台,擅长将不同编程语言编写的爬虫整合在一处,方便监控和管理。Crawlab 有精美的可视化界面,能对多个爬虫进行运行和管理。任务调度引擎是本身支持分布式架构的 Celery,因此 Crawlab 可以天然集成分布式爬虫。有一些朋友认为 Crawlab 只是一个任务调度引擎,其实这样认为并不完全正确。Crawlab 是类似 Gerapy 这样的专注于爬虫的管理平台。

    本文将介绍如何使用 Crawlab 和 Puppeteer 抓取主流的技术博客文章,然后用 Flask+Vue 搭建一个小型的技术文章聚合平台。

    Crawlab

    在前一篇文章《分布式通用爬虫管理平台 Crawlab 》已介绍了 Crawlab 的架构以及安装使用,这里快速介绍一下如何安装、运行、使用 Crawlab。

    安装

    到 Crawlab 的Github Repo用克隆一份到本地。

    git clone https://github.com/tikazyq/crawlab
    

    安装相应的依赖包和库。

    cd crawlab
    
    # 安装 python 依赖
    pip install -r crawlab/requirements
    
    # 安装前端依赖
    cd frontend
    npm install
    

    安装 mongodb 和 redis-server。Crawlab 将用 MongoDB 作为结果集以及运行操作的储存方式,Redis 作为 Celery 的任务队列,因此需要安装这两个数据库。

    运行

    在运行之前需要对 Crawlab 进行一些配置,配置文件为config.py

    # project variables
    PROJECT_SOURCE_FILE_FOLDER = '/Users/yeqing/projects/crawlab/spiders' # 爬虫源码根目录
    PROJECT_DEPLOY_FILE_FOLDER = '/var/crawlab'  # 爬虫部署根目录
    PROJECT_LOGS_FOLDER = '/var/logs/crawlab'  # 日志目录
    PROJECT_TMP_FOLDER = '/tmp'  # 临时文件目录
    
    # celery variables
    BROKER_URL = 'redis://192.168.99.100:6379/0'  # 中间者 URL,连接 redis
    CELERY_RESULT_BACKEND = 'mongodb://192.168.99.100:27017/'  # CELERY 后台 URL
    CELERY_MONGODB_BACKEND_SETTINGS = {
        'database': 'crawlab_test',
        'taskmeta_collection': 'tasks_celery',
    }
    CELERY_TIMEZONE = 'Asia/Shanghai'
    CELERY_ENABLE_UTC = True
    
    # flower variables
    FLOWER_API_ENDPOINT = 'http://localhost:5555/api'  # Flower 服务地址
    
    # database variables
    MONGO_HOST = '192.168.99.100'
    MONGO_PORT = 27017
    MONGO_DB = 'crawlab_test'
    
    # flask variables
    DEBUG = True
    FLASK_HOST = '127.0.0.1'
    FLASK_PORT = 8000
    

    启动后端 API,也就是一个 Flask App,可以直接启动,或者用 gunicorn 代替。

    cd ../crawlab
    python app.py
    

    启动 Flower 服务(抱歉目前集成 Flower 到 App 服务中,必须单独启动来获取节点信息,后面的版本不需要这个操作)。

    python ./bin/run_flower.py
    

    启动本地 Worker。在其他节点中如果想只是想执行任务的话,只需要启动这一个服务就可以了。

    python ./bin/run_worker.py
    

    启动前端服务器。

    cd ../frontend
    npm run serve
    

    使用

    首页 Home 中可以看到总任务数、总爬虫数、在线节点数和总部署数,以及过去 30 天的任务运行数量。

    点击侧边栏的 Spiders 或者上方到 Spiders 数,可以进入到爬虫列表页。

    这些是爬虫源码根目录PROJECT_SOURCE_FILE_FOLDER下的爬虫。Crawlab 会自动扫描该目录下的子目录,将子目录看作一个爬虫。Action 列下有一些操作选项,点击部署 Deploy 按钮将爬虫部署到所有在线节点中。部署成功后,点击运行 Run 按钮,触发抓取任务。这时,任务应该已经在执行了。点击侧边栏的 Tasks 到任务列表,可以看到已经调度过的爬虫任务。

    基本使用就是这些,但是 Crawlab 还能做到更多,大家可以进一步探索,详情请见Github

    Puppeteer

    Puppeteer 是谷歌开源的基于 Chromium 和 NodeJS 的自动化测试工具,可以很方便的让程序模拟用户的操作,对浏览器进行程序化控制。Puppeteer 有一些常用操作,例如点击,鼠标移动,滑动,截屏,下载文件等等。另外,Puppeteer 很类似 Selenium,可以定位浏览器中网页元素,将其数据抓取下来。因此,Puppeteer 也成为了新的爬虫利器。

    相对于 Selenium,Puppeteer 是新的开源项目,而且是谷歌开发,可以使用很多新的特性。对于爬虫来说,如果前端知识足够的话,写数据抓取逻辑简直不能再简单。正如其名字一样,我们是在操作木偶人来帮我们抓取数据,是不是很贴切?

    掘金上已经有很多关于 Puppeteer 的教程了(爬虫利器 Puppeteer 实战Puppeteer 与 Chrome Headless —— 从入门到爬虫),这里只简单介绍一下 Puppeteer 的安装和使用。

    安装

    安装很简单,就一行npm install命令,npm 会自动下载 Chromium 并安装,这个时间会比较长。为了让安装好的 puppeteer 模块能够被所有 nodejs 爬虫所共享,我们在PROJECT_DEPLOY_FILE_FOLDER目录下安装 node 的包。

    # PROJECT_DEPLOY_FILE_FOLDER 变量值
    cd /var/crawlab
    
    # 安装 puppeteer
    npm i puppeteer
    
    # 安装 mongodb
    npm i mongodb
    

    安装 mongodb 是为了后续的数据库操作。

    使用

    以下是 Copy/Paste 的一段用 Puppeteer 访问简书然后截屏的代码,非常简洁。

    const puppeteer = require('puppeteer');
    
    (async () => {
      const browser = await (puppeteer.launch());
      const page = await browser.newPage();
      await page.goto('https://www.jianshu.com/u/40909ea33e50');
      await page.screenshot({
        path: 'jianshu.png',
        type: 'png',
        // quality: 100, 只对 jpg 有效
        fullPage: true,
        // 指定区域截图,clip 和 fullPage 两者只能设置一个
        // clip: {
        //   x: 0,
        //   y: 0,
        //   width: 1000,
        //   height: 40
        // }
      });
      browser.close();
    })();
    

    关于 Puppeteer 的常用操作,请移步《我常用的 puppeteer 爬虫 api 》

    编写爬虫

    啰嗦了这么久,终于到了万众期待的爬虫时间了。Talk is cheap, show me the code !咦?我们不是已经 Show 了不少代码了么...

    由于我们的目标是建立一个技术文章聚合平台,我们需要去各大技术网站抓取文章。资源当然是越多越好。作为展示用,我们将抓取下面几个具有代表性的网站:

    • 掘金
    • SegmentFault
    • CSDN

    研究发现这三个网站都是由 Ajax 获取文章列表,生成动态内容以作为传统的分页替代。这对于 Puppeteer 来说很容易处理,因为 Puppeteer 绕开了解析 Ajax 这一部分,浏览器会自动处理这样的操作和请求,我们只着重关注数据获取就行了。三个网站的抓取策略基本相同,我们以掘金为例着重讲解。

    掘金

    首先是引入 Puppeteer 和打开网页。

    const puppeteer = require('puppeteer');
    const MongoClient = require('mongodb').MongoClient;
    
    (async () => {
      // browser
      const browser = await (puppeteer.launch({
        headless: true
      }));
    
      // define start url
      const url = 'https://juejin.im';
    
      // start a new page
      const page = await browser.newPage();
      
      ...
      
    })();
    

    headless设置为true可以让浏览器以 headless 的方式运行,也就是指浏览器不用在界面中打开,它会在后台运行,用户是看不到浏览器的。browser.newPage()将新生成一个标签页。后面的操作基本就围绕着生成的page来进行。

    接下来我们让浏览器导航到 start url。

      ...
      
      // navigate to url
      try {
        await page.goto(url, {waitUntil: 'domcontentloaded'});
        await page.waitFor(2000);
      } catch (e) {
        console.error(e);
    
        // close browser
        browser.close();
    
        // exit code 1 indicating an error happened
        code = 1;
        process.emit("exit ");
        process.reallyExit(code);
    
        return
      }
      
      ...
    

    这里try catch的操作是为了处理浏览器访问超时的错误。当访问超时时,设置exit code1表示该任务失败了,这样 Crawlab 会将该任务状态设置为FAILURE

    然后我们需要下拉页面让浏览器可以读取下一页。

      ...
      
      // scroll down to fetch more data
      for (let i = 0; i < 100; i++) {
        console.log('Pressing PageDown...');
        await page.keyboard.press('PageDown', 200);
        await page.waitFor(100);
      }
      
      ...
    

    翻页完毕后,就开始抓取数据了。

      ...
      // scrape data
      const results = await page.evaluate(() => {
        let results = [];
        document.querySelectorAll('.entry-list > .item').forEach(el => {
          if (!el.querySelector('.title')) return;
          results.push({
            url: 'https://juejin.com' + el.querySelector('.title').getAttribute('href'),
            title: el.querySelector('.title').innerText
          });
        });
        return results;
      });
      ...
    

    page.evaluate可以在浏览器 Console 中进行 JS 操作。这段代码其实可以直接在浏览器 Console 中直接运行。调试起来是不是方便到爽?前端工程师们,开始欢呼吧!

    获取了数据,接下来我们需要将其储存在数据库中。

      ...
      
      // open database connection
      const client = await MongoClient.connect('mongodb://192.168.99.100:27017');
      let db = await client.db('crawlab_test');
      const colName = process.env.CRAWLAB_COLLECTION || 'results_juejin';
      const taskId = process.env.CRAWLAB_TASK_ID;
      const col = db.collection(colName);
    
      // save to database
      for (let i = 0; i < results.length; i++) {
        // de-duplication
        const r = await col.findOne({url: results[i]});
        if (r) continue;
    
        // assign taskID
        results[i].task_id = taskId;
    
        // insert row
        await col.insertOne(results[i]);
      }
      
      ...
    

    这样,我们就将掘金最新的文章数据保存在了数据库中。其中,我们用url字段做了去重处理。CRAWLAB_COLLECTIONCRAWLAB_TASK_ID是 Crawlab 传过来的环境变量,分别是储存的 collection 和任务 ID。任务 ID 需要以task_id为键保存起来,这样在 Crawlab 中就可以将数据与任务关联起来了。

    整个爬虫代码如下。

    const puppeteer = require('puppeteer');
    const MongoClient = require('mongodb').MongoClient;
    
    (async () => {
      // browser
      const browser = await (puppeteer.launch({
        headless: true
      }));
    
      // define start url
      const url = 'https://juejin.im';
    
      // start a new page
      const page = await browser.newPage();
    
      // navigate to url
      try {
        await page.goto(url, {waitUntil: 'domcontentloaded'});
        await page.waitFor(2000);
      } catch (e) {
        console.error(e);
    
        // close browser
        browser.close();
    
        // exit code 1 indicating an error happened
        code = 1;
        process.emit("exit ");
        process.reallyExit(code);
    
        return
      }
    
      // scroll down to fetch more data
      for (let i = 0; i < 100; i++) {
        console.log('Pressing PageDown...');
        await page.keyboard.press('PageDown', 200);
        await page.waitFor(100);
      }
    
      // scrape data
      const results = await page.evaluate(() => {
        let results = [];
        document.querySelectorAll('.entry-list > .item').forEach(el => {
          if (!el.querySelector('.title')) return;
          results.push({
            url: 'https://juejin.com' + el.querySelector('.title').getAttribute('href'),
            title: el.querySelector('.title').innerText
          });
        });
        return results;
      });
    
      // open database connection
      const client = await MongoClient.connect('mongodb://192.168.99.100:27017');
      let db = await client.db('crawlab_test');
      const colName = process.env.CRAWLAB_COLLECTION || 'results_juejin';
      const taskId = process.env.CRAWLAB_TASK_ID;
      const col = db.collection(colName);
    
      // save to database
      for (let i = 0; i < results.length; i++) {
        // de-duplication
        const r = await col.findOne({url: results[i]});
        if (r) continue;
    
        // assign taskID
        results[i].task_id = taskId;
    
        // insert row
        await col.insertOne(results[i]);
      }
    
      console.log(`results.length: ${results.length}`);
    
      // close database connection
      client.close();
    
      // shutdown browser
      browser.close();
    })();
    

    SegmentFault & CSDN

    这两个网站的爬虫代码基本与上面的爬虫一样,只是一些参数不一样而已。我们的爬虫项目结构如下。

    运行爬虫

    在 Crawlab 中打开 Spiders,我们可以看到刚刚编写好的爬虫。

    点击各个爬虫的 View 查看按钮,进入到爬虫详情。

    在 Execute Command 中输入爬虫执行命令。对掘金爬虫来说,是node juejin_spider.js。输入完毕后点击 Save 保存。然后点击 Deploy 部署爬虫。最后点击 Run 运行爬虫。

    点击左上角到刷新按钮可以看到刚刚运行的爬虫任务已经在运行了。点击 Create Time 后可以进入到任务详情。Overview 标签中可以看到任务信息,Log 标签可以看到日志信息,Results 信息中可以看到抓取结果。目前在 Crawlab 结果列表中还不支持数据导出,但是不久的版本中肯定会将导出功能加入进来。

    总结

    在这一小节,我们已经能够将 Crawlab 运行起来,并且能用 Puppeteer 编写抓取三大网站技术文章的爬虫,并且能够用 Crawlab 运行爬虫,并且读取抓取后的数据。下一节,我们将用 Flask+Vue 做一个简单的技术文章聚合网站。能看到这里的都是有耐心的好同学,赞一个。

    Github: tikazyq/crawlab

    如果感觉 Crawlab 还不错的话,请加作者微信拉入开发交流群,大家一起交流关于 Crawlab 的使用和开发。

    11 条回复    2019-03-17 00:54:07 +08:00
    haoji
        1
    haoji  
       2019-03-15 21:32:09 +08:00
    正想造轮子来着,发现 Crawlab 挺不错的,向大佬学习!
    tikazyq
        2
    tikazyq  
    OP
       2019-03-15 23:37:05 +08:00 via iPhone
    @haoji 感谢关注,欢迎加群 star 提 issue
    484A4B
        3
    484A4B  
       2019-03-16 00:02:18 +08:00
    思路不错,支持一下,不过看上去还有很多地方需要完善
    binux
        4
    binux  
       2019-03-16 00:06:07 +08:00 via Android
    worker 多了之后,MongoDB 连接数不会爆炸吗
    coolloves
        5
    coolloves  
       2019-03-16 07:23:56 +08:00 via iPhone
    马克,感谢分享
    tikazyq
        6
    tikazyq  
    OP
       2019-03-16 12:02:48 +08:00 via iPhone
    @484A4B 希望能多多提意见,感谢支持
    tikazyq
        7
    tikazyq  
    OP
       2019-03-16 12:03:55 +08:00 via iPhone
    @binux 目前 mongodb 最大连接数默认 819 个,每一个连接占 1m 内存,如果 worker 数过多,可以考虑增大连接数限制和内存
    tikazyq
        8
    tikazyq  
    OP
       2019-03-16 12:04:11 +08:00 via iPhone
    @coolloves 感谢支持
    momocraft
        9
    momocraft  
       2019-03-16 13:15:06 +08:00
    完全不懂爬虫, 想用用看但是有些地方不懂...

    - 例子中的 juejin_spider 只保存了文章链接和标题, 如果要从这样一个 task 的 result 派生其他 task (如: 去那个链接抓文章全文) 应该怎样做?
    - spider 的类型是什么?
    tikazyq
        10
    tikazyq  
    OP
       2019-03-16 16:49:28 +08:00 via iPhone
    @momocraft
    1. 这个只是文章的种子 url,需要其他一个 spider 来数据库获取该 url,然后将抓到的文章数据存储下来
    2. spider 的类型应该是属于 puppeteer,在 crawlab 中没有体现,后面会加上
    moxiaonai
        11
    moxiaonai  
       2019-03-17 00:54:07 +08:00 via Android
    赞一个
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   3880 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 27ms · UTC 10:25 · PVG 18:25 · LAX 02:25 · JFK 05:25
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.