你已經是一個成熟的爬蟲了,應該學會自己去對抗反爬碼農了??-『爬蟲進階指南』

點這里排版好

因為各種原因,這段時間又寫了好多爬蟲 (不務正業 劃掉 ??),也順帶接著這個機會來總結一下,自己認為的爬蟲進階技巧

ps: 爬蟲千萬條,克制第一條。我們也要照顧一下反爬工程師的感受,克制開多線程,降低并發數

以下代碼已開源,基本支持開箱即用,自帶高可用代理 IP 池,嗚嗚嗚(開源一時爽,一直開源一直爽 ??

開胃菜->字體

這基本上已經成了反爬蟲工程師最拿手,最常見的一招了。

像貓眼,東方財富,實習僧,天眼查,起點,etc.

簡單一點的每次返回一個隨機字體(這個隨機指的是字形字符映射關系隨機,字形 set,字符 set 還是不變的)

做的狠一點的就連字庫也隨機一下(是個狠人,這種解決起來成本就有點高了

反爬的基本原理就是利用字體庫中不太常用的一些 高位字符字段(比如說 0xEFFF) ,它是uint16

image

原始文本替換成這些高位字符,然后使用自定義的一個 font 表示高位字符和字形之間的關系

字形的表示方式,感性的想象一下,大抵就是用類似 svg 之類的坐標點集合的方式來表示

但總是去匹配這很長的一串坐標點來判斷是什么字形就顯得很低能,就需要有一個能表示字形的索引,于是就有 Glyph index,
然后還有一大堆表和規劃, 比如用的最多的camp表, 有興趣的同學可以參考這篇文章cmap — Character to Glyph Index Mapping Table

字形索引值一般是 Unicode,但要注意不同的字形可能字形索引值一樣(相當于發生了 hash 碰撞)

在實操中,利用 fonttools 的包可以解析出來字符編碼 uint16 和字形索引 Unicode 之間的映射關系

from fontTools.ttLib import TTFont
font_map = TTFont(font_name).getBestCmap() # uint16 -> unicode

一般像這種,操作的字符集不會太大,畢竟太大對自己服務也是一個不小的壓力

常見的有數字替換,部分文字替換,像這種反爬模式,利用 selenium,splash,mitm 之類的非網絡請求庫就沒有什么效果了 hhh

因為要考慮到隨機 font,即字符 uint16 和字形索引 unicode 之間的關系發生改變,但字形和字形索引 unicode 之間的關系一般不會變。

So, 我們就可以建立一個已知的字形索引 Unicode 與原始字符 str 之間的對應關系 dict_base

當 font 發生改變的時候 字形索引 Unicode 和 uint16 字符之間的關系發生改變,根據 dict_base 反推出字符 uint16 和原始字符 str 之間的關系

舉個 ??, 比如說爬東方財富(個人覺得這是一個特別適合入門的網站,他代碼可讀性比較強,注釋比較多 hhh 很真實 不知道他們前端都是怎么想的)

當然東方財富不是所有頁面都采用了 font 欺騙,應該也是出于效率考慮,以http://data.eastmoney.com/bbsj/201806/lrb.html為例

image

可以看見使用了一個叫做stonefont的 font 來實現字符到字形的映射

經過分析可以發現,table 里面的數據都是預先存放在 html 的 script 里面,直接讀 json 的,其格式即已經加密過后的 uint16 字符

image

既然已經知道了拿到的數據是已經被替換的字符,那么找到 css:stonefont 所引用的字體,把字體 load 都本地分析對比其映射關系即可

因為字體是隨機指派的,那么 font_url 就一定不會被寫死 css 中 為了使得首次加載時間盡量短也一般不會通過 XHR 來獲得,一般都是放在 html 的 script 里面動態 compile 生成

在本例中,font_url 和 data 存放在一起,都在 html 的 script 中。

url = 'http://data.eastmoney.com/bbsj/201806/lrb.html'
req = requests.get(url, headers=header, timeout=30) # need headers
origin_str = req.text

''' parse json '''
begin_index = origin_str.index('defjson')
end_index = origin_str.index(']}},\r\n')
json_str = origin_str[begin_index + 9:end_index + 3]
json_str = json_str.replace('data:', '"data":')
                   .replace('pages:', '"pages":')
                   .replace('font:', '"font":')
json_req = json.loads(json_str)
font_url = json_req['font']['WoffUrl']

在經過上面腳本解析出來的 json 中,lz 竟然驚奇的發現一個神奇的東西

image

竟然直接把 origin_data 和加密之后的字符 uint16 對應關系直接 po 出來 Excuse me!!! ?? 第一反應 怕不是煙霧彈哦

但是經過對 js 代碼的追蹤,我可以很負責的告訴你,這就是真的對應關系,至于他們為什么這么奇葩的做,請往下看:

動態把數據塞到<tb></tb>標簽中的工作是在http://data.eastmoney.com/js_001/load_table_data_pc.js?201606021831中做的

image

hhh 康康人家的注釋,你還好意思寫那種稀爛的代碼哇(lz 下線了 過于真實 但是生產環境放這種代碼 這不就是給大家做教科書的嘛 hhh

display: function () {
    var _t = this;
    try {
        if (_t.options.data.font && _t.options.data.font.WoffUrl) { // 去找font_map
            _t.options.font = _t.options.data.font;} else {//設置默認}
        _t.loadFontFace(); // update css: stonefont
        var _d = _t.options.data.data, _body = _t.options.tbody;
        var trs = _body.childNodes;
        for (var i = trs.length - 1; i >= 0; i--) {_body.removeChild(trs[i])} // remove tb
        if (_d && _d.length && _d[0].stats == undefined) {
            for (var i = 0; i < _d.length; i++) {
                var data, row = rowTp.cloneNode(true);
                _body.appendChild(row);
                _t.uncrypt(data) // 解密
                _t.maketr(row, data, i, ((_p - 1) * _ps + 1 + i)); // 上顏色
                _t.crypt(row)   // 加密
            }
        }
    }
}

來看一下把數據填充到 tb 這個過程的入口函數(省去了一些不太重要的邏輯

從json中找font信息 -> 動態修改css:stonefont -> 刪除tb子標簽 -> 解密數據(uncrypt) -> 給數據加樣式(maketr) -> 對加完樣式的文本重新加密(crypt) -> 塞回tb標簽

一開始,我看到解密再加密這個過程是懵逼的,'難不成加密解密用的不是一個秘鑰'。看到后面我發現我錯了,兩個 font_map 一毛一樣呀

分析一下,當時他們加這個應該是前端不太好處理樣式問題,弄的一個折中方案(對嗎,前端也沒辦法解析 font 內的映射關系

其實加一個映射關系不變的正負標志位不就好了(畢竟你顯示樣式主要看數字正負號,要處理顯示萬,千等位數完全可以根據字符位數來

這樣改完全就失去了本來反爬設置的效果,當然這給了廣大致力于學習爬蟲的同學一個入門的機會 ??

分析到這里,理下思路,通過 json 解析出的 font_map 生成一個 base 映射關系(其實你也可以直接用 font_map 進行解析 hhh

然后每次把 font load 到本地對比 base 映射關系,生成這個字體對應的映射關系

具體代碼可見eastmoney.eastmoney

稍微提一下自己踩的兩個坑

error: unpack requires a buffer of 20 bytes
How to analysis font
  • 利用 fonttools 包
  • 獲得 cmap 表 TTFont().getBestCamp()
  • 和 base 進行對比

冷菜->js compile

這個話題,其實最近另外一個 dalao 在知乎講過,我就大概提一下

一開始看到那個面試題http://shaoq.com:7777/exam的時候也是比較驚奇的,以前遇到 css 里面塞信息的還是比較少的, 上一個還是 goubanjia???

image

只不過 goubanjia 的 css 是靜態資源,這邊 shaoq 用的是動態編譯生成,其實還是差不多的,用一下 execjs + jsdom 進行動態編譯 js,得到 style

首次請求獲得cookie -> 請求image -> 等5.5s(注意一定是獲得html后5.5s) -> 編譯js 獲得css -> 塞css的content到對應的標簽(這一步需要把一些無關的標簽剔除掉)

具體代碼可見exam.shaoq

然后也附一下自己踩得坑

Can't get true html
  • Wait time must be 5.5s.

  • So you can use threading or await asyncio.gather or aiohttp to request image

  • Coroutines and Tasks

Error: Cannot find module 'jsdom'

jsdom must install in local not in global

remove subtree & edit subtree & re.findall
subtree.extract()
subtree.string = new_string
parent_tree.find_all(re.compile('''))

甜點->websocket

其實這一塊內容就和壓測有點像了,用處不只是用來爬取信息,很多時候是用來模擬長連接請求

如果開多進程的話實際上效果就是壓測 websocket(所以大家悠著點

首先,什么是長連接, 什么是 websocket,什么是 socket

socket,實際上是一個 unix 的概念。我們知道進程之間的通信問題稱之為 IPC(InterProcess Communication, IPC)有管道,消息隊列,信號量,共享存儲,套接字 Socket 等方式

但這些都是在本機范圍的通信,即 Unix 域內 IPC,如果把問題拓展到網絡內的通信則變成了網絡域套接字

因為網絡通信的不可信,需要做一系列的計算校驗和執行協議處理添加或刪除網絡報頭產生相應的順序號發送確認報文(注意理解這一部分內容,對后面讀懂、模擬二進制報文很有幫助)

http 是一種基于 TCP 的短鏈接,三次握手 ?? 之后建立連接,完成任務之后,馬上四次握手 ?? 關閉連接

長連接則是在完成任務之后不立即關閉連接,而是當連接的一方退出之后才關閉連接,常見的協議有 websocket 和 http 的長連接

我們知道 TCP 是可靠的連接,建立連接的代價比 UDP 大多了,如果有一個需求需要反復建立連接,比如說聊天直播彈幕數千萬用戶反復請求短鏈接,會花費大量時間在協議上

另外也是為了能使得服務器可以主動發生給用戶數據,而不是客戶端輪詢,websocket 就騰空出世

在 java 中建立長連接常用 Netty 解決

在 py 里面就得用一下異步 io 庫 asyncio 和 異步 httpaiohttp (hhh 竟然還資瓷 websocket)

建立 websocket 連接的過程并不復雜,關鍵是分析 header 頭部字節含義

舉個 ??,比如說爬取 b 站 up 主視頻的實時訪問量,以 18 年百大第一的炒面筋為例https://www.bilibili.com/video/av21061574

image

分析 network 可以發現視頻左下角的 XX 人正在看XX 條實時彈幕新增彈幕推送都是基于 websocket 協議進行傳輸的

再來仔細研究一下具體發送的字節碼

Send

00000000: 0000 005b 0012 0001 0000 0007 0000 0001  ...[............
00000001: 0000 7b22 726f 6f6d 5f69 6422 3a22 7669  ..{"room_id":"vi
00000002: 6465 6f3a 2f2f 3231 3036 3135 3734 2f33  deo://21061574/3
00000003: 3435 3438 3336 3622 2c22 706c 6174 666f  4548366","platfo
00000004: 726d 223a 2277 6562 222c 2261 6363 6570  rm":"web","accep
00000005: 7473 223a 5b31 3030 305d 7d              ts":[1000]}

00000000: 0000 0021 0012 0001 0000 0002 0000 0002  ...!............  30s heart beat
00000001: 0000 5b6f 626a 6563 7420 4f62 6a65 6374  ..[object Object
00000002: 5d                                       ]

00000000: 0000 0021 0012 0001 0000 0002 0000 0003  ...!............
00000001: 0000 5b6f 626a 6563 7420 4f62 6a65 6374  ..[object Object
00000002: 5d                                       ]
...

可以看出字節碼用的是大端字節序,前 18 個字節是 header 頭,緊跟著的是 body 內容

I H H I I H
0000 005b 0012 0001 0000 0007 0000 0001 0000
0000 0021 0012 0001 0000 0002 0000 0002 0000
0000 0021 0012 0001 0000 0002 0000 0003 0000
socket 長度 header 長度 協議版本,1 操作碼 序列號 0

明白這點之后就比較好構造字節碼了,先初始化一個 header_struct,然后往 struct 加入每一部分的內容

HEARTBEAT_BODY = '[object Object]'
HEADER_STRUCT = struct.Struct('>I2H2IH')

def parse_struct(self, data: dict, operation: int):
    ''' parse struct '''
    if operation == 7:
        body = json.dumps(data).replace(" ", '').encode('utf-8')
    else:
        body = self.HEARTBEAT_BODY.encode('utf-8')
    header = self.HEADER_STRUCT.pack(
        self.HEADER_STRUCT.size + len(body),
        self.HEADER_STRUCT.size,
        1,
        operation,
        self._count,
        0
    )
    self._count += 1
    return header + body

需要注意的是建立連接時,所需要 room_id 并不只是 av_id,需要先去 html 中取一下 cid(嗯,只能在 html 中解析,cid 是一個優先級比較高的變量,在基本上后面所有變量中都會使用

def _getroom_id(self, next_to=True, proxy=True):
    ''' get av room id '''
    url = self.ROOM_INIT_URL % self._av_id
    html = get_request_proxy(url, 0) if proxy else basic_req(url, 0)
    head = html.find_all('head')
    if not len(head) or len(head[0].find_all('script')) < 4 or not '{' in head[0].find_all('script')[3].text:
        if can_retry(url):
            self._getroom_id(proxy=proxy)
        else:
            self._getroom_id(proxy=False)
        next_to = False
    if next_to:
        script_list = head[0].find_all('script')[3].text
        script_begin = script_list.index('{')
        script_end = script_list.index(';')
        script_data = script_list[script_begin:script_end]
        json_data = json.loads(script_data)
        if self._p == -1 or len(json_data['videoData']['pages']) < self._p:
            self._room_id = json_data['videoData']['cid']
        else:
            self._room_id = json_data['videoData']['pages'][self._p - 1]['cid']
        print('Room_id:', self._room_id)

注意有些視頻可能會有多個 page,每個 page 的 cid 其實是不一樣的

Receive

00000000: 0000 002b 0012 0001 0000 0008 0000 0001  ...+............
00000001: 0000 7b22 636f 6465 223a 302c 226d 6573  ..{"code":0,"mes
00000002: 7361 6765 223a 226f 6b22 7d              sage":"ok"}

00000000: 0000 006f 0012 0001 0000 0003 0000 0002  ...o............ every 30s
00000001: 0000 7b22 636f 6465 223a 302c 226d 6573  ..{"code":0,"mes
00000002: 7361 6765 223a 2230 222c 2264 6174 6122  sage":"0","data"
00000003: 3a7b 2272 6f6f 6d22 3a7b 226f 6e6c 696e  :{"room":{"onlin
00000004: 6522 3a32 3232 2c22 726f 6f6d 5f69 6422  e":222,"room_id"
00000005: 3a22 7669 6465 6f3a 2f2f 3231 3036 3135  :"video://210615
00000006: 3734 2f33 3435 3438 3336 3622 7d7d 7d    74/34548366"}}}

00000000: 0000 007b 0012 0001 0000 0005 0000 0000  ...{............ danmuku 1
00000001: 0000 7b22 636d 6422 3a22 444d 222c 2269  ..{"cmd":"DM","i
00000002: 6e66 6f22 3a5b 2237 312e 3137 2c31 2c32  nfo":["71.17,1,2
00000003: 352c 3136 3737 3732 3135 2c31 3535 3435  5,16777215,15545
00000004: 3339 3238 322c 3136 3739 3335 3332 332c  39282,167935323,
00000005: 302c 6562 3636 3033 6161 2c31 3433 3633  0,eb6603aa,14363
00000006: 3937 3436 3136 3231 3936 3530 222c 22e8  974616219650",".
00000007: 9e8d e58c 96e4 bda0 225d 7d              ........"]}

00000000: 0000 0079 0012 0001 0000 0009 0000 0000  ...y............ danmuku2
00000001: 0000 0000 0067 0012 0001 0000 03e8 0000  .....g..........
00000002: 0000 0000 5b22 3731 2e31 372c 312c 3235  ....["71.17,1,25
00000003: 2c31 3637 3737 3231 352c 3135 3534 3533  ,16777215,155453
00000004: 3932 3832 2c31 3637 3933 3533 3233 2c30  9282,167935323,0
00000005: 2c65 6236 3630 3361 612c 3134 3336 3339  ,eb6603aa,143639
00000006: 3734 3631 3632 3139 3635 3022 2c22 e89e  74616219650","..
00000007: 8de5 8c96 e4bd a022 5d                   ......."]

可以看出 header 結構和 send 一毛一樣,除了收到 danmuku 的時候序列號為 0(這一點也很好理解,因為不是主動客戶端發送得到的返回,而是服務端主動推送給客戶端的)

  • 可以看到當 operation=3 的時候,收到了實時在線人數
  • operation=5 時收到一個 body 里面帶一個 json 的 commond,其中的cmd內容表示具體的類別
  • operation=9 的時候,實際上是兩個嵌套字節碼,里面那個 operation=0x03e8=1000, 里面存放的是一個 list

總結一下 operation

操作碼 含義
2 發送心跳包
3 在線數據
5 cmd 模式 具體看['cmd']
7 建立連接
8 連接建立成功
9 嵌套header
1000 danmuka list

看下效果

image

具體代碼可見bilibili/bsocket.py

另外開發了一套根據排行榜爬取 up 時序累計數據,附帶監控評論內容的系統,可用于分析 b 站視頻評分原理的分析,支持開箱即用,歡迎 star

如果有做b站直播數據的爬取可以參考另外一位dalao的博客,直播的字節碼規則略有不同

好了,大概的爬蟲進階技巧就說到這,歡迎各位 dalao 批評指正,轉載請聯系博主

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容