1. 程式人生 > >如何給網站加入優雅的實時反爬蟲策略

如何給網站加入優雅的實時反爬蟲策略

你的網站內容很有價值,希望被google,百度等正規搜尋引擎爬蟲收錄,卻不想讓那些無節操的山寨爬蟲把你的資料扒走坐享其成。本文將探討如何在網站中加入優雅的反爬蟲策略。

【思路】

反爬蟲策略要考慮以下幾點:

  1. 能被google、百度等正規搜尋引擎爬蟲抓取,不限流量和併發數;

  2. 阻止山寨爬蟲的抓取;

  3. 反爬蟲策略應該是實時檢測的,而不是通過一段時間後的訪問統計分析得出;

  4. 誤判後的人性化處理(優雅之所在);

大部分的爬蟲不是以瀏覽器方式來訪問頁面的,爬蟲只下載網頁的html原始碼,不載入包含在頁面中的js/css/圖片,這是區分爬蟲與否的一個關鍵。一個請求被識別出來不是瀏覽器訪問,一定是爬蟲,為了滿足上面所說的第1點和第2點,進一步對http頭agent進行驗證,是否標記為google、百度的spider,嚴格一點的話應該判別來源IP是否為google、baidu的爬蟲IP,這些IP在網上都可以找到。校驗出來IP不在白名單就可以阻止訪問內容。

當然,有一部分爬蟲是以瀏覽器載入的方式來抓取內容的,所以,即使被識別出來是瀏覽器訪問的來源ip。還要檢測這個個ip在一個時間片內的併發數,超過一定閥值,可以認為是爬蟲,阻止訪問內容。

由於我們的反爬蟲策略是基於IP的,會出現誤判,尤其是併發量限制的判別。我們需要一種友好的方式來阻止訪問。直接返回50x/40x空白或者錯誤頁面是很粗魯的,當真正的使用者被誤判阻止訪問時能夠手動解鎖繼續訪問才是比較優雅的方式,大家不約而同的會想到驗證碼,對!讓使用者輸入圖形中的驗證碼解鎖,可是我們平常見到的驗證碼都還是野蠻的,驗證碼技術從一開始的簡單的數字,發展今天有輸入漢字的、輸入數學計算結果的等等五花八門,不僅以複雜的驗證碼刁難使用者,還要加上各種干擾字元,美其名曰提高安全性,實際上是開發工程師腦殘扎堆鑽牛角尖的產物,使用者是怨聲載道。驗證碼的目的是區分人工和機器,要做到機器無法自動操作,同時讓人工操作很方便、優雅。在本文的案例中,我們採用了一種比較有趣的驗證碼,讓人識別物體,在驗證碼系統中預存了大量的事物,包括動物、植物、傢俱等等日常遇到的東西,驗證使用者的過程就是系統從這些事物中隨機選出少量圖形,並要求使用者選中預設答案中的某一個即可解鎖。

回到識別爬蟲的步驟,我們用流程圖理一下:

【實現】

我們用nodejs(express)和redis來實現反爬蟲系統,redis用來存放一些計數。

1、判別是否為瀏覽器訪問

返回頁面請求時,在redis中給該IP的頁面訪問計數+1。在每個頁面中會引入一個js,當請求這個js檔案時在redis中給該IP頁面訪問計數-1,這樣,如果不是瀏覽器的請求,redis中的頁面計數會不斷增大,如果是瀏覽器請求,下載頁面原始碼時增1,隨後瀏覽器載入js檔案時減1,redis中的頁面計數會歸零。我們只需要判斷頁面計數是否為0來區分是否為瀏覽器訪問,我們還可以給頁面下載完了但是js沒有載入這種特殊情況留點餘地,設定一個閥值,例如:5,頁面計數大於5就判別出該IP內有爬蟲訪問。

2、爬蟲白名單識別

如果上一步被識別為爬蟲訪問,則進一步檢測使用者http頭的user-agent、ip,判斷是否在預設的白名單內。如果不在則阻止訪問顯示驗證碼。這個步驟很簡單,不用多說。

3、瀏覽器訪問下的併發量限制

同樣在 redis下給每個IP做計數,和上面不同的是利用redis key的過期機制,每次計數累加時將key設定在一定的時間內過期,比如5秒,這個相當於一個時間片,如果5秒內有另外一個請求,會計數增加1,過期時間會延長5秒,如果在一個5秒內沒有其他請求,這個key就會消失。此後一個請求進來計數從1開始。我們可以設定一個閥值,比如20,任意5秒內有20個請求進來為超限,阻止訪問顯示驗證碼。

4、優雅的驗證碼

系統預設了很多圖片,每個圖片是一個動物、植物、傢俱等,每個圖片有一個ID值。從這些圖片中任意抽出3個,並且選中其中一個為標準答案,注意這個過程都是程式後臺進行,將標準答案ID放在session中。前臺頁面顯示了這3幅圖片,並根據預設的答案要求使用者選擇其中一個,使用者只要選中對應的圖片,將表單提交到後臺,系統將提交的ID與session中ID比較判別是否正確。當然,每個圖片都有一個固定的ID值有被窮舉的漏洞,有很多改進的餘地,此處僅討論原型不做過多探討。

效果如圖:

【程式碼】

攔截請求(其他語言類似,例如java可以用攔截器)

app.get('/weixin/*', antiCrawler.openDoor);//需要保護的目錄
app.get('/helper/close-door.js', antiCrawler.closeDoor); //偽js檔案

antiCrawler.js

/**
 * anti crawler
 * Created by Cherokee on 14-7-13.
 */
var settings = require("../settings.json");
var redis = require("redis");
var cache = require("../lib/cache.js");
var vcode = require('../lib/vcode.js');
var ac_redis_cli = redis.createClient(settings['anti_crawler_redis_db']['port'],settings['anti_crawler_redis_db']['host']);
var IP_RECORD_EXPIRE = settings['anti_crawler_redis_db']['ip_record_expire'];
var IP_LOCK_EXPIRE = settings['anti_crawler_redis_db']['ip_lock_expire'];
var IP_HAIR_EXPIRE = settings['anti_crawler_redis_db']['ip_hair_expire'];
var DOOR_THRESHOLD = settings['anti_crawler_redis_db']['door_threshold'];
var HAIR_THRESHOLD = settings['anti_crawler_redis_db']['hair_threshold'];


ac_redis_cli.on('ready',function(){
    console.log('redis for anti-crawler is ready');
});

ac_redis_cli.on('error',function(err){
    console.error('redis for anti-crawler error'+err);
});

ac_redis_cli.on('end',function(){
    console.error('redis for anti-crawler closed');
});

ac_redis_cli.select(settings['anti_crawler_redis_db']['db'],function(err){
    if(err)throw err;
    else {
        cache.set('ac_redis_cli',ac_redis_cli,77760000);
        console.log('redis for anti-crawler switch db :'+settings['anti_crawler_redis_db']['db']);
    }
});

exports.openDoor = function(req, res, next) {
    var ua = req.get('User-Agent');
    var ip = req.ip;
    var url = req.url;

    if(/\/weixin\//.test(url)){
        ac_redis_cli.exists('lock:'+ip,function(err,bol){
            if(bol){
                send421(req,res);
            }else{
                ac_redis_cli.get('door:'+ip,function(err,d_num){
                    if(d_num>DOOR_THRESHOLD){//some one didn't use browser
                        if(isTrustSpider(ua,ip)){//it's trusted spider
                            kickDoor(ip,function(val){
                                leaveHair(ip,function(val){
                                    next();
                                });
                            });
                        }else{
                            blockIt(req,res);
                        }
                    }else{//perhaps using simulated browser to crawl pages
                        ac_redis_cli.get('hair:'+ip,function(err,h_num){
                            if(h_num>HAIR_THRESHOLD){//suspicious
                                blockIt(req,res);
                            }else {
                                kickDoor(ip,function(val){
                                    leaveHair(ip,function(val){
                                        next();
                                    });
                                });
                            }
                        });
                    }
                });
            }
        });
    }
};

exports.closeDoor = function(req,res){
    ac_redis_cli.multi()
        .decr('door:'+req.ip)
        .expire('door:'+req.ip,IP_RECORD_EXPIRE)
        .exec(function(err, replies){
            if(replies&&parseInt(replies[0])<0){
                ac_redis_cli.set('door:'+req.ip,0,function(err){
                    res.set('Content-Type', 'application/x-javascript');
                    res.send(200,'{"zeroize":true}');
                });
            }else{
                res.set('Content-Type', 'application/x-javascript');
                res.send(200,'{"zeroize":false}');
            }
        });
}

exports.verify = function(req,res){
    var vcode = req.body.vcode;
    var origin_url = req.body.origin_url;
    if(req.session.vcode&&vcode==req.session.vcode){
        req.session.vcode = null;
        ac_redis_cli.multi()
            .del('lock:'+req.ip)
            .del('door:'+req.ip)
            .del('hair:'+req.ip)
            .exec(function(err, replies){
                res.redirect(origin_url);
            });
    }else send421(req,res,origin_url);

}

var blockIt = function(req,res){
    ac_redis_cli.multi()
        .set('lock:'+req.ip,1)
        .expire('lock:'+req.ip,IP_LOCK_EXPIRE)
        .exec(function(err, replies){
            send421(req,res);
        });
}

var send421 = function(req,res,origin_url){
    var code_map = {};
    var code_arr = [];

    while(code_arr.length<3){
        var rindex = Math.ceil(Math.random() * vcode.list.length) - 1;
        if(!code_map[rindex]){
            code_map[rindex] = true;
            code_arr.push(rindex);
        }
    }
    var answer = code_arr[Math.ceil(Math.random() * 3) - 1];
    req.session.vcode = answer;
    res.status(421).render('weixin/421',{'code_list':code_arr,'code_label':vcode.list[answer],'origin_url':origin_url||req.url});
}

var isTrustSpider = function(ua,ip){
    var trustBots  = [
        /Baiduspider/ig,
        /Googlebot/ig,
        /Slurp/ig,
        /Yahoo/ig,
        /iaskspider/ig,
        /Sogou/ig,
        /YodaoBot/ig,
        /msnbot/ig,
        /360Spider/ig
    ];
    for(var i=0;i<trustBots.length;i++){
        if(trustBots[i].test(ua))return true;
    }
    return false;
}

var kickDoor = function(ip,callback){
    ac_redis_cli.multi()
        .incr('door:'+ip)
        .expire('door:'+ip,IP_RECORD_EXPIRE)
        .exec(function(err, replies){
            if(callback)callback(replies?replies[0]:null);
        });
}

var leaveHair = function(ip,callback){
    ac_redis_cli.multi()
        .incr('hair:'+ip)
        .expire('hair:'+ip,IP_HAIR_EXPIRE)
        .exec(function(err, replies){
            if(callback)callback(replies?replies[0]:null);
        });
}

實際應用中不僅要檢測User-agent,還要有IP白名單檢測,以上程式碼並沒有包含 IP白名單。

send421函式就是顯示驗證碼的步驟,verify函式是檢驗使用者輸入的驗證碼。