论坛爬虫系统架构设计

2,825 阅读12分钟
原文链接: www.jianshu.com

系统概述

  • 渠道监测旨在通过爬取各种渠道、网盘、论坛、贴吧等抓取和App相关的信息,通过对获取信息的分析识别正盗版,下发统计分析报告,帮忙App开发者监控应用市场、帖子、论坛、网盘等上的正盗版情况。

  • 下面是具体的功能点:

  1. 通过后台上传APP提交渠道监测系统。
  2. 后台获取到上传APP后对APP进行处理获取应用签名信息、获取应用名称、获取应用包名、获取应用文件结构等信息。
  3. 根据获取的应用信息与网络爬虫技术对互联网渠道进行定点爬取。
  4. 对国内428家应用市场、网盘、70家开发者论坛与开发者社区、78个涉及客户端安全相关的贴吧等渠道进行实时爬取。(完善中......)
  5. 对爬取数据进行统一入库储存。
  6. 对发现的可疑盗版应用进行一键分析,发现可疑盗版应用注入的恶意代码、修改的资源等。(完善中......)
  7. 对发现的可疑盗版应用进行下架支持。(后续计划)
  8. 对监测的渠道数据、发现的可疑盗版应用数据和可疑盗版的下架操作过程与结果生成文档予以反馈。

功能正在逐渐完善中.....

  • 附上一张效果图:


    效果图

思维导图

渠道监控思维导图

上面红色线框圈起来的就是今天我要给大家介绍的bbs爬虫系统。

BBS爬虫系统的实现

bbs爬虫系统基于github上开源的pyspider爬虫系统实现,start的数量已经过万,fork的数量也超过了2600多,也算是一个比较优质的开源项目了。我先大致介绍下pyspider的功能点和架构设计:

pyspider的介绍

pyspider的介绍

上面的截图,来自pyspider官网文档中的介绍。pyspider采用python语言编写,支持可视化界面在线编辑、调试爬虫脚本,支持爬虫任务的实时监控,支持分布式部署,数据存储支持mysql、sqlite、ES、MongoDB等常见的数据库。其分布式部署通过消息中间件实现,可以使用redis,rabbitmq等中间件作为其分布式部署的消息中间件。除此之外,还支持爬虫任务的重试,通过令牌桶的方式限速,任务的优先级可控制,爬虫任务的过期时间等等。它的很多特性都是通用的爬虫系统所应该具备的,如果我们要从头写一个爬虫系统,就要去解决这些问题,费时费力,不如站在巨人的肩膀上,把它改造成适合我们轨道的车轮。

Pyspider的架构设计

  • pyspider爬虫可以分为下面几个核心组件:
  1. Fetcher - 根据url抓取互联网资源,下载html内容。Fetcher通过异步IO的方式实现,可以支持很大并发量的抓取,瓶颈主要在IO开销和IP资源上。单个ip如果爬取过快,很容易触动了bbs的反爬虫系统,很容易被屏蔽,可以使用ip代理池的方式解决ip限制的问题。如果没有ip代理池可以通过pyspider的限速机制,防止触碰反爬虫发机制。支持多节点部署。

  2. Processor - 处理我们编写的爬虫脚本。比如,提取页面中的链接,提取翻页链接,提取页面中的详细信息等,这块比较消耗CPU资源。支持多节点部署。

  3. WebUI - 可视化的界面,支持在线编辑、调试爬虫脚本;支持爬虫任务的实时在线监控;可通过界面启动、停止、删除、限速爬虫任务。支持多节点部署。

  4. Scheduler - 定时任务组件。每个url对应一个task,scheduler负责task的分发,新的url的入库,通过消息队列协调各个组件。只能单节点部署。

  5. ResultWorker - 结果写入组件。支持爬虫结果的自定义实现,比如我们实现了基于RDS的自定义结果写入。可以不实现,默认采用sqlite作为结果输出,可以导出为execel,json等格式。

  • 架构图


    pyspider的架构
  • pyspider的webui界面长下面这个样子

webui
  • 我们要编写的脚本
from pyspider.libs.base_handler import *

class Handler(BaseHandler):
    # 全局设定,遇到反爬的情况需要在其中配置对应的措施(如代理,UA,Cookies,渲染.......),
    crawl_config = {
    }

    @every(minutes=24 * 60)
    def on_start(self):
        # seed urls
        self.crawl('http://scrapy.org/', callback=self.index_page)
    
    @config(age=10 * 24 * 60 * 60)
    def index_page(self, response):
        for each in response.doc('a[href^="http"]').items():
            self.crawl(each.attr.href, callback=self.detail_page)

    def detail_page(self, response):
        # result
        return {
            "url": response.url,
            "title": response.doc('title').text(),
        }

关于Pyspider更多的文档,可以参考:pyspider文档

BBS爬虫系统中Pyspider的应用

流程图

流程图的步骤说明如下:

  • 1-1步: 用户通过WebUI界面写好脚本,调试、保存好之后,点击Run启动爬虫脚本。WebUI会通过xmlrpc的方式调用scheduler的new_task方法,创建新的爬虫任务。(这一步通过java对pyspider的创建脚本过程进行了封装,实现了根据用户上传的apk自动创建爬虫脚本)

  • 2步:scheduler通过xmlrpc的方式得到爬虫任务后(json_string定义),开始了一些更新项目状态,更新优先级,更新时间等处理。处理完这些逻辑后,通过send_task方法把url发往fetcher。

  • 3,4步:fetcher通过异步IO的方式抓取html页面,然后把结果发往processor。

  • 5步:processor拿到fetcher发过来的html页面后,调用用户的index_page方法。在 index_page方法中获取页面中的超链接,同时回调detial_page方法。Processor把解析后的结果发送到ResultWorker(基于NCR的list实现的queue)。

  • 6,7步: ResultWorker组件从NCR获取result,写入数据库RDS中。

  • 1-2步:processor中的index_page除了可以提取所需页面的详情外,还可以获取翻页链接,然后把获取后的分页链接发往scheduler,从而形成一整个闭环。

部署结构图

部署图
  • Fetcher部署在外网中,防止反爬虫系统获取到ip来源。线上部署了7个Fetcher,Fetcher主要用来http访问,抓取html页面,使用异步IO的方式。
  • Processor部署在内网中,线上部署了2个节点。
  • Scheduler部署在内网中,线上部署了1一个节点,并且和WebUI部署在一台机器上。
  • ResultWorker部署在内网中,部署了2个节点。
  • WebUI部署在内网中,只部署了2个节点,并且使用Http Auth对页面访问进行权限限制。
  • Fetcher, Processor, Scheduler, ResultWorker,它们均通过Redis的队列(基于list实现)互相通信,Scheduler是控制中,负责爬虫任务(task)的分发。一个url的爬虫就是一个task,task对象中有task_id,默认基于url的md5值实现,用于url去重。每个task都有一个默认的优先级,用户可以使用@priority在index_page和detail_page方法上使用;通过自定义优先级,我们可以实现页面的深度优先或广度优先的遍历。

BBS对比正盗版流程

自动登录和回复

对bbs的爬虫是基于python语言实现的,而爬取到html页面后的逻辑是基于java语言实现的。pyspider爬虫后的结果插入到数据库了,java的定时任务读取数据库记录,进行后续的处理。

  1. 把自动登录和回复模块注册到服务注册中心。自动登录和回复基于HtmlUnit或WebDriver + Headless chrome实现的。验证码识别服务可以识别中文、英文、数字和问答题等。不支持滑动验证码。对于滑动验证码的方式目前采用Chrome插件手动复制Cookie内容的方式实现。登录成功后,获取cookie内容用于后续的自动回复等;同时为了保证cookie的有限性,进行了定时检查cookie的有效性,及时更新cookie。

  2. java定时任务开始读取pyspider的爬虫结果记录。

  3. 根据数据库的字段instanceName去调用自动登录和回复模块。instanceName字段是为了保证不同的爬虫结果页面正确的对应自己bbs的cookie,方便自动回复。自动回复成功后,把回复过的页面插入到RDS中,方便后续逻辑的处理。

  4. 从RDS中读取回复过的页面,遍历每个页面的节点、属性等获取下载链接。这里的遍历比较复杂,需要各种异常和不规则的处理。提取到的链接可能为网盘链接,附件链接等等。论坛上的附件大多时百度网盘的短链接,并且很多都需要输入提取码,我们实现了这个过程的自动化。可以在帖子的页面提取到百度网盘链接,并且获取到对应的提取码;然后,使用NodeJs+PhathomJs的方式实现了真实下载地址的提取。由于百度网盘进行了限制速度,所有我们后续又支持了断点下载的方式,支持更高的容错。

  5. 根据获取到下载链接进行分类处理。如果是百度网盘的下载链接,则调用百度网盘相关的工具类,获取网盘中的文件的真实下载地址,支持单个和批量下载;如果直接是真实的下载地址,则直接进行下载。

  6. 根据对比算法分析正盗版。

  7. 把对比后的结果插入数据库,然后统计分析。

提取百度网盘的真实下载地址

提取网盘链接
  • java定时任务获取到回复过的帖子后,分析、遍历html元素的节点、内容等,根据正在提取出网盘链接和对应的提取码。获取到链接和提取码后,PhantomJs执行js获取提取网盘链接的信息,其中可能涉及到验证码识别,因为百度网盘限制了短时间内同一个链接提取真实下载地址的次数,超过三次提取就需要输入验证码了。在获取到需要的所有信息后,比如:token,loginid等,然后使用java发ajax请求带上这些参数去获取真实的下载地址。下载到的文件可能是一个压缩包,也可能是一个单独的apk文件,这取决于分享的是多个文件,还是单个文件。

  • 提取百度网盘信息的js脚本:

var page = require('webpage').create(), stepIndex = 0, loadInProgress = false;
var fs = require('fs');
var system = require('system');

page.viewportSize = {
  width: 480,
  height: 800
};

// 命令行参数
var args = system.args;
var codeVal = (args[2] === 'null') ? "" : args[2], loadUrl = args[1];
console.log('codeVal=' + codeVal + '; loadUrl=' + loadUrl);

// 直接运行脚本的参数
// var codeVal = "nd54";
// var loadUrl = "https://pan.baidu.com/s/1sltxlYP";

// phantom.setProxy('116.62.112.142','16816', 'http', 'jingxuan2046', 'p4ke0xy1');
// 配置
page.settings.userAgent = 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.78 Safari/537.36';
page.settings.resourceTimeout = 5000;

// 回调函数
page.onConsoleMessage = function (msg) {
  console.log(msg);
};

page.onResourceRequested = function (request) {
  //console.log('Request Faild:' + JSON.stringify(request, undefined, 4));
};

page.onError = function (msg, trace) {
  var msgStack = ['PHANTOM ERROR: ' + msg];
  if (trace && trace.length) {
    msgStack.push('TRACE:');
    trace.forEach(function (t) {
      msgStack.push(
          ' -> ' + (t.file || t.sourceURL) + ': ' + t.line + (t.function
              ? ' (in function ' + t.function + ')' : ''));
    });
  }
  console.error(msgStack.join('\n'));
  // phantom.exit(1);
};

// 变量定义
var baiDuObj;
var steps = [
  function () {
    page.clearCookies();// 每次请求之前先清理cookie

    if (loadUrl === null || loadUrl.length == 0) {
      console.error('loadUrl不能为空串!');
      phantom.exit();
      return;
    }

    // 渲染页面
    console.log("加载页面中... loadUrl= " + loadUrl);

    // 目的是为了先刷新出必要的Cookie,再去访问shareUrl,不然会报403
    page.open(loadUrl, function (status) {
      console.log('status=' + status);
      setTimeout(function () {
        page.evaluate(function (loadUrl) {
          // console.log(document.cookie)
          window.location.href = loadUrl;
        }, loadUrl);
      }, 500)
    });
  },

  function () {
    // page.render('step1.png');

    var currentUrl = page.url;

    console.log("currentUrl=" + currentUrl);
    console.log("codeVal=" + codeVal);

    if (currentUrl === null || currentUrl === "") {
      console.log('当前url为空,脚本退出执行...');
      phantom.exit(1);
      return;
    }

    // 提取码为空时就不需要再输入了
    if (codeVal === null || codeVal.length == 0 || codeVal === "") {
      console.log('当前分享不需要提取码...');
      return;
    }

    // 自动输入提取码
    page.evaluate(function (codeVal) {
      // 当请求页面中不存在accessCode元素时,就不继续执行了
      var accessCodeEle = document.getElementsByTagName('input').item(0);
      console.log(accessCodeEle);
      if (accessCodeEle === null) {
        console.info("页面不存在accessCode元素..." + accessCodeEle);
      } else {
        accessCodeEle.value = codeVal;
        var element = document.getElementsByClassName('g-button').item(0);
        console.log(element);
        var event = document.createEvent("MouseEvents");
        event.initMouseEvent(
            "click", // 事件类型
            true,
            true,
            window,
            1,
            0, 0, 0, 0, // 事件的坐标
            false, // Ctrl键标识
            false, // Alt键标识
            false, // Shift键标识
            false, // Meta键标识
            0, // Mouse左键
            element); // 目标元素

        element.dispatchEvent(event);

        // 点击提交提取码,然后跳转到下载页面
        element.click();
      }
    }, codeVal);
  },
  function () {
    // page.render('step2.png');

    page.includeJs('https://cdn.bootcss.com/jquery/1.12.4/jquery.min.js',
        function () {
          baiDuObj = page.evaluate(function () {
            var yunData = window.yunData;
            var cookies = document.cookie;
            var panAPIUrl = location.protocol + "//" + location.host + "/api/";
            var shareListUrl = location.protocol + "//" + location.host
                + "/share/list";

            // 变量定义
            var sign, timestamp, logid, bdstoken, channel, shareType, clienttype, encrypt, primaryid, uk, product, web, app_id, extra, shareid, is_single_share;
            var fileList = [], fidList = [];
            var vcode;// 验证码

            // 初始化参数
            function initParams() {
              shareType = getShareType();
              sign = yunData.SIGN;
              timestamp = yunData.TIMESTAMP;
              bdstoken = yunData.MYBDSTOKEN;
              channel = 'chunlei';
              clienttype = 0;
              web = 1;
              app_id = 250528;
              logid = getLogID();
              encrypt = 0;
              product = 'share';
              primaryid = yunData.SHARE_ID;
              uk = yunData.SHARE_UK;
              shareid = yunData.SHARE_ID;
              is_single_share = isSingleShare();

              if (shareType == 'secret') {
                extra = getExtra();
              }

              if (is_single_share) {
                var obj = {};
                if (yunData.CATEGORY == 2) {
                  obj.filename = yunData.FILENAME;
                  obj.path = yunData.PATH;
                  obj.fs_id = yunData.FS_ID;
                  obj.isdir = 0;
                } else {
                  obj.filename = yunData.FILEINFO[0].server_filename;
                  obj.path = yunData.FILEINFO[0].path;
                  obj.fs_id = yunData.FILEINFO[0].fs_id;
                  obj.isdir = yunData.FILEINFO[0].isdir;
                }
                fidList.push(obj.fs_id);
                fileList.push(obj);
              } else {
                fileList = getFileList();
                $.each(fileList, function (index, element) {
                  fidList.push(element.fs_id);
                });
              }
            }

            //判断分享类型(public或者secret)
            function getShareType() {
              return yunData.SHARE_PUBLIC === 1 ? 'public' : 'secret';
            }

            //判断是单个文件分享还是文件夹或者多文件分享
            function isSingleShare() {
              return yunData.getContext === undefined;
            }

            // 获取cookie
            function getCookie(e) {
              var o, t;
              var n = document, c = decodeURI;
              return n.cookie.length > 0 && (o = n.cookie.indexOf(e + "="), -1
              != o) ? (o = o + e.length + 1, t = n.cookie.indexOf(";", o), -1
              == t && (t = n.cookie.length), c(n.cookie.substring(o, t))) : "";
            }

            // 私密分享时需要sekey
            function getExtra() {
              var seKey = decodeURIComponent(getCookie('BDCLND'));
              return '{' + '"sekey":"' + seKey + '"' + "}";
            }

            function base64Encode(t) {
              var a, r, e, n, i, s, o = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
              for (e = t.length, r = 0, a = ""; e > r;) {
                if (n = 255 & t.charCodeAt(r++), r == e) {
                  a += o.charAt(n >> 2);
                  a += o.charAt((3 & n) << 4);
                  a += "==";
                  break;
                }
                if (i = t.charCodeAt(r++), r == e) {
                  a += o.charAt(n >> 2);
                  a += o.charAt((3 & n) << 4 | (240 & i) >> 4);
                  a += o.charAt((15 & i) << 2);
                  a += "=";
                  break;
                }
                s = t.charCodeAt(r++);
                a += o.charAt(n >> 2);
                a += o.charAt((3 & n) << 4 | (240 & i) >> 4);
                a += o.charAt((15 & i) << 2 | (192 & s) >> 6);
                a += o.charAt(63 & s);
              }
              return a;
            }

            // 获取登录id
            function getLogID() {
              var name = "BAIDUID";
              var u = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/~!@#¥%……&";
              var d = /[\uD800-\uDBFF][\uDC00-\uDFFFF]|[^\x00-\x7F]/g;
              var f = String.fromCharCode;

              function l(e) {
                if (e.length < 2) {
                  var n = e.charCodeAt(0);
                  return 128 > n ? e : 2048 > n ? f(192 | n >>> 6) + f(
                      128 | 63 & n) : f(224 | n >>> 12 & 15) + f(
                      128 | n >>> 6 & 63) + f(128 | 63 & n);
                }
                var n = 65536 + 1024 * (e.charCodeAt(0) - 55296)
                    + (e.charCodeAt(1) - 56320);
                return f(240 | n >>> 18 & 7) + f(128 | n >>> 12 & 63) + f(
                        128 | n >>> 6 & 63) + f(128 | 63 & n);
              }

              function g(e) {
                return (e + "" + Math.random()).replace(d, l);
              }

              function m(e) {
                var n = [0, 2, 1][e.length % 3];
                var t = e.charCodeAt(0) << 16 | (e.length > 1 ? e.charCodeAt(1)
                        : 0) << 8 | (e.length > 2 ? e.charCodeAt(2) : 0);
                var o = [u.charAt(t >>> 18), u.charAt(t >>> 12 & 63),
                  n >= 2 ? "=" : u.charAt(t >>> 6 & 63),
                  n >= 1 ? "=" : u.charAt(63 & t)];
                return o.join("");
              }

              function h(e) {
                return e.replace(/[\s\S]{1,3}/g, m);
              }

              function p() {
                return h(g((new Date()).getTime()));
              }

              function w(e, n) {
                return n ? p(String(e)).replace(/[+\/]/g, function (e) {
                  return "+" == e ? "-" : "_";
                }).replace(/=/g, "") : p(String(e));
              }

              return w(getCookie(name));
            }

            //获取当前目录
            function getPath() {
              var hash = location.hash;
              var regx = /(^|&|\/)path=([^&]*)(&|$)/i;
              var result = hash.match(regx);
              return decodeURIComponent(result[2]);
            }

            //获取分类显示的类别,即地址栏中的type
            function getCategory() {
              var hash = location.hash;
              var regx = /(^|&|\/)type=([^&]*)(&|$)/i;
              var result = hash.match(regx);
              return decodeURIComponent(result[2]);
            }

            function getSearchKey() {
              var hash = location.hash;
              var regx = /(^|&|\/)key=([^&]*)(&|$)/i;
              var result = hash.match(regx);
              return decodeURIComponent(result[2]);
            }

            //获取当前页面(list或者category)
            function getCurrentPage() {
              var hash = location.hash;
              return decodeURIComponent(
                  hash.substring(hash.indexOf('#') + 1, hash.indexOf('/')));
            }

            //获取文件信息列表
            function getFileList() {
              var result = [];
              if (getPath() == '/') {
                result = yunData.FILEINFO;
              } else {
                logid = getLogID();
                var params = {
                  uk: uk,
                  shareid: shareid,
                  order: 'other',
                  desc: 1,
                  showempty: 0,
                  web: web,
                  dir: getPath(),
                  t: Math.random(),
                  bdstoken: bdstoken,
                  channel: channel,
                  clienttype: clienttype,
                  app_id: app_id,
                  logid: logid
                };
                $.ajax({
                  url: shareListUrl,
                  method: 'GET',
                  async: false,
                  data: params,
                  success: function (response) {
                    if (response.errno === 0) {
                      result = response.list;
                    }
                  }
                });
              }
              return result;
            }

            //生成下载时的fid_list参数
            function getFidList(list) {
              var retList = null;
              if (list.length === 0) {
                return null;
              }

              var fileidlist = [];
              $.each(list, function (index, element) {
                fileidlist.push(element.fs_id);
              });
              retList = '[' + fileidlist + ']';
              return retList;
            }

            // 初始化
            initParams();

            // console.log('fileList=---------' + fileList);
            // console.log('fidList=---------' + getFidList(fileList))

            var retObj = {
              'sign': sign,
              'timestamp': timestamp,
              'logid': logid,
              'bdstoken': bdstoken,
              'channel': channel,
              'shareType': shareType,
              'clienttype': clienttype,
              'encrypt': encrypt,
              'primaryid': primaryid,
              'uk': uk,
              'product': product,
              'web': web,
              'app_id': app_id,
              'extra': extra,
              'shareid': shareid,
              'fid_list': getFidList(fileList),// 要下载的文件id
              'file_list': fileList,
              'panAPIUrl': panAPIUrl,
              'single_share': is_single_share,
              'cookies': cookies
            };

            return retObj;
          });

          console.log("data=" + JSON.stringify(baiDuObj));
        });
  },
  function () {
  }
];

// main started
setInterval(function () {
  if (!loadInProgress && typeof steps[stepIndex] == "function") {

    console.log(
        '                                                                                               ');
    console.log(
        '===============================================================================================');
    console.log('                                    step ' + (stepIndex + 1)
        + '                               ');
    console.log(
        '===============================================================================================');
    console.log(
        '                                                                                               ');

    steps[stepIndex]();
    stepIndex++;
  }

  if (typeof steps[stepIndex] != "function") {
    console.log("Completed!");
    console.log('FinalOutPut: codeVal=' + codeVal + "; loadUrl=" + loadUrl
        + "; result=" + JSON.stringify(baiDuObj));
    phantom.exit();
  }
}, 5000);

上面的大致就是整个bbs爬取的大致过程了。由于篇幅有限,有的地方可能说的比较含糊或者不清晰的地方,欢迎讨论。本人技术水平有限,有错误或不足的地方欢迎批评指正。