├── .gitattributes ├── .gitignore ├── README.md ├── assets ├── flask-weixin-output.png ├── weixin-simulator-gui.png └── weixin-simulator-output.png ├── gui.py ├── requirements.txt └── screenshot.png /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | *.sln merge=union 7 | *.csproj merge=union 8 | *.vbproj merge=union 9 | *.fsproj merge=union 10 | *.dbproj merge=union 11 | 12 | # Standard to msysgit 13 | *.doc diff=astextplain 14 | *.DOC diff=astextplain 15 | *.docx diff=astextplain 16 | *.DOCX diff=astextplain 17 | *.dot diff=astextplain 18 | *.DOT diff=astextplain 19 | *.pdf diff=astextplain 20 | *.PDF diff=astextplain 21 | *.rtf diff=astextplain 22 | *.RTF diff=astextplain 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ################# 2 | ## Eclipse 3 | ################# 4 | 5 | *.pydevproject 6 | .project 7 | .metadata 8 | bin/ 9 | tmp/ 10 | *.tmp 11 | *.bak 12 | *.swp 13 | *~.nib 14 | local.properties 15 | .classpath 16 | .settings/ 17 | .loadpath 18 | 19 | # External tool builders 20 | .externalToolBuilders/ 21 | 22 | # Locally stored "Eclipse launch configurations" 23 | *.launch 24 | 25 | # CDT-specific 26 | .cproject 27 | 28 | # PDT-specific 29 | .buildpath 30 | 31 | 32 | ################# 33 | ## Visual Studio 34 | ################# 35 | 36 | ## Ignore Visual Studio temporary files, build results, and 37 | ## files generated by popular Visual Studio add-ons. 38 | 39 | # User-specific files 40 | *.suo 41 | *.user 42 | *.sln.docstates 43 | 44 | # Build results 45 | [Dd]ebug/ 46 | [Rr]elease/ 47 | *_i.c 48 | *_p.c 49 | *.ilk 50 | *.meta 51 | *.obj 52 | *.pch 53 | *.pdb 54 | *.pgc 55 | *.pgd 56 | *.rsp 57 | *.sbr 58 | *.tlb 59 | *.tli 60 | *.tlh 61 | *.tmp 62 | *.vspscc 63 | .builds 64 | *.dotCover 65 | 66 | ## TODO: If you have NuGet Package Restore enabled, uncomment this 67 | #packages/ 68 | 69 | # Visual C++ cache files 70 | ipch/ 71 | *.aps 72 | *.ncb 73 | *.opensdf 74 | *.sdf 75 | 76 | # Visual Studio profiler 77 | *.psess 78 | *.vsp 79 | 80 | # ReSharper is a .NET coding add-in 81 | _ReSharper* 82 | 83 | # Installshield output folder 84 | [Ee]xpress 85 | 86 | # DocProject is a documentation generator add-in 87 | DocProject/buildhelp/ 88 | DocProject/Help/*.HxT 89 | DocProject/Help/*.HxC 90 | DocProject/Help/*.hhc 91 | DocProject/Help/*.hhk 92 | DocProject/Help/*.hhp 93 | DocProject/Help/Html2 94 | DocProject/Help/html 95 | 96 | # Click-Once directory 97 | publish 98 | 99 | # Others 100 | [Bb]in 101 | [Oo]bj 102 | sql 103 | TestResults 104 | *.Cache 105 | ClientBin 106 | stylecop.* 107 | ~$* 108 | *.dbmdl 109 | Generated_Code #added for RIA/Silverlight projects 110 | 111 | # Backup & report files from converting an old project file to a newer 112 | # Visual Studio version. Backup files are not needed, because we have git ;-) 113 | _UpgradeReport_Files/ 114 | Backup*/ 115 | UpgradeLog*.XML 116 | 117 | 118 | 119 | ############ 120 | ## Windows 121 | ############ 122 | 123 | # Windows image file caches 124 | Thumbs.db 125 | 126 | # Folder config file 127 | Desktop.ini 128 | 129 | 130 | ############# 131 | ## Python 132 | ############# 133 | 134 | *.py[co] 135 | 136 | # Packages 137 | *.egg 138 | *.egg-info 139 | dist 140 | build 141 | eggs 142 | parts 143 | bin 144 | var 145 | sdist 146 | develop-eggs 147 | .installed.cfg 148 | 149 | # Installer logs 150 | pip-log.txt 151 | 152 | # Unit test / coverage reports 153 | .coverage 154 | .tox 155 | 156 | #Translations 157 | *.mo 158 | 159 | #Mr Developer 160 | .mr.developer.cfg 161 | 162 | # Mac crap 163 | .DS_Store 164 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # weixin-simulator 2 | 3 | 微信公众平台没有本地调试环境,在开发时遇到了很多麻烦。写测试固然是一种方式,但维护一大批测试样例挺费神的,所以希望能有什么东西模拟微信客户端在本地与应用进行交互。求谷歌不得,于是用`tkinter`自己写了个模拟器。 4 | 5 | 6 | ## 配置 7 | 请根据需要在主文件`gui.py`中修改settings字典,最重要的是把`url`修改为相应Handler的URL。 8 | ```python 9 | settings = { 10 | # `ToUserName` & `FromUserName` will be placed in the XML data posted to 11 | # the given URL. 12 | "ToUserName": "gh_bea8cf2a04fd", 13 | "FromUserName": "oLXjgjiWeAS1gfe4ECchYewwoyTc", 14 | 15 | # URL of your Wexin handler. 16 | "url": "http://localhost:8080/weixin", 17 | 18 | # These will be displayed in GUI. 19 | "mp_display_name": "APP", 20 | "me_display_name": "ME", 21 | 22 | # The token you submitted to Weixin MP. Used to generate signature. 23 | "token": "" 24 | } 25 | ``` 26 | 27 | ## 使用 28 | 完成设置后直接运行主文件`gui.py`即可。效果如下图: 29 | 30 | ![Simulator GUI Screenshot](screenshot.png) 31 | 32 | 点击`关注公众帐号`将模拟一位微信用户关注该公众帐号。 33 | 34 | 点击`取消关注`将模拟一位微信用户取消关注该公众帐号。 35 | 36 | 发送`c@EVENT_KEY`将模拟一位微信用户点击以`EVENT_KEY`为`EventKey`的`CLICK`类型自定义菜单选项。 37 | 38 | 发送`v@URL`将模拟一位微信用户点击以`URL`为`EventKey`的`VIEW`类型自定义菜单选项。 39 | 40 | ## 其他 41 | 功能很简单也很不完善,不过最基本的开发够用了。好在代码也很简单,请随意修改以满足自己的需求。欢迎发PR。 42 | 43 | 44 | ## Example: Interact with Flask-Weixin example 45 | 46 | [Flask-Weixin](https://github.com/lepture/flask-weixin) 47 | 48 | ![Simulator GUI](assets/weixin-simulator-gui.png) 49 | 50 | ![Simulator output](assets/weixin-simulator-output.png) 51 | 52 | ![Flask-Weixin output](assets/flask-weixin-output.png) 53 | -------------------------------------------------------------------------------- /assets/flask-weixin-output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ushuz/weixin-simulator/c396244a0eba51c7389673462eca619af1425fbf/assets/flask-weixin-output.png -------------------------------------------------------------------------------- /assets/weixin-simulator-gui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ushuz/weixin-simulator/c396244a0eba51c7389673462eca619af1425fbf/assets/weixin-simulator-gui.png -------------------------------------------------------------------------------- /assets/weixin-simulator-output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ushuz/weixin-simulator/c396244a0eba51c7389673462eca619af1425fbf/assets/weixin-simulator-output.png -------------------------------------------------------------------------------- /gui.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import time 4 | import random 5 | import hashlib 6 | import xml.etree.cElementTree as ET 7 | 8 | import tkinter as tk 9 | from tkinter import scrolledtext as st 10 | 11 | import requests 12 | 13 | 14 | settings = { 15 | # `ToUserName` & `FromUserName` will be placed in the XML data posted to 16 | # the given URL. 17 | "ToUserName": "gh_bea8cf2a04fd", 18 | "FromUserName": "oLXjgjiWeAS1gfe4ECchYewwoyTc", 19 | 20 | # URL of your Wexin handler. 21 | "url": "http://localhost:8080/weixin", 22 | # Path to client certificate 23 | # Put certificate and secret key into a single PEM file 24 | "cert": "", 25 | 26 | # These will be displayed in GUI. 27 | "mp_display_name": "APP", 28 | "me_display_name": "ME", 29 | 30 | # The token you submitted to Weixin MP. Used to generate signature. 31 | "token": "B0e8alq5ZmMjcnG5gwwLRPW2", 32 | } 33 | 34 | 35 | TPL_TEXT = ''' 36 | 37 | 38 | 39 | %(time)d 40 | 41 | 42 | %(id)s 43 | 44 | ''' 45 | 46 | TPL_EVENT = ''' 47 | 48 | 49 | 50 | %(time)d 51 | 52 | 53 | 54 | ''' 55 | 56 | 57 | def post(qs, data): 58 | headers = {'Content-Type': 'text/xml'} 59 | r = requests.post(settings["url"] + qs, data=data.encode(), cert=settings["cert"], verify=False, headers=headers) 60 | return r.content 61 | 62 | 63 | def send(): 64 | s = e.get() 65 | 66 | if not s: 67 | return 68 | 69 | # Simulation for clicking the menu. 70 | # Usage: EVENT_TYPE@EVENT_KEY 71 | # 'c@KEY_FIND' - 'CLICK' event with 'KEY_FIND' as event key 72 | # 'v@www.qq.com' - 'VIEW' event with 'www.qq.com' as event key 73 | if s.startswith("c@") or s.startswith("v@"): 74 | event, key = s.split("@") 75 | msg = { 76 | "to": settings["ToUserName"], 77 | "from": settings["FromUserName"], 78 | "time": time.time(), 79 | "event": "CLICK" if event == "c" else "VIEW", 80 | "key": key 81 | } 82 | qs = "?signature=%s×tamp=%s&nonce=%s" % \ 83 | mix(int(msg["time"])) 84 | receive(msg["time"], post(qs, TPL_EVENT % msg)) 85 | # Simulation for sending a message. 86 | else: 87 | t.insert(tk.END, settings["me_display_name"]+"\n", "send_name") 88 | t.insert(tk.END, s+"\n", "send_content") 89 | 90 | msg = { 91 | "to": settings["ToUserName"], 92 | "from": settings["FromUserName"], 93 | "time": time.time(), 94 | "content": s, 95 | "id": str(random.random())[-10:] 96 | } 97 | 98 | qs = "?signature=%s×tamp=%s&nonce=%s" % \ 99 | mix(int(msg["time"])) 100 | receive(msg["time"], post(qs, TPL_TEXT % msg)) 101 | 102 | 103 | def receive(start, response): 104 | if time.time() - start > 4.95: 105 | return 106 | 107 | if not response: 108 | print("No response.") 109 | return 110 | 111 | print("Received:\n%s\n" % response.decode()) 112 | try: 113 | et = ET.fromstring(response) 114 | except ET.ParseError: 115 | print("Bad XML.") 116 | return 117 | 118 | to = et.find("ToUserName").text 119 | fr = et.find("FromUserName").text 120 | 121 | type = et.find("MsgType").text 122 | if type == "text": 123 | c = et.find("Content").text 124 | elif type == "news": 125 | l = ["[news]"] 126 | for i in et.find("Articles").findall("item"): 127 | l.append("Title={0}".format(i.find("Title").text)) 128 | l.append("Description={0}".format(i.find("Description").text)) 129 | l.append("PicUrl={0}".format(i.find("PicUrl").text)) 130 | l.append("Url={0}".format(i.find("Url").text)) 131 | l.append("---") 132 | c = "\n".join(l) 133 | elif type == "image": 134 | c = "[image]\nMediaId={0}\n---".format(et.find("Image").find("MediaId").text) 135 | else: 136 | print("Unknown response.") 137 | return 138 | 139 | t.insert(tk.END, settings["mp_display_name"]+"\n", "receive_name") 140 | t.insert(tk.END, c+"\n", "receive_content") 141 | t.yview_moveto(1.0) 142 | 143 | 144 | def mix(time): 145 | timestamp = str(time) 146 | 147 | # I don't know how Weixin generate the 9-digit nonce, so I turn to random. 148 | nonce = str(int(random.random()))[-9:] 149 | 150 | l = [timestamp, nonce, settings["token"]] 151 | l.sort() 152 | signature = hashlib.sha1("".join(l).encode()).hexdigest() 153 | 154 | return signature, timestamp, nonce 155 | 156 | 157 | def follow(): 158 | msg = { 159 | "to": settings["ToUserName"], 160 | "from": settings["FromUserName"], 161 | "time": time.time(), 162 | "event": "subscribe", 163 | "key": "" 164 | } 165 | qs = "?signature=%s×tamp=%s&nonce=%s" % \ 166 | mix(int(msg["time"])) 167 | receive(msg["time"], post(qs, TPL_EVENT % msg)) 168 | 169 | 170 | def unfollow(): 171 | msg = { 172 | "to": settings["ToUserName"], 173 | "from": settings["FromUserName"], 174 | "time": time.time(), 175 | "event": "unsubscribe", 176 | "key": "" # `EventKey` in `unsubscribe` event is empty. 177 | } 178 | qs = "?signature=%s×tamp=%s&nonce=%s" % \ 179 | mix(int(msg["time"])) 180 | receive(msg["time"], post(qs, TPL_EVENT % msg)) 181 | 182 | 183 | top = tk.Tk() 184 | top.title("微信模拟器") 185 | 186 | t = st.ScrolledText(top, width=45, height=30) 187 | t.pack() 188 | 189 | t.tag_add("send_name", "1.0", "1.end") 190 | t.tag_config("send_name", font=("Arial", "10", "bold"), spacing3=5, rmargin=6, 191 | justify=tk.RIGHT) 192 | t.tag_add("send_content", "2.0", "2.end") 193 | t.tag_config("send_content", spacing3=2, justify=tk.RIGHT, rmargin=6) 194 | 195 | t.tag_add("receive_name", "1.0", "1.end") 196 | t.tag_config("receive_name", font=("Arial", "10", "bold"), spacing3=5, lmargin1=2) 197 | t.tag_add("receive_content", "2.0", "2.end") 198 | t.tag_config("receive_content", lmargin1=2) 199 | 200 | e = tk.Entry(top) 201 | e.pack(side=tk.LEFT) 202 | 203 | b = tk.Button(top, text="发送", command=send) 204 | b.pack(side=tk.LEFT) 205 | 206 | a = tk.Button(top, text="关注公众帐号", command=follow) 207 | a.pack(side=tk.RIGHT) 208 | 209 | a = tk.Button(top, text="取消关注", command=unfollow) 210 | a.pack(side=tk.RIGHT) 211 | 212 | if __name__ == "__main__": 213 | top.mainloop() 214 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ushuz/weixin-simulator/c396244a0eba51c7389673462eca619af1425fbf/screenshot.png --------------------------------------------------------------------------------