遷移自我的知乎專欄《手把手教你寫爬蟲》
此面試題網站已被作者關閉,但處理方式依然可以拿來參考。
最近(2019年6月)有一個爬蟲面試題(http://shaoq.com:7777/exam)在圈內看起來挺火的,經常在各個爬蟲群裡看到它被提到,而幾乎所有提到這個面試題的人在題目限制的條件下就不知道該怎麼辦了,但這題目其實真的並不難,甚至可以說應該只是為了在招人時再過濾一遍只會寫解析,拿著Selenium和代理池硬懟的人罷了(之前招人的時候見過很多,甚至有很多2-3年經驗還處於這個水平)。
造成爬蟲圈子現在這個情況的原因我覺得可能是因為各種爬蟲書籍/培訓班/網課都沒有講到過關於逆向方面的知識,他們的教學更傾向於Python語法、正則表達式、XPath這些非常基礎的東西和常見爬蟲框架/工具的簡單用法,而讀者/學員學完之後的水平充其量也就只能爬爬豆瓣之類的簡單網站,面對有點簡單反爬的就一臉懵逼,只能拿著Selenium和代理池硬懟。那麼為了提升一下爬蟲圈內的平均水平,寫點別人沒講或者不想講的東西並分享出來就很有必要了,這個專欄也是因此而生的。
扯遠了,開始講這個面試題吧,請站穩扶好,老司機要開始飆車了。首先做好以下準備,等會兒會用上,括號內是文中所使用的工具名或版本號:
瀏覽器(Chrome)
Fiddler/Charles之類的抓包工具(Fiddler)
Python和JavaScript的IDE或編輯器(Pycharm + WebStorm)
Python3.x和NodeJS(Python3.6.5 + NodeJS10.15.1)
Python庫:pyexecjs、aiohttp、aiohttp_requests、lxml(最新版本)
NodeJS庫:jsdom(最新版本)
準備好了之後就可以開始了,先抓個包看看題目是啥樣的。
先是一個跳轉頁。
然後會跳轉到內容頁,已經可以看到需要的文字了。
看起來好像只需要拿到跳轉後的HTML就行了?實際並不是,這裡可以看到上面這一行字裡除了「python」和「題」以外,其他的標籤在HTML中都是沒有文本內容的,對應的內容全都顯示在了右邊的CSS樣式中。
但是抓包的時候也沒看到CSS,是不是把CSS嵌在了HTML中呢?打開這個HTML的代碼看看,一大坨加密的JS一眼可見,也並沒有看到style標籤,顯然這個CSS是通過JS生成後加進去的。
很多人對JS逆向毫無了解,看到這裡已經懵逼了,碰到這種情況還不讓用Selenium之類的工具,又要爬到內容,似乎完全沒辦法了啊。那應該怎麼辦呢?其實很簡單,看完這篇文章你就知道應該怎麼做了,下面我將用代碼對這個面試題的考點逐個擊破(完整代碼將在文章結尾處放出)。
先請求一下這個URL看看會返回什麼結果。
提示:aiohttp_requests庫能讓你在用aiohttp進行請求時能使用類似於requests庫的語法,並且能正常使用session功能,而不需要寫一層接一層的async with xxxxxxx。
請求返回的結果是最開始的跳轉頁,距離真正的內容頁還差一點距離。
斷點斷下來看看resp,已經可以看到一個名為session的Cookie被set了,之前抓包的時候也是有看到伺服器返回這個Cookie的。那麼直接帶著這個Cookie再次請求是不是就可以拿到那個內容頁了呢?我們將代碼改一下,對這個URL再次請求:
咦?有了這個Cookie之後的請求怎麼還是返回這個跳轉頁呢?
現在再回到抓包工具中仔細看看,是不是發現抓到的瀏覽器請求裡這兩個請求之間是有一堆圖片的,且第二次請求時,請求頭裡的東西也沒有啥變化?
是這樣的,其實它的服務端對客戶端是否加載了圖片進行了判斷,如果客戶端沒有加載圖片就直接開始取內容,那除了網速慢和刻意關閉了圖片的人以外,基本就可以確定是爬蟲了,所以這是一個簡單粗暴的反爬措施。
知道了這個考點之後就很簡單了,取出圖片的URL並和瀏覽器一樣進行請求就好了。再次修改代碼:
提示:因為這裡重用host部分的次數很多,我把host部分寫成了一個常量。
提示:f"{HOST}{image.get('src')}"是format string,python3的一個語法糖,最開始有這個語法糖的版本已經記不清了,如果你發現這段代碼在你的環境裡無法運行,可以把這裡改成"{}{}".format(HOST, image.get("src"))。
提示:asyncio.gather是asyncio庫的並發執行任務函數,傳入的是一個協程函數列表,所以裡面的requests.get不需要加await。
可以看到已經取到了內容頁的HTML,第一個考點我們已經跨過去了,接下來要想想怎麼拿到那個CSS的部分了。
那麼這個JS要怎麼處理呢?其實我們可以使用Python調用JS的方式去執行它頁面中的那段代碼,從而生成出標籤中對應文字部分的CSS。這裡推薦使用pyexecjs庫 + NodeJS來執行JS代碼,pyexecjs庫可以說是目前最好的Python執行JS代碼的庫了,另外一個比較常見的庫——PyV8,存在嚴重的內存洩漏BUG,不建議使用。
但是直接執行這段JS代碼是不可能有用的,我們還需要分析一下它的內容並按我們的使用方式修改一下。先把那段JS複製出來,打開JavaScript IDE/編輯器,並把它丟進去進行分析。
此處省略幾百行變量。
可以看到script標籤裡是一個匿名函數,傳入了一個document參數(函數內的uH),而實際這個匿名函數的主要流程代碼非常地少,只有兩個部分。
一個是開頭的這裡,
一個是靠近結尾位置的這裡。
第一部分沒有做什麼操作,只是創建了一個element,那麼核心部分應該就是第二部分,跳到它調用的jE_函數看看。
提示:WebStorm中可以用滑鼠中鍵或Ctrl+滑鼠左鍵點擊jE_,跳轉到對應的函數位置
這個jE_是這麼一坨看不懂的東西,看不懂就沒法搞了,怎麼辦呢?仔細看看上面那些用到的變量,是不是都是那一坨給變量賦值的地方出來的?那麼我們只需要把那一串加起來的東西寫成一個新的變量,打個斷點在下面然後運行一下,就能直接看出它是啥了。(更高級的加密JS在還原時需要用到AST解析庫和相關知識寫工具處理而非手動處理,這裡暫時還不需要用)
等一等,現在你還不能運行這段代碼,因為你沒有document,document是瀏覽器中特有的一個全局變量,而NodeJS中是不存在document這東西的,是不是覺得事情有點麻煩了起來?沒關係,問題不大,既然NodeJS中沒有,那我們就自己造一個,這裡使用jsdom庫來模擬瀏覽器中的dom部分,從而做到在NodeJS中使用document的操作。當然你如果想要自己造也是可以的,只需要按著報錯提示一個一個地實現這段JS代碼中調用的document.xxx即可。
這個jsdom庫的使用方式很簡單,只需要按照文檔上的說明導入jsdom,再new一個dom實例就可以了。
Basic usage1const jsdom = require("jsdom");
2const { JSDOM } = jsdom;
To use jsdom, you will primarily use the JSDOM constructor, which is a named export of the jsdom main module. Pass the constructor a string. You will get back a JSDOM object, which has a number of useful properties, notably window:
1const dom = new JSDOM(`<!DOCTYPE html><p>Hello world</p>`);
2console.log(dom.window.document.querySelector("p").textContent); // "Hello world"
注意了,這裡的dom變量還並不是我們要的document變量,真正的document變量是dom.window.document,所以我們的代碼可以這樣寫:
執行一下看看效果。
原來上面的兩個參數分別是decodeURIComponent和%E6%81%AF%E6%95%B0%E9%9D%A2%E7%88%AC%E8%99%AB%E4%BF%A1%E6%8A%80%E5%88%9B%E8%AF%95%E7%A7%91,我們把後面那段一眼就能看出是經過urlencode的字符串還原一下看看。
嗯…其實就是頁面上的那句話了,只不過它是亂序的,我們接著往下執行看看它還做了什麼操作。
往下執行時報錯了,看起來是缺少了decodeURIComponent這個函數,那decodeURIComponent前面的那個uc_又是什麼呢?用同樣的方式可以看到,其實是window。
也就是說這句代碼還原成正常的樣子其實就是this.window.decodeURIComponent("%E6%81%AF%E6%95%B0%E9%9D%A2%E7%88%AC%E8%99%AB%E4%BF%A1%E6%8A%80%E5%88%9B%E8%AF%95%E7%A7%91"),而NodeJS的decodeURIComponent並不在this.window中,所以我們還是需要通過最開始造document的操作,再給它弄一個this.window.decodeURIComponent,代碼很簡單,改成這樣即可:
然後我們再執行一遍。
這次就能正常運行完畢了,但是我們要的東西去哪兒了呢?我們繼續往下打斷點看,vz_是亂序的文字,ti_是一個裡面只有數字的數組,SE_則只有兩個空字符串,KI_函數沒有進行賦值,而最後的return其實是沒有任何作用的,因為jE_在主流程中是最後一個被執行的函數,它返回的值賦給了xe_後並不會被使用。所以這裡似乎只有SE_和KI_比較可疑了,斷點進入給SE_賦值的Er_函數看看。
看來這個Er_函數並不會做什麼,那麼我們要的核心部分可以確定就是KI_這個函數了。接著追到下面的KI_函數。
這裡它又調用了一個叫Ks_的函數,跟著它繼續往下跳。
又是熟悉的Er_,還記得剛剛看到的嗎,它只是做了一個split操作而已,ti_是前面那個只有數字的數組,這裡的NL_只不過是按順序取了一個ti_裡的元素罷了,下面沒見過的BD_和Je_才是重點。
這裡斷下來看出BD_其實是一個取前面那串亂序字符串中其中一個文字的東西,繼續往下執行可以看到最終出來的YO_是一個字。
那麼Je_呢?繼續往下執行看看
Je_裡調用了ee_.insertRule,而ee_是前面被賦值的
所以實際上它是新建了一個element並往裡面寫了我們要的CSS。看到這裡,其實這個考點已經被破掉了,我們只需要讀出ee_返回給Python,就可以把那段文字給恢復出來了。
將JS代碼再修改一下:
然後我們試一下能不能用,記得將這裡的html字符串替換成你請求時返回的。(通常這種用到瀏覽器內特有的一些變量的JS都會埋下一些坑,建議讀者養成完全模擬瀏覽器環境的習慣,當然如果不怕遇到坑的話只給JS中需要用到的東西也可以,而這個題目本身並沒有這種坑,所以只弄一個空的dom並且魔改一下只傳入字符串和數組部分也能用。)
boom!CSS成功地被我們拿到手了,左邊的codexx對應右邊的content部分文字,與瀏覽器中的一模一樣,JS部分算是搞好了,我們要繼續寫我們的Python代碼,先把html=xxx開始的部分全部刪除掉,只保留上面導入包的部分和get_css這個函數的部分。
回到Python代碼部分,修改成調用JS得到CSS後處理一下CSS和HTML的對應關係,並取出所有文字內容再列印出來。
提示:這裡的dict(list)是一個Python的語法糖,可以快速地將[[1,2],[3,4]]轉成{1:2, 3:4}
提示:這裡可能會出現一個問題,之前直接用NodeJS執行沒問題的代碼,經過PyExecJS調用之後卻報錯了,這個問題似乎只有在Windows系統上才會出現,主要原因應該是Windows的編碼問題,碰到這種情況可以用Buffer.from(string).toString("base64");將返回的字符串編碼為Base64,在Python中再進行解碼。
執行一下看看,是不是已經拿到了需要的那行字了呢?
發送消息「shaoq爬蟲面試題」到我的公眾號【小周碼字】即可獲得文代碼下載地址~
這個時代各種東西變化太快,而網絡上的垃圾信息又很多,你需要有一個良好的知識獲取渠道,很多時候早就是一種優勢,還不趕緊關注我的公眾號並置頂/星標一波~