一個月前心血來潮用python實現了一個簡單的douban.fm客戶端,計劃是陸續將其完善成為Ubuntu下可替代web版本的douban.fm客戶端。但後來因為事多,被一直擱著,沒有再繼續完善。就在昨天,一位園友在評論中提到了登入的實現,雖然最近依然事多,但突然很想實現這個功能。正好,前幾天因為一些需要,曾用python實現過網站登入,約摸估計這douban.fm的登入不會差太多。
關於網站身份驗證
http協議被設計為無連線協議,但現實中,很多網站需要對使用者進行身份識別,cookie就是為此而誕生的。當我們用瀏覽器瀏覽網站時,瀏覽器會幫我們透明的處理cookie。而我們現在要第三方登入網站,這就必須對cookie的工作流程有一定的瞭解。
另外,很多網站為了防止程式自動登入而使用了驗證碼機制,驗證碼的介入會使登入過程變得麻煩,但也還不算太難處理。
實際中douban.fm的登入流程
為了模擬一個乾淨(不使用已有cookie)的登入流程,我使用chromium的隱身模式。
觀察請求和響應頭,可以看到,第一次請求的請求頭是沒有Cookie欄位的,而伺服器的響應頭中包含著Set-Cookie欄位,這告訴瀏覽器下次請求該網站時需要攜帶Cookie。
這裡我注意到了一個有意思的現象,訪問douban.fm,實際中經過了3次重定向。當然,一般來說我們並不需要關注這些細節,瀏覽器和高階的httplib會透明的處理重定向,但如果使用底層的C Socket,就必須小心的處理這些重定向。
點選登入按鈕,瀏覽器發起幾個新的請求,其中有幾個至關重要的請求,這幾個請求是我們第三方登入douban.fm的關鍵所在。
首先,有一條請求的URL是http://douban.fm/j/new_captcha,請求該URL,伺服器會返回一個隨機字串,這有什麼用呢?(其實是個驗證碼)
再看下一條請求,http://douban.fm/misc/captcha?size=m&id=0iPlm837LsnSsJTMJrf5TZ7e,這條請求會返回驗證碼。原來如此,請求http://douban.fm/j/new_captcha,將伺服器返回的字串作為下一條請求的id引數值。
我們可以寫一段python程式碼來驗證我們的想法。
值得注意的是python提供了3個http庫,httplib、urllib和urllib2,能透明處理cookie的是urllib2,想我之前用httplib手動處理cookie,那個痛苦啊。
程式碼如下:
opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(CookieJar())) captcha_id = opener.open(urllib2.Request('http://douban.fm/j/new_captcha')).read().strip('"') captcha = opener.open(urllib2.Request('http://douban.fm/misc/captcha?size=m&id=' + captcha_id)).read()) file = open('captcha.jpg', 'wb') file = write(captcha) file.close()
這段程式碼實現了驗證碼的下載。
接著,我們填寫表單,並提交。
可以看到,登入表單的目標地址為http://douban.fm/j/login,引數有:
source: radio
alias: 使用者名稱
form_password: 密碼
captcha_solution: 驗證碼
captcha_id: 驗證碼ID
task: sync_channel_list
接下來要做的是用python構造一個表單。
opener.open( urllib2.Request('http://douban.fm/j/login'), urllib.urlencode({ 'source': 'radio', 'alias': username, 'form_password': password, 'captcha_solution': captcha, 'captcha_id': captcha_id, 'task': 'sync_channel_list'}))
伺服器返回的資料格式是json,具體格式這裡不贅訴了,大家可以自己測試。
我們怎麼知道登入是否起作用了呢?是了,之前的文章提到過channel=-3為紅心兆赫,是使用者的收藏列表,沒有登入是獲取不到該頻道的播放列表的。請求http://douban.fm/j/mine/playlist?type=n&channel=-3,如果返回你自己收藏過的音樂列表,那麼就說明登入起作用了。
程式碼整理
結合之前的版本和新增的登入功能,再加上命令列引數處理、頻道選擇,一個稍稍完善的douban.fm就完成的。
View Code #!/usr/bin/python # coding: utf-8 import sys import os import subprocess import getopt import time import json import urllib import urllib2 import getpass import ConfigParser from cookielib import CookieJar # 儲存到檔案 def save(filename, content): file = open(filename, 'wb') file.write(content) file.close() # 獲取播放列表 def getPlayList(channel='0', opener=None): url = 'http://douban.fm/j/mine/playlist?type=n&channel=' + channel if opener == None: return json.loads(urllib.urlopen(url).read()) else: return json.loads(opener.open(urllib2.Request(url)).read()) # 傳送桌面通知 def notifySend(picture, title, content): subprocess.call([ 'notify-send', '-i', os.getcwd() + '/' + picture, title, content]) # 登入douban.fm def login(username, password): opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(CookieJar())) while True: print '正在獲取驗證碼……' captcha_id = opener.open(urllib2.Request( 'http://douban.fm/j/new_captcha')).read().strip('"') save( '驗證碼.jpg', opener.open(urllib2.Request( 'http://douban.fm/misc/captcha?size=m&id=' + captcha_id )).read()) captcha = raw_input('驗證碼: ') print '正在登入……' response = json.loads(opener.open( urllib2.Request('http://douban.fm/j/login'), urllib.urlencode({ 'source': 'radio', 'alias': username, 'form_password': password, 'captcha_solution': captcha, 'captcha_id': captcha_id, 'task': 'sync_channel_list'})).read()) if 'err_msg' in response.keys(): print response['err_msg'] else: print '登入成功' return opener # 播放douban.fm def play(channel='0', opener=None): while True: if opener == None: playlist = getPlayList(channel) else: playlist = getPlayList(channel, opener) if playlist['song'] == []: print '獲取播放列表失敗' break picture, for song in playlist['song']: picture = 'picture/' + song['picture'].split('/')[-1] # 下載專輯封面 save( picture, urllib.urlopen(song['picture']).read()) # 傳送桌面通知 notifySend( picture, song['title'], song['artist'] + '\n' + song['albumtitle']) # 播放 player = subprocess.Popen(['mplayer', song['url']]) time.sleep(song['length']) player.kill() def main(argv): # 預設引數 channel = '0' user = '' password = '' # 獲取、解析命令列引數 try: opts, args = getopt.getopt( argv, 'u:p:c:', ['user=', 'password=', 'channel=']) except getopt.GetoptError as error: print str(error) sys.exit(1) # 命令列引數處理 for opt, arg in opts: if opt in ('-u', '--user='): user = arg elif opt in ('-p', '--password='): password = arg elif opt in ('-c', '--channel='): channel = arg if user == '': play(channel) else: if password == '': password = getpass.getpass('密碼:') opener = login(user, password) play(channel, opener) if __name__ == '__main__': main(sys.argv[1:])
執行效果圖