一、概要
目的:实现一个具有web微信类似功能的项目
框架:Django
模块:render、HttpResponse、BeautifulSoup、re、time、requests、json、random
特点:web微信和其他的不太一样,这里不需要账号和密码,只需要扫描网页提供的二维码即可
二、具体步骤
1、登录页面
既然是要实现web版的微信,那么我们就要知道web微信都干了些什么。打开一个网页,右键点击检查,在地址栏输入web微信(https://wx.qq.com/)回车,我们会看到一个等待扫描的二维码页面。我们先来看一下这个二维码是如何来的,我们会看到二维码的标签有个src="https://login.weixin.qq.com/qrcode/oc8PLqKx0w==", 因为每次请求的时候二维码都会变化,我们猜测这个src中最后一个'/'后面的值是变化的,我们再去Network中去找到这个返回值。检查后我们会发现一个请求名为jsloginappid=wx782c26e4c19acffb&redirect_uri=https%3A%2F%2Fwx.qq.com%2Fcgi-bin%2Fmmwebwxbin%2Fwebwxnewloginpage&fun=new&lang=zh_CN&_=1487297537475的response中有个uuid的值和我们需要的值类似。我们把这个求情的URL保存下来:https://login.wx.qq.com/jslogin?appid=wx782c26e4c19acffb&redirect_uri=https%3A%2F%2Fwx.qq.com%2Fcgi-bin%2Fmmwebwx-bin%2Fwebwxnewloginpage&fun=new&lang=zh_CN&_=1487297850694,这个请求的方式是"GET"。观察后发现这个URL里的大部分的参数都是状态值,只有一个'_'我们猜测是时间戳。现在我们就可以试试能不能获取到二维码。
代码:
1 <!DOCTYPE html>
2 <html lang="en">
3 <head>
4 <meta charset="UTF-8">
5 <title>Title</title>
6 </head>
7 <body>
8 <div style="width: 300px; margin: 0 auto">
9 <!--二维码路径-->
10 <img src="https://login.weixin.qq.com/qrcode/{{ code }}">
11 </div>
12 <!--注释掉的部分是稍后请求扫码状态的函数-->
13 <!--<script src="/static/jquery-3.1.1.js"></script>
14 <script>
15 $(function () {
16 polling();
17 });
18 function polling() {
19 $.ajax({
20 url: '/long_polling/',
21 type: 'GET',
22 dataType: 'json',
23 success: function (arg) {
24 if (arg.status == 408){
25 polling()
26 }else if (arg.status == 201){
27 console.log(123);
28 $('img').attr('src', arg.data);
29 polling()
30 }else {
31 location.href = '/index/'
32 }
33 }
34 })
35 }
36 </script>-->
37 </body>
38 </html>
login.html
1 from django.shortcuts import render
2
3 from django.shortcuts import HttpResponse
4
5 from bs4 import BeautifulSoup
6
7 import re
8
9 import time
10
11 import requests
12
13 import json
14
15 import random
16
17 CURRENT_TIME = None
18 QCODE = None
19 LOGIN_COOKIE_DICT = {}
20 TICKET_COOKIE_DICT = {}
21 TICKET_DICT = {}
22 TIPS = 1
23 BASE_URL = ''
24 BASE_SYNC_URL = ''
25 USER_ID = ''
26 USER_INFO = {}
27 USER_LIST_DIC = {}
28 # 这里用不到的全局变量后面会用到
29
30
31 def login(request):
32 # 登录页面,显示登录的二维码
33 base_qcode_url = 'https://login.wx.qq.com/jslogin?appid=wx782c26e4c19acffb&redirect_uri=https%3A%2F%2Fwx.qq.com%' \
34 '2Fcgi-bin%2Fmmwebwx-bin%2Fwebwxnewloginpage&fun=new&lang=zh_CN&_={0}'
35 global CURRENT_TIME
36 CURRENT_TIME = str(time.time())
37 q_code_url = base_qcode_url.format(CURRENT_TIME)
38 respons = requests.get(q_code_url)
39 # 二维码后缀
40 global QCODE
41 QCODE = re.findall('uuid = "(.*)";', respons.text)[0] # 拿括号里的内容的列表
42
43 return render(request, 'login.html', {'code': QCODE})
Views 获取二维码函数
这样我们可以看到一个二维码界面。接下来分析web微信做了什么:先给我们一个二维码,等待我们扫描,我们扫描后二维码会变成我们的头像,在手机端点击确认之后页面刷新,登录成功。
2、扫描并确认登录
猜测会有一个请求一直在发送。观察几分钟,会发现每隔25s左右会有一个请求发送,请求的地址为:https://login.wx.qq.com/cgi-bin/mmwebwx-bin/login?loginicon=true&uuid=******&tip=0&r=******&_=******。我们看下这个请求的response,然后测试扫描和确认登录后这个返回值会不会有变化。当没有扫描二维码的时候返回值是window.code=408,扫描二维码之后是window.code=201,确认登录后是window.code=200。这个url里loginicon和tip是状态值,uuid我们猜测是刚才的二维码uuid,'_'是的值是一个时间戳,那么还剩下r我们没有值,检查之后我们发现并没有类似的返回值,我们先它作为一个随机值看,请求方式"GET",在请求的时候,直接将我们看到的数复制。然后我们去测试一下。在登录的HTML中我们在加载好页面之后执行一个类似于web等待扫描的长轮循函数,到views函数中去发送这个请求。我们将第一步HTML代码中注释掉的部分恢复。并在views中添加登录的代码。这里需要注意,在扫描或登录之后需要改变tip值为1,避免重复请求。确认登录之后我们将cookies进行保存。之后的请求中需要用到。
代码:
1 def long_polling(request):
2 ret = {'status': 408, 'data': None}
3
4 try:
5 global TIPS
6 base_login_url = 'https://login.wx.qq.com/cgi-bin/mmwebwx-bin/login?loginicon=true&uuid={0}&tip={1}&' \
7 'r=-940286750&_={2}'
8
9 login_url = base_login_url.format(QCODE, TIPS, CURRENT_TIME)
10
11 response_login = requests.get(login_url)
12
13 if 'window.code=201' in response_login.text:
14 TIPS = 0
15 avatar = re.findall("userAvatar = '(.*)';", response_login.text)
16 ret['status'] = 201
17 ret['data'] = avatar
18 elif 'window.code=200' in response_login.text:
19 ret['status'] = 200
20 # 扫码点击确认后获取cookie
21 LOGIN_COOKIE_DICT.update(response_login.cookies.get_dict())
22 # 获取redirect的url
23 base_ticket_url = re.findall('redirect_uri="(.*)";', response_login.text)[0]
24 # 不同的微信号在初始话数据的时候有不同的地址,需要甄别
25 global BASE_URL
26 global BASE_SYNC_URL
27 if base_ticket_url.startswith('https://wx2.qq.com'):
28 BASE_URL = 'https://wx2.qq.com'
29 BASE_SYNC_URL = 'https://webpush.wx2.qq.com'
30 else:
31 BASE_URL = 'https://wx.qq.com'
32 BASE_SYNC_URL = 'https://webpush.wx.qq.com'
33 # 组成获取票据的url
34 ticket_url = base_ticket_url + '&fun=new&version=v2&lang=zh_CN'
35 # 获取票据同时获取cookies
36
37 response_ticket = requests.get(url=ticket_url, cookies=LOGIN_COOKIE_DICT)
38 TICKET_COOKIE_DICT.update(response_ticket.cookies.get_dict())
39 # 分析票据
40 soup = BeautifulSoup(response_ticket.text, 'html.parser')
41 for tag in soup.find():
42 TICKET_DICT[tag.name] = tag.string
43 except Exception as e:
44 print(e)
45 return HttpResponse(json.dumps(ret))
Views 扫描登录函数
我们在获取返回值的时候有一些在之后会用到,需要保存,并且web微信在确认登录后,会跳转页面,新页面会有两个,需要区别对待,如果这里不正确的话不能获取到信息。
登录成功后需要获取用户的基本信息,以及最近联系人列表。这是我们下一步要做的,初始化用户数据。
3、初始化用户数据
web微信在登录成功后会跳转一个页面,我们模仿这个方式,在确认登录之后,跳转URL,显示用户数据。我们再回到web微信检查Network看用户数据是哪个请求的response。可以找到一个webwxinit开头的请求,内部有初始化的数据。URL:https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxinit?r=****&pass_ticket=****,这个URL有的参数我们是没有的,那么就要看看在这个请求之前是否有其他请求返回这些数据。可以发现一个webwxnewloginpage开头的请求,它有一个票据的返回数据,正是我们需要的。拿到数据,获取票据的时候需要重新赋值一个cookies。后边会用到。获取票据的代码我们写在登录的那个函数中。使用BeautifulSoup重构数据。然后去请求用户数据初始化。然后将初始化的数据拿出展示在页面。初始化用户数据的时候用的是POST请求,数据这里需要通过这个请求去看需要发送什么样的数据,以及在headers里检查数据类型是什么类型。所以我们发送POST请求的时候,在数据这边,是以"json"为key的。数据中有一个设备ID,可以参考前几次请求的ID填写。另外几条都在webwxnewloginpage的response中。
代码:
1 <!DOCTYPE html>
2 <html lang="en">
3 <head>
4 <meta charset="UTF-8">
5 <title>Title</title>
6 </head>
7 <body>
8 <div>
9 <h1>个人信息</h1>
10 <a style="font-size: 20px; color: #1c5a9c">{{ info.User.NickName }}</a>
11 <a id="from_user_id">{{ info.User.UserName }}</a>
12 <p><input id="user_id" type="text" placeholder="请输入用户ID"></p>
13 <p><input id="msg_content" type="text" placeholder="请输入内容"></p>
14 <input id="send_msg" onclick="send_msg(this)" type="button" value="发送">
15 </div>
16 <div id="msg_box" style="height: 300px; width: 800px; border: solid 1px gray; overflow: auto">
17
18 </div>
19 <h1>最近联系人</h1>
20 {% for item in info.ContactList %}
21 <p>
22 <a style="font-size: 20px; color: #2F72AB">{{ item.NickName }}</a><a>{{ item.UserName }}</a>
23 <a>{{ item.Signature }}</a>
24 </p>
25 {% endfor %}
26 <div>
27 <div id="get_list" onclick="get_list()" style="cursor: pointer">获取全部好友</div>
28 <div class="empty"></div>
29 </div>
30 <h1>公众号</h1>
31 {% for item in info.MPSubscribeMsgList %}
32 <p>
33 <a style="font-size: 20px; color: #8a6d3b;">{{ item.NickName }}</a><a style="display: none">{{ item.UserName }}</a>
34 </p>
35 <p>
36 {% for i in item.MPArticleList %}
37 <div>
38 <a style="font-size: 18px">{{ i.Title }}</a>
39 <a href="{{ i.Url }}">{{ i.Digest }}</a>
40 </div>
41
42 {% endfor %}
43 </p>
44 {% endfor %}
45
46 <!--注释的部分是在获取好友列表以及发送和接收消息的时候用到的-->
47 <!--<script src="/static/jquery-3.1.1.js"></script>
48 <script>
49 <!--在页面加载好之后启动获取消息的函数-->
50 $(function () {
51 get_msg()
52 });
53 <!--获取好友列表函数-->
54 function get_list() {
55 $.ajax({
56 url: '/get_list',
57 type: 'GET',
58 dataType: 'json',
59 success: function (arg) {
60 var list = $("#get_list").siblings()[0];
61 if ($(list).hasClass('empty')){
62 var tag = '';
63 for (var i in arg.MemberList){
64 tag += "<div><a>" + arg.MemberList[i].NickName + "</a><a>[" + arg.MemberList[i].UserName + "]</a><a>[" + arg.MemberList[i].Province + arg.MemberList[i].City +"]</a></dib>";
65 }
66 $(list).append(tag);
67 $(list).removeClass();
68 }
69 }
70 })
71 }
72 <!--获取消息函数-->
73 function send_msg(self) {
74 var to_uid = $('#user_id').val();
75 var msg = $('#msg_content').val();
76 $.ajax({
77 url: '/send_msg',
78 type: 'GET',
79 dataType: 'json',
80 data: {'to_uid': to_uid, 'msg': msg},
81 success: function (arg) {
82 console.log(arg);
83 }
84 })
85 }
86 function get_msg() {
87 $.ajax({
88 url: '/get_msg',
89 type: 'GET',
90 dataType: 'json',
91 success: function (arg) {
92 if (arg.status){
93 var tag = "<div>" + arg.msg.user_id + "</div><div>" + arg.msg.msg_info + "</div>";
94 console.log(tag);
95 $('#msg_box').append(tag)
96 }
97 console.log(arg);
98 get_msg()
99 }
100 })
101 }
102 </script>-->
103 </body>
104 </html>
index.html
def index(request):
# 初始化用户数据
base_index_url = '{0}/cgi-bin/mmwebwx-bin/webwxinit?pass_ticket={1}&r={2}'
index_url = base_index_url.format(BASE_URL, TICKET_DICT['pass_ticket'], int(time.time()))
user_cookies = {}
user_cookies.update(LOGIN_COOKIE_DICT)
user_cookies.update(TICKET_COOKIE_DICT)
response_init = requests.post(url=index_url,
cookies=LOGIN_COOKIE_DICT,
json={
'BaseRequest': {
'DeviceID': "e199625221824018",
'Sid': TICKET_DICT['wxsid'],
'Skey': TICKET_DICT['skey'],
'Uin': TICKET_DICT['wxuin']
}
})
response_init.encoding = 'utf-8'
user_init_data = json.loads(response_init.text)
USER_INFO.update(user_init_data)
return render(request, 'index.html', {'info': user_init_data})
Views 用户数据初始化函数
这样可以获取近期联系过的好友、群、公众号,还有一些公众号的信息。下一步我们要获取全部的好友。需要发送另一个请求获取。
4、获取好友列表
我们接着去看登录成功的web微信请求,查找返回全部好友信息的那一条: webwxgetcontact, URL:https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxgetcontact?pass_ticket=****&r=1487313589641&seq=0&skey=****,这个请求是get请求,链接中passticket和skey可以在票据中拿到,r是时间戳,seq是状态。在发送这个请求的时候我们加上登录成功后的cookie和获取票据时的cookie就可以了。然后将请求到的数据渲染到页面上。
代码:
我们将index.html中的get_list函数恢复。
1 def get_list(request):
2 all_user_cookies = {}
3
4 base_get_list_url = '{0}/cgi-bin/mmwebwx-bin/webwxgetcontact?lang=zh_CN&pass_ticket={1}&r={2}&seq=0&skey={3}'
5
6 get_list_url = base_get_list_url.format(BASE_URL, TICKET_DICT['pass_ticket'], int(time.time()), TICKET_DICT['skey'])
7
8 all_user_cookies.update(LOGIN_COOKIE_DICT)
9
10 # all_user_cookies.update(TICKET_COOKIE_DICT)
11
12 response_list = requests.get(get_list_url, cookies=all_user_cookies)
13
14 # 我们在获取数据的时候使用response_list.text会默认编码,但是一般我们指定使用'utf-8'进行编码
15
16 response_list.encoding = 'utf-8'
17
18 list_info = response_list.text
19
20 return HttpResponse(list_info)
Views 获取好友列表函数
这样可以将我们想看到的数据显示到页面上。接下来应该选择一个好友,然后给他发送消息了。
5、发送微信消息
我们回到web微信,发送一个消息,然后看Network里有什么变化。我们会看到一个webwxsendmsg开头的请求,URL:https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxsendmsg?pass_ticket=****,同样,URL中的passticket去票据中取。这个请求是post请求,去查看数据。有三部分:第一部分是我们在获取好友列表的时候用过的,可以直接粘过来。第二部分需要我们去找。ClientMsgId和LocalID可以用时间戳,FromUserName、ToUserName、Content都可以在前端传过来,其中FromUserName也可以在之前初始化数据中找到,Type直接写1即可。我们只实现文字类型的传输。第三部分很简单,只有一个状态值。按照格式复制就可以了。
代码:
我们将index.html中的send_list函数恢复。
1 def send_msg(request):
2 base_send_url = '{0}/cgi-bin/mmwebwx-bin/webwxsendmsg?pass_ticket={1}'
3 send_url = base_send_url.format(BASE_URL, TICKET_DICT['pass_ticket'])
4 from_uid = USER_INFO['User']['UserName']
5 to_uid = request.GET.get('to_uid')
6 msg = request.GET.get('msg')
7
8 # current_time = str(int(time.time() * 1000)) + str(random.random())[:5].replace('.', '')
9
10 form_data = {
11 'BaseRequest': {
12 'DeviceID': "e199625221824018",
13 'Sid': TICKET_DICT['wxsid'],
14 'Skey': TICKET_DICT['skey'],
15 'Uin': TICKET_DICT['wxuin']
16 },
17 'Msg': {
18 'ClientMsgId': str(time.time()),
19 'Content': '%(content)s',
20 'FromUserName': from_uid,
21 'LocalID': str(time.time()),
22 'ToUserName': to_uid,
23 'Type': 1
24 },
25 'Scene': 0
26 }
27
28 all_cookies = {}
29
30 all_cookies.update(LOGIN_COOKIE_DICT)
31
32 all_cookies.update(TICKET_COOKIE_DICT)
33
34 form_data_str = json.dumps(form_data)
35
36 form_data_str = form_data_str % {'content': msg}
37
38 form_data_bytes = bytes(form_data_str, encoding='utf-8')
39
40 response_send = requests.post(
41 url=send_url,
42 data=form_data_bytes,
43 cookies=all_cookies,
44 headers={
45 'Content-Type': 'application/json',
46 }
47 )
48
49 return HttpResponse("ok")
Views 发送消息函数
需要注意的是,在发送消息的时候,我们要先将data进行json.dumps,之后再将发送消息的部分进行bytes转换。否则,汉字会变为ascii编码格式发出。是因为我们在json的时候,会将汉字转换为ascii编码格式,再发送前还会进行一次bytes类型转换。这样就把源数据改变了。也可以在dumps的时候加上一个ensure_ascii=False参数阻止转变成ascii编码格式。这样我们就剩最后一步没有做了。
6、接收微信消息
接收消息,其实就是服务器将别人发送的消息发送给我们,那么之前说过http是无状态的,说到这里,应该都已经想到了,我们还是要做一个长轮循来监听消息。在web界面登录成功后我们还会看到一个一直在发送的请求,去检查它。没错就是synccheck开头的那个。URL:https://webpush.wx.qq.com/cgi-bin/mmwebwx-bin/synccheck?r=1487320137207&skey=***&sid=****&uin=****&deviceid=****&synckey=****,请求方式:GET,对用get请求方式,URL后面的数据我们也可以通过在requests请求的的时候在参数中添加params传递。在这里r对应的是时间戳,skey、sid、uin都可以在票据中取到。deviceid使用我们之前使用过的就好。synckey稍微有一点麻烦,需要我们构造。其数据可以通过用户初始化数据取到。这个请求发送过去之后,会返回一个值,来告诉浏览器是否有消息发送过来。当收到有消息过来的时候我们就要发送另一个请求:webwxsync开头的那个,URL:https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxsync?sid=****&skey=****&pass_ticket=****,方式是post,URL中的三个参数都可以从票据中获取。post的数据有也都是我们用过的,只有一个"SyncKey",需要到用户初始数据中取,找到那个key就可以拿到。拿到数据之后使用"utf-8"进行编码,之后使用json.loads,将拿到的数据进行分析。首先看数据中的"StatusNotifyCode"是否为0,如果不是,那么数据不做处理,是因为,我们在手机客户端点进一个群的时候就会有数据返回,但是是历史消息,这个我们不要,当有即时消息发送过来的时候刚才的那个key对应的数据为0。然后将数据拿到返回到页面显示即可。
代码:
我们将index.html中的get_msg函数恢复。
def get_msg(request):
ret = {"status": False, "msg": ''}
# 构造synckey
synckey = []
for i in USER_INFO['SyncKey']['List']:
synckey.append(str(i['Key']) + '_' + str(i['Val']))
synckey_str = "|".join(synckey)
synckey_url = '%s/cgi-bin/mmwebwx-bin/synccheck' % BASE_SYNC_URL
current_time = str(time.time())
all_cookies = {}
all_cookies.update(LOGIN_COOKIE_DICT)
all_cookies.update(TICKET_COOKIE_DICT)
respons_synckey = requests.get(
url=synckey_url,
cookies=all_cookies,
params={
'r': current_time,
'skey': TICKET_DICT['skey'],
'sid': TICKET_DICT['wxsid'],
'uin': TICKET_DICT['wxuin'],
'deviceid': "e199625221824018",
'synckey': synckey_str
}
)
content = ""
if 'selector:"2"' in respons_synckey.text:
base_get_msg_url = '{0}/cgi-bin/mmwebwx-bin/webwxsync?sid={1}&skey={2}&pass_ticket={3}'
get_msg_url = base_get_msg_url.format(BASE_URL, TICKET_DICT['wxsid'], TICKET_DICT['skey'], TICKET_DICT['pass_ticket'])
form_data = {
'BaseRequest': {
'DeviceID': "e199625221824018",
'Sid': TICKET_DICT['wxsid'],
'Skey': TICKET_DICT['skey'],
'Uin': TICKET_DICT['wxuin']
},
'SyncKey': USER_INFO['SyncKey'],
'rr': current_time
}
respons_get_msg = requests.post(
url=get_msg_url,
json=form_data
)
respons_get_msg.encoding = 'utf-8'
res_fetch_msg_dict = json.loads(respons_get_msg.text)
USER_INFO['SyncKey'] = res_fetch_msg_dict['SyncKey'] # 有消息来到,需要更新SyncKey状态否则会一直是有消息的状态
print(res_fetch_msg_dict)
for item in res_fetch_msg_dict['AddMsgList']:
if item['StatusNotifyCode'] == 0:
print(item['Content'], ":::::", item['FromUserName'], "---->", item['ToUserName'], )
ret["status"] = True
ret['msg'] = {'user_id': item['FromUserName'], 'msg_info': item['Content']}
return HttpResponse(json.dumps(ret))
Views 获取消息函数
这里需要注意的是,在接收消息后,将用户初始化数据中的"SyncKey"更新为发送的消息中的"SyncKey",如果不更新的话,这条数据就会一直被取到。