Python爬蟲之Web端協(xié)議網(wǎng)頁登錄POST分析
本節(jié)探討的是那些需要登錄之后才能進(jìn)行頁面爬取的情況,屬于深層次的網(wǎng)頁爬取。我們將講一些大家熟悉的例子,比如爬取論壇或者貼吧的內(nèi)容,這種網(wǎng)站對(duì)權(quán)限的管理非常嚴(yán)格,不同的角色權(quán)限,對(duì)應(yīng)的網(wǎng)頁內(nèi)容是不同的。假如你沒有登錄該論壇或貼吧,相當(dāng)于游客權(quán)限,基本上爬取不到任何有價(jià)值的數(shù)據(jù)。本節(jié)要做的就是完成登錄獲取Cookie這一步,現(xiàn)在的網(wǎng)頁登錄基本上都是使用表單提交
POST請(qǐng)求來完成驗(yàn)證。接下來就講解登錄POST請(qǐng)求中需要注意的情況。
10.1.1 隱藏表單分析
大家在分析POST請(qǐng)求時(shí)經(jīng)常碰到這種情況,通過FireBug截獲POST請(qǐng)求,發(fā)現(xiàn)POST出去的數(shù)據(jù)比我們?cè)诒韱沃刑顚懙臄?shù)據(jù)多,而且這些數(shù)據(jù)的內(nèi)容每次還變化,這非常影響我們使用Python發(fā)送
POST請(qǐng)求進(jìn)行模擬登錄。下面以知乎(https://www.zhihu.com/#signin)為例,如圖10-1所示。
圖10-1 登錄知乎
打開Firebug,打開網(wǎng)絡(luò)監(jiān)聽,輸入賬號(hào)和密碼進(jìn)行登錄。截獲的請(qǐng)求如圖10-2所示。
POST內(nèi)容如下:
_xsrf=03be292fc21b83fa6ddb48760af4f4c2 password=XXXXXXXX phone_num=XXXXXXXX remember_me=true
我使用的是手機(jī)號(hào)登錄,賬號(hào)密碼使用XXXXXXXX代替。大家發(fā)現(xiàn)phone_num、password、remember_me這三個(gè)字段是我們?cè)诒韱沃休斎牖蛘哌x中的,除了這三個(gè)還多了一個(gè)_xsrf參數(shù),做過Web前端的朋友肯定認(rèn)識(shí)這個(gè)字段,這是用來防跨站請(qǐng)求偽造的。那這個(gè)參數(shù)在哪呢?我們需要使用_xsrf這個(gè)參數(shù)模擬登錄。
這就需要Firebug強(qiáng)大的搜索功能,將_xsrf后面的值03be292fc21b83fa6ddb48760af4f4c2填入搜索框中并回車,如圖10-3所示。
圖10-4 _xsrf位置
知道了_xsrf的位置,既可以使用Beautiful Soup提取其中的值,也可以直接使用正則表達(dá)式提取。這次使用正則表達(dá)式進(jìn)行提取,然后使用Requests提交POST請(qǐng)求。代碼如下:
# coding:utf-8 # 構(gòu)造 Request headers
import re
import requestsdef get_xsrf(session):
'''_xsrf 是一個(gè)動(dòng)態(tài)變化的參數(shù),從網(wǎng)頁中提取'''
index_url = 'http://www.zhihu.com'
# 獲取登錄時(shí)需要用到的_xsrf
index_page = session.get(index_url, headers=headers)
html = index_page.text
pattern = r'name="_xsrf" value="(.*)"'
# 這里的_xsrf 返回的是一個(gè)list
_xsrf = re.findall(pattern, html)
return _xsrf[0] agent = 'Mozilla/5.0 (Windows NT 5.1; rv:33.0) Gecko/20100101 Firefox/33.0'
headers = { 'User-Agent': agent
} session = requests.session()
_xsrf = get_xsrf(session)
post_url = 'http://www.zhihu.com/login/phone_num'
postdata = { '_xsrf': _xsrf, 'password': 'xxxxxxxx', 'remember_me': 'true', 'phone_num': 'xxxxxxx', } login_page = session.post(post_url, data=postdata, headers=headers)
login_code = login_page.text
print(login_page.status_code)
print(login_code)
登錄成功的輸出結(jié)果為:
200 {"r":0, "msg": "\u767b\u5f55\u6210\u529f"}
10.1.2 加密數(shù)據(jù)分析
上面看到的知乎賬號(hào)和密碼都是使用明文進(jìn)行發(fā)送,但是為了安全,很多網(wǎng)站都會(huì)將密碼進(jìn)行加密,然后添加一系列附加的參數(shù)到
POST請(qǐng)求中,而且還有驗(yàn)證碼,分析難度和知乎登錄完全不是一個(gè)量級(jí)。下面我們就進(jìn)行一下挑戰(zhàn),分析百度POST登錄方式,強(qiáng)化大家的分析能力。由于百度登錄使用的是同一套加密規(guī)則,所以這次就以百度云盤的登錄為例進(jìn)行分析,整個(gè)分析過程分為三個(gè)部分。
第一部分首先打開FireBug,訪問http://yun.baidu.com/,監(jiān)聽網(wǎng)絡(luò)數(shù)據(jù),如圖10-5所示。
圖10-5 百度網(wǎng)盤
操作流程:
1)輸入賬號(hào)和密碼。
2)點(diǎn)擊登錄。(第一次POST登錄。)
3)這時(shí)候會(huì)出現(xiàn)驗(yàn)證碼,輸入驗(yàn)證碼。
4)最后點(diǎn)擊登錄成功上線。(第二次POST登錄成功。)在一次成功的登錄過程中,我們需要點(diǎn)擊兩次登錄按鈕,也就出現(xiàn)了兩次POST請(qǐng)求,如圖10-6所示。
圖10-6 兩次POST請(qǐng)求
將上面兩次的POST請(qǐng)求記錄下來,記錄完成之后,清空cookie,再進(jìn)行一次成功的登錄,用于比較POST請(qǐng)求字段中那些是會(huì)變化的,那些是不會(huì)變化的。兩次登錄四次POST請(qǐng)求,我們將這四次POST
請(qǐng)求命名為post1_1、post1_2、post2_1、post2_2,以便區(qū)分是哪一次登錄的哪一個(gè)POST請(qǐng)求。
現(xiàn)在先關(guān)注post2_2和post1_2,這是兩次登錄最后成功的POST請(qǐng)求,如圖10-7所示:
圖10-7 post2_2參數(shù)
通過比較post2_2和post1_2,我們可以發(fā)現(xiàn)一些字段是變化的,一些是不變的,如表10-1所示。
表10-1 POST參數(shù)值狀態(tài)表
通過表10-1,我們可以了解到那些變化的字段,這也是我們著重要分析的地方。接著分析一下變化的參數(shù),看哪些是可以輕易獲取的。
·callback:不清楚是什么,不知道怎么獲取。
·codestring:不清楚是什么,不知道怎么獲取。
·gid:一個(gè)生成的ID號(hào),不知道怎么獲取。
·password:加密后的密碼,不知道怎么獲取。
·ppui_logintime:時(shí)間,不知道怎么獲取。
·rsakey:RSA加密的密鑰(可以推斷出密碼肯定是經(jīng)過了RSA加密),不知道怎么獲取。
·token:訪問令牌,不知道怎么獲取。
·tt:時(shí)間戳,可以使用Python的time模塊生成。
·verifycode:驗(yàn)證碼,可以輕易獲取驗(yàn)證碼圖片并獲取驗(yàn)證碼值。
通過上面的分析,又確定了tt、verifycode參數(shù)的提取方式,現(xiàn)在只剩下callback、codestring、gid、password、ppui_logintime、rsakey、token等參數(shù)的分析。
第二部分既然已經(jīng)知道了需要確定的參數(shù),接下來要做的是確定callback、codestring、gid、password、ppui_logintime、rsakey、token這些參數(shù)是在哪一次登錄過程的哪一個(gè)post請(qǐng)求中產(chǎn)生的。將post2_1和post2_2的請(qǐng)求參數(shù)進(jìn)行比較,如圖10-8是post2_1請(qǐng)求的內(nèi)容,可以和圖10-7進(jìn)行比較,以發(fā)現(xiàn)參數(shù)的變化。
圖10-8 post2_1參數(shù)
通過比較,參數(shù)變化如表10-2所示。
表10-2 post2_1和post2_2參數(shù)值對(duì)比
通過上表我們看到出現(xiàn)明顯變化的是codestring,從無到有??梢曰旧洗_定codestring是在post2_1之后產(chǎn)生的,所以codestring這個(gè)字段應(yīng)該是在post2_1的響應(yīng)中找到。果不其然,如圖10-9所示:
圖10-9 codestring參數(shù)
codestring這個(gè)字段的獲取位置已經(jīng)確定。
接著分析post2_1已經(jīng)產(chǎn)生,post2_1內(nèi)容沒有發(fā)生變化的參數(shù):
gid、rsakey、token。這些參數(shù)可以確定是在post2_1請(qǐng)求發(fā)送之前就已經(jīng)產(chǎn)生,根據(jù)網(wǎng)絡(luò)響應(yīng)的順序,從下到上,看看能不能發(fā)現(xiàn)一些敏感命名的鏈接。在post2_1的不遠(yuǎn)處,發(fā)現(xiàn)了一個(gè)敏感鏈接:https://
passport.baidu.com/v2/getpublickeytoken=69a056f475fc955dc16215ab66a985af&tpl=netdisk&
subpro=netdisk_web&apiver=v3&tt=1469844359188&gid=58DDBCC-672F-423D-9A02-688ACB9EB252&callback=bd__cbs__rn85cf,如圖10-10所示。
圖10-10 敏感鏈接
通過查看響應(yīng)我們找到rsakey,雖然在響應(yīng)中變成了key,可是值是一樣的。通過之前的信息,我們知道密碼是通過RSA加密的,所以響應(yīng)中的publickey可能是公鑰,這個(gè)要重點(diǎn)注意,如圖10-11所示:
圖10-11 敏感鏈接響應(yīng)
還可以發(fā)現(xiàn)callback參數(shù),參數(shù)中出現(xiàn)callback字段,之后響應(yīng)中也出現(xiàn)了callback字段的值將響應(yīng)包裹,由此可以推斷callback字段可能只是進(jìn)行標(biāo)識(shí)作用,不參與實(shí)際的參數(shù)校驗(yàn)。
通過對(duì)這個(gè)敏感鏈接的請(qǐng)求參數(shù)可以得出以下結(jié)論:gid和token
可以得到rsakey參數(shù)。
接著分析gid參數(shù)和token參數(shù)。直接在FireBug的搜索框中輸入
token,進(jìn)行搜索。搜索兩到三次,可以發(fā)現(xiàn)token的出處位于https://passport.baidu.com/v2/api/getapi&tpl=netdisk&subpro=netdisk_web&apiver=v3&tt=1469844296412&class=login&gid=58DDBCC-672F-423D-9A02-688ACB9EB252&logintype=basicLogin&callback=bd__cbs__cmkxjj,如圖10-12所示:
圖10-12 token出處
通過這個(gè)鏈接的get參數(shù),我們可以得到如下的結(jié)論:通過gid可以得出Token。
最后分析一下gid參數(shù)。依舊是通過搜索的辦法,很快在http://passport.bdimg.com/passApi/js/login_tangram_a829ef5.js中找到了gid
的出處,如圖10-13所示:
圖10-13 gid位置
格式化腳本之后,咱們看一下這個(gè)gid是怎么產(chǎn)生的。通過gid:e.guideRandom,我們可以知道gid是由guideRandom這個(gè)函數(shù)產(chǎn)生的,接著在腳本中搜索這個(gè)函數(shù),如圖10-14所示:
最后找到這個(gè)函數(shù)的原型,通過代碼可以看到,這是隨機(jī)生成的字符串,這就好辦了。函數(shù)原型如下:
gid = this.guideRandom = function () { return 'xxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (e) { var t = 16 * Math.random() | 0, n='x'==et:3&t|8;
return n.toString(16)
}).toUpperCase()
}()
圖10-14 guideRandom函數(shù)
最后將第二部分進(jìn)行一下總結(jié):
·codestring:從第一次POST之后的響應(yīng)中提取出來
·gid:由一個(gè)已知函數(shù)guideRandom隨機(jī)產(chǎn)生,可以通過調(diào)用函數(shù)獲取
·token:https://passport.baidu.com/v2/api/getapi&tpl=netdisk&subpro=netdisk_web&apiver=v3&tt=1469844296412&class=login&gid=58DDBCC-672F-423D-9A02-688ACB9EB252&logi
ntype=basicLogin&callback=bd__cbs__cmkxjj,將gid帶入鏈接,獲取響應(yīng)中的token·rsakey:https://passport.baidu.com/v2/getpublickeytoken=69a056f475fc955dc16215ab66a985af&tpl=netdisk&
subpro=netdisk_web&apiver=v3&tt=1469844359188&gid=58DDBCC-672F-423D-9A02-688ACB9EB252&callback=bd__cbs__rn85c,將獲取的gid和token帶入鏈接,從響應(yīng)中可以提取出rsakey
第三部分最后還剩callback、password和ppui_logintime參數(shù)。通過之前的分析,可以了解到callback可能沒啥用,所以放到后面再分析。一般來說password是最難分析的,所以也放到后面分析。
接下來分析ppui_logintime,搜索ppui_logintime,在下面的鏈接中找到了ppui_logintime的出處:http://passport.bdimg.com/passApi/js/login_tangram_a829ef5.js,如圖10-15所示。
找到了timeSpan:’ppui_logintime‘,接著搜索timeSpan,如圖10-16所示。
找到了r.timeSpan=(new Date).getTime()-e.initTime,接著搜索initTime,如圖10-17所示。
通過上面的代碼我們可以知道ppui_logintime可能是從輸入登錄信息,一直到點(diǎn)擊登錄按鈕提交的這段時(shí)間,可以直接使用之前的
POST請(qǐng)求所發(fā)送的數(shù)據(jù),沒有什么影響。
圖10-15 ppui_logintime參數(shù)
圖10-16 timeSpan
圖10-17 initTime
接著分析callback參數(shù),搜索callback,我們將可以找到callback的生成方式,如圖10-18所示。
callback生成方式為:
圖10-18 callback
最后分析password的加密方式,搜索password,發(fā)現(xiàn)敏感內(nèi)容,在http://passport.bdimg.com/passApi/js/login_tangram_a829ef5.js
鏈接中,如圖10-19所示。
圖10-19 password
通過設(shè)置斷點(diǎn),動(dòng)態(tài)調(diào)試可以知道,password是通過公鑰pubkey對(duì)密碼進(jìn)行加密,最后對(duì)輸出進(jìn)行base64編碼,即為最后的加密密碼。
通過以上三部分的分析,基本上將POST所有參數(shù)的產(chǎn)生方式都確定了。最后我們進(jìn)行模擬登錄,其中使用到了pyv8引擎,可以直接運(yùn)行JavaScript代碼,這樣生成gid和callback的JavaScript函數(shù)可以直接使用,不用轉(zhuǎn)化為Python語言,不過轉(zhuǎn)化也是非常簡(jiǎn)單的。完整的登錄代碼如下,每一部分我都進(jìn)行了詳細(xì)的注釋,大家也可以從我的
GitHub上進(jìn)行下載:https://github.com/qiyeboy/baidulogin.git。
# coding:utf-8 import base64 import json
import re
from Crypto.Cipher import PKCS1_v1_5 from Crypto.PublicKey import RSA import PyV8 from urllib import quote
import requests
import time
if __name__=='__main__':
s = requests.Session()
s.get('http://yun.baidu.com')
js='''
function callback(){ return 'bd__cbs__'+Math.floor(2147483648 * Math.random()).toString(36)
} function gid(){ return 'xxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (e)
{ var t = 16 * Math.random() | 0, n = 'x' == e t : 3 & t | 8;
return n.toString(16)
}).toUpperCase()
}
'''
ctxt = PyV8.JSContext()
ctxt.enter()
ctxt.eval(js)
########### 獲取gid############################# 3 gid = ctxt.locals.gid()
########### 獲取callback############################# 3 callback1 = ctxt.locals.callback()
########### 獲取token############################# 3 tokenUrl="https:// passport.baidu.com/v2/api/getapi&tpl=netdisk&subpro=net
disk_web&apiver=v3" \ "&tt=%d&class=login&gid=%s&logintype=basicLogin&callback=%s"
%(time.
time()*1000,gid,callback1)
token_response = s.get(tokenUrl)
pattern = re.compile(r'"token"\s*:\s*"(\w+)"')
match = pattern.search(token_response.text)
if match:
token = match.group(1)
else:
raise Exception
########### 獲取callback############################# 3 callback2 = ctxt.locals.callback()
########### 獲取rsakey和pubkey############################# 3 rsaUrl = "https:// passport.baidu.com/v2/getpublickeytoken=%s&" \ "tpl=netdisk&subpro=netdisk_web&apiver=v3&tt=%d&gid=%s&
callback= %s"%(token,time.time()*1000,gid,callback2)
rsaResponse = s.get(rsaUrl)
pattern = re.compile("\"key\"\s*:\s*'(\w+)'")
match = pattern.search(rsaResponse.text)
if match:
key = match.group(1)
print key
else:
raise Exception
pattern = re.compile("\"pubkey\":'(.+)'")
match = pattern.search(rsaResponse.text)
if match:
pubkey = match.group(1)
print pubkey
else:
raise Exception
################ 加密password######################## 3 password = 'xxxxxxx'# 填上自己的密碼
pubkey = pubkey.replace('\\n','\n').replace('\\','')
rsakey = RSA.importKey(pubkey)
cipher = PKCS1_v1_5.new(rsakey)
password = base64.b64encode(cipher.encrypt(password))
print password
########### 獲取callback############################# 3 callback3 = ctxt.locals.callback()
data={
'apiver':'v3', 'charset':'utf-8', 'countrycode':'', 'crypttype':12, 'detect':1, 'foreignusername':'', 'idc':'', 'isPhone':'', 'logLoginType':'pc_loginBasic', 'loginmerge':True, 'logintype':'basicLogin', 'mem_pass':'on', 'quick_user':0, 'safeflg':0, 'staticpage':'http://yun.baidu.com/res/static/thirdparty/pass_v3_jump.html'
, 'subpro':'netdisk_web', 'tpl':'netdisk', 'u':'http://yun.baidu.com/', 'username':'xxxxxxxxx',# 填上自己的用戶名
'callback':'parent.'+callback3, 'gid':gid,'ppui_logintime':71755, 'rsakey':key, 'token':token, 'password':password, 'tt':'%d'%(time.time()*1000), }
########### 第一次post############################# 3 post1_response = s.post('https:// passport.baidu.com/v2/api/login',data=data)
pattern = re.compile("codeString=(\w+)&")
match = pattern.search(post1_response.text)
if match:
########### 獲取codeString############################# 3 codeString = match.group(1)
print codeString
else:
raise Exception
data['codestring']= codeString
############# 獲取驗(yàn)證碼################################### verifyFail = True
while verifyFail:
genimage_param = ''
if len(genimage_param)==0:
genimage_param = codeString
verifycodeUrl="https:// passport.baidu.com/cgi-bin/genimage%s"%
genimage_param
verifycode = s.get(verifycodeUrl)
############# 下載驗(yàn)證碼##################################
# with open('verifycode.png','wb') as codeWriter:
codeWriter.write(verifycode.content)
codeWriter.close()
############# 輸入驗(yàn)證碼##################################
#
verifycode = raw_input("Enter your input verifycode: ");
callback4 = ctxt.locals.callback()
############# 檢驗(yàn)驗(yàn)證碼##################################
# checkVerifycodeUrl='https:// passport.baidu.com/v2/' \ 'checkvcode&token=%s' \ '&tpl=netdisk&subpro=netdisk_web&apiver=v3&tt=%d' \ '&verifycode=%s&codestring=%s' \ '&callback=%s'%(token,time.time()*1000,quote(verifycode), codeString,callback4)
print checkVerifycodeUrl
state = s.get(checkVerifycodeUrl)
print state.text
if state.text.find(u'驗(yàn)證碼錯(cuò)誤')!=-1:
print '驗(yàn)證碼輸入錯(cuò)誤...已經(jīng)自動(dòng)更換...'
callback5 = ctxt.locals.callback()
changeVerifyCodeUrl = "https:// passport.baidu.com/v2/
reggetcodestr" \ "&token=%s" \ "&tpl=netdisk&subpro=netdisk_web&apiver=v3" \ "&tt=%d&fr=login&" \ "vcodetype=de94eTRcVz1GvhJFsiK5G+ni2k2Z78PYR
xUaRJLEmxdJO5ftPhviQ3/ JiT9vezbFtwCyqdkNWSP29oeOvYE0SYPocOGL+
iTafSv8pw" \ "&callback=%s"%(token,time.time()*1000,callb ack5)
print changeVerifyCodeUrl
verifyString = s.get(changeVerifyCodeUrl)
pattern = re.compile('"verifyStr"\s*:\s*"(\w+)"')
match = pattern.search(verifyString.text)
if match:
########### 獲取verifyString#############################
3 verifyString = match.group(1)
genimage_param = verifyString
print verifyString
else:
verifyFail = False
raise Exception
else:
verifyFail = False
data['verifycode']= verifycode
########### 第二次post############################# 3 data['ppui_logintime']=81755 #################################################### # 特地說明,大家會(huì)發(fā)現(xiàn)第二次的post出去的密碼是改變的,為什么我這里沒有變化呢?
# 是因?yàn)镽SA加密,加密密鑰和密碼原文即使不變,每次加密后的密碼都是改變的,RSA有隨機(jī)因子的關(guān)系
# 所以我這里不需要在對(duì)密碼原文進(jìn)行第二次加密了,直接使用上次加密后的密碼即可,是沒有問題的。
############################################################
##############
post2_response = s.post('https:// passport.baidu.com/v2/api/login',data=data)
if post2_response.text.find('err_no=0')!=-1:
print '登錄成功'
else:
print '登錄失敗'
注意 以上百度登錄分析過程僅限于當(dāng)時(shí)的加密情況,如果之后換了登錄方式,以上代碼可能會(huì)失效,但是分析方法不變
如對(duì)本文有疑問,請(qǐng)?zhí)峤坏浇涣髡搲?,廣大熱心網(wǎng)友會(huì)為你解答??! 點(diǎn)擊進(jìn)入論壇