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

来源:互联网 发布:win10笔记本优化教程 编辑:程序博客网 时间:2024/06/11 10:16

最近在研究爬虫和客户端抓取网页的相关内容,想要做一个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();// 设置请求urlbuilder.url("http://api.kejie.tk");// 设置method,只支持POST和GET两种请求builder.method("POST");builder.method("GET");// 添加header,有两种方式,addHeader或者setHeadersbuilder.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/jsonbuilder.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

原创粉丝点击