天天看點

微網誌爬蟲“免登入”技巧詳解及Java實作

一、微網誌一定要登入才能抓取?

目前,對于微網誌的爬蟲,大部分是基于模拟微網誌賬号登入的方式實作的,這種方式如果真的營運起來,實際上是一件非常頭疼痛苦的事,你可能每天都過得提心吊膽,生怕新浪爸爸把你的那些賬号給封了,而且現在随着實名制的落地,獲得賬号的管道估計也會變得越來越少。

但是日子還得繼續,在如此艱難的條件下,為了生存爬蟲們必須尋求進化。好在上帝關門的同時會随手開窗,微網誌在其他諸如頭條,一點等這類新媒體平台的沖擊之下,逐漸放開了資訊流的檢視權限。現在的微網誌即便在不登入的狀态下,依然可以看到很多微網誌資訊流,而我們的落腳點就在這裡。

本文詳細介紹如何擷取相關的Cookie并重新封裝Httpclient達到免登入的目的,以支援微網誌上的各項資料抓取任務。下面就從微網誌首頁http://weibo.com開始。

二、準備工作

準備工作很簡單,一個現代浏覽器(你知道我為什麼會寫”現代”兩個字),以及httpclient(我用的版本是4.5.3)

跟登入爬蟲一樣,免登入爬蟲也是需要裝載Cookie。這裡的Cookie是用來标明遊客身份,利用這個Cookie就可以在微網誌平台中通路那些允許通路的内容了。

這裡我們可以使用浏覽器的network工具來看一下,請求http://weibo.com之後伺服器都傳回哪些東西,當然事先清空一下浏覽器的緩存。

不出意外,應該可以看到下圖中的内容

微網誌爬蟲“免登入”技巧詳解及Java實作

第1次請求weibo.com的時候,其狀态為302重定向,也就是說這時并沒有真正地開始加載頁面,而最後一個請求weibo.com的狀态為200,表示了請求成功,對比兩次請求的header:

微網誌爬蟲“免登入”技巧詳解及Java實作

明顯地,中間的這些過程給用戶端加載了各種Cookie,進而使得可以順利通路頁面,接下來我們逐個進行分析。

三、抽絲剝繭

第2個請求是https://passport.weibo.com/vi...……,各位可以把這個url複制出來,用httpclient單獨通路一下這個url,可以看到傳回的是一個html頁面,裡面有一大段Javascript腳本,另外頭部還引用一個JS檔案mini_original.js,也就是第3個請求。腳本的功能比較多,就不一一叙述了,簡單來說就是微網誌通路的入口控制,而值得我們注意的是其中的一個function:

// 為使用者賦予訪客身份 。 

    var incarnate = function (tid, where, conficence) { 

        var gen_conf = ""; 

        var from = "weibo"; 

        var incarnate_intr = window.location.protocol + "//" + window.location.host + "/visitor/visitor?a=incarnate&t=" + encodeURIComponent(tid) + "&w=" + encodeURIComponent(where) + "&c=" + encodeURIComponent(conficence) + "&gc=" + encodeURIComponent(gen_conf) + "&cb=cross_domain&from=" + from + "&_rand=" + Math.random(); 

        url.l(incarnate_intr); 

    };  

這裡是為請求者賦予一個訪客身份,而控制跳轉的連結也是由一些參數拼接起來的,也就是上圖中第6個請求。是以下面的工作就是獲得這3個參數:tid,w(where),c(conficence,從下文來看應為confidence,大概是新浪工程師的手誤)。繼續閱讀源碼,可以看到該function是tid.get方法的回調函數,而這個tid則是定義在那個mini_original.js中的一個對象,其部分源碼為:

 var tid = { 

        key: 'tid', 

        value: '', 

        recover: 0, 

        confidence: '', 

        postInterface: postUrl, 

        fpCollectInterface: sendUrl, 

        callbackStack: [], 

        init: function () { 

            tid.get(); 

        }, 

        runstack: function () { 

            var f; 

            while (f = tid.callbackStack.pop()) { 

                f(tid.value, tid.recover, tid.confidence);//注意這裡,對應上述的3個參數 

            } 

        get: function (callback) { 

            callback = callback || function () { 

            }; 

            tid.callbackStack.push(callback); 

            if (tid.value) { 

                return tid.runstack(); 

            Store.DB.get(tid.key, function (v) { 

                if (!v) { 

                    tid.getTidFromServer(); 

                } else { 

                    …… 

                } 

            }); 

    …… 

    } 

…… 

 getTidFromServer: function () { 

            tid.getTidFromServer = function () { 

            if (window.use_fp) { 

                getFp(function (data) { 

                    util.postData(window.location.protocol + '//' + window.location.host + '/' + tid.postInterface, "cb=gen_callback&fp=" + encodeURIComponent(data), function (res) { 

                        if (res) { 

                            eval(res); 

                        } 

                    }); 

                }); 

            } else { 

                util.postData(window.location.protocol + '//' + window.location.host + '/' + tid.postInterface, "cb=gen_callback", function (res) { 

                    if (res) { 

                        eval(res); 

                    } 

//獲得參數 

window.gen_callback = function (fp) { 

        var value = false, confidence; 

        if (fp) { 

            if (fp.retcode == 20000000) { 

                confidence = typeof(fp.data.confidence) != 'undefined' ? '000' + fp.data.confidence : '100'; 

                tid.recover = fp.data.new_tid ? 3 : 2; 

                tid.confidence = confidence = confidence.substring(confidence.length - 3); 

                value = fp.data.tid; 

                Store.DB.set(tid.key, value + '__' + confidence); 

        } 

        tid.value = value; 

        tid.runstack(); 

顯然,tid.runstack()是真正執行回調函數的地方,這裡就能看到傳入的3個參數。在get方法中,當cookie為空時,tid會調用getTidFromServer,這時就産生了第5個請求https://passport.weibo.com/vi...,它需要兩個參數cb和fp,其參數值可以作為常量:

微網誌爬蟲“免登入”技巧詳解及Java實作

該請求的結果傳回一串json

  "msg": "succ", 

  "data": { 

    "new_tid": false, 

    "confidence": 95, 

    "tid": "kIRvLolhrCR5iSCc80tWqDYmwBvlRVlnY2+yvCQ1VVA=" 

  }, 

  "retcode": 20000000 

}  

其中就包含了tid和confidence,這個confidence,我猜大概是推測用戶端是否真實的一個置信度,不一定出現,根據window.gen_callback方法,不出現時預設為100,另外當new_tid為真時參數where等于3,否則等于2。

此時3個參數已經全部獲得,現在就可以用httpclient發起上面第6個請求,傳回得到另一串json:

    "sub": "_2AkMu428tf8NxqwJRmPAcxWzmZYh_zQjEieKYv572JRMxHRl-yT83qnMGtRCnhyR4ezQQZQrBRO3gVMwM5ZB2hQ..", 

    "subp": "0033WrSXqPxfM72-Ws9jqgMF55529P9D9WWU2MgYnITksS2awP.AX-DQ" 

參考最後請求weibo.com的header,這裡的sub和subp就是最終要擷取的cookie值。大家或許有一個小疑問,第一個Cookie怎麼來的,沒用嗎?是的,這個Cookie是第一次通路weibo.com産生的,經過測試可以不用裝載。

微網誌爬蟲“免登入”技巧詳解及Java實作

最後我們用上面兩個Cookie裝載到HttpClient中請求一次weibo.com,就可以獲得完整的html頁面了,下面就是見證奇迹的時刻:

<!doctype html> 

<html> 

<head> 

<meta charset="utf-8"> 

<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> 

<meta name="viewport" content="initial-scale=1,minimum-scale=1" /> 

<meta content="随時随地發現新鮮事!微網誌帶你欣賞世界上每一個精彩瞬間,了解每一個幕後故事。分享你想表達的,讓全世界都能聽到你的心聲!" name="description" /> 

<link rel="mask-icon" sizes="any" href="//img.t.sinajs.cn/t6/style/images/apple/wbfont.svg" color="black" /> 

<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico" /> 

<script type="text/javascript"> 

try{document.execCommand("BackgroundImageCache", false, true);}catch(e){} 

</script> 

<title>微網誌-随時随地發現新鮮事</title> 

<link href="//img.t.sinajs.cn/t6/style/css/module/base/frame.css?version=6c9bf6ab3b33391f" type="text/css" rel="stylesheet" charset="utf-8" /> 

<link href="//img.t.sinajs.cn/t6/style/css/pages/growth/login_v5.css?version=6c9bf6ab3b33391f" type="text/css" rel="stylesheet" charset="utf-8"> 

<link href="//img.t.sinajs.cn/t6/skin/default/skin.css?version=6c9bf6ab3b33391f" type="text/css" rel="stylesheet" id="skin_style" /> 

var $CONFIG = {}; 

$CONFIG['islogin'] = '0'; 

$CONFIG['version'] = '6c9bf6ab3b33391f'; 

$CONFIG['timeDiff'] = (new Date() - 1505746970000); 

$CONFIG['lang'] = 'zh-cn'; 

$CONFIG['jsPath'] = '//js.t.sinajs.cn/t5/'; 

$CONFIG['cssPath'] = '//img.t.sinajs.cn/t5/'; 

$CONFIG['imgPath'] = '//img.t.sinajs.cn/t5/'; 

$CONFIG['servertime'] = 1505746970; 

$CONFIG['location']='login'; 

$CONFIG['bigpipe']='false'; 

$CONFIG['bpType']='login'; 

$CONFIG['mJsPath'] = ['//js{n}.t.sinajs.cn/t5/', 1, 2]; 

$CONFIG['mCssPath'] = ['//img{n}.t.sinajs.cn/t5/', 1, 2]; 

$CONFIG['redirect'] = ''; 

$CONFIG['vid']='1008997495870'; 

<style>#js_style_css_module_global_WB_outframe{height:42px;}</style> 

</head> 

……  

如果之前有微網誌爬蟲開發經驗的小夥伴,看到這裡,一定能想出來很多玩法了吧。

四、代碼實作

下面附上我的源碼,通過上面的詳細介紹,應該已經比較好了解,是以這裡就簡單地說明一下:

我把Cookie擷取的過程做成了一個靜态内部類,其中需要發起2次請求,一次是genvisitor獲得3個參數,另一次是incarnate獲得Cookie值;

如果Cookie擷取失敗,會調用HttpClientInstance.changeProxy來改變代理IP,然後重新擷取,直到擷取成功為止;

在使用時,出現了IP被封或無法正常擷取頁面等異常情況,外部可以通過調用cookieReset方法,重新擷取一個新的Cookie。這裡還是要聲明一下,科學地使用爬蟲,維護世界和平是程式員的基本素養;

雖然加了一些鎖的控制,但是還未在高并發場景實測過,不能保證百分百線程安全,如使用下面的代碼,請根據需要自行修改,如有問題也請大神們及時指出,拜謝!

HttpClientInstance是我用單例模式重新封裝的httpclient,對于每個傳進來的請求重新包裝了一層RequestConfig,并且使用了代理IP;

不是所有的微網誌頁面都可以抓取得到,但是博文,評論,轉發等基本的資料還是沒有問題的;

後續我也會把代碼push到github上,請大家支援,謝謝! 

import com.fullstackyang.httpclient.HttpClientInstance; 

import com.fullstackyang.httpclient.HttpRequestUtils; 

import com.google.common.base.Strings; 

import com.google.common.collect.Maps; 

import com.google.common.net.HttpHeaders; 

import lombok.NoArgsConstructor; 

import lombok.extern.slf4j.Slf4j; 

import org.apache.commons.lang3.StringUtils; 

import org.apache.http.client.config.CookieSpecs; 

import org.apache.http.client.config.RequestConfig; 

import org.apache.http.client.methods.HttpGet; 

import org.apache.http.client.methods.HttpPost; 

import org.json.JSONObject; 

import java.io.UnsupportedEncodingException; 

import java.math.BigDecimal; 

import java.net.URLEncoder; 

import java.util.Map; 

import java.util.concurrent.locks.Lock; 

import java.util.concurrent.locks.ReentrantLock; 

/** 

 * 微網誌免登陸請求用戶端 

 * 

 * @author fullstackyang 

 */ 

@Slf4j 

public class WeiboClient { 

    private static CookieFetcher cookieFetcher = new CookieFetcher(); 

    private volatile String cookie; 

    public WeiboClient() { 

        this.cookie = cookieFetcher.getCookie(); 

    private static Lock lock = new ReentrantLock(); 

    public void cookieReset() { 

        if (lock.tryLock()) { 

            try { 

                HttpClientInstance.instance().changeProxy(); 

                this.cookie = cookieFetcher.getCookie(); 

                log.info("cookie :" + cookie); 

            } finally { 

                lock.unlock(); 

    /** 

     * get方法,擷取微網誌平台的其他頁面 

     * @param url 

     * @return 

     */ 

    public String get(String url) { 

        if (Strings.isNullOrEmpty(url)) 

            return ""; 

        while (true) { 

            HttpGet httpGet = new HttpGet(url); 

            httpGet.addHeader(HttpHeaders.COOKIE, cookie); 

            httpGet.addHeader(HttpHeaders.HOST, "weibo.com"); 

            httpGet.addHeader("Upgrade-Insecure-Requests", "1"); 

            httpGet.setConfig(RequestConfig.custom().setSocketTimeout(3000) 

                    .setConnectTimeout(3000).setConnectionRequestTimeout(3000).build()); 

            String html = HttpClientInstance.instance().tryExecute(httpGet, null, null); 

            if (html == null) 

                cookieReset(); 

            else return html; 

     /** 

     * 擷取通路微網誌時必需的Cookie 

    @NoArgsConstructor 

    static class CookieFetcher { 

        static final String PASSPORT_URL = "https://passport.weibo.com/visitor/visitor?entry=miniblog&a=enter&url=http://weibo.com/?category=2" 

                + "&domain=.weibo.com&ua=php-sso_sdk_client-0.6.23"; 

        static final String GEN_VISITOR_URL = "https://passport.weibo.com/visitor/genvisitor"; 

        static final String VISITOR_URL = "https://passport.weibo.com/visitor/visitor?a=incarnate"; 

        private String getCookie() { 

            Map<String, String> map; 

            while (true) { 

                map = getCookieParam(); 

                if (map.containsKey("SUB") && map.containsKey("SUBP") && 

                        StringUtils.isNoneEmpty(map.get("SUB"), map.get("SUBP"))) 

                    break; 

            return " YF-Page-G0=" + "; _s_tentry=-; SUB=" + map.get("SUB") + "; SUBP=" + map.get("SUBP"); 

        private Map<String, String> getCookieParam() { 

            String time = System.currentTimeMillis() + ""; 

            time = time.substring(0, 9) + "." + time.substring(9, 13); 

            String passporturl = PASSPORT_URL + "&_rand=" + time; 

            String tid = ""; 

            String c = ""; 

            String w = ""; 

            { 

                String str = postGenvisitor(passporturl); 

                if (str.contains("\"retcode\":20000000")) { 

                    JSONObject jsonObject = new JSONObject(str).getJSONObject("data"); 

                    tid = jsonObject.optString("tid"); 

                    try { 

                        tid = URLEncoder.encode(tid, "utf-8"); 

                    } catch (UnsupportedEncodingException e) { 

                    c = jsonObject.has("confidence") ? "000" + jsonObject.getInt("confidence") : "100"; 

                    w = jsonObject.optBoolean("new_tid") ? "3" : "2"; 

            String s = ""; 

            String sp = ""; 

                if (StringUtils.isNoneEmpty(tid, w, c)) { 

                    String str = getVisitor(tid, w, c, passporturl); 

                    str = str.substring(str.indexOf("(") + 1, str.indexOf(")")); 

                    if (str.contains("\"retcode\":20000000")) { 

                        System.out.println(new JSONObject(str).toString(2)); 

                        JSONObject jsonObject = new JSONObject(str).getJSONObject("data"); 

                        s = jsonObject.getString("sub"); 

                        sp = jsonObject.getString("subp"); 

            Map<String, String> map = Maps.newHashMap(); 

            map.put("SUB", s); 

            map.put("SUBP", sp); 

            return map; 

        private String postGenvisitor(String passporturl) { 

            Map<String, String> headers = Maps.newHashMap(); 

            headers.put(HttpHeaders.ACCEPT, "*/*"); 

            headers.put(HttpHeaders.ORIGIN, "https://passport.weibo.com"); 

            headers.put(HttpHeaders.REFERER, passporturl); 

            Map<String, String> params = Maps.newHashMap(); 

            params.put("cb", "gen_callback"); 

            params.put("fp", fp()); 

            HttpPost httpPost = HttpRequestUtils.createHttpPost(GEN_VISITOR_URL, headers, params); 

            String str = HttpClientInstance.instance().execute(httpPost, null); 

            return str.substring(str.indexOf("(") + 1, str.lastIndexOf("")); 

        private String getVisitor(String tid, String w, String c, String passporturl) { 

            String url = VISITOR_URL + "&t=" + tid + "&w=" + "&c=" + c.substring(c.length() - 3) 

                    + "&gc=&cb=cross_domain&from=weibo&_rand=0." + rand(); 

            headers.put(HttpHeaders.HOST, "passport.weibo.com"); 

            headers.put(HttpHeaders.COOKIE, "tid=" + tid + "__0" + c); 

            HttpGet httpGet = HttpRequestUtils.createHttpGet(url, headers); 

            httpGet.setConfig(RequestConfig.custom().setCookieSpec(CookieSpecs.STANDARD).build()); 

            return HttpClientInstance.instance().execute(httpGet, null); 

        private static String rand() { 

            return new BigDecimal(Math.floor(Math.random() * 10000000000000000L)).toString(); 

        private static String fp() { 

            JSONObject jsonObject = new JSONObject(); 

            jsonObject.put("os", "1"); 

            jsonObject.put("browser", "Chrome59,0,3071,115"); 

            jsonObject.put("fonts", "undefined"); 

            jsonObject.put("screenInfo", "1680*1050*24"); 

            jsonObject.put("plugins", 

                    "Enables Widevine licenses for playback of HTML audio/video content. (version: 1.4.8.984)::widevinecdmadapter.dll::Widevine Content Decryption Module|Shockwave Flash 26.0 r0::pepflashplayer.dll::Shockwave Flash|::mhjfbmdgcfjbbpaeojofohoefgiehjai::Chrome PDF Viewer|::internal-nacl-plugin::Native Client|Portable Document Format::internal-pdf-viewer::Chrome PDF Viewer"); 

            return jsonObject.toString(); 

本文作者:fullstackyang

來源:51CTO