模拟新浪微博登录:从原理分析到实现

上一篇文章小试牛刀:使用Python模拟登录知乎介绍了如何模拟知乎登录,虽然用到了验证码信息,但请求的参数都是原封不动的传递,刚开始接触的时候,觉得难度适中,回头再看的时候,反而感觉挺容易的。在这篇文章,将继续介绍模拟登录。与之前不一样的是,这次选择的对象是新浪微博,难度稍微提升了点,好在以往的许多码友们都留有许多经验贴,经过几番斟酌,微博的模拟登录算是实现了。这两天还在研究如何高性能地爬取微博数据,业余之际乘着还有点记忆,索性将先前的小实验加工成文,算是一份小结吧。下面来看看整个实验过程。

开发工具

一如既往,笔者使用的还是之前的工具,如下:

  • Windows 7 + Python 2.75
  • Chrome + Fiddler

微博登录请求过程分析

新浪微博的登录有多个URL链接,笔者在实验的时候试了两个,这两个都是新浪通行证登录页面,都是不需要验证码的。一个是 【http://login.sina.com.cn】,另一个是 【https://login.sina.com.cn/signup/signin.php?entry=sso】。两个URL虽然很大部分相同,登录过程中仅仅是传递参数不一样。第一个URL传递的过程对“password”进行了加密,而第二个没有加密,所以如果使用第二个URL进行模拟登录,就简单多了。在这里,笔者决定选择使用第一种方式进行分析,下面来看详细过程。

请求登录过程可归纳为三部分

  1. 请求登录login.php页面的参数预获取
  2. 请求登录login.php页面的参数分析
  3. 提交POST请求时的参数构造

Step 1:GET方式请求prelogin.php页面

在模拟登录之前,先观察浏览器登录过程中Fiddler抓到的包,在/sso/login.php打开之前会先使用“GET”方式请求“/sso/prelogin.php”,请求的URL为:【https://login.sina.com.cn/sso/prelogin.php?entry=account&callback=sinaSSOController.preloginCallBack&su=bGl1ZGl3ZWkxOCU0MHNpbmEuY29t&rsakt=mod&client=ssologin.js(v1.4.15)】,可以看看下面这张图:

在Fiddler中,可以点击“Preview”查看具体详情,也可以直接将Request URL复制到浏览器上查看,效果图如下:

可以看出,这是一个json数据,并且携带了几个参数,我们关心的有以下四个:

  • servertime
  • nonce
  • pubkey
  • rsakv

说明一下,之所以认为这几个参数比较重要,那是因为后面对“password”的加密需要用到,对其他参数没有提及的原因是在提交POST时其它的参数并没有用到。好了,为了进行进一步探索,我们从Fiddler的结果可以看出,接下来到了“/sso/login.php”。

Step 2:POST方式请求login.php页面

从这里开始,就进行“login.php”页面的请求分析了(详细的Request URL:【https://login.sina.com.cn/sso/login.php?client=ssologin.js(v1.4.15)】,后面的时间戳可省略)。点击查看详情,结果图如下:

可以发现/sso/login.php页面有如下参数(From Data):

cdult: 3
domain: sina.com.cn
encoding: UTF-8
entry: account
from:
gateway: 1
nonce: AFE3O9
pagerefer: http://login.sina.com.cn/sso/logout.php
prelt: 41
pwencode: rsa2
returntype: TEXT
rsakv: 1330428213
savestate: 30
servertime: 1478568922
service: sso
sp: password
sr: 1366*768
su: username
useticket: 0
vsnf: 1

到了这里,我们大概可以知道我们需要哪些参数了。在From Data 参数列表中,需要我们指定的参数有下面几个:

  • servertime
  • nonce
  • rsakv
  • sp:加密后的密码
  • su:加密后的用户名

对于参数“nonce”、“servertime”、“rsakv”,都可以从第一步中的“prelogin.php” 中直接获取,而“sp”和“su”则是经过加密后的字符串值,至于具体的加密规则,我们下面通过查看源码分析得出。

Step 3:探索加密规则

首先看看请求“/sso/prelogin.php”的具体情况,看到“client”为“ssologin.js”,见下图:

然后我们到登录页面https://login.sina.com.cn中查看源码【view-source:https://login.sina.com.cn/】并搜索“ssllogin.js”,接着点击进入ssologin.js文件,这时我们可在文件中搜索“username”字符串,找到与“username”相应的加密部分(需仔细查看+揣测),接着搜索“password”,找到“password”的加密部分,最后分析出“username”和“password”的加密规则。加密部分的代码如下图:

加密用户名的代码:

1
request.su = sinaSSOEncoder.base64.encode(urlencode(username));

加密密码的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if ((me.loginType & rsa) && me.servertime && sinaSSOEncoder && sinaSSOEncoder.RSAKey) {
request.servertime = me.servertime;
request.nonce = me.nonce;
request.pwencode = "rsa2";
request.rsakv = me.rsakv;
var RSAKey = new sinaSSOEncoder.RSAKey();
RSAKey.setPublic(me.rsaPubkey, "10001");
password = RSAKey.encrypt([me.servertime, me.nonce].join("\t") + "\n" + password)
} else {
if ((me.loginType & wsse) && me.servertime && sinaSSOEncoder && sinaSSOEncoder.hex_sha1) {
request.servertime = me.servertime;
request.nonce = me.nonce;
request.pwencode = "wsse";
password = sinaSSOEncoder.hex_sha1("" + sinaSSOEncoder.hex_sha1(sinaSSOEncoder.hex_sha1(password)) + me.servertime + me.nonce)
}
}

微博对于“username”的加密规则比较单一,使用的是“Base64”加密算法,而对“password”的加密规则比较复杂,虽然使用的是“RSA2”(python中需要使用pip install rsa 安装rsa模块),但加密的逻辑比较多。根据上面的代码,可以看出“password”加密是这样的一个过程:首先创建一个“rsa”公钥,公钥的两个参数都是固定值,第一个参数是登录过程中“prelogin.php”中的“pubkey”,第二个参数是加密的“js”文件中指定的“10001”(这两个值需要先从16进制转换成10进制,把“10001”转成十进制为“65537”)。最后再加入“servertime”和“nonce”进行进一步加密。

经过上面的分析之后,发起“POST”请求时的“post_data”基本上已经全部可以得到了,接下来就跟模拟登录其它网站类似了,可以使用“request”,也可以使用“urllib2”。下面来看详细代码部分。

源码实现

Github源码链接:https://github.com/csuldw/WSpider/tree/master/SinaLogin,源码包括下列文件:

  • dataEncode.py:用于对提交POST请求的数据进行编码处理
  • Logger.py:用于打印log
  • SinaSpider.py:用于爬取sina微博数据的文件(主文件)

为了方便扩展,笔者将代码进行了封装,所以看起来代码量比较多,不过个人觉得可读性还是比较良好,算是凑合吧。

1.dataEncode.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# -*- coding: utf-8 -*-
"""
Created on Tue Nov 08 10:14:38 2016

@author: liudiwei
"""
import base64
import rsa
import binascii
import requests
import json
import re

#使用base64对用户名进行编码
def encode_username(username):
return base64.encodestring(username)[:-1]

#使用rsa2对password进行编码
def encode_password(password, servertime, nonce, pubkey):
rsaPubkey = int(pubkey, 16)
RSAKey = rsa.PublicKey(rsaPubkey, 65537) #创建公钥
codeStr = str(servertime) + '\t' + str(nonce) + '\n' + str(password) #根据js拼接方式构造明文
pwd = rsa.encrypt(codeStr, RSAKey) #使用rsa进行加密
return binascii.b2a_hex(pwd) #将加密信息转换为16进制。

#读取preinfo.php,获取servertime, nonce, pubkey, rsakv四个参数值
def get_prelogin_info():
url = r'http://login.sina.com.cn/sso/prelogin.php?entry=weibo&callback=sinaSSOController.preloginCallBack&su=&rsakt=mod&client=ssologin.js(v1.4.18)'
html = requests.get(url).text
jsonStr = re.findall(r'\((\{.*?\})\)', html)[0]
data = json.loads(jsonStr)
servertime = data["servertime"]
nonce = data["nonce"]
pubkey = data["pubkey"]
rsakv = data["rsakv"]
return servertime, nonce, pubkey, rsakv

#根据Fiddler抓取的数据,构造post_data
def encode_post_data(username, password, servertime, nonce, pubkey, rsakv):
su = encode_username(username)
sp = encode_password(password, servertime, nonce, pubkey)
#用于登录到 http://login.sina.com.cn
post_data = {
"cdult" : "3",
"domain" : "sina.com.cn",
"encoding" : "UTF-8",
"entry" : "account",
"from" : "",
"gateway" : "1",
"nonce" : nonce,
"pagerefer" : "http://login.sina.com.cn/sso/logout.php",
"prelt" : "41",
"pwencode" : "rsa2",
"returntype" : "TEXT",
"rsakv" : rsakv,
"savestate" : "30",
"servertime" : servertime,
"service" : "sso",
"sp" : sp,
"sr" : "1366*768",
"su" : su,
"useticket" : "0",
"vsnf" : "1"
}
#用于登录到 http://login.sina.com.cn/signup/signin.php?entry=ss,将POST替换成下面的即可
"""
post_data = {
"cdult" : "3",
"domain" : "sina.com.cn",
"encoding" : "UTF-8",
"entry" : "sso",
"from" : "null",
"gateway" : "1",
"pagerefer" : "",
"prelt" : "0",
"returntype" : "TEXT",
"savestate" : "30",
"service" : "sso",
"sp" : password,
"sr" : "1366*768",
"su" : su,
"useticket" : "0",
"vsnf" : "1"
}
"""
return post_data

2.Logger.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# -*- coding: utf-8 -*-
"""
Created on Thu Nov 02 14:01:17 2016

@author: liudiwei
"""
import os
import logging

class LogClient(object):
def __init__(self):
self.logger = None

"""#EXAMPLE
logger = createLogger('mylogger', 'temp/logger.log')
logger.debug('logger debug message')
logger.info('logger info message')
logger.warning('logger warning message')
logger.error('logger error message')
logger.critical('logger critical message')
"""
def createLogger(self, logger_name, log_file):
prefix = os.path.dirname(log_file)
if not os.path.exists(prefix):
os.makedirs(prefix)
# 创建一个logger
logger = logging.getLogger(logger_name)
logger.setLevel(logging.INFO)
# 创建一个handler,用于写入日志文件
fh = logging.FileHandler(log_file)
# 再创建一个handler,用于输出到控制台
ch = logging.StreamHandler()
# 定义handler的输出格式formatter
formatter = logging.Formatter('%(asctime)s | %(name)s | %(levelname)s | %(message)s')
fh.setFormatter(formatter)
ch.setFormatter(formatter)
# 给logger添加handler
logger.addHandler(fh)
logger.addHandler(ch)
self.logger = logger
return self.logger

2.SinaSpider.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# -*- coding: utf-8 -*-
"""
Created on Tue Nov 08 10:14:38 2016

@author: liudiwei
"""
import os
import getpass
import json
import requests
import cookielib
import urllib
import urllib2
import gzip
import StringIO
import time

import dataEncode
from Logger import LogClient

class SinaClient(object):
def __init__(self, username=None, password=None):
#用户输入的用户名与密码
self.username = username
self.password = password
#从prelogin.php中获取的数据
self.servertime = None
self.nonce = None
self.pubkey = None
self.rsakv = None
#请求时提交的数据列表
self.post_data = None
self.headers = {}
#用于存储登录后的session
self.session = None
self.cookiejar = None
#用于输出log信息
self.logger = None
#存储登录状态,初始状态为False
self.state = False
#初始时调用initParams方法,初始化相关参数
self.initParams()

#初始化参数
def initParams(self):
self.logger = LogClient().createLogger('SinaClient', 'out/log_' + time.strftime("%Y%m%d", time.localtime()) + '.log')
self.headers = dataEncode.headers
return self

#设置username 和 password
def setAccount(self, username, password):
self.username = username
self.password = password
return self

#设置post_data
def setPostData(self):
self.servertime, self.nonce, self.pubkey, self.rsakv = dataEncode.get_prelogin_info()
self.post_data = dataEncode.encode_post_data(self.username, self.password, self.servertime, self.nonce, self.pubkey, self.rsakv)
return self

#使用requests库登录到 https://login.sina.com.cn
def login(self, username=None, password=None):
#根据用户名和密码给默认参数赋值,并初始化post_data
self.setAccount(username, password)
self.setPostData()
#登录时请求的url
login_url = r'https://login.sina.com.cn/sso/login.php?client=ssologin.js(v1.4.15)'
session = requests.Session()
response = session.post(login_url, data=self.post_data)
json_text = response.content.decode('gbk')
res_info = json.loads(json_text)
try:
if res_info["retcode"] == "0":
self.logger.info("Login success!")
self.state = True
#把cookies添加到headers中
cookies = session.cookies.get_dict()
cookies = [key + "=" + value for key, value in cookies.items()]
cookies = "; ".join(cookies)
session.headers["Cookie"] = cookies
else:
self.logger.error("Login Failed! | " + res_info["reason"])
except Exception, e:
self.logger.error("Loading error --> " + e)
self.session = session
return session

#生成Cookie,接下来的所有get和post请求都带上已经获取的cookie
def enableCookie(self, enableProxy=False):
self.cookiejar = cookielib.LWPCookieJar() # 建立COOKIE
cookie_support = urllib2.HTTPCookieProcessor(self.cookiejar)
if enableProxy:
proxy_support = urllib2.ProxyHandler({'http': 'http://122.96.59.107:843'}) # 使用代理
opener = urllib2.build_opener(proxy_support, cookie_support, urllib2.HTTPHandler)
self.logger.info("Proxy enable.")
else:
opener = urllib2.build_opener(cookie_support, urllib2.HTTPHandler)
urllib2.install_opener(opener)

#使用urllib2模拟登录过程
def login2(self, username=None, password=None):
self.logger.info("Start to login...")
#根据用户名和密码给默认参数赋值,并初始化post_data
self.setAccount(username, password)
self.setPostData()
self.enableCookie()
#登录时请求的url
login_url = r'https://login.sina.com.cn/sso/login.php?client=ssologin.js(v1.4.15)'
headers = self.headers
request = urllib2.Request(login_url, urllib.urlencode(self.post_data), headers)
resText = urllib2.urlopen(request).read()
try:
jsonText = json.loads(resText)
if jsonText["retcode"] == "0":
self.logger.info("Login success!")
self.state = True
#将cookie加入到headers中
cookies = ';'.join([cookie.name + "=" + cookie.value for cookie in self.cookiejar])
headers["Cookie"] = cookies
else:
self.logger.error("Login Failed --> " + jsonText["reason"])
except Exception, e:
print e
self.headers = headers
return self

#打开url时携带headers,此header需携带cookies
def openURL(self, url, data=None):
req = urllib2.Request(url, data=data, headers=self.headers)
text = urllib2.urlopen(req).read()
return self.unzip(text)

#功能:将文本内容输出至本地
def output(self, content, out_path, save_mode="w"):
self.logger.info("Download html page to local machine. | path: " + out_path)
prefix = os.path.dirname(out_path)
if not os.path.exists(prefix):
os.makedirs(prefix)
fw = open(out_path, save_mode)
fw.write(content)
fw.close()
return self

"""
防止读取出来的HTML乱码,测试样例如下
req = urllib2.Request(url, headers=headers)
text = urllib2.urlopen(req).read()
unzip(text)
"""
def unzip(self, data):
data = StringIO.StringIO(data)
gz = gzip.GzipFile(fileobj=data)
data = gz.read()
gz.close()
return data

#调用login1进行登录
def testLogin():
client = SinaClient()
username = raw_input("Please input username: ")
password = getpass.getpass("Please input your password: ")
session = client.login(username, password)

follow = session.post("http://weibo.cn/1669282904/follow").text.encode("utf-8")
client.output(follow, "out/follow.html")


#调用login2进行登录
def testLogin2():
client = SinaClient()
username = raw_input("Please input username: ")
password = getpass.getpass("Please input your password: ")
session = client.login2(username, password)

info = session.openURL("http://weibo.com/1669282904/info")
client.output(info, "out/info2.html")

if __name__ == '__main__':
testLogin2()

关于源码的分析,可以参考代码中的注解,如有不理解的地方,可在评论中提出。

运行

直接在Windows控制台运行python SinaSpider.py,然后根据提示输入用户名和密码即可。

运行结果展示

OK,匆忙之际赶出了本文,如有言之不合理之处,可在评论中指出。现在可以成功地登录到微博了,接下来想爬取什么数据就尽情的爬吧。后续笔者将进一步介绍如何爬取微博数据,好了,后会有期吧!

References