前言
目標網站
https://www.aqistudy.cn/historydata/daydata.php?city=%E6%9D%AD%E5%B7%9E&month=2013-12
爬取內容
頁面上的表格中的所有數據
爬取過程
先F12檢查吧。沒想到一打開控制台,頁面就變成這樣了。看樣子做了反爬。
F12檢測-解決方法
我所采取的方法是使用 油猴下一個斷點。因為油猴的執行時機較早。因此那個時候debugger住,就可以看到這個頁面的邏輯
下面便是油猴的代碼,其實只有debugger這一行語句。
(function() { 'use strict'; debugger; // Your code here... })();
打開F12,刷新頁面,就可以看到頁面在斷點處停下了
有可能打開頁面就並不是上面的樣子(2020.8月18日)
也是這個樣子,並且我們的右鍵啥的都被禁用了。
找到禁用F12的代碼處
調整tampermonkey的運行時機,將其調到document-start。這樣油猴便是第一個運行的js文件。
在油猴的腳本處停止后,我們直接在resource面板上找到加載的html文件。
如何重新定義方法呢?
控制台上輸入 function txsdefwsw(){} 便可以了。
這時候翻一下頁面,發現數據已經被加載出來了。但可喜的是,頁面沒有出現那個提示了。
這時候去network面板找找所有的請求,會很驚喜的發現,貌似沒有找到相關的請求啊。
答案是 並沒有發送請求,而是使用了localhost本地存儲。
有人可能在想了,這是啥玩意啊。你怎么這么確實就是我們想要的數據呢?
這點等一下我們可以在源碼中見到。現在我們需要將localstorage中的緩存全部清掉,這樣頁面就會發起真正的請求了。
然后還需要下一個XHR斷點,因為油猴下的斷點其實有些晚了(油猴可以設置腳本的執行時機,可以設置到最早,默認的不是最早的)
然后刷新下頁面。
這次的斷點就不是那個油猴斷點了。熟悉前端的朋友一眼就可以看出這個便是ajax請求。
切換調用棧,我們很快就會找到發送ajax請求的源頭
其中這個s76開頭的函數的第二個參數便是要請求的參數
那下一步是不是直接在這個s76函數的第一行代碼處打一個斷點,然后觀察請求就可以了?
並不是的哦。如果你仔細觀察的話,就會發現這部分代碼並沒有啥具體的地址,取而代之的是VM5735之類的東西
這說明了啥?說明了這部分代碼是通過eval執行的,動態執行的js代碼。因此在此處下斷點沒用。
我們還得順着調用棧向上找,找到一處不是在vm中執行的。
在1056行處下一個斷點,再次刷新,然后斷點就會在此處停住了。
這個sU開頭的函數便是整個的加載邏輯了,第三個是異步回調函數,用於設置數據用的。
我們進入是sU開頭函數了,解釋下函數的作用
有些人看到這個就會覺得好簡單了,我直接把171行的函數和179行的數據解密函數扣下來不就搞定了?
但事實上沒那么簡單,171行的函數其實是動態生成的(見后面的"關於eval的200行代碼")。里面並不是固定死的算法。有些參數的值是變化的
下面兩張圖中畫框的內容便是變化的東西。
很不幸,不但值在變,變量名也在變。因此如果想要用正則來匹配的話,實際操作難度很大。
我的想法是這個代碼肯定是可以運行的,我們大可不必考慮變量名變來變去的。我們只需要修改下sU函數里的東西就可以了。
經過測試,只需要保存下面的幾處代碼即可在node環境中運行了
還有個小小的問題,這些函數名都不是固定的,如果想要調用,根本沒有辦法調用。其實可以做一層映射即可。
function sUBtOIE2skajXLT(muHwwFDaJ, oBRgVeYIlQ, cs16YvBHk, pN62eFL) { const k9dI = hex_md5(muHwwFDaJ + JSON.stringify(oBRgVeYIlQ)); var psyg2ok = pZ5JcsDR5kiPoq(muHwwFDaJ, oBRgVeYIlQ); return ["hr9jdXsuU", psyg2ok]; }
function getParam(obj){ // 以后要調用的話 getParam({city: "杭州", "month": "201312"}) 便可以得到需要post的data了。
return sUBtOIE2skajXLT("GETDAYDATA", obj); // 這里要對應上 上面具體的函數(有工具可以自動解析到)
}
function parseData(input){ // 也是一樣的,如果想要解密下響應內容,調用此函數即可。
return d0UUVoZ0h8GEzpjSCLcq(input);
}
你可能會問了,這樣寫有啥用。下次函數名變了,難道還要手動去改函數名嗎?
No,No,No。並不需要。
這里就要引入一個工具了,它叫babel。
干啥用了?
這個工具可以將我們的js代碼先變成一個ast語法樹,然后我們通過修改或者訪問這個樹的節點。最終生成的代碼就會被我們這樣修改掉了。
我拿這個工具舉個簡單的例子吧。
我們需要在所有的console.log中輸出我們所調用的函數名字(經典例子)
function foo(){ console.log(111); } // 變成這個樣子 function foo(){ console.log("function foo", 111); }

// 下面的四個依賴包需要npm安裝下 const generator = require("@babel/generator"); const parser = require("@babel/parser"); const traverse = require("@babel/traverse"); const types = require("@babel/types"); function compile(code) { const ast = parser.parse(code); // 將代碼解析成ast語法樹 const visitor = { CallExpression(path) { // 下面的節點名稱啥的可以通過 https://astexplorer.net/ 找到 const node = path.node; if ( node.callee.type === "MemberExpression" && node.callee.object.name === 'console' && node.callee.property.name === 'log' ) { // 找到函數的名字 const parentNode = path.findParent(p => types.isFunctionDeclaration(p)) const parentName = parentNode.node.id.name; console.log(parentName); // ast增加一個結構 node.arguments.unshift(types.stringLiteral(`function ${parentName}`)) } // 找到函數中的console.log語句 } } traverse.default(ast, visitor); return generator.default(ast, {}, code); } const code = ` function foo(){ console.log(111); } `; const output = compile(code); console.log(output.code);
那對於這個網站,我也寫了個對應的ast來應對。
const generator = require("@babel/generator"); const parser = require("@babel/parser"); const traverse = require("@babel/traverse"); const types = require("@babel/types"); const fs = require("fs"); function compile(code) { // 1.parse 將代碼解析為抽象語法樹(AST) const ast = parser.parse(code); const visitor = { FunctionDeclaration(path) { const node = path.node; // console.log(node.params) // 目標函數長這個樣子 // function sBAAH3A7LFcJgXe(method, object, callback, period) { } if ( node.params.length === 4 // && node.params[0].name === "method" // && node.params[1].name === "object" // && node.params[2].name === "callback" // && node.params[3].name === "period" ) { // 獲取此函數名,暴露出接口 const funcName = node.id.name console.log(funcName); // 去除第二行的 const data = getDataFromLocalStorage(key, period); const ifStatement = node.body.body[2]; const addParamExpression = ifStatement.consequent.body[0] node.body.body.splice(0, 2, addParamExpression); // 獲取post請求中data的key postDataKey = ifStatement.consequent.body[1].expression.arguments[0].properties[1].value.properties[0].key.name console.log(postDataKey) // 解密函數映射 const decFuncName = ifStatement.consequent.body[1].expression.arguments[0].properties[3].value.body.body[0].expression.right.callee.name; console.log(decFuncName) // 刪除if語句 node.body.body.pop() // 增加 return [param, postDataKey] const paramName = node.body.body[0].declarations[0].id.name; const returnStatement = types.returnStatement(types.ArrayExpression([types.identifier(paramName), types.stringLiteral(postDataKey)])) node.body.body.push(returnStatement) // 增加映射 const globalBody = path.findParent(p => { return true; }) const funcMappingForGetParam = types.functionDeclaration( types.identifier("getParam"), [ types.identifier("obj") ], types.blockStatement([ types.returnStatement( types.callExpression( types.identifier(funcName), [ types.stringLiteral("GETDAYDATA"), types.identifier("obj") ] ) ) ]) ) globalBody.container.program.body.push(funcMappingForGetParam) // 關於數據解密函數 // function parseData(input) { // return dA3Gc6OUqeCBgWSh53T(input); // } const funcMappingForParseData = types.functionDeclaration( types.identifier("parseData"), [ types.identifier("input") ], types.blockStatement([ types.returnStatement( types.callExpression( types.identifier(decFuncName), [ types.identifier("input") ] ) ) ]) ) globalBody.container.program.body.push(funcMappingForParseData) } } } // 2,traverse 轉換代碼 traverse.default(ast, visitor); // 3. generator 將 AST 轉回成代碼 return generator.default(ast, {}, code); } // const code = fs.readFileSync("out.js", "utf-8"); // const newCode = compile(code) // fs.writeFileSync("out2.js", newCode.code, "utf-8")
ast轉換
這樣便可以實現上面所說的效果了。刪除不需要的結構,添加我們所需的結構。
這個需要進行ast轉化的js代碼其實只有200來行,它依賴多個加密庫。因為行數過多,就不在此處展示了。
關於eval的那200行代碼
代碼具體實現
百度網盤
鏈接: https://pan.baidu.com/s/1DfMIDDc-SLjd0tVIeyyKOQ 密碼: e2ou
具體效果
總結
這個網站的反爬做的很簡單。算是普通的反爬吧。
花了3個來小時就搞定了。
這網站反爬倒是更新很勤快。
關於execjs執行速度
可能有人覺得獲取數據好慢,其實有部分時間花在了babel編譯過程中與js代碼的運行上(600毫秒到1.2秒)
只是因為execjs是通過純字符串與解釋器通信的,損耗很大。
並且每次都要新生成一個代碼,繼續編譯執行。(算法部分的代碼其實是不變的)
如果真的想提升速度的話,不妨將所有的代碼都放到node環境里(有一定的風險,因為node可以直接刪除文件啥的)