Web微信模拟
一、概要
目的:实现一个具有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>
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})
这样我们可以看到一个二维码界面。接下来分析web微信做了什么:先给我们一个二维码,等待我们扫描,我们扫描后二维码会变成我们的头像,在手机端点击确认之后页面刷新,登录成功。
2、扫描并确认登录
我们扫描的时候是手机端给微信服务器发送了一个确认的请求。然后微信服务器将这个状态返回到web。但我们知道HTTP是无状态的,那么服务器如何将状态发送给我们的,我们猜测会有一个请求一直在发送。观察几分钟,会发现每隔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))
我们在获取返回值的时候有一些在之后会用到,需要保存,并且web微信在确认登录后,会跳转页面,新页面会有两个,一个是:https://wx.qq.com/,另一个是:https://wx2.qq.com/。需要区别对待,如果这里不正确的话不能获取到信息。
登录成功后需要获取用户的基本信息,以及最近联系人列表。这是我们下一步要做的,初始化用户数据。
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>
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})
这样可以获取近期联系过的好友、群、公众号,还有一些公众号的信息。下一步我们要获取全部的好友。需要发送另一个请求获取。
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)
这样可以将我们想看到的数据显示到页面上。接下来应该选择一个好友,然后给他发送消息了。
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")
需要注意的是,在发送消息的时候,我们要先将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))
这里需要注意的是,在接收消息后,将用户初始化数据中的"SyncKey"更新为发送的消息中的"SyncKey",如果不更新的话,这条数据就会一直被取到。