前言
最近遇到了一个比较好玩的反爬--携程eleven参数的生成。
说好玩的原因是请求一个接口后,会返回js代码,只要稍微调试下,便可以在浏览器上得到eleven参数了。
但如果想要在node或者无头浏览器之类的东西完成的话,只会报错。
(需要代码的大佬可以跳到最后(node环境+油猴+py, 通过websocket给油猴和py代码通信))
爬取目标
说一下我们要爬的数据吧。(如下内容) https://hotels.ctrip.com/hotel/beijing1#ctm_ref=hod_hp_sb_lst
这些数据来自于此接口
是一个post请求,在请求体中便是著名的eleven参数了。
如果eleven参数错误的话,是不会返回上面的数据的。
Eleven参数
前面说过了,Eleven参数来自于一段js代码(这段js代码是请求一个url后直接返回的)
下面便是那个所要请求的url
请求此url后返回的js代码
1. 测试返回的js代码
我们新开一个标签页,然后将返回的js代码复制到控制台执行。会发现报了一个错
应该是缺少了其它js代码造成的
// 这样可以产生一个与上图相似的错误 Function.prototype.toString.call(1);
我们还可以将返回的js代码复制到携程页面的控制台运行下,发现并没有报错,但我们的页面被重定向到了登陆页面
因此猜测返回的js代码的只能被执行一次(毕竟就是在携程的页面执行的。)
2. 下断点(看看返回的js代码是怎么运行的)
怎么下断点?搜索url中的关键字? 下xhr断点。
当时我用的方式是搜索url中的关键字, oceanball。那天是直接可以搜索到的。但今天没有了。
下xhr断点是不可能断下来的。因为他用到是jsonp来请求的。
那用啥方法呢?
看这个请求的发起者。
鼠标移到下面红色箭头指的位置便可以看到这个请求的发起者
点击第二个发起者(js @cQuery_110421.js:formatted:823那行)
为什么是第二个,因为第一个不行,可以自行尝试
如图 下一个断点(823行,也是第二个发起者代码执行行数)
我们其实也可以在 "欢迎度排序" 和 "好评优先" 之间来回切换,不然总是刷新的话,效率不高
如果页面刷新了,或者如上切换了选项的话。页面会在我们之前下的断点停住。
注意下右边的 call stack(调用栈)
我们如下图点击一下 ,来到上一层的执行环境。
往上翻一下,就会发现这部分便是那个url从发起请求到处理返回结果的所有细节
如下图所示
其实只要在返回的js代码里加上如下内容
// 这里的o是请求url中callback参数。
window[o] = function (e){
console.log(e()); // 这样便可以输出结果了。
}
// 下面是请求url后返回的js代码
这样代码便可以在浏览器中输出结果了
好了,重点部分来了。
3. 如何批量生成eleven参数
我不能说我手动复制到浏览器中运行,然后复制下结果吧。
在node环境中运行难度比较大,他会严格检测执行环境。
也别想断点调试。见过一个函数被调用18多万次吗?
是不是想问我是怎么知道这些的?
我是通过Object.defineProperty 劫持了 navigator.userAgent。
当这个js代码想要获取 navigator.userAgent 时,代码便会在此就会停住
Object.defineProperty(navigator, "userAgent", { get(){ debugger; return navigator.userAgent; } })
然后慢慢堆栈时发现某一个函数貌似便是用于检测环境的函数啥的。然后那个函数被调用了18万多次。
检测的内容非常多,不光是node环境,还有无头浏览器啥的,你听过的没听过的都有。
处理方法
已经有大佬在node环境中模拟了这个浏览器环境了,像我这种菜鸡,估计是难做到了。
我的想法很简单,还是通过浏览器执行那些js代码,但是需要自动执行。
vscode的自动保存便刷新页面的插件给了我启发,他是通过websocket进行通信,服务器会将最新的html传给客户端,客户端可以做一定的处理
1. 首先需要使用nodejs搭建一个websocket的环境,可以使用 nodejs-websocket 模块搭建
代码如下
需要安装下node环境(node官网下载,像装软件一样安装即可。)
var ws = require("nodejs-websocket"); console.log("开始建立连接...") var cached = { } var server = ws.createServer(function(conn){ conn.on("text", function (msg) { if (!msg) return; // conn.sendText(str) // console.log(str); if (msg.length > 1000){ console.log("msg 这是js代码") }else{ console.log("msg", msg); } var key = conn.key; if ((msg === "Browser") || (msg === "Python")){ // browser或者python第一次连接 cached[msg] = key; console.log("cached",cached); return; } console.log(cached, key); if (Object.values(cached).includes(key)){ console.log(server.connections.forEach(conn=>conn.key)); var targetConn = server.connections.filter(function(conn){ return conn.key !== key; }) console.log(targetConn.key); console.log("将要发送的js代码"); targetConn.forEach(conn=>{ conn.send(msg); }) } // broadcast(server, str); }) conn.on("close", function (code, reason) { console.log("关闭连接") }); conn.on("error", function (code, reason) { console.log("异常关闭") }); }).listen(8014) console.log("WebSocket建立完毕") // var server = http.createServer(function(request, response){ // response.end("ok"); // }).listen(8000);
2. 其次, 需要安装浏览器插件 油猴(英文名 tampermonkey),需要FQ。
点击应用后就会有 谷歌应用商店(需要FQ),然后搜索 油猴便可以了。
关于油猴的代码
// ==UserScript== // @name 携程websocket // @namespace http://tampermonkey.net/ // @version 0.1 // @description try to take over the world! // @author You // @match https://hotels.ctrip.com/hotel/beijing1 // @grant none // ==/UserScript== (function() { var mess = document.getElementById("mess"); if(window.WebSocket){ ws = new WebSocket('ws://127.0.0.1:8014/'); ws.onopen = function(e){ // console.log("连接服务器成功"); ws.send("Browser"); } ws.onclose = function(e){ console.log("服务器关闭"); } ws.onerror = function(){ console.log("连接出错"); } ws.onmessage = function(e){ var data = e.data; var execJS = document.getElementById("execJS"); if (execJS){ document.body.removeChild(execJS); } execJS = document.createElement("script"); execJS.id = "execJS"; execJS.innerHTML = data; document.body.appendChild(execJS); } } // Your code here... })();
说明一下,为什么需要油猴?
使用油猴,使得js代码的运行环境直接就是携程的网页,而不是单独打开的页面。
(注意,携程的服务器每天验证的严格程度都不太一样。)
那天测试的时候,我是直接写了一个html文件的,然后本地打开。就可以直接用了。
如果没有装油猴,可以先试试我下面提供的html文件。如果验证没有通过,就需要使用油猴环境
<!doctype html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Document</title> <style> #mess{text-align: center} </style> </head> <body> <script id="execJS"></script> <script> var mess = document.getElementById("mess"); var execJS = document.getElementById("execJS"); if(window.WebSocket){ var ws = new WebSocket('ws://127.0.0.1:8010/'); ws.onopen = function(e){ // console.log("连接服务器成功"); ws.send("Browser"); } ws.onclose = function(e){ console.log("服务器关闭"); } ws.onerror = function(){ console.log("连接出错"); } ws.onmessage = function(e){ var data = e.data; var execJS = document.getElementById("execJS"); if (execJS){ document.body.removeChild(execJS); } execJS = document.createElement("script"); execJS.id = "execJS"; execJS.innerHTML = data; document.body.appendChild(execJS); } } </script> </body> </html>
返回的eleven参数是正确的,请求也成功了。但是今天测试时失败了,然后我对比了一下在携程的控制台下和我本地路径下的html的控制台的结果
# 21e3255d0f89cdf5c3a347d61e7dafbcf15db34f7afe97cda2b5a7ec578652ee_1965113742
# 21e3255d0f89cdf5c3a347d61e7cafbcf15db34f7afe97cda2b5a7ec578652ee_1965113417
如果不仔细看的话,还看不出来。最后的三位是不一样的,应该是对location的检测。
油猴的作用是在携程的网站打开时注入我们的js代码,然后接下来要运行的代码环境便是携程的了。这样产生的eleven参数便是正确的。
3. python代码的编写
python的作用其实是连接websocket服务,发送我们需要运行的js代码,node会帮我们将js代码传给前端页面(油猴插件)。
当js代码在携程的环境里运行完毕后,它会将eleven参数通过websocket传给node,node会把结果返回给我们。这样我们的py代码就能获取到eleven参数了。
import requests import time import datetime import execjs import os from ws4py.client.threadedclient import WebSocketClient class CG_Client(WebSocketClient): def opened(self): print("连接成功") # req = open("../a.js").read() self.send("Python") def closed(self, code, reason=None): print("Closed down:", code, reason) def received_message(self, resp): print("resp", resp) currentDate = time.strftime("%Y-%m-%d") today = datetime.datetime.now() # 今天,如 "2020-05-11" last_time = today + datetime.timedelta(hours=-24) tomorrow = last_time.strftime("%Y-%m-%d") # 明天,如 '2020-05-10' data = { "__VIEWSTATEGENERATOR": "DB1FBB6D", "cityName": "%E5%8C%97%E4%BA%AC", "StartTime": today, "DepTime": tomorrow, "RoomGuestCount": "1,1,0", "txtkeyword": "", "Resource": "", "Room": "", "Paymentterm": "", "BRev": "", "Minstate": "", "PromoteType": "", "PromoteDate": "", "operationtype": "NEWHOTELORDER", "PromoteStartDate": "", "PromoteEndDate": "", "OrderID": "", "RoomNum": "", "IsOnlyAirHotel": "F", "cityId": "1", "cityPY": "beijing", "cityCode": "010", "cityLat": "39.9105329229", "cityLng": "116.413784021", "positionArea": "", "positionId": "", "hotelposition": "", "keyword": "", "hotelId": "", "htlPageView": "0", "hotelType": "F", "hasPKGHotel": "F", "requestTravelMoney": "F", "isusergiftcard": "F", "useFG": "F", "HotelEquipment": "", "priceRange": "-2", "hotelBrandId": "", "promotion": "F", "prepay": "F", "IsCanReserve": "F", "k1": "", "k2": "", "CorpPayType": "", "viewType": "", "checkIn": today, "checkOut": tomorrow, "DealSale": "", "ulogin": "", "hidTestLat": "0%7C0", "AllHotelIds": "", "psid": "", "isfromlist": "T", "ubt_price_key": "htl_search_noresult_promotion", "showwindow": "", "defaultcoupon": "", "isHuaZhu": "False", "hotelPriceLow": "", "unBookHotelTraceCode": "", "showTipFlg": "", "traceAdContextId": "", "allianceid": "0", "sid": "0", "pyramidHotels": "", "hotelIds": "", "markType": "0", "zone": "", "location": "", "type": "", "brand": "", "group": "", "feature": "", "equip": "", "bed": "", "breakfast": "", "other": "", "star": "", "sl": "", "s": "", "l": "", "price": "", "a": "0", "keywordLat": "", "keywordLon": "", "contrast": "0", "PaymentType": "", "CtripService": "", "promotionf": "", "allpoint": "", "page_id_forlog": "102002", "contyped": "0", "productcode": "", "eleven": resp.data, "orderby": "3", "ordertype": "0", "page": "1", } headers = { "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36", "referer": "https://hotels.ctrip.com/hotel/shanghai2", "cookie": 请在此处写入你的cookie,因为携程会检测cookie的ip字段(经过混淆加密) } url = "https://hotels.ctrip.com/Domestic/Tool/AjaxHotelList.aspx" a = requests.post(url, data=data, headers=headers) print(a.text) # resp = json.loads(str(resp)) # data = resp['data'] # if type(data) is dict: # ask = data['asks'][0] # print('Ask:', ask) # bid = data['bids'][0] # print('Bid:', bid) def getTime(): return str(time.time()).replace(".", "")[0:13] def getCallbackParam(): f = open("./callback.js") context = execjs.compile(f.read()) return context.call("getCallback") def getContent(): t = getTime() callback = getCallbackParam() print(callback) url = "https://hotels.ctrip.com/domestic/cas/oceanball?callback=%s&_=%s" % ( callback, t, ) headers = { "user-agent": "Mozilla/5.0 (darwin) AppleWebKit/537.36 (KHTML, like Gecko) jsdom/16.2.2", "referer": "https://hotels.ctrip.com/hotel/shanghai2", } r = requests.get(url, headers=headers) code = ( """ window["%s"] = function (e) { var f = e(); console.log(f); ws.send(f); };; """ % callback + r.text ) print(code) ws.send(code) # getContent() ws = None try: ws = CG_Client("ws://127.0.0.1:8014/") ws.connect() getContent() # 如果想要多次请求,可在此处再写一个 ws.run_forever() except KeyboardInterrupt: ws.close()
python代码需要依赖一个callback.js文件,内容如下
// callback.js function e(e) { var t = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"], a = "CAS", o = 0 for (; o < e; o++) { var i = Math.ceil(51 * Math.random()); a += t[i] } return a } function getCallback() { return e(15); }
4. 代码的启动顺序
1. 启动node websocket服务 (node app.js)
2. 刷新携程网页,F12后查看是否连接上了node websocket服务
3. 启动python代码
5. 注意事项
如果有端口占用错误(如果是mac,这个现象很正常,可以npm i nodemon, 然后nodemon app.js 启动。这样我们只要保存app.js,就会重启)
如果python代码运行后一直收不到结果,可以先看看node有没有报错,然后刷新下携程的页面
6. 关于运行速度
基本就是浏览器运行js脚本的速度,(浏览器引擎的解释速度可能比node快很多,毕竟浏览器专门做这个的)。
只有中间websocket通信的时耗,并且websocket是复用的,不是用一次就连接一次。
7. 关于canvas指纹
如果大量采集的话,会是一样的canvas指纹。可以选择hook canvas相关的api。
8. 关于爬取评论的py代码
import requests import time import datetime import execjs import os from ws4py.client.threadedclient import WebSocketClient callback = "" class CG_Client(WebSocketClient): def opened(self): print("连接成功") # req = open("../a.js").read() self.send("Python") def closed(self, code, reason=None): print("Closed down:", code, reason) def received_message(self, resp): global callback print("resp", resp.data) headers = { "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.35", "referer": "https://hotels.ctrip.com/hotel/shanghai2", "cookie": 请在此处输入你的cookie, } eleven = resp.data params = { "MasterHotelID": "608516", "hotel": "608516", "NewOpenCount": "0", "AutoExpiredCount": "0", "RecordCount": "1659", "OpenDate": "", "card": "-1", "property": "-1", "userType": "-1", "productcode": "", "keyword": "", "roomName": "", "orderBy": "2", "currentPage": "2", "viewVersion": "c", "contyped": "0", "eleven": "", "callback": callback, "_": str(time.time()).replace(".", "")[0:13], } comment_url = ( "https://hotels.ctrip.com/Domestic/tool/AjaxHotelCommentList.aspx?" ) r = requests.get(comment_url, params=params, headers=headers) print(r.url) print(r.text) # a = requests.post(url, data=data, headers=headers) # print(a.text) # resp = json.loads(str(resp)) # data = resp['data'] # if type(data) is dict: # ask = data['asks'][0] # print('Ask:', ask) # bid = data['bids'][0] # print('Bid:', bid) def getTime(): return str(time.time()).replace(".", "")[0:13] def getCallbackParam(): # f = open("./callback.js") callbackCode = """ function e(e) { var t = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"], a = "CAS", o = 0 for (; o < e; o++) { var i = Math.ceil(51 * Math.random()); a += t[i] } return a } function getCallback() { return e(15); } """ context = execjs.compile(callbackCode) return context.call("getCallback") def getContent(): global callback t = getTime() callback = getCallbackParam() print(callback) url = "https://hotels.ctrip.com/domestic/cas/oceanball?callback=%s&_=%s" % ( callback, t, ) headers = { "user-agent": "Mozilla/5.0 (darwin) AppleWebKit/537.36 (KHTML, like Gecko) jsdom/16.2.2", "referer": "https://hotels.ctrip.com/hotel/shanghai2", "cookie": 请在此处输入你的cookie, } r = requests.get(url, headers=headers) code = ( """ window["%s"] = function (e) { var f = e(); console.log(f); ws.send(f); };; """ % callback + r.text ) # print(code) ws.send(code) # open("a.js", "w").write(code) # # os.system("node a.js") # getContent() ws = None try: ws = CG_Client("ws://127.0.0.1:8014/") ws.connect() getContent() ws.run_forever() except KeyboardInterrupt: ws.close()
View Code