使用 slimerjs 抓取 DDos 保护的站点
我准备在一个图片站上抓点图,但发现它启用了 DDos 保护。站点会首先显示一段文本:
This process is automatic. Your browser will redirect to your requested content shortly.
要求你等待几秒钟检测浏览器,然后通过 302 重定向跳转到正确的页面(当然,这个正确的页面地址依然没变)。
等待的过程表现在浏览器上是这样的:
这个保护的详细说明在这里: CloudFlare advanced DDoS protection 。
让我们看看怎么来解决这个问题。
源码
打开页面看源码,可以看到这样的 javascript 代码:
1(function(){
2var a = function() {try{return !!window.addEventListener} catch(e) {return !1} },
3b = function(b, c) {a() ? document.addEventListener("DOMContentLoaded", b, c) : document.attachEvent("onreadystatechange", b)};
4b(function(){
5 var a = document.getElementById('cf-content');a.style.display = 'block';
6 setTimeout(function(){
7 var t,r,a,f, cgksoUW={"bRM":+((+!![]+[])+(!+[]+!![]))};
8 t = document.createElement('div');
9 t.innerHTML="<a href='/'>x</a>";
10 t = t.firstChild.href;r = t.match(/https?:\/\//)[0];
11 t = t.substr(r.length); t = t.substr(0,t.length-1);
12 a = document.getElementById('jschl-answer');
13 f = document.getElementById('challenge-form');
14 ;cgksoUW.bRM-=+((!+[]+!![]+!![]+!![]+[])+(!+[]+!![]+!![]+!![]+!![]+!![]+!![]+!![]));cgksoUW.bRM*=+((!+[]+!![]+[])+(!+[]+!![]+!![]+!![]+!![]+!![]));cgksoUW.bRM*=+((!+[]+!![]+!![]+!![]+[])+(!+[]+!![]+!![]+!![]+!![]));cgksoUW.bRM+=+((!+[]+!![]+[])+(!+[]+!![]));cgksoUW.bRM*=+((!+[]+!![]+!![]+[])+(+[]));a.value = parseInt(cgksoUW.bRM, 10) + t.length;
15 f.submit();
16 }, 5000);
17}, false);
18})();
这是 JS 中出现的几个相关 Element:
1<p data-translate="process_is_automatic">This process is automatic. Your browser will redirect to your requested content shortly.</p>
2<p data-translate="allow_5_secs">Please allow up to 5 seconds…</p>
3</div>
4<form id="challenge-form" action="/cdn-cgi/l/chk_jschl" method="get">
5<input type="hidden" name="jschl_vc" value="c841963d655c6bb040c4ef02fab8d5a3"/>
6<input type="hidden" name="pass" value="1441270501.274-WM96e3sQx+"/>
7<input type="hidden" id="jschl-answer" name="jschl_answer"/>
8</form>
分析
上面代码的作用,主要是原来判断访问站点的客户端是否是一个真实的浏览器。
访问网站主页的时候,首先得到的是一个 503
错误,显示上面的页面内容。然后等待 5 秒,让用户看清楚提示信息,然后再根据得到的随机值东拼西凑计算出另一个值,写入 jschl-answer
中,最后通过 challenge-form
这个表单提交。
提交成功之后, /cdn-cgi/l/chk_jschl
会使用 302
重定向返回真实的网页内容。
cgksoUW
是一个随机的名称,pass
那个 input 中包含的值也是一个变化的值。每次进入这个页面,这两个值都会变化。
那么,pass 的值在提交的时候到底是如何计算出来的?
从 f = document.getElementById('challenge-form');
下面的那段代码可以看出,这个计算无非是对 cgksoUW.bRM 进行一些加乘运算。然后再转成一个 Int 进行提交。关键在于那些看起来莫名其妙的加号、括号和方括号的集合的含义为何?
这是一个简单的障眼法。
让我们看看 cgksoUW.bRM
的初始值,这个表达式的结果等于数字 12 :
1+((+!![]+[])+(!+[]+!![]))
我们把上面的代码拆成几个子表达式,就好理解了。
首先是第一个嵌套括号中的: (+!![]+[])
,打开浏览器的 console ,在其中输入 +!![]
回车,可以得到数字 1 。
先不管哪个加号,单独看 !![]
的值,它是布尔值 true
。这是因为 []
返回的是一个有效的 Array,代表 true
,两次取反,依然是 true
。
而左边的那个加号由于没有提供左操作数,因此它的含义是正数,这里做了一次数字转换,自动把 true 转换成了 1 。
接着往后算,后面是个加号,然后是个 []
。空的数组,默认是作为字符串处理的。[].toString()
的值为空字符串。前面的 1 加上后面的空字符串,得到一个字符串 "1"
。
知道了原理,可以使用同样的方式算出第二个嵌套括号中的值为数字 2 。
接下来是字符串 "1"
加上数字 2,得到字符串 "12"
。
最左边的加号又做了一次转换,将字符串 "12"
转换成了数字 12 。
所以,这一段看似乱码的代码,只是为了获取一些随机的数字,便于和服务器验证罢了。我判断这些数值都是有意义的,而且和服务器时间关联,用来判断访问网站的是不是标准的浏览器。
选择
分析完之后,就要进行技术的选择了。
写爬虫自然是首选 Python 。开始,我准备使用 requests 来模拟请求,使用 BeautifulSoup4 来解析得到的 HTML,然后进行资源的下载。
但 requests 并不擅于模拟浏览器的行为,也不能执行 Javascript,还需要用 PyV8 等库来执行上面的数据计算,得到最终的 pass 。
PyExecJS 可以实现在 Python 环境中调用 Javascript 引擎,支持的引擎有 PyV8 ,Node.js 等等。
成功跳过 DDos 防护之后,还需要保持 cookies,构建 heads 。
既然计算部分要依赖别的引擎解析 Javascript ,那还不如直接使用 Javascript 来写好了。
这就是 Headless Browser 大显身手的时候了。
Headless Browser
Headless Browser ,通俗地来说就是个无界面且完整功能的浏览器。目前最流行的 Headless Browser 框架非 PhantomJS 莫属。它是基于 WebKit 开发的,想要整个网页截图神马的(再长也不怕!)用它就最好了。
不过,用 PhantomJS 保存网页中的图片 还真是有点麻烦。由于上面的 DDOS 防护的存在,每个单独的图像文件依然存在防护,所以不能拿到地址后直接下载,必须使用浏览器来浏览这个图片。
于是我选择了另一个框架 SlimerJS ,它和 PhantomJS 功能基本一致,API 也基本一致,所不同的就是它使用 Mozilla Firefox 的 Gecko 引擎。
SlimerJS 的方便之处就在于它的 OnResourceReceived 回调支持 body
这个属性,其中包含正在访问的页面资源(页面中的 CSS、JS、Image 或者页面本身都算资源)的内容。我只需要简单地把它写入到文件中就能得到我需要的图片了。就像这样:
1function onResourceReceived(response)
2{
3 if(state != 'image' || response.stage!="end" || response.stage == "fail" || !response.bodySize) return;
4 console.log('[onResourceReceived] id: '+response.id + ' starge:' + response.stage + ' contentType:' + response.contentType+' url:'+response.url);
5 var curGalleryObj = galleryList[curGallery];
6 var curImageObj = curGalleryImages[curImage];
7
8 if(!fs.exists(curGalleryObj.dir))
9 {
10 fs.makeTree(curGalleryObj.dir);
11 }
12 var fname = curGalleryObj.dir+"/"+curImage+'.'+curImageObj.ext;
13 curImageObj.file = fname;
14 curImageObj.done = true;
15
16 fs.write(fname, response.body, 'b');
17}
下载流程
下面我挑选下载流程中的一些比较重要的实现贴出来,其实很简单的啦。
1function getGalleryList(htmlFile, callback)
2{
3 //htmlFile 是个本地的 HTML 文件,这是为了方便
4 var file = htmlDir + '/' + htmlFile + '.html';
5 var p = WebPage.create();
6 //不解析这个文件中的javascript 因为有些 js 保存在 google 的服务器上,下载很慢
7 p.settings.javascriptEnabled = false;
8 //不再如这个文件中的图像,原因同上
9 p.settings.loadImages = false;
10 p.open(file, function(status)
11 {
12 //在页面中调用 DOM 函数或者 DOM 列表
13 var list = p.evaluate(function()
14 {
15 return document.querySelectorAll('.content .gallery-list div a');
16
17 var newList = [];
18 //根据列表构建需要下载的 Gallery 列表
19 for(var i=0;i<list.length;i++)
20 {
21 var href = list[i].href.slice(8);
22 var arr = href.split('/');
23 var obj = {};
24 obj.url = href;
25 obj.id = arr[1];
26 obj.name = arr[2].split('.')[0];
27 obj.dir = imageDir+'/['+obj.id+']'+obj.name;
28 obj.index = site + '/images/' + obj.id + '/' + obj.name + '.html';
29 obj.progress = obj.dir + '/progress.json';
30 newList[i] = obj;
31 }
32 callback(newList);
33 });
34}
35
36function onIndexOpen(status)
37{
38 console.log('[onIndexOpen]');
39 state = 'index';
40 var intervalId = null;
41 var t = 0;
42 var _checkTimeout = function()
43 {
44 if(t > 60)
45 {
46 console.log('!!!!Timeout!!!!');
47 clearInterval(intervalId);
48 slimer.exit();
49 }
50 }
51
52 var _checkTitle = function()
53 {
54 console.log('[_checkTitle]');
55 console.log('['+t+'] Check Title...'+page.title);
56 t++;
57 _checkTimeout();
58 //若出现正确的标题则代表载入成功
59 if(page.title.indexOf('Welcome') == 0)
60 {
61 clearInterval(intervalId);
62 t = 0;
63 page.close();
64 openGallery();
65 }
66 }
67
68 intervalId = setInterval(_checkTitle, 1000);
69}
70
71function openGallery()
72{
73 console.log('[openGallery '+curGallery+']');
74 if(curGallery >= galleryList.length)
75 {
76 console.log('==== Download all Gallery Done! ====');
77 slimer.exit();
78 }
79 curImage = 0;
80 var curGalleryObj = galleryList[curGallery];
81 //本地已有列表,不必再获取
82 if(fs.exists(curGalleryObj.progress))
83 {
84 curGalleryImages = JSON.parse(fs.read(curGalleryObj.progress, 'r')).images;
85 //本地已经有这个文件了(上次下载成功),下一个
86 while(curGalleryImages[curImage] && curGalleryImages[curImage].done)
87 {
88 console.log('The file '+curGalleryImages[curImage].file + ' is downloaded.');
89 curImage++;
90 }
91 //开始下载文件
92 downloadImage();
93 }
94 else
95 {
96 state = 'gallery';
97 page.open(curGalleryObj.index, onGalleryOpen);
98 }
99}
100
101function onGalleryOpen(status)
102{
103 console.log('[onGalleryOpen '+curGallery+']');
104 //script 标签中保存了 JSON 格式的图像列表,直接作为字符串获取它们
105 //这样就不必翻页分析 DOM 了
106 var script = page.evaluate(function()
107 {
108 return document.querySelector('body > div.outer-wrapper.image-page > div.page-wrapper.page-wrapper-full > script');
109 });
110 if(script)
111 {
112 var re = /\"images\":(.+\])\}\);/i;
113 var images = script.innerHTML.match(re)[1];
114 curGalleryImages = JSON.parse(images);
115 curGalleryImages.forEach(function(e,i,a)
116 {
117 var name = e.f.split('.');
118 a[i].ext = name[1];
119 a[i].name = name[0];
120 });
121 curImage = 0;
122 downloadImage();
123 }
124 else
125 {
126 console.log('NO SCRIPT');
127 slimer.exit();
128 }
129}
130
131function downloadImage()
132{
133 if(curImage >= curGalleryImages.length)
134 {
135 var curGalleryObj = galleryList[curGallery];
136 console.log('==== download '+curGalleryObj.dir +' has done. ====');
137 //下载下一个 gallery
138 curGallery ++;
139 openGallery();
140 return;
141 }
142 state = 'image';
143 var image = getCurImage();
144 console.log('[downloadImage]'+image);
145 //关闭当前的 page
146 page.close();
147 //重用资源
148 page.open(image);
149}
150
151// 检测文件是否是标准的 JPEG
152function checkJPEG(path)
153{
154 console.log('[checkJPEG '+path+ ' '+fs.exists(path)+']');
155 if(fs.exists(path))
156 {
157 var content = fs.read(path, 'rb');
158 if( content &&
159 //content.length > 10240 &&
160 content.charCodeAt(0) == 0xff &&
161 content.charCodeAt(1) == 0xd8 &&
162 content.charCodeAt(2) == 0xff &&
163 content.charCodeAt(3) == 0xe0)
164 {
165 return true;
166 }
167 }
168 return false;
169}
170
171function onLoadFinished(status, url, isFrame)
172{
173 loading = false;
174 console.log('==== ['+state+']onLoadFinished Loading page('+url+') '+ status + " loading:"+loading);
175 if(state == 'image')
176 {
177 var curGalleryObj = galleryList[curGallery];
178 var curImageObj = curGalleryImages[curImage];
179 var progressObj = {'gallery':curGalleryObj, 'images':curGalleryImages};
180
181 // 判断 JPEG 文件头,看看是否需要重新下载
182 if(checkJPEG(curImageObj.file))
183 {
184 curImageObj.done = true;
185 curImage++;
186 }
187 else
188 {
189 curImageObj.done = false;
190 }
191 // 将当前的下载进度写入到配置文件中,以便一次下载不完,
192 // 或异常退出后。下次下载时可以知道进度
193 fs.write(curGalleryObj.progress, JSON.stringify(progressObj), 'w');
194 //下载下一个文件,或者重新下载
195 downloadImage();
196 }
197}
198
199function onLoadStarted()
200{
201 loading = true;
202 var currentUrl = page.evaluate(function() {
203 return window.location.href;
204 });
205 console.log('==== ['+state+']onLoadStarted Current page(' + currentUrl + ') will gone. loading:'+loading);
206};
207
208function onUrlChanged(targetUrl) {
209 console.log('=== ['+state+']onUrlChanged New URL: ' + targetUrl+' loading:'+loading);
210}
211
212var WebPage = require('webpage');
213var page = WebPage.create();
214var loading = false;
215var imageDir = 'images';
216var htmlDir = 'html';
217var site = 'http://examples.com';
218var fs = require('fs');
219
220var page = WebPage.create();
221var galleryList = null;
222var curGallery = null;
223var curGalleryImages = null;
224var curImage = 0;
225var state = null;
226
227page.onLoadStarted = onLoadStarted;
228page.onLoadFinished = onLoadFinished;
229page.onUrlChanged = onUrlChanged
230page.onResourceReceived = onResourceReceived;
231getGalleryList('favorites_0', function(list)
232 {
233 galleryList = list;
234 curGallery = 1;
235 page.open(site, onIndexOpen);
236 });
(全文完)
- 文章ID:2366
- 原文作者:zrong
- 原文链接:https://blog.zengrong.net/post/use-slimerjs-to-grab-pages-under-cloudflare-ddos-protection/
- 版权声明:本作品采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可,非商业转载请注明出处(原文作者,原文链接),商业转载请联系作者获得授权。