Web 端反爬蟲技術方案

對於內容型的公司,數據的安全性很重要。對於內容公司來講,數據的重要性不言而喻。好比你一個作在線教育的平臺,題目的數據很重要吧,可是被別人經過爬蟲技術所有爬走了?若是核心競爭力都被拿走了,那就是涼涼。再比說有個獨立開發者想抄襲你的產品,經過抓包和爬蟲手段將你核心的數據拿走,而後短時間內作個網站和 App,短時間內成爲你的勁敵。javascript

爬蟲手段

  • 目前爬蟲技術都是從渲染好的 html 頁面直接找到感興趣的節點,而後獲取對應的文本
  • 有些網站安全性作的好,好比列表頁可能好獲取,可是詳情頁就須要從列表頁點擊對應的 item,將 itemId 經過 form 表單提交,服務端生成對應的參數,而後重定向到詳情頁(重定向過來的地址後才帶有詳情頁的參數 detailID),這個步驟就能夠攔截掉一部分的爬蟲開發者

制定出Web 端反爬技術方案

本人從這2個角度(網頁所見非所得、查接口請求沒用)出發,制定了下面的反爬方案。css

  • 使用HTTPS 協議html

  • 單位時間內限制掉請求次數過多,則封鎖該帳號前端

  • 前端技術限制 (接下來是核心技術)java

# 好比須要正確顯示的數據爲「19950220」

1. 先按照本身需求利用相應的規則(數字亂序映射,好比正常的0對應仍是0,可是亂序就是 0 <-> 1,1 <-> 9,3 <-> 8,...)製做自定義字體(ttf)
2. 根據上面的亂序映射規律,求獲得須要返回的數據 19950220 -> 17730220
3. 對於第一步獲得的字符串,依次遍歷每一個字符,將每一個字符根據按照線性變換(y=kx+b)。線性方程的係數和常數項是根據當前的日期計算獲得的。好比當前的日期爲「2018-07-24」,那麼線性變換的 k 爲 7,b 爲 24。
4. 而後將變換後的每一個字符串用「3.1415926」拼接返回給接口調用者。(爲何是3.1415926,由於對數字僞造反爬,因此拼接的文本確定是數字的話不太會引發研究者的注意,可是數字長度過短會誤傷正常的數據,因此用所熟悉的 Π)

​```
1773 -> 「1*7+24」 + 「3.1415926」 + 「7*7+24」 + 「3.1415926」 + 「7*7+24」 + 「3.1415926」 + 「3*7+24」 -> 313.1415926733.1415926733.141592645
02 -> "0*7+24" + "3.1415926" + "2*7+24" -> 243.141592638
20 -> "2*7+24" + "3.1415926" + "0*7+24" -> 383.141592624
​```

# 前端拿到數據後再解密,解密後根據自定義的字體 Render 頁面
1. 先將拿到的字符串按照「3.1415926」拆分爲數組
2. 對數組的每1個數據,按照「線性變換」(y=kx+b,k和b一樣按照當前的日期求解獲得),逆向求解到本來的值。
3. 將步驟2的的到的數據依次拼接,再根據 ttf 文件 Render 頁面上。
  • 後端須要根據上一步設計的協議將數據進行加密處理

下面以 Node.js 爲例講解後端須要作的事情node

  • 首前後端設置接口路由webpack

  • 獲取路由後面的參數git

  • 根據業務須要根據 SQL 語句生成對應的數據。若是是數字部分,則須要按照上面約定的方法加以轉換。github

  • 將生成數據轉換成 JSON 返回給調用者web

    // json
    var JoinOparatorSymbol = "3.1415926";
    function encode(rawData, ruleType) {
      if (!isNotEmptyStr(rawData)) {
        return "";
      }
      var date = new Date();
      var year = date.getFullYear();
      var month = date.getMonth() + 1;
      var day = date.getDate();
    
      var encodeData = "";
      for (var index = 0; index < rawData.length; index++) {
        var datacomponent = rawData[index];
        if (!isNaN(datacomponent)) {
          if (ruleType < 3) {
            var currentNumber = rawDataMap(String(datacomponent), ruleType);
            encodeData += (currentNumber * month + day) + JoinOparatorSymbol;
          }
          else if (ruleType == 4) {
            encodeData += rawDataMap(String(datacomponent), ruleType);
          }
          else {
            encodeData += rawDataMap(String(datacomponent), ruleType) + JoinOparatorSymbol;
          }
        }
        else if (ruleType == 4) {
          encodeData += rawDataMap(String(datacomponent), ruleType);
        }
    
      }
      if (encodeData.length >= JoinOparatorSymbol.length) {
        var lastTwoString = encodeData.substring(encodeData.length - JoinOparatorSymbol.length, encodeData.length);
        if (lastTwoString == JoinOparatorSymbol) {
          encodeData = encodeData.substring(0, encodeData.length - JoinOparatorSymbol.length);
        }
      }
    //字體映射處理
    function rawDataMap(rawData, ruleType) {
    
      if (!isNotEmptyStr(rawData) || !isNotEmptyStr(ruleType)) {
        return;
      }
      var mapData;
      var rawNumber = parseInt(rawData);
      var ruleTypeNumber = parseInt(ruleType);
      if (!isNaN(rawData)) {
        lastNumberCategory = ruleTypeNumber;
        //字體文件1下的數據加密規則
        if (ruleTypeNumber == 1) {
          if (rawNumber == 1) {
            mapData = 1;
          }
          else if (rawNumber == 2) {
            mapData = 2;
          }
          else if (rawNumber == 3) {
            mapData = 4;
          }
          else if (rawNumber == 4) {
            mapData = 5;
          }
          else if (rawNumber == 5) {
            mapData = 3;
          }
          else if (rawNumber == 6) {
            mapData = 8;
          }
          else if (rawNumber == 7) {
            mapData = 6;
          }
          else if (rawNumber == 8) {
            mapData = 9;
          }
          else if (rawNumber == 9) {
            mapData = 7;
          }
          else if (rawNumber == 0) {
            mapData = 0;
          }
        }
        //字體文件2下的數據加密規則
        else if (ruleTypeNumber == 0) {
    
          if (rawNumber == 1) {
            mapData = 4;
          }
          else if (rawNumber == 2) {
            mapData = 2;
          }
          else if (rawNumber == 3) {
            mapData = 3;
          }
          else if (rawNumber == 4) {
            mapData = 1;
          }
          else if (rawNumber == 5) {
            mapData = 8;
          }
          else if (rawNumber == 6) {
            mapData = 5;
          }
          else if (rawNumber == 7) {
            mapData = 6;
          }
          else if (rawNumber == 8) {
            mapData = 7;
          }
          else if (rawNumber == 9) {
            mapData = 9;
          }
          else if (rawNumber == 0) {
            mapData = 0;
          }
        }
        //字體文件3下的數據加密規則
        else if (ruleTypeNumber == 2) {
    
          if (rawNumber == 1) {
            mapData = 6;
          }
          else if (rawNumber == 2) {
            mapData = 2;
          }
          else if (rawNumber == 3) {
            mapData = 1;
          }
          else if (rawNumber == 4) {
            mapData = 3;
          }
          else if (rawNumber == 5) {
            mapData = 4;
          }
          else if (rawNumber == 6) {
            mapData = 8;
          }
          else if (rawNumber == 7) {
            mapData = 3;
          }
          else if (rawNumber == 8) {
            mapData = 7;
          }
          else if (rawNumber == 9) {
            mapData = 9;
          }
          else if (rawNumber == 0) {
            mapData = 0;
          }
        }
        else if (ruleTypeNumber == 3) {
    
          if (rawNumber == 1) {
            mapData = "&#xefab;";
          }
          else if (rawNumber == 2) {
            mapData = "&#xeba3;";
          }
          else if (rawNumber == 3) {
            mapData = "&#xecfa;";
          }
          else if (rawNumber == 4) {
            mapData = "&#xedfd;";
          }
          else if (rawNumber == 5) {
            mapData = "&#xeffa;";
          }
          else if (rawNumber == 6) {
            mapData = "&#xef3a;";
          }
          else if (rawNumber == 7) {
            mapData = "&#xe6f5;";
          }
          else if (rawNumber == 8) {
            mapData = "&#xecb2;";
          }
          else if (rawNumber == 9) {
            mapData = "&#xe8ae;";
          }
          else if (rawNumber == 0) {
            mapData = "&#xe1f2;";
          }
        }
        else{
          mapData = rawNumber;
        }
      } else if (ruleTypeNumber == 4) {
        var sources = ["年", "萬", "業", "人", "信", "元", "千", "司", "州", "資", "造", "錢"];
        //判斷字符串爲漢字
        if (/^[\u4e00-\u9fa5]*$/.test(rawData)) {
    
          if (sources.indexOf(rawData) > -1) {
            var currentChineseHexcod = rawData.charCodeAt(0).toString(16);
            var lastCompoent;
            var mapComponetnt;
            var numbers = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];
            var characters = ["a", "b", "c", "d", "e", "f", "g", "h", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"];
    
            if (currentChineseHexcod.length == 4) {
              lastCompoent = currentChineseHexcod.substr(3, 1);
              var locationInComponents = 0;
              if (/[0-9]/.test(lastCompoent)) {
                locationInComponents = numbers.indexOf(lastCompoent);
                mapComponetnt = numbers[(locationInComponents + 1) % 10];
              }
              else if (/[a-z]/.test(lastCompoent)) {
                locationInComponents = characters.indexOf(lastCompoent);
                mapComponetnt = characters[(locationInComponents + 1) % 26];
              }
              mapData = "&#x" + currentChineseHexcod.substr(0, 3) + mapComponetnt + ";";
            }
          } else {
            mapData = rawData;
          }
    
        }
        else if (/[0-9]/.test(rawData)) {
          mapData = rawDataMap(rawData, 2);
        }
        else {
          mapData = rawData;
        }
    
      }
      return mapData;
    }
    //api
    module.exports = {
        "GET /api/products": async (ctx, next) => {
            ctx.response.type = "application/json";
            ctx.response.body = {
                products: products
            };
        },
    
        "GET /api/solution1": async (ctx, next) => {
    
            try {
                var data = fs.readFileSync(pathname, "utf-8");
                ruleJson = JSON.parse(data);
                rule = ruleJson.data.rule;
            } catch (error) {
                console.log("fail: " + error);
            }
    
            var data = {
                code: 200,
                message: "success",
                data: {
                    name: "@杭城小劉",
                    year: LBPEncode("1995", rule),
                    month: LBPEncode("02", rule),
                    day: LBPEncode("20", rule),
                    analysis : rule
                }
            }
    
            ctx.set("Access-Control-Allow-Origin", "*");
            ctx.response.type = "application/json";
            ctx.response.body = data;
        },
    
    
        "GET /api/solution2": async (ctx, next) => {
            try {
                var data = fs.readFileSync(pathname, "utf-8");
                ruleJson = JSON.parse(data);
                rule = ruleJson.data.rule;
            } catch (error) {
                console.log("fail: " + error);
            }
    
            var data = {
                code: 200,
                message: "success",
                data: {
                    name: LBPEncode("建造師",rule),
                    birthday: LBPEncode("1995年02月20日",rule),
                    company: LBPEncode("中天公司",rule),
                    address: LBPEncode("浙江省杭州市拱墅區石祥路",rule),
                    bidprice: LBPEncode("2萬元",rule),
                    negative: LBPEncode("2018年辦事效率過高、負面基本沒有",rule),
                    title: LBPEncode("建造師",rule),
                    honor: LBPEncode("最佳獎",rule),
                    analysis : rule
                }
            }
            ctx.set("Access-Control-Allow-Origin", "*");
            ctx.response.type = "application/json";
            ctx.response.body = data;
        },
    
        "POST /api/products": async (ctx, next) => {
            var p = {
                name: ctx.request.body.name,
                price: ctx.request.body.price
            };
            products.push(p);
            ctx.response.type = "application/json";
            ctx.response.body = p;
        }
    };
    //路由
    const fs = require("fs");
    
    function addMapping(router, mapping){
        for(var url in mapping){
            if (url.startsWith("GET")) {
                var path = url.substring(4);
                router.get(path,mapping[url]);
                console.log(`Register URL mapping: GET: ${path}`);
            }else if (url.startsWith('POST ')) {
                var path = url.substring(5);
                router.post(path, mapping[url]);
                console.log(`Register URL mapping: POST ${path}`);
            } else if (url.startsWith('PUT ')) {
                var path = url.substring(4);
                router.put(path, mapping[url]);
                console.log(`Register URL mapping: PUT ${path}`);
            } else if (url.startsWith('DELETE ')) {
                var path = url.substring(7);
                router.del(path, mapping[url]);
                console.log(`Register URL mapping: DELETE ${path}`);
            } else {
                console.log(`Invalid URL: ${url}`);
            }
    
        }
    }
    
    
    function addControllers(router, dir){
        fs.readdirSync(__dirname + "/" + dir).filter( (f) => {
            return f.endsWith(".js");
        }).forEach( (f) => {
            console.log(`Process controllers:${f}...`);
            let mapping = require(__dirname + "/" + dir + "/" + f);
            addMapping(router,mapping);
        });
    }
    
    module.exports = function(dir){
        let controllers = dir || "controller";
        let router = require("koa-router")();
    
        addControllers(router,controllers);
        return router.routes();
    };
  • 前端根據服務端返回的數據逆向解密

    $("#year").html(getRawData(data.year,log));
    
    // util.js
    var JoinOparatorSymbol = "3.1415926";
    function isNotEmptyStr($str) {
      if (String($str) == "" || $str == undefined || $str == null || $str == "null") {
        return false;
      }
      return true;
    }
    
    function getRawData($json,analisys) {
      $json = $json.toString();
      if (!isNotEmptyStr($json)) {
        return;
      }
    
      var date= new Date();
      var year = date.getFullYear();
      var month = date.getMonth() + 1;
      var day = date.getDate();
      var datacomponents = $json.split(JoinOparatorSymbol);
      var orginalMessage = "";
      for(var index = 0;index < datacomponents.length;index++){
        var datacomponent = datacomponents[index];
          if (!isNaN(datacomponent) && analisys < 3){
              var currentNumber = parseInt(datacomponent);
              orginalMessage += (currentNumber -  day)/month;
          }
          else if(analisys == 3){
             orginalMessage += datacomponent;
          }
          else{
            //其餘狀況待續,本 Demo 根據本人在研究反爬方面的技術並實踐後持續更新
          }
      }
      return orginalMessage;
    }

好比後端返回的是323.14743.14743.1446,根據咱們約定的算法,能夠的到結果爲1773

  • 根據 ttf 文件 Render 頁面 自定義字體文件 上面計算的到的1773,而後根據ttf文件,頁面看到的就是1995

  • 而後爲了防止爬蟲人員查看 JS 研究問題,因此對 JS 的文件進行了加密處理。若是你的技術棧是 Vue 、React 等,webpack 爲你提供了 JS 加密的插件,也很方便處理

    JS混淆工具

  • 我的以爲這種方式還不是很安全。因而想到了各類方案的組合拳。好比

 反爬升級版

我的以爲若是一個前端經驗豐富的爬蟲開發者來講,上面的方案可能仍是會存在被破解的可能,因此在以前的基礎上作了升級版本

  1. 組合拳1: 字體文件不要固定,雖然請求的連接是同一個,可是根據當前的時間戳的最後一個數字取模,好比 Demo 中對4取模,有4種值 0、一、二、3。這4種值對應不一樣的字體文件,因此當爬蟲絞盡腦汁爬到1種狀況下的字體時,沒想到再次請求,字體文件的規則變掉了
  2. 組合拳2: 前面的規則是字體問題亂序,可是隻是數字匹配打亂掉。好比 1 -> 4, 5 -> 8。接下來的套路就是每一個數字對應一個 unicode 碼 ,而後製做本身須要的字體,能夠是 .ttf、.woff 等等。

網頁檢察元素獲得的效果 接口返回數據

這幾種組合拳打下來。對於通常的爬蟲就放棄了。

反爬手段再升級

上面說的方法主要是針對數字作的反爬手段,若是要對漢字進行反爬怎麼辦?接下來提供幾種方案

  1. 方案1: 對於你站點頻率最高的詞雲,作一個漢字映射,也就是自定義字體文件,步驟跟數字同樣。先將經常使用的漢字生成對應的 ttf 文件;根據下面提供的連接,將 ttf 文件轉換爲 svg 文件,而後在下面的「字體映射」連接點進去的網站上面選擇前面生成的 svg 文件,將svg文件裏面的每一個漢字作個映射,也就是將漢字專爲 unicode 碼(注意這裏的 unicode 碼不要去在線直接生成,由於直接生成的東西也就是有規律的。我給的作法是先用網站生成,而後將獲得的結果作個簡單的變化,好比將「e342」轉換爲 「e231」);而後接口返回的數據按照咱們的這個字體文件的規則反過去映射出來。

  2. 方案2: 將網站的重要字體,將 html 部分生成圖片,這樣子爬蟲要識別到須要的內容成本就很高了,須要用到 OCR。效率也很低。因此能夠攔截掉一部分的爬蟲

  3. 方案3: 看到攜程的技術分享「反爬的最高境界就是 Canvas 的指紋,原理是不一樣的機器不一樣的硬件對於 Canvas 畫出的圖老是存在像素級別的偏差,所以咱們判斷當對於訪問來講大量的 canvas 的指紋一致的話,則認爲是爬蟲,則能夠封掉它」。

    本人將方案1實現到 Demo 中了。

關鍵步驟

  1. 先根據大家的產品找到經常使用的關鍵詞,生成詞雲
  2. 根據詞雲,將每一個字生成對應的 unicode 碼
  3. 將詞雲包括的漢字作成一個字體庫
  4. 將字體庫 .ttf 作成 svg 格式,而後上傳到 icomoon 製做自定義的字體,可是有規則,好比 「年」 對應的 unicode 碼「\u5e74」 ,可是咱們須要作一個 愷撒加密 ,好比咱們設置 偏移量 爲1,那麼通過愷撒加密 「年」對應的 unicode 碼是「\u5e75」 。利用這種規則製做咱們須要的字體庫
  5. 在每次調用接口的時候服務端作的事情是:服務端封裝某個方法,將數據通過方法判斷是否是在詞雲中,若是是詞雲中的字符,利用規則(找到漢字對應的 unicode 碼,再根據凱撒加密,設置對應的偏移量,Demo 中爲1,將每一個漢字加密處理)加密處理後返回數據
  6. 客戶端作的事情:
    • 先引入咱們前面製做好的漢字字體庫
    • 調用接口拿到數據,顯示到對應的 Dom 節點上
    • 若是是漢字文本,咱們將對應節點的 css 類設置成漢字類,該類對應的 font-family 是咱們上面引入的漢字字體庫
//style.css
@font-face {
  font-family: "NumberFont";
  src: url('http://127.0.0.1:8080/Util/analysis');
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

@font-face {
  font-family: "CharacterFont";
  src: url('http://127.0.0.1:8080/Util/map');
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

h2 {
  font-family: "NumberFont";
}

h3,a{
  font-family: "CharacterFont";
}

接口效果 審查元素效果

傳送門

字體制做的步驟ttf轉svg字體映射規則

實現的效果

  1. 頁面上看到的數據跟審查元素看到的結果不一致
  2. 去查看接口數據跟審覈元素和界面看到的三者不一致
  3. 頁面每次刷新以前得出的結果更不一致
  4. 對於數字和漢字的處理手段都不一致

這幾種組合拳打下來。對於通常的爬蟲就放棄了。

數字反爬-網頁顯示效果、審查元素、接口結果狀況1 數字反爬-網頁顯示效果、審查元素、接口結果狀況2 數字反爬-網頁顯示效果、審查元素、接口結果狀況3 數字反爬-網頁顯示效果、審查元素、接口結果狀況4 漢字反爬-網頁顯示效果、審查元素、接口結果狀況1 漢字反爬-網頁顯示效果、審查元素、接口結果狀況2

<hr>

前面的 ttf 轉 svg 網站當 ttf 文件太大會限制轉換,讓你購買,下面貼出個新的連接。

ttf轉svg

Demo 地址

效果演示

運行步驟

//客戶端。先查看本機 ip 在 Demo/Spider-develop/Solution/Solution1.js 和 Demo/Spider-develop/Solution/Solution2.js  裏面將接口地址修改成本機 ip

$ cd Demo
$ ls
REST		Spider-release	file-Server.js
Spider-develop	Util		rule.json
$ node file-Server.js 
Server is runnig at http://127.0.0.1:8080/

//服務端 先安裝依賴
$ cd REST/
$ npm install
$ node app.js
相關文章
相關標籤/搜索