你已经是一个成熟的爬虫了,应该学会自己去对抗反爬码农了🙊-『爬虫进阶指南』
update 19.5.7 QA 一下http://www.66ip.cn/ 首次进入的 js 逆向思路
update 19.4.21 更新了一篇关于 js 逆向的文章 究竟是道德的沦丧,还是现实的骨感,让携程反爬工程师在代码里写下这句话-『爬虫进阶第二弹』
QA 环节
Q: @liu wong 一段 js 代码在浏览器上执行的结果和在 python 上用 execjs 执行的结果不一样,有啥原因呢? http://www.66ip.cn/
A: 一般 eval 差异 主要是有编译环境,DOM,py 与 js 的字符规则,context 等有关
像 66ip 这个网站,主要是从 py 与 js 的字符规则不同 + DOM 入手的,当然它也有可能是无意的(毕竟爬虫工程师用的不只是 py)
首次访问 66ip 这个网站,会返回一个 521 的 response,header 里面塞了一个 HTTP-only 的 cookie,body 里面塞了一个 script
var x = "@...".replace(/@*$/, "").split("@"),
y = "...",
f = function(x, y) { return num;},
z = f(y.match(/\w/g).sort(function(x, y) {return f(x) - f(y);}).pop());
while (z++)
try {
    eval(y.replace(/\b\w+\b/g, function(y) {
    return x[f(y, z) - 1] || "_" + y;
    }));
    break;
} catch (_) {}
可以看到 eval 的是 y 字符串用 x 数组做了一个字符替换之后的结果,所以按道理应该和编译环境没有关系,但把 eval 改成 aa 之后放在 py 和放在 node,chrome 中编译结果却不一样
这是因为在 p 正则\b 会被转义为\x80,这就会导致正则匹配不到,就更不可能替换了,导致我们拿到的 eval_script 实际上是一串乱码
这里用 r'{}'.format(eval_script) 来防止特殊符号被转义
剩下的就是 对拿到的 eval_script 进行 dom 替换操作
总的来说是一个挺不错的 js 逆向入门练手项目, 代码量不大,逻辑清晰
具体代码参见iofu728/spider

-------- 原文从这里开始 --------
因为各种原因,这段时间又写了好多爬虫 (不务正业 划掉 😶),也顺带接着这个机会来总结一下,自己认为的爬虫进阶技巧
ps: 爬虫千万条,克制第一条。我们也要照顾一下反爬工程师的感受,克制开多线程,降低并发数
以下代码已开源,基本支持开箱即用,自带高可用代理 IP 池,呜呜呜(开源一时爽,一直开源一直爽 🤧
 开胃菜->字体
这基本上已经成了反爬虫工程师最拿手,最常见的一招了。
像猫眼,东方财富,实习僧,天眼查,起点,etc.
简单一点的每次返回一个随机字体(这个随机指的是字形和字符映射关系随机,字形 set,字符 set 还是不变的)
做的狠一点的就连字库也随机一下(是个狠人,这种解决起来成本就有点高了
反爬的基本原理就是利用字体库中不太常用的一些 高位字符字段(比如说 0xEFFF) ,它是uint16。

把原始文本替换成这些高位字符,然后使用自定义的一个 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为例

可以看见使用了一个叫做stonefont的 font 来实现字符到字形的映射
经过分析可以发现,table 里面的数据都是预先存放在 html 的 script 里面,直接读 json 的,其格式即已经加密过后的 uint16 字符

既然已经知道了拿到的数据是已经被替换的字符,那么找到 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 竟然惊奇的发现一个神奇的东西

竟然直接把 origin_data 和加密之后的字符 uint16 对应关系直接 po 出来 Excuse me!!! 😯 第一反应 怕不是烟雾弹哦
但是经过对 js 代码的追踪,我可以很负责的告诉你,这就是真的对应关系,至于他们为什么这么奇葩的做,请往下看:
动态把数据塞到http://data.eastmoney.com/js_001/load_table_data_pc.js?201606021831中做的

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
requests.text -> str,
requests.content-> byte or str
How to analysis font
- 利用 fonttools 包
 - 获得 cmap 表 
TTFont().getBestCamp() - 和 base 进行对比
 
 冷菜->js compile
这个话题,其实最近另外一个 dalao 在知乎讲过,我就大概提一下
一开始看到那个面试题http://shaoq.com:7777/exam的时候也是比较惊奇的,以前遇到 css 里面塞信息的还是比较少的, 上一个还是 goubanjia???

只不过 goubanjia 的 css 是静态资源,这边 shaoq 用的是动态编译生成,其实还是差不多的,用一下 execjs + jsdom 进行动态编译 js,得到 style
shaoq 的思路:
首次请求获得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
threadingorawait asyncio.gatheroraiohttpto request image
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

分析 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 | 
看下效果

具体代码可见bilibili/bsocket.py
另外开发了一套根据排行榜爬取 up 时序累计数据,附带监控评论内容的系统,可用于分析 b 站视频评分原理的分析,支持开箱即用,欢迎 star
如果有做 b 站直播数据的爬取可以参考另外一位 dalao 的博客,直播的字节码规则略有不同
好了,大概的爬虫进阶技巧就说到这,欢迎各位 dalao 批评指正,转载请联系博主