开源:可热更新的客户端爬虫框架JsCrawler

最近在研究爬虫和客户端抓取网页的相关内容,想要做一个android客户端抓取博客内容的应用,思考了一段时间需求,发现常规的实现方案非常容易出现一些意外问题,再次思考了一段时间,最后做了一个简单易用可热更新的爬虫抓取方案。

相信做过网页抓取的都了解抓取的基本步骤:
1. 选取一个需要抓取的URL
2. 通过网络请求获取完整的html文档
3. 通过jsoup等工具解析html获取需要的内容
4. 重新整理内容,整合成实体类
5. 进行存储或展示

这几个步骤,必须要全部正常执行,才能最终正确的展示抓取的页面内容。
这其中,第4第5个步骤相对比较稳定,而前三个步骤,是比较容易出问题的,一旦有其中一个步骤出错,就会导致应用不可用。

下面是几种较容易出现问题的点:
1. 源数据服务器宕机了,直接导致抓取页面无响应,这种问题基本无解,会导致抓取的第2个步骤出错。
2. 源数据网页小幅度更新了,某些核心内容的布局发生了改变,这会导致抓取的第3个步骤出错,在解析html的过程中无法获得正确的数据。
3. 源数据网站改版,整个访问架构都发生了改变,这种情况有可能会使第1个步骤确定的抓取URL都变得无效,应用变得完全不可用。

在常规的客户端抓取做法下,一旦出现上述问题,开发者只能修改整个客户端,把客户端重新往各大应用商店扔,然后再提示用户需要更新新版本。这整个流程都走下来,需要的时间可能会比较长,而且容易流失用户,毕竟一些用户都不习惯或不喜欢去更新应用(例如我)。

那么有没有办法解决这些问题?
当然有,下面几种方法都能在一定程度上解决这些问题:
1. 通过服务器中转,客户端的请求通过服务端做代理请求,服务端请求源数据网页后整理并返回给客户端(优点: 抓取逻辑放在服务端,随时都可以修改,出了问题修复起来也快,只需要重新部署一次服务器。 缺点: 当用户量增加的时候,服务器的压力也会成倍增加,而且多人访问同一个页面的时候会造成冗余请求,耗费资源。)
2. 服务端定时开启爬虫爬取源数据页面的内容,更新到数据库,所有客户端请求的数据实际上只是获取我们自己服务器的数据。(优点: 节省了第一种方式频繁发生中转请求的资源,即使源数据网页改版或宕机,也不会影响现有客户端的使用。 缺点: 增加了服务端开发的工作量,维护成本几乎翻倍,客户端获取的数据有可能不是最新的,出现数据更新延迟的问题。)
3. 结合第一第二种方法,客户端第一次请求A页面的时候,服务端进行中转请求,并缓存这个页面,在下一次请求同一个页面的时候,服务端可以直接从缓存中返回相应的A页面数据给客户端,不需要再次中转请求。(优点: 解决了冗余请求和服务器中转压力增加的问题。 缺点: 维护服务端的成本以及数据更新延迟依旧存在。)
4. 引入客户端HotFix热修复等框架,抓取内容交给客户端自己处理,出现问题的时候可以很方便的更新抓取逻辑,在用户弱感知的情况下就能完成客户端的更新。(优点: 可快速动态修复,减轻了服务端工作量,降低维护成本,客户端抓取不会对服务器造成压力。 缺点: 热修复学习成本较高,应用复杂度增加。)

把几种方案罗列出来对比后,需求也都变得清晰多了,第1、2、3种方案,在脱离了服务端之后都不能正常工作,这并不符合我的预期目标。相比之下,第4种方案是最合适的,可以脱离服务端运行,也有很强的热更新热修复能力,但热修复框架并不算简单易用。

为了解决这些问题,最后实现了一套更简易的热更新框架。
这套框架工具的核心在于把抓取的1、2、3、4步骤,都抽取到脚本文件中完成了。这个脚本文件,当然就是开发者必备语言javascript了。把核心url,抓取请求,解析数据并重新包装这些步骤都放到脚本文件,一旦遇到源数据结构更新,网站改版等问题,完全不用担心,只需要在服务器更新js脚本和版本号,让客户端下载一份新的抓取脚本即可修复问题。
除了修复问题外,使用这套框架还可以很轻易的对相同类型网站的抓取展示进行拓展。例如,博客类的网站,CSDN的博客,或者技术小黑屋等个人博客,它们都是同一种类型的网站,有几乎相同的展示方式。使用框架可以在后台中更新js脚本动态增加支持展示的博客,也可以做多套爬取脚本,供用户自行选择下载订阅哪些博客的脚本。


JsCrawler基本使用

下面来介绍一下这个框架的使用方式:

  • 在application中初始化JsCrawler
public class MyApplication extends Application {

    @Override
    public void onCreate() {
        super.onCreate();
        JsCrawler.initialize(this);
        // 获取JsCrawler实例
        JsCrawler jsCrawler = JsCrawler.getInstance();
        // 设置是否开启使用JQuery
        jsCrawler.setJQueryEnabled(true);
    }

    @Override
    public void onTerminate() {
        super.onTerminate();
        JsCrawler.release();
    }
}
  • 在Activity获取JsCrawler的实例,加载js脚本并调用getBlogList()函数。PS: jsCrawler.callFunction执行js是异步执行不会阻塞UI线程的,而返回的result是在UI线程上执行的,请务必在UI线程中调用callFunction()方法。
public class MainActivity extends Activity {
    private JsCrawler jsCrawler;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        // 获取JsCrawler实例
        jsCrawler = JsCrawler.getInstance();

        final String js = loadJs();
        jsCrawler.callFunction(js, new JsCallback() {
            @Override
            public void onResult(String result) {
                Log.d(TAG, "onResult: " + result);
                // js与java之间的通信只能使用基本类型
                // 对于复杂对象,使用json即可
                Gson gson = new Gson();
                MyModel model = gson.fromJson(result, MyModel.class);
                // do something
            }

            @Override
            public void onError(String errorMessage) {
                Log.d(TAG, "onError: " + errorMessage);
            }
        }, "getBlogList");

    }


    public String loadJs() {
        String path = Environment.getExternalStorageDirectory()
                .getAbsolutePath() + "/Download/crawler.js";
        try {
            File file = new File(path);
            InputStream inputStream = new FileInputStream(file);

            Scanner scanner = new Scanner(inputStream, "UTF-8");
            return scanner.useDelimiter("\\A").next();
        } catch (final IOException e) {
            e.printStackTrace();
        }
        return null;
    }
}
  • 对于需要传递参数的js函数,调用方式如下:
jsCrawler.callFunction("function myFunction(a, b, c, a) { return 'result'; }", 
    new JsCallback() {

        @Override
        public void onResult(String result) {
            // 处理JavaScript返回结果
        }

        @Override
        public void onError(String errorMessage) {
            // 处理JavaScript调用错误信息
        }
    }, "myFunction", "parameter 1", "parameter 2", 912, 101.3);
  • 下面是js脚本的内容,在脚本中定义关键url,执行请求,用JQuery的方式解析内容,整个过程真的是非常顺手。
function getBlogList() {
    // 定义抓取url
    var url = "http://droidyue.com";

    // 通过RequestBuilder构造请求
    var request = new RequestBuilder()
        .url(url).method("GET")
        .timeout(10000).build();

    // 调用RequestEngine.executeByRequest()传入构造好的request对象
    var response = RequestEngine.executeByRequest(request);

    // 得到response对象的json字符串,格式如下:
    // {"code":"200", "message":"OK", "body":"请求获取的内容"}
    // {"code":"404", "message":"NOT FOUND", "body":"请求获取的内容"}
    // {"code":"-1", "message":"Request Exception", "body":""}
    // 通过eval函数, 转成js对象
    response = eval("("+response+")");

    // 处理异常的请求返回码
    if(response.code != 200) {
        return "response error";
    }

    // 得到正确内容后, 获取相应的body并通过JQuery对内容进行处理
    var body = response.body;
    var articleEles = $(body).find(".blog-index article");
    var articleList = new Array();

    // 处理元素数组
    $.each(articleEles, function(index, element){
        var article = new Object();
        element = $(element);
        var entry = element.find(".entry-title a").first();
        article.title = entry.text();
        article.url = url + entry.attr("href");
        article.describe = element.find(".entry-content").text().trim();
        articleList.push(article);
    });

    // 把js数组对象转成json字符串返回
    return JSON.stringify(articleList);
}

上面这段脚本的关键点在RequestBuilder构造请求,以及RequestEngine根据构造的请求参数执行请求。看到这里可能有人会发出疑问,既然都可以执行JQuery了,为什么不用ajax直接发起请求,方便简单同时对会JQuery的开发者完全零学习成本,再封装一个请求引擎不是多此一举吗?最初封装的时候,确实有打算直接使用ajax做请求,后来还是重新封装了。为什么?上面这段脚本只使用了一些基本的请求参数,但有些api接口,只设置url设置method是不够的,还需要同时设置一些特殊的header请求头。而不用ajax的原因是因为,ajax对请求头的设置支持不全面,例如User-AgentRefererCookie等,这些请求头都非常重要,像cnBeta的api接口,如果不设置Referer的话不会返回任何数据。下面列举RequestBuilder的请求设置:

// 创建builder对象,支持链式调用
var builder = new RequestBuilder();

// 设置请求url
builder.url("http://api.kejie.tk");

// 设置method,只支持POST和GET两种请求
builder.method("POST");
builder.method("GET");

// 添加header,有两种方式,addHeader或者setHeaders
builder.addHeader("User-Agent", "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N)")
    .addHeader("Referer", "http://api.kejie.tk");

var headers = {
    "User-Agent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N)",
    "Referer": "http://api.kejie.tk"
}
builder.setHeaders(headers);

// 添加Cookie方式跟Header类似,也有两种方式
builder.addCookie("uid", "1170120F8E53899BC88B236FA6A731FC");

var cookies = {
    "uid": "1170120F8E53899BC88B236FA6A731FC",
    "type": "1"
}
builder.setCookies(cookies);

// 设置data,同上有两种设置方式
// 对于GET请求,data数据会以query方式追加到url
// 对于POST请求,data数据会以form-data方式设置到请求体中
builder.addData("wd", "testData");

var data = {
    "wd": "testData",
    "qid": "59"
}
builder.setData(data);

// 设置请求body内容字符串
// 有些api是以application/json的方式发起请求
// 并把请求内容以json字符串的方式设置到body中
// 注意,设置了body,上面的form-data形式参数将会失效,两者不能同时使用
// Content-type将自动设为application/json
builder.body('{"username":"kejie","pwd":"d8j3kduui461p"}');

// 设置请求超时时间,单位毫秒
builder.timeout(10000);

// 生成request对象
var request = builder.build();

生成了request请求对象后,通过js全局对象RequestEngine.executeByRequest(request)发起请求,并获取返回的json数据。返回的内容包含响应的状态码、状态码信息、响应body。在js中通过eval函数把json转成js对象即可自由操作。需要注意的是当请求发生异常例如无网络或者请求超时,状态码将返回-1,用户可自行处理。

var response = RequestEngine.executeByRequest(request);

// {"code":"200", "message":"OK", "body":"请求获取的内容"}
// {"code":"404", "message":"NOT FOUND", "body":"请求获取的内容"}
// {"code":"-1", "message":"Request Exception", "body":""}
response = eval("("+response+")");

// 使用console.log可以打印字符串到android log中,无法打印js对象,虽然不太方便,但聊胜于无
console.log(response.code);
console.log(response.message);
console.log(response.body);

在框架内封装了两个请求引擎,一个是Jsoup,一个是OkHttp,默认使用Jsoup。如果想要切换成OkHttp只需要调用一句代码。

jsCrawler.setRequestEngine(new OkHttpEngine());

拓展请求引擎

对于内置的请求引擎,可配置的请求参数能够对大多数请求适用,但对于一些更特殊的请求,可能不够用。开发者可以自行进行拓展内置的请求引擎,或继承RequestEngine抽象类实现一个新的完整的请求引擎。下面以拓展JsoupEngine为例,介绍拓展代理请求proxy的方法。

1.继承RequestModel,拓展proxy属性。

public class MyRequestModel extends RequestModel {
    private String proxy;

    public String getProxy() {
        return proxy;
    }

    public void setProxy(String proxy) {
        this.proxy = proxy;
    }
}

2.继承JsoupEngine,重写process方法,加入processProxy(),重写jsonToModel方法,把json转成新的MyRequestModel。

public class MyJsoupEngine extends JsoupEngine {

    protected void processProxy(MyRequestModel model) {
        if (model.getProxy() != null) {
            String[] proxy = model.getProxy().split(":");
            if (proxy.length > 1) {
                // connection是jsoup请求的关键对象
                connection.proxy(proxy[0], Integer.parseInt(proxy[1]));
            }
        }
    }

    @Override
    protected void process(RequestModel model) {
        super.process(model);
        processProxy((MyRequestModel) model);
    }

    @Override
    protected RequestModel jsonToModel(String request) {
        return gson.fromJson(request, MyRequestModel.class);
    }
}

3.在脚本文件头部拓展RequestBuilder的proxy()方法以及build()方法,使构造的request中加入proxy字段,即可链式调用proxy并传入代理相关参数。其他调用方式不变。

RequestBuilder.prototype.proxy = function(host){
    this.mProxy = host;
    return this;
}

RequestBuilder.prototype.build = function() {
    var request = new Request(this);
    request.proxy = this.mProxy;
    return JSON.stringify(request);
}

function getBlogList() {
    // your js code ...
    var request = new RequestBuilder()
        .url(url).method("GET").proxy("127.0.0.1:8088")
        .timeout(10000).build();
   var response = RequestEngine.executeByRequest(request);
   // your js code ...
}

4.在Application初始化时增加一句代码修改JsCrawler的请求引擎。

public class MyApplication extends Application {

    @Override
    public void onCreate() {
        super.onCreate();
        JsCrawler.initialize(this);
        // 获取JsCrawler实例
        JsCrawler jsCrawler = JsCrawler.getInstance();
        // 设置是否开启使用JQuery
        jsCrawler.setJQueryEnabled(true);
        // 修改JsCrawler请求引擎
        jsCrawler.setRequestEngine(new MyJsoupEngine());
    }

    @Override
    public void onTerminate() {
        super.onTerminate();
        JsCrawler.release();
    }
}

github地址: https://github.com/YuanKJ-/JsCrawler

  • 0
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值