最近因開發項目的需要,有一個需求,就是很多SNS網站都有的通過 Email地址 導入好友列表,不過這次要導入的不是Email 列表,而是QQ的好友列表。
實現方式:
通過google一搜,實現的方式大概有下面這篇文章提到的幾種方法:
http://www.cnblogs.com/hblhs/archive/2008/07/30/1256597.html
最后我選擇了通過模擬登錄QQ郵箱的方式來實現,該實現方式在海內網上的好友查找功能也可以看到。
QQ郵箱的官方登陸地址是http://mail.qq.com/
與其他大部分郵箱不同的是,如果使用純數字的QQ號登錄的話,除了密碼,還需要輸入驗證碼。
看到海內上的QQ好友導入功能也是需要輸入驗證碼的,而且驗證碼的樣子和QQ郵箱的很像。由于這是需要在用戶手動輸入密碼的情況下才能實現的功能,因此輸入驗證碼的工作也可以讓用戶手動來完成。
驗證碼處理:
通過對 http://mail.qq.com/ 頁面的分析, QQ郵箱的驗證碼方式實現原理其實是很簡單,當需要一張驗證碼圖片或看不清而需要換一張時,它都是向地址 http://ptlogin2.qq.com/getimage?aid=23000101 發出請求,(頁面上該地址是通過js生成的,為了防止瀏覽器緩存,地址末尾還會帶有隨機一個隨機數),而該鏈接不但返回一張圖片,還在http頭部帶有設置cookie的一段header。這樣當用戶提交表單的時候,瀏覽器就會把該cookie發送回服務器,服務器通過比較 該cookie值和經過某種運算后的表單中的驗證碼值 就可以判斷驗證碼是否填寫正確。
現在的問題是由于cookie的安全機制,驗證碼圖片不能直接從騰訊的服務器上去取,那樣用戶在將QQ和密碼發送到我們的服務器時,驗證碼的cookie不會一起發過來。
解決方式其實也很簡單,將驗證碼的獲取地址改為我們自己的服務器,我們的服務器作為簡單的代理,從騰訊的服務器上去獲取真正的驗證碼,再將圖片內容和那段cookie發送回用戶瀏覽器。那樣用戶提交表單的時候,那段cookie就又會發送回我們的服務器了。
繞過其他驗證安全機制:
一般上有了賬號,密碼,驗證碼這3樣東西就可以實現模擬登錄很多網站了,但是QQ郵箱還有其他的安全機制,在QQ郵箱登陸的表單中還有一個像這樣 <input type="hidden" name="ts" value="1234672721" /> 的 hidden 域,該value每次刷新頁面都會改變,同時在表單提交的時候,還會通過js將該值與其他hidden 域的值進行某些計算才正式提交表單。
通過多次模擬登錄,估計該值是用來判斷登錄session超時的,同時也參與其他的一些干擾加密的計算。而且該值與驗證碼是完全無關的,因此在顯示我們表單時,只要先去抓取一下 http://mail.qq.com/ 頁面,從里面提取出ts 值, 連同其他所有 hidden 域 和相關計算的js代碼放入我們的表單中就可以了。
因此,實際上我們的表單只需要稍微修改一下 http://mail.qq.com/ 頁面的內容就可以作為顯示給用戶的表單。主要包括以下幾個方面,這里我使用的django,所以使用django的模板語法:
1、<input type="hidden" name="ts" value="1234672721" /> 改為 <input type="hidden" name="ts" value="{{ts}}" />
2、表單的action地址改為我們自己這里假設為 /friends/ 因此
<form name="form1" method="post" action="http://m11.mail.qq.com/cgi-bin/login?sid=0,2,zh_CN" onSubmit="return checkInput();" >
改為
<form name="form1" method="post" action="/friends/" onSubmit="return checkInput();">
3、圖片驗證碼地址,有兩個地方要改:
document.write("<img id='vfcode' src='http://ptlogin2.qq.com/getimage?aid=23000101&",Math.random(),"' style='cursor:pointer;border:1px solid #e4eef9' onclick='changeimg()'>");
改為
document.write("<img id='vfcode' src='/qq-captcha/?aid=23000101&", Math.random(), "' style='cursor:pointer;border:1px solid #e4eef9' onclick='changeimg()'>");
另外一個changeimg 函數內, 也將相應的地址改為我們自己的服務器即可。
改了這些,頁面看上去和原來幾乎一樣,只是所有交互都改到了我們的服務器上,出于版權和頁面統一的需要,在使用到自己的網站上時,可以使用自己設計的頁面,只要表單的初始化和提交與原來一樣就可以了,甚至也可以通過閱讀js部分的源代碼,把ts部分的計算移到服務器端進行。
示例代碼:
以下是整個views.py的代碼,包括后面會講到模擬登錄部分,login和qq_captcha分別用來初始化登陸頁和獲取圖片驗證碼:
-
- from django.shortcuts import render_to_response
- from urllib2 import Request, urlopen, build_opener, HTTPCookieProcessor
- from urllib import urlencode
- from cookielib import CookieJar
- from django.http import HttpResponse
- import re
- from xml.sax.saxutils import unescape
- from BeautifulSoup import BeautifulSoup
- server_no = 'm11'
- login_error_re = re.compile('"errtype=(\d)"')
- login_succ_re = re.compile('"frame_html\?sid=(.+?)"')
- hacked_friendlist_page_re = re.compile(r'\<ul\s+class="grouplist"\>.+?\</ul\>', re.DOTALL)
- body_re = re.compile(r'\<body\sclass="tbody"\sid="list"\>.+?\</body\>', re.DOTALL)
-
- def login(request):
- url = 'http://mail.qq.com/'
- re_obj = re.compile(r'name="ts"\svalue="(\d+)"')
- match_obj = re_obj.search(urlopen(url).read())
- ts = match_obj.group(1)
- return render_to_response('login.html', locals())
-
- def qq_captcha(request):
- url = 'http://ptlogin2.qq.com/getimage?aid=%s' % request.GET['aid']
- f = urlopen(url)
- r = HttpResponse(f.read(), mimetype = f.info()['Content-Type'], )
- r['Pragma'] = 'no-cache'
- r.set_cookie('verifysession', f.info()['Set-Cookie'].split(';')[0].split('=')[1].strip())
- return r
-
- def qq_friends(request):
- for k in request.POST:
- print '%s : %s' % (k, request.POST[k])
- verifysession = request.COOKIES['verifysession']
- print verifysession
- headers = {'Cookie':'' % verifysession,
- 'Content-Type':'application/x-www-form-urlencoded',
- 'Referer':'http://mail.qq.com/',
- 'User-Agent':'Mozilla/5.0 (Windows; U; Windows NT 5.1; zh-CN; rv:1.9.0.1) Gecko/2008070208 Firefox/3.0.1',
- }
- data = urlencode(request.POST)
- login_request = Request('http://%s.mail.qq.com/cgi-bin/login?sid=0,2,zh_CN' % server_no, data, headers)
- result = urlopen(login_request)
- content = result.read()
- login_error = login_error_re.search(content)
- if login_error:
- error_no = login_error.group(1)
- if error_no == '1':
- error_msg = 'password or qq wrong'
- elif error_no == '2':
- error_msg = 'captcha wrong'
- return render_to_response('friends.html', locals())
- sid = login_succ_re.search(content).group(1)
-
- friends_list_headers = {'Referer':'http://mail.qq.com/',
- 'User-Agent':'Mozilla/5.0 (Windows; U; Windows NT 5.1; zh-CN; rv:1.9.0.1) Gecko/2008070208 Firefox/3.0.1',
- }
- friends_list_request = Request('http://%s.mail.qq.com/cgi-bin/addr_listall?sid=%s&sorttype=null&category=common' % (server_no, sid), headers = friends_list_headers)
- cj = CookieJar()
- cj.extract_cookies(result, friends_list_request)
- opener = build_opener(HTTPCookieProcessor(cj))
- result = opener.open(friends_list_request)
- grouplist = hacked_friendlist_page_re.search(result.read().decode('gb2312', 'ignore')).group(0)
- soup = BeautifulSoup(grouplist, fromEncoding = 'utf-8')
- grouplist = soup.findAll('li')
- friend_list = {}
- for group in grouplist:
- friend_list[group.a.string] = []
- list_request = Request('http://%s.mail.qq.com%s' % (server_no, group.a['href']), headers = friends_list_headers)
- result = opener.open(list_request)
- body = BeautifulSoup(body_re.search(result.read().decode('gb2312', 'ignore')).group(0), fromEncoding = 'utf-8')
- friends = body.findAll('div', attrs={'class':'M'})
- for friend in friends:
- friend_name = unescape(friend.p.span.contents[1].replace(' ', '', 1))
- friend_email = friend.p.img['addr']
- friend_list[group.a.string].append((friend_name, friend_email))
-
- return render_to_response('friends.html', locals())
# Create your views here.
from django.shortcuts import render_to_response
from urllib2 import Request, urlopen, build_opener, HTTPCookieProcessor
from urllib import urlencode
from cookielib import CookieJar
from django.http import HttpResponse
import re
from xml.sax.saxutils import unescape
from BeautifulSoup import BeautifulSoup
server_no = 'm11'
login_error_re = re.compile('"errtype=(\d)"')
login_succ_re = re.compile('"frame_html\?sid=(.+?)"')
hacked_friendlist_page_re = re.compile(r'\<ul\s+class="grouplist"\>.+?\</ul\>', re.DOTALL)
body_re = re.compile(r'\<body\sclass="tbody"\sid="list"\>.+?\</body\>', re.DOTALL)
def login(request):
url = 'http://mail.qq.com/'
re_obj = re.compile(r'name="ts"\svalue="(\d+)"')
match_obj = re_obj.search(urlopen(url).read())
ts = match_obj.group(1)
return render_to_response('login.html', locals())
def qq_captcha(request):
url = 'http://ptlogin2.qq.com/getimage?aid=%s' % request.GET['aid']
f = urlopen(url)
r = HttpResponse(f.read(), mimetype = f.info()['Content-Type'], )
r['Pragma'] = 'no-cache'
r.set_cookie('verifysession', f.info()['Set-Cookie'].split(';')[0].split('=')[1].strip())
return r
def qq_friends(request):
for k in request.POST:
print '%s : %s' % (k, request.POST[k])
verifysession = request.COOKIES['verifysession']
print verifysession
headers = {'Cookie':'''verifysession=%s''' % verifysession,
'Content-Type':'application/x-www-form-urlencoded',
'Referer':'http://mail.qq.com/',
'User-Agent':'Mozilla/5.0 (Windows; U; Windows NT 5.1; zh-CN; rv:1.9.0.1) Gecko/2008070208 Firefox/3.0.1',
}
data = urlencode(request.POST)
login_request = Request('http://%s.mail.qq.com/cgi-bin/login?sid=0,2,zh_CN' % server_no, data, headers)
result = urlopen(login_request)
content = result.read()
login_error = login_error_re.search(content)
if login_error:
error_no = login_error.group(1) #1:password wrong 2: captcha wrong
if error_no == '1':
error_msg = 'password or qq wrong'
elif error_no == '2':
error_msg = 'captcha wrong'
return render_to_response('friends.html', locals())
sid = login_succ_re.search(content).group(1)
friends_list_headers = {'Referer':'http://mail.qq.com/',
'User-Agent':'Mozilla/5.0 (Windows; U; Windows NT 5.1; zh-CN; rv:1.9.0.1) Gecko/2008070208 Firefox/3.0.1',
}
friends_list_request = Request('http://%s.mail.qq.com/cgi-bin/addr_listall?sid=%s&sorttype=null&category=common' % (server_no, sid), headers = friends_list_headers)
cj = CookieJar()
cj.extract_cookies(result, friends_list_request)
opener = build_opener(HTTPCookieProcessor(cj))
result = opener.open(friends_list_request)
grouplist = hacked_friendlist_page_re.search(result.read().decode('gb2312', 'ignore')).group(0)
soup = BeautifulSoup(grouplist, fromEncoding = 'utf-8')
grouplist = soup.findAll('li')
friend_list = {}
for group in grouplist:
friend_list[group.a.string] = []
list_request = Request('http://%s.mail.qq.com%s' % (server_no, group.a['href']), headers = friends_list_headers)
result = opener.open(list_request)
body = BeautifulSoup(body_re.search(result.read().decode('gb2312', 'ignore')).group(0), fromEncoding = 'utf-8')
friends = body.findAll('div', attrs={'class':'M'})
for friend in friends:
friend_name = unescape(friend.p.span.contents[1].replace(' ', '', 1))
friend_email = friend.p.img['addr']
friend_list[group.a.string].append((friend_name, friend_email))
return render_to_response('friends.html', locals())
模板 friends.html 的代碼:
- <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
- <html>
- <head>
- <title>好友列表</title>
- <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
- </head>
- <body>
- {%if error_msg%}
- {{error_msg}}
- {%endif%}
-
- {%for group, friends in friend_list.items %}
- <ul>
- {{group}}:
- {%for name, email in friends%}
- <li>{{name|safe}} {{email}}</li>
- {%endfor%}
- </ul>
- {%endfor%}
- </body>
- </html>
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
<title>好友列表</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
</head>
<body>
{%if error_msg%}
{{error_msg}}
{%endif%}
{%for group, friends in friend_list.items %}
<ul>
{{group}}:
{%for name, email in friends%}
<li>{{name|safe}} {{email}}</li>
{%endfor%}
</ul>
{%endfor%}
</body>
</html>
模擬登錄部分都在view qq_friends 里面進行,基本上就像前面說得,獲取圖片驗證碼的cookie,將表單提交的值原樣發送post到騰訊的服務器,如果都正確的話就可以登錄了。
發送的結果大概分為3種,驗證碼錯誤,賬號或密碼錯誤,登陸成功。不管在哪種情況下,騰訊的服務器都是返回一段js代碼,通過那段代碼重定向到錯誤頁或郵箱登陸后的首頁。
其中驗證碼錯誤像是下面這樣:
<script> var urlHead="http://m11.mail.qq.com/cgi-bin/"; var targetUrl=""; var mailto=""; targetUrl = urlHead + "loginpage?" +"errtype=2" +"&verify=true" +"&clientuin=12345678" +"&t=" +"&alias=" +"®alias=" +"&delegate_url=" +"&title=" +"&url=%2Fcgi-bin%2Flogin%3Fsid%3D0%2C2%2Czh_CN" +"&org_fun=" +"&aliastype=@qq.com" +"&ss=" +"&from=" +"&autologin=n" if (targetUrl == "") { targetUrl = ""; } document.write("<META http-equiv=Refresh content=\'0; url=\"" + targetUrl + "\"\'/>"); </script> <script type="text/javascript"> location.href=targetUrl </script>
賬號或密碼錯誤是這樣:
<script> var urlHead="http://m11.mail.qq.com/cgi-bin/"; var targetUrl=""; var mailto=""; targetUrl = urlHead + "loginpage?" +"errtype=1" +"&verify=false" +"&clientuin=172564012" +"&t=" +"&alias=" +"®alias=" +"&delegate_url=" +"&title=" +"&url=%2Fcgi-bin%2Flogin%3Fsid%3D0%2C2%2Czh_CN" +"&org_fun=" +"&aliastype=@qq.com" +"&ss=" +"&from=" +"&autologin=n" if (targetUrl == "") { targetUrl = ""; } document.write("<META http-equiv=Refresh content=\'0; url=\"" + targetUrl + "\"\'/>"); </script> <script type="text/javascript"> location.href=targetUrl </script>
登錄成功是這樣:
<script> var urlHead="http://m11.mail.qq.com/cgi-bin/"; var targetUrl=""; var mailto=""; targetUrl = urlHead + "frame_html?sid=UJh1e2XMWhOEbWcu"; if (targetUrl == "") { targetUrl = ""; } document.write("<META http-equiv=Refresh content=\'0; url=\"" + targetUrl + "\"\'/>"); </script> <script type="text/javascript"> location.href=targetUrl </script>
其中登錄成功后的反饋不但是上面的js代碼,還包括一大堆cookie,與郵箱服務器的后續交互都需要用到這些cookie。
另外上面js代碼中frame_html?sid=UJh1e2XMWhOEbWcu的sid部分需要多次用到在后面抓取好友列表頁面的url中,所以也需要提取出來。
登錄郵箱后,就可以抓取好友列表了,可以通過先訪問頁面
http://m11.mail.qq.com/cgi-bin/addr_listall?sid=UJh1e2XMWhOEbWcu&sorttype=null&category=common
該頁面的<ul class="grouplist"></ul>部分可以獲取好友分組名稱和相應的url地址。
然后在相應的訪問各個好友分組的頁面的就可以獲取好友列表了。
這里由于我自己的QQ號限制,不知道如果沒有好友沒有分組頁面會是什么情況,以及如果某個分組的好友很多的話,該好友列表頁面是否會有分頁(目前是沒有見到分頁)。有什么錯誤的話可以留言給我。
其他說明:
1、由于有些QQ用戶給自己取火星文昵稱,導致抓取的頁面上很容易出現字符編碼錯誤及HTML轉義問題。
2、不知道是故意為了防止抓取還是設計不規范,好多頁面用BeautifulSoup直接實例化,會導致大量信息的丟失,所以上面的代碼都是盡可能先用正則表達式提取必須部分的信息。
3、由于QQ郵箱服務器的代碼也在升級(事實上,我前幾天剛寫的代碼就剛失效了,馬上做了一些修改,昨天海內上的那個功能也出現了問題,看來他們也是通過QQ郵箱方式來實現的),所以并不能保證你在看到本文章時,上面的代碼依然使用有效。但是總體上的思路依然可以提供參考。