├── .gitignore ├── LICENSE ├── MYLOG.md ├── README.md ├── setup.py └── vrecord ├── __init__.py ├── main.py └── recorder.py /.gitignore: -------------------------------------------------------------------------------- 1 | vrecord.egg-info/ 2 | __pycache__/ 3 | dist/ 4 | build/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 cilame 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MYLOG.md: -------------------------------------------------------------------------------- 1 | #20190520 2 | # 考虑使用以项目结构的文件夹生成方式去生成工具文件,因为需要考虑到图片资源的存储。 3 | # 因为在判断中需要考虑对画面中的信息进行对比选择,所以需要图片, 4 | # 相较于抓像素的方式,目前的方式可能会更好一点。 5 | 6 | #20190521 7 | # 考虑先模仿着其他类型的工具的使用方式。录制的处理可能也需要考虑一下? 8 | 9 | #20190630 10 | # 抛弃之前自己实现底层 windows 的各种处理问题,考虑直接使用 pynput 作为底层使用 11 | # 并且考察了一下,pynput 没有其他依赖,这样在安装方面也不会遇到各种奇怪问题。 12 | # 简直快要爱上了 pynput 的作者。 13 | 14 | #20200106 15 | # 后续可能有计划将处理图片分类,图片定位,定位检测之类的功能集成进来 16 | # 不过现在比较懒,暂时还只是想,想着,至少那样工具会稍微好用一点点 17 | # 或许之后有点类似按键精灵?再看吧。 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ##### 一个简单易用的键盘鼠标操作的录制,执行,代码生成的工具,开发于py3,依赖于pynput 3 | 4 | ```bash 5 | # 安装方式,通过 pip 安装或通过 pip+git 安装 6 | C:\Users\zhoulin08>pip install vrecord 7 | C:\Users\zhoulin08>pip install git+https://github.com/cilame/vrecord.git 8 | ``` 9 | 10 | - ##### 打开方式 11 | 12 | ```bash 13 | # 在安装该函数库后就有一个命令行工具,直接在命令行输入 vvv 即可打开该GUI界面 14 | # eg. 15 | C:\Users\Administrator>vvv 16 | # 录制键鼠操作的便捷之处不用多说。工具一用便知。 17 | ``` 18 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | import vrecord 3 | 4 | setup( 5 | name = "vrecord", 6 | version = vrecord.__version__, 7 | keywords = "vrecord", 8 | author = "cilame", 9 | author_email = "opaquisrm@hotmail.com", 10 | url="https://github.com/cilame/vrecord", 11 | license = "MIT", 12 | description = "", 13 | classifiers = [ 14 | 'License :: OSI Approved :: MIT License', 15 | 'Programming Language :: Python', 16 | 'Intended Audience :: Developers', 17 | ], 18 | packages = [ 19 | "vrecord", 20 | ], 21 | python_requires=">=3.6", 22 | install_requires=[ 23 | 'pynput', 24 | ], 25 | entry_points={ 26 | 'gui_scripts': [ 27 | 'vvv = vrecord.recorder:execute', 28 | ] 29 | }, 30 | ) -------------------------------------------------------------------------------- /vrecord/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'cilame' 2 | __version__ = '1.0.4' 3 | __email__ = 'opaquism@hotmail.com' 4 | __github__ = 'https://github.com/cilame/vrecord' -------------------------------------------------------------------------------- /vrecord/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import shutil 4 | import traceback 5 | 6 | import tkinter 7 | import tkinter.ttk as ttk 8 | import tkinter.messagebox 9 | from tkinter import scrolledtext 10 | from tkinter.simpledialog import askstring 11 | from tkinter.font import Font 12 | 13 | def get_homepath(_dir=None): 14 | home = os.environ.get('HOME') 15 | home = home if home else os.environ.get('HOMEDRIVE') + os.environ.get('HOMEPATH') 16 | return home if _dir is None else os.path.join(home, _dir) 17 | 18 | Frame = tkinter.Frame 19 | Treeview = ttk.Treeview 20 | Label = ttk.Label 21 | Button = ttk.Button 22 | Text = scrolledtext.ScrolledText 23 | Combobox = ttk.Combobox 24 | Entry = ttk.Entry 25 | Checkbutton = tkinter.Checkbutton 26 | 27 | root = tkinter.Tk() 28 | font = Font(family='Consolas',size=10) 29 | 30 | def load_file_tree( 31 | local_dir, 32 | tree, 33 | local_node = None, 34 | ignore = ["__pycache__","__init__.py"], 35 | exignore = [], 36 | mapdir_name = {}, 37 | sorter = {} 38 | ): 39 | # 不能直接 ignore.extend(exignore), 解释起来太麻烦了,不解释了。 40 | for i in exignore: 41 | if i not in ignore: 42 | ignore.append(i) 43 | next_nodes = [] 44 | for idx,i in enumerate(os.listdir(local_dir)): 45 | if i in ignore: continue 46 | abs_path = '/'.join([local_dir,i]).replace('\\','/') 47 | next_node = local_node if local_node else "" 48 | if os.path.isdir(abs_path): 49 | name = mapdir_name.get(i) if mapdir_name.get(i) is not None else i 50 | idx = sorter.get(i) if i in sorter else 100000 51 | node = tree.insert(next_node,idx,text=name,values=abs_path) 52 | next_nodes.append((abs_path,node)) 53 | elif os.path.isfile(abs_path): 54 | tree.insert(next_node,idx,text=i,values=abs_path) 55 | for abs_path, node in next_nodes: 56 | load_file_tree( 57 | abs_path, tree, node, 58 | ignore = ignore, 59 | mapdir_name = mapdir_name, 60 | sorter = sorter 61 | ) 62 | 63 | def tree_on_select(tree): 64 | items = tree.selection() 65 | if len(items) != 1: return 66 | filepath = ''.join(tree.item(items[0],"values")) 67 | if os.path.isfile(filepath): 68 | # 由于 notebooks 与内部的代码 Text 组件的关联还未做好 69 | # 所以这里的处理将暂缓。等到关联做好就考虑使用 70 | print(PROJECTCURR) 71 | print(notebooks) 72 | nb = notebooks.get(PROJECTCURR)['nb'] 73 | print(nb) 74 | pass 75 | elif os.path.isdir(filepath): 76 | for item in tree.get_children(): 77 | cfilepath = ''.join(tree.item(item,"values")) 78 | if filepath == cfilepath: 79 | projectname = tree.item(item, 'text') 80 | change_project(projectname) 81 | 82 | class SimpleDialog: 83 | def __init__(self, master, 84 | text='', buttons=[], default=None, cancel=None, 85 | title=None, class_=None): 86 | self.root = tkinter.Toplevel(master, class_=class_) if class_ else tkinter.Toplevel(master) 87 | if title: 88 | self.root.title(title) 89 | self.root.iconname(title) 90 | self.message = tkinter.Message(self.root, text=text, aspect=400) 91 | self.message.pack(expand=1, fill='both') 92 | self.frame = Frame(self.root) 93 | self.frame.pack(fill='both') 94 | self.num = default 95 | self.cancel = cancel 96 | self.default = default 97 | self.root.bind('', self.return_event) 98 | for num in range(len(buttons)): 99 | s = buttons[num] 100 | b = Button(self.frame, text=s, 101 | command=(lambda self=self, num=num: self.done(num))) 102 | b.pack(side='top', fill='both') 103 | self.root.protocol('WM_DELETE_WINDOW', self.wm_delete_window) 104 | self._set_transient(master) 105 | def _set_transient(self, master, relx=0.5, rely=0.3): 106 | widget = self.root 107 | widget.withdraw() # Remain invisible while we figure out the geometry 108 | widget.transient(master) 109 | widget.update_idletasks() # Actualize geometry information 110 | if master.winfo_ismapped(): 111 | m_width = master.winfo_width() 112 | m_height = master.winfo_height() 113 | m_x = master.winfo_rootx() 114 | m_y = master.winfo_rooty() 115 | else: 116 | m_width = master.winfo_screenwidth() 117 | m_height = master.winfo_screenheight() 118 | m_x = m_y = 0 119 | w_width = widget.winfo_reqwidth() 120 | w_height = widget.winfo_reqheight() 121 | x = m_x + (m_width - w_width) * relx 122 | y = m_y + (m_height - w_height) * rely 123 | if x+w_width > master.winfo_screenwidth(): 124 | x = master.winfo_screenwidth() - w_width 125 | elif x < 0: 126 | x = 0 127 | if y+w_height > master.winfo_screenheight(): 128 | y = master.winfo_screenheight() - w_height 129 | elif y < 0: 130 | y = 0 131 | widget.geometry("+%d+%d" % (x, y)) 132 | widget.deiconify() # Become visible at the desired location 133 | def go(self): 134 | self.root.wait_visibility() 135 | self.root.grab_set() 136 | self.root.mainloop() 137 | self.root.destroy() 138 | return self.num 139 | def return_event(self, event): 140 | if self.default is None: 141 | self.root.bell() 142 | else: 143 | self.done(self.default) 144 | def wm_delete_window(self): 145 | if self.cancel is None: 146 | self.root.bell() 147 | else: 148 | self.done(self.cancel) 149 | def done(self, num): 150 | self.num = num 151 | self.root.quit() 152 | 153 | PROJECT = '.vrecord' 154 | PROJECTDEFAULTNAME = '默认项目' 155 | PROJECTHOME = get_homepath(PROJECT) 156 | PROJECTDEFAULT = os.path.join(PROJECTHOME, PROJECTDEFAULTNAME) 157 | PROJECTSTRUCT = ['操作', '识别', '启动', '合并'] 158 | PROJECTCURR = PROJECTDEFAULTNAME 159 | PROJECTDEFAULTCONFIG = 'c4ca4238a0b923820dcc509a6f75849b' 160 | PROJECTCONFIGNAME = 'vconfig.cfg' 161 | PROJECTCONFIGFILE = os.path.join(PROJECTHOME, PROJECTCONFIGNAME) 162 | 163 | def init_project(): 164 | if not os.path.isdir(PROJECTHOME): 165 | os.mkdir(PROJECTHOME) 166 | if not os.path.isdir(PROJECTDEFAULT): 167 | for i in PROJECTSTRUCT: 168 | d = os.path.join(PROJECTDEFAULT, i) 169 | if not os.path.isdir(d): 170 | os.makedirs(d) 171 | change_project(PROJECTDEFAULTNAME) 172 | reload_file_tree() 173 | 174 | def clear_tree(*a): 175 | for item in tree.get_children(): 176 | tree.delete(item) 177 | 178 | def reload_file_tree(): 179 | clear_tree() 180 | exignore = [PROJECTCONFIGNAME] 181 | load_file_tree(PROJECTHOME, tree, exignore=exignore, sorter={'默认项目':0}) 182 | 183 | def change_project(projectname): 184 | global PROJECTCURR 185 | oprojectname = PROJECTCURR 186 | PROJECTCURR = projectname 187 | rlab1['text'] = '当前项目[{}]'.format(projectname) 188 | if oprojectname not in notebooks: 189 | fr = Frame(rightfr) 190 | nb = ttk.Notebook(fr) 191 | fr.pack(expand=True,fill="both") 192 | nb.pack(expand=True,fill="both") 193 | notebooks[oprojectname] = {'fr': fr, 'nb': nb, 'init':False} 194 | notebooks[oprojectname]['fr'].forget() 195 | if projectname not in notebooks: 196 | fr = Frame(rightfr) 197 | nb = ttk.Notebook(fr) 198 | fr.pack(expand=True,fill="both") 199 | nb.pack(expand=True,fill="both") 200 | notebooks[projectname] = {'fr': fr, 'nb': nb, 'init':False} 201 | else: 202 | notebooks[projectname]['fr'].pack(expand=True,fill="both") 203 | if notebooks[projectname]['init'] == False: 204 | notebooks[projectname]['init'] = True 205 | _config = CONFIG.get(PROJECTDEFAULTNAME) 206 | for x,y in _config.items(): 207 | d = y.copy() 208 | make_tab(nb, d.pop('name'), **d) 209 | CONFIG[projectname] = _config.copy() 210 | 211 | 212 | def create_project(*a): 213 | projectname = askstring('项目名称','请输入项目名称,尽量不要使用特殊字符。') 214 | if not projectname: return 215 | try: 216 | for i in PROJECTSTRUCT: 217 | d = os.path.join(PROJECTHOME, projectname, i) 218 | if not os.path.isdir(d): 219 | os.makedirs(d) 220 | change_project(projectname) 221 | reload_file_tree() 222 | except: 223 | einfo = '创建项目失败.' 224 | tkinter.messagebox.showinfo(einfo,traceback.format_exc()) 225 | 226 | def change_or_choice_project(*a): 227 | v = os.listdir(PROJECTHOME) 228 | if PROJECTCONFIGNAME in v: v.remove(PROJECTCONFIGNAME) 229 | if PROJECTDEFAULTNAME in v:v.remove(PROJECTDEFAULTNAME) 230 | v = [PROJECTDEFAULTNAME] + v 231 | g = SimpleDialog(root, buttons=v, default=0, cancel=-1) 232 | g = g.go() 233 | if g != -1: 234 | projectname = v[g] 235 | change_project(projectname) 236 | 237 | def delete_project(*a): 238 | v = os.listdir(PROJECTHOME) 239 | if PROJECTCONFIGNAME in v: v.remove(PROJECTCONFIGNAME) 240 | if PROJECTDEFAULTNAME in v:v.remove(PROJECTDEFAULTNAME) 241 | if len(v) == 0: 242 | einfo = '无法删除默认项目.' 243 | tkinter.messagebox.showinfo(einfo, einfo) 244 | return 245 | g = SimpleDialog(root, buttons=v, default=0, cancel=-1) 246 | g = g.go() 247 | if g != -1: 248 | projectname = v[g] 249 | d = os.path.join(PROJECTHOME, projectname) 250 | shutil.rmtree(d) 251 | change_project(PROJECTDEFAULTNAME) 252 | reload_file_tree() 253 | try: CONFIG.pop(projectname) 254 | except: pass 255 | 256 | def save_project(*a): 257 | with open(PROJECTCONFIGFILE, 'w', encoding='utf-8') as f: 258 | f.write(json.dumps(CONFIG, indent=4)) 259 | 260 | fr = Frame(root) 261 | topfr = Frame(fr) 262 | leftfr = Frame(fr) 263 | rightfr = Frame(fr) 264 | fr.pack(expand=True,fill='both') 265 | topfr .pack(side='top',fill='x') 266 | leftfr .pack(side='left',expand=True,fill='both') 267 | rightfr.pack(side='left',expand=True,fill='both') 268 | rlab1 = Label(topfr,text='当前项目[{}]'.format(PROJECTDEFAULTNAME)) 269 | rlab1.pack(side='left') 270 | tbtn1 = Button(topfr,text='创建',width=4, command=create_project) 271 | tbtn1.pack(side='right') 272 | tbtn2 = Button(topfr,text='删除',width=4, command=delete_project) 273 | tbtn2.pack(side='right') 274 | tbtn3 = Button(topfr,text='选择',width=4, command=change_or_choice_project) 275 | tbtn3.pack(side='right') 276 | tbtn3 = Button(topfr,text='保存',width=4, command=save_project) 277 | tbtn3.pack(side='right') 278 | 279 | # 树状图frame 280 | tree = Treeview(leftfr, show="tree") 281 | tree.pack(side='left',fill="both") 282 | tree.column("#0",minwidth=0,width=100, stretch='no') 283 | 284 | # notebook页frame 285 | # 不同的项目需要不同的 notebook 286 | notebooks = {} 287 | 288 | def create_pack_code_style(codetxt): 289 | try: 290 | from idlelib.colorizer import ColorDelegator 291 | from idlelib.percolator import Percolator 292 | d = ColorDelegator() 293 | Percolator(codetxt).insertfilter(d) 294 | except: 295 | import traceback 296 | traceback.print_exc() 297 | 298 | def create_txt_fr(): 299 | tempfr = Frame() 300 | codetxt = Text(tempfr) 301 | codetxt.pack(expand=True,fill='both') 302 | create_pack_code_style(codetxt) 303 | return tempfr 304 | 305 | def create_lab_fr(text): 306 | tempfr = Frame() 307 | labtxt = Label(tempfr, text=text) 308 | labtxt.pack(expand=True,fill='both') 309 | return tempfr 310 | 311 | def create_tab(notebook, frame, name, **kw): 312 | v = set(notebook.tabs()) 313 | notebook.add(frame, text=name) 314 | tab_id = (set(notebook.tabs())^v).pop() 315 | 316 | def make_tab(notebook, name, **kw): 317 | # 后续可以在此处处理项目对应的 notebook 与对应的处理的绑定。 318 | if kw.get('type') == 'txt': 319 | create_tab(notebook, create_txt_fr(), name, **kw) 320 | elif kw.get('type') == 'lab': 321 | create_tab(notebook, create_lab_fr(text=kw.get('text')), name, **kw) 322 | else: 323 | print('none type setting.') 324 | 325 | 326 | 327 | 328 | 329 | # 这里主要处理快捷键以及右键菜单。 330 | def bind_ctl_key(func, key=None, shift=False): 331 | key = key.upper() if shift else key 332 | root.bind("".format(key),lambda e:func()) 333 | 334 | menu = tkinter.Menu(root, tearoff=0) 335 | def bind_menu(func, name): 336 | menu.add_command(label=name, command=func) 337 | root.bind("",lambda e:menu.post(e.x_root,e.y_root)) 338 | 339 | def _create_file(filename, foc, new): 340 | try: 341 | filename = filename if filename.endswith('.py') else filename+'.py' 342 | filenamepath = os.path.join(new[0], filename).replace('\\','/') 343 | if not os.path.isfile(filenamepath): 344 | with open(filenamepath, 'w', encoding='utf-8') as f: 345 | f.write('# start.') 346 | c = tree.insert(foc,0,text=filename,values=filenamepath) 347 | tree.see(c) 348 | return c 349 | else: 350 | return 'exist' 351 | except: 352 | traceback.print_exc() 353 | print('create error.') 354 | 355 | def _menu_create_file(create_file_func): 356 | foc = tree.focus() 357 | new = tree.item(foc, 'values') 358 | if new and os.path.isdir(new[0]): 359 | create_file_func(foc, new) 360 | else: 361 | einfo = '创建脚本失败.' 362 | tkinter.messagebox.showinfo(einfo,'请选中相应的文件夹节点进行文件创建。') 363 | 364 | def create_file(foc, new): 365 | filename = askstring('创建脚本名','请输入脚本名称,尽量不要使用特殊字符。') 366 | if not filename: return 367 | _create_file(filename, foc, new) 368 | 369 | def create_file_default(foc, new, idx=1): 370 | filename = 'dft{}'.format(idx) 371 | if _create_file(filename, foc, new) == 'exist': 372 | create_file_default(foc, new, idx=idx+1) 373 | 374 | def menu_create_file(): 375 | _menu_create_file(create_file) 376 | 377 | def menu_create_file_default(): 378 | _menu_create_file(create_file_default) 379 | 380 | bind_menu(menu_create_file, '创建命名脚本') 381 | bind_menu(menu_create_file_default, '创建默认脚本') 382 | 383 | 384 | 385 | 386 | 387 | 388 | # 需要用一个 json 来保存相应的配置信息 389 | # 后续可以考虑扩展,不过目前用处不大,主要用于保证 notebook 生成的结构。 390 | CONFIG = { 391 | PROJECTDEFAULTNAME: { # 默认空间的配置 392 | 0:{ 393 | 'name':'帮助', 394 | 'type':'lab', 395 | 'text':'asdfasdf', 396 | }, 397 | 1:{ 398 | 'name':'代码', 399 | 'type':'txt', 400 | 'text':'asdfasdf', 401 | } 402 | }, 403 | } 404 | 405 | if __name__ == '__main__': 406 | init_project() 407 | root.geometry('400x500+100+100') 408 | root.bind('',lambda e:root.quit()) 409 | tree.bind("<>", lambda e:tree_on_select(tree)) 410 | root.mainloop() 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | -------------------------------------------------------------------------------- /vrecord/recorder.py: -------------------------------------------------------------------------------- 1 | import re 2 | import time 3 | import pprint 4 | import tkinter 5 | import threading 6 | import traceback 7 | 8 | from pynput.mouse import Listener as mlistener 9 | from pynput.mouse import Controller as mcontroller 10 | from pynput.keyboard import Key 11 | from pynput.keyboard import Listener as klistener 12 | from pynput.keyboard import Controller as kcontroller 13 | 14 | SHIFT_DIFF = False 15 | 16 | class recorder: 17 | def __init__( 18 | self, 19 | start_record_key = 'f7', 20 | stop_record_key = 'f7', 21 | start_repeat_key = 'f8', 22 | stop_repeat_key = 'f8', 23 | start_repeats_key = 'f9', 24 | stop_repeats_key = 'f9', 25 | close_key = 'esc', 26 | debug = True, 27 | debug_info = False, 28 | outclass = None, 29 | ): 30 | self.lock = threading.Lock() 31 | self.record = [] 32 | self.debug = debug 33 | self.debug_info = debug_info 34 | self.start_record_key = start_record_key 35 | self.stop_record_key = stop_record_key 36 | self.start_repeat_key = start_repeat_key 37 | self.stop_repeat_key = stop_repeat_key 38 | self.start_repeats_key = start_repeats_key 39 | self.stop_repeats_key = stop_repeats_key 40 | self.close_key = close_key 41 | self.record_status = 'stop' # only [start or stop] 42 | self.repeat_status = 'stop' # only [start or stop] 43 | self.unrecord_key = [ 44 | self.start_record_key, 45 | self.stop_record_key, 46 | self.start_repeat_key, 47 | self.stop_repeat_key, 48 | self.start_repeats_key, 49 | self.stop_repeats_key, 50 | self.close_key, 51 | ] 52 | self.outclass = outclass 53 | self.key_hook = getattr(self.outclass, 'key_hook', None) 54 | if self.key_hook: 55 | self.unrecord_key.extend(list(self.key_hook)) 56 | 57 | self.keyboard_listen_thread = None 58 | self.mouse_listen_thread = None 59 | self.main_keybord_thread = None 60 | self.speed = 1.5 61 | 62 | def safe_add_action(self, msg): 63 | with self.lock: self.record.append(msg) 64 | if self.debug_info: 65 | print('{} {} {}'.format(msg['type'], msg['action'], msg['time'])) 66 | if msg['type'] == 'mouse': 67 | if msg['action'] == 'scroll': 68 | print(' scroll:{}'.format((msg['x'], msg['y'], msg['dx'], msg['dy']))) 69 | else: 70 | print(' xy:{}'.format((msg['x'], msg['y']))) 71 | if msg['type'] == 'keyboard': 72 | print(' key:{}'.format(msg['key'])) 73 | 74 | def on_move(self, x, y): 75 | msg = {} 76 | msg['type'] = 'mouse' 77 | msg['action'] = 'move' 78 | msg['time'] = time.time() 79 | msg['x'], msg['y'] = x, y 80 | self.safe_add_action(msg) 81 | 82 | def on_click(self, x, y, button, pressed): 83 | msg = {} 84 | msg['type'] = 'mouse' 85 | msg['action'] = 'press' if pressed else 'release' 86 | msg['time'] = time.time() 87 | msg['x'], msg['y'] = x, y 88 | msg['button'] = button 89 | self.safe_add_action(msg) 90 | 91 | def on_scroll(self, x, y, dx, dy): 92 | msg = {} 93 | msg['type'] = 'mouse' 94 | msg['action'] = 'scroll' 95 | msg['time'] = time.time() 96 | msg['x'], msg['y'] = x, y 97 | msg['dx'], msg['dy'] = dx, dy 98 | self.safe_add_action(msg) 99 | 100 | def on_press(self, key): 101 | msg = {} 102 | msg['type'] = 'keyboard' 103 | msg['action'] = 'press' 104 | msg['time'] = time.time() 105 | msg['key'] = key 106 | self.safe_add_action(msg) 107 | if key == Key.shift and not SHIFT_DIFF: 108 | msg = msg.copy() # 由于某些原因,shift 必须与 shift_r 配合才能实现 “shift选中” 109 | msg['key'] = Key.shift_r # 所以这里将 “按下shift” 粗暴的填充为同时按下 左右shift。通常不会有影响 110 | self.safe_add_action(msg) # 相较于左右shift的区分,shift 处理文本时的选中更为重要 111 | 112 | def on_release(self, key): 113 | msg = {} 114 | msg['type'] = 'keyboard' 115 | msg['action'] = 'release' 116 | msg['time'] = time.time() 117 | msg['key'] = key 118 | self.safe_add_action(msg) 119 | if key == Key.shift and not SHIFT_DIFF: 120 | msg = msg.copy() # 由于某些原因,shift 必须与 shift_r 配合才能实现 “shift选中” 121 | msg['key'] = Key.shift_r # 所以这里将 “按下shift” 粗暴的填充为同时按下 左右shift。通常不会有影响 122 | self.safe_add_action(msg) # 相较于左右shift的区分,shift 处理文本时的选中更为重要 123 | 124 | def start_record(self): 125 | self.record.clear() 126 | self.mouse_listen_thread = mlistener( 127 | on_move=self.on_move, 128 | on_click=self.on_click, 129 | on_scroll=self.on_scroll) 130 | self.keyboard_listen_thread = klistener( 131 | on_press=self.on_press, 132 | on_release=self.on_release) 133 | self.mouse_listen_thread.start() 134 | self.keyboard_listen_thread.start() 135 | self.mouse_listen_thread.join() 136 | self.keyboard_listen_thread.join() 137 | 138 | def repeat_times(self, times=1): 139 | mouse = mcontroller() 140 | keyboard = kcontroller() 141 | record_data = self.record 142 | self.repeat_stop_toggle = False 143 | for _ in range(times): 144 | if not record_data: 145 | self.record_status = 'stop' 146 | print('error empty record.') 147 | return 148 | action_start_time = record_data[0]['time'] 149 | for idx,action in enumerate(record_data): 150 | if self.repeat_stop_toggle: 151 | return 152 | gtime = action['time'] - action_start_time 153 | if gtime < 0.02 and idx != 0 and action['action'] == 'move': 154 | # 针对鼠标移动的稍稍优化 155 | continue 156 | if action['type'] == 'mouse': 157 | mouse.position = (int(action['x']), int(action['y'])) 158 | act = action['action'] 159 | if act == 'scroll': 160 | getattr(mouse, action['action'])(action['dx'], action['dy']) 161 | elif act == 'press' or act == 'release': 162 | getattr(mouse, action['action'])(action['button']) 163 | elif action['type'] == 'keyboard': 164 | if getattr(action['key'], 'name', None) not in self.unrecord_key: 165 | getattr(keyboard, action['action'])(action['key']) 166 | if self.speed: time.sleep(gtime/self.speed) 167 | action_start_time = action['time'] 168 | self.hook_repeat_stop('force_stop') 169 | 170 | def hook_record_start(self, key): 171 | if key == getattr(Key, self.start_record_key) and self.record_status == 'stop': 172 | try: 173 | self.hook_repeat_stop('force_stop') 174 | except: traceback.print_exc() 175 | self.record_status = 'start' 176 | threading.Thread(target=self.start_record).start() 177 | if self.debug: print('{} record start.'.format(key)) 178 | return True 179 | 180 | def hook_record_stop(self, key): 181 | ''' force_stop 是为了处理在未结束录制时就开始 repeat 时的问题。可以强制结束录制行为。 ''' 182 | if (key == getattr(Key, self.stop_record_key) and self.record_status == 'start') or key == 'force_stop': 183 | self.record_status = 'stop' 184 | if self.keyboard_listen_thread: self.keyboard_listen_thread.stop() 185 | if self.mouse_listen_thread: self.mouse_listen_thread.stop() 186 | if self.debug: print('{} record stop.'.format(key)) 187 | return True 188 | 189 | def hook_repeat_start(self, key): 190 | if (key == getattr(Key, self.start_repeat_key) or key == getattr(Key, self.start_repeats_key)) \ 191 | and self.repeat_status == 'stop': 192 | if self.record_status == 'start': self.hook_record_stop('force_stop') 193 | self.repeat_status = 'start' 194 | args = (1,) if key == getattr(Key, self.start_repeat_key) else (100000000,) 195 | threading.Thread(target=self.repeat_times, args=args).start() 196 | if self.debug: print('{} repeat start.'.format(key)) 197 | return True 198 | 199 | def hook_repeat_stop(self, key): 200 | if ((key == getattr(Key, self.stop_repeat_key) or key == getattr(Key, self.stop_repeats_key)) \ 201 | and self.repeat_status == 'start') or key == 'force_stop': 202 | self.repeat_status = 'stop' 203 | self.repeat_stop_toggle = True 204 | if self.debug: print('{} repeat stop.'.format(key)) 205 | return True 206 | 207 | def hook_main_stop(self, key): 208 | if (key == getattr(Key, self.close_key)) or key == 'force_stop': 209 | if self.main_keybord_thread: self.main_keybord_thread.stop() 210 | if self.debug: print('{} tool stop.'.format(key)) 211 | return True 212 | 213 | def hook_outclass_stop(self, key): 214 | if self.outclass: 215 | try: 216 | self.outclass.close_sign() 217 | except: 218 | traceback.print_exc() 219 | 220 | def hook_outclass(self, key): 221 | for _key in self.key_hook: 222 | if key == getattr(Key, _key): 223 | self.key_hook[_key]() 224 | 225 | def main_keybord(self, key): 226 | self.hook_record_start(key) or self.hook_record_stop(key) 227 | self.hook_repeat_start(key) or self.hook_repeat_stop(key) 228 | self.hook_outclass(key) 229 | if self.hook_main_stop(key): 230 | self.hook_outclass_stop(key) 231 | 232 | def start(self): 233 | self.main_keybord_thread = klistener(on_release=self.main_keybord) 234 | self.main_keybord_thread.start() 235 | self.main_keybord_thread.join() 236 | 237 | 238 | from tkinter import ttk 239 | from tkinter import scrolledtext 240 | from tkinter.font import Font 241 | import tkinter.messagebox 242 | Frame = tkinter.Frame 243 | Text = scrolledtext.ScrolledText 244 | Label = ttk.Label 245 | Button = ttk.Button 246 | Combobox = ttk.Combobox 247 | info = ''' 248 | 键盘鼠标操作录制工具 249 | F7 开始/停止录制 250 | F8 执行/停止录制好的任务 251 | F9 执行/停止录制好的任务(重复执行) 252 | F10 生成代码 253 | ESC 关闭工具 254 | '''.strip() 255 | 256 | warning = ''' 257 | *注意: 258 | 区分左右 shift 某些环境将会失去 shift 组合方向键选中文本的能力,请一定注意。 259 | 原因: 260 | 某些环境无法实现 shift 选中文本,有一种解决方法,是在代码中解决是在代码中左右 shift 同时按才能实现 shift 选中文本 261 | 为了兼容,所以默认 shift 捕捉为每次同时按下左右 shift,正常环境下,这种也适用。 262 | 同时这个也可以取消,只要将 “是否区分左右shift” 设置为是即可。 263 | '''.strip() 264 | 265 | record_code = ''' 266 | import time 267 | from pynput.keyboard import Key 268 | from pynput.keyboard import Controller as kcontroller 269 | from pynput.mouse import Controller as mcontroller 270 | from pynput.mouse import Button 271 | 272 | record_data = $record_data 273 | 274 | def repeat_times(record_data, times=1, speed=1.5): 275 | mouse = mcontroller() 276 | keyboard = kcontroller() 277 | for _ in range(times): 278 | if not record_data: 279 | print('error empty record.') 280 | return 281 | action_start_time = record_data[0]['time'] 282 | for idx,action in enumerate(record_data): 283 | gtime = action['time'] - action_start_time 284 | if gtime < 0.02 and idx != 0 and action['action'] == 'move': 285 | # 针对鼠标移动的稍稍优化 286 | continue 287 | if action['type'] == 'mouse': 288 | mouse.position = (int(action['x']), int(action['y'])) 289 | act = action['action'] 290 | if act == 'scroll': 291 | getattr(mouse, action['action'])(action['dx'], action['dy']) 292 | elif act == 'press' or act == 'release': 293 | getattr(mouse, action['action'])(action['button']) 294 | elif action['type'] == 'keyboard': 295 | if getattr(action['key'], 'name', None) not in $unrecord_key: 296 | getattr(keyboard, action['action'])(action['key']) 297 | if speed: time.sleep(gtime/speed) 298 | action_start_time = action['time'] 299 | 300 | if __name__ == '__main__': 301 | speed = $speed 302 | repeat_times(record_data, speed=speed) 303 | '''.strip() 304 | class recorder_gui: 305 | def __init__(self): 306 | self.root = tkinter.Tk() 307 | self.key_hook = {'f10': self.create_code} 308 | self.recorder = recorder(outclass = self) # 这里 outclass 是为了能在 recorder 内的关闭函数中接收关闭信号函数 309 | self.root.protocol("WM_DELETE_WINDOW", self.on_closing) 310 | self.close_sign = self.on_closing 311 | self.ft = Font(family='Consolas',size=10) 312 | Label(self.root, text=info, font=self.ft).pack(padx=5) 313 | 314 | fr = Frame(self.root) 315 | fr.pack(fill=tkinter.X) 316 | Label(fr, text='速度 [执行速度,None模式慎用]', font=self.ft).pack(side=tkinter.LEFT, padx=5) 317 | self.cbx = Combobox(fr,width=5,state='readonly') 318 | self.cbx['values'] = (0.5,1.0,1.5,2.5,6.5,17.5,37.0,67.5,115.0,'None') 319 | self.cbx.current(2) 320 | self.cbx.pack(side=tkinter.RIGHT) 321 | self.cbx.bind('<>', self.change_speed) 322 | fr = Frame(self.root) 323 | fr.pack(fill=tkinter.X) 324 | Label(fr, text='是否区分左右shift [推荐默认否]', font=self.ft).pack(side=tkinter.LEFT, padx=5) 325 | self.cbx2 = Combobox(fr,width=5,state='readonly') 326 | self.cbx2['values'] = ('否', '是') 327 | self.cbx2.current(0) 328 | self.cbx2.pack(side=tkinter.RIGHT) 329 | self.cbx2.bind('<>', self.change_shift_diff) 330 | Button(self.root, text='注意事项', command=self.create_warning).pack(fill=tkinter.X) 331 | Button(self.root, text='生成代码', command=self.create_code).pack(fill=tkinter.X) 332 | self.txt = Text(self.root, width=30, height=11, font=self.ft) 333 | self.txt.pack(fill=tkinter.BOTH,expand=True) 334 | global print 335 | print = self.print 336 | try: 337 | from idlelib.colorizer import ColorDelegator 338 | from idlelib.percolator import Percolator 339 | p = ColorDelegator() 340 | Percolator(self.txt).insertfilter(p) 341 | except: 342 | traceback.print_exc() 343 | 344 | def _recorder_close(self): 345 | self.recorder.hook_repeat_stop('force_stop') 346 | self.recorder.hook_record_stop('force_stop') 347 | self.recorder.hook_main_stop('force_stop') 348 | 349 | def on_closing(self): 350 | self._recorder_close() 351 | toggle = tkinter.messagebox.askokcancel('关闭','是否关闭工具?') 352 | if toggle: 353 | self.root.wm_attributes('-toolwindow',1) # 关闭前必须显示且置顶,否则 tkinter 窗口会滞黏 354 | self.root.wm_attributes('-topmost',1) 355 | self.root.quit() 356 | else: 357 | self.recorder.start() 358 | 359 | def start(self): 360 | threading.Thread(target=self.recorder.start).start() 361 | self.root.mainloop() 362 | 363 | def create_code(self): 364 | self.recorder.hook_record_stop('force_stop') 365 | self.recorder.hook_repeat_stop('force_stop') 366 | def format_record_data(record_data): 367 | _filter = lambda string: string.group(0).rsplit(':',1)[0].replace('<','')+',' 368 | record_data = re.sub(r"'key': <[^\n]+>,", _filter, record_data) 369 | record_data = re.sub(r"'button': <[^\n]+>,", _filter, record_data) 370 | return record_data 371 | unrecord_key = str(list(set(self.recorder.unrecord_key))) 372 | record_data = format_record_data(pprint.pformat(self.recorder.record, indent=2, width=200)) 373 | self.clear_txt() 374 | speed = 'None' if self.cbx.get() == 'None' else self.cbx.get() 375 | print(record_code.replace('$record_data', record_data) 376 | .replace('$unrecord_key', unrecord_key) 377 | .replace('$speed', speed)) 378 | 379 | def create_warning(self): 380 | self.clear_txt() 381 | print(warning) 382 | 383 | def clear_txt(self): 384 | self.txt.delete(0., tkinter.END) 385 | 386 | def print(self, *a): 387 | text = ' '.join(map(str, a)) + '\n' 388 | self.txt.insert(tkinter.END, text) 389 | self.txt.see(tkinter.END) 390 | 391 | def change_speed(self, *a): 392 | self.recorder.speed = None if self.cbx.get() == 'None' else float(self.cbx.get()) 393 | 394 | def change_shift_diff(self, *a): 395 | global SHIFT_DIFF 396 | SHIFT_DIFF = True if self.cbx2.get() == '是' else False 397 | 398 | def execute(): 399 | recorder_gui().start() 400 | 401 | if __name__ == '__main__': 402 | recorder_gui().start() --------------------------------------------------------------------------------