taojy123 / KeymouseGo

类似按键精灵的鼠标键盘录制和自动化操作 模拟点击和键入 | automate mouse clicks and keyboard input
http://taojy123.github.io/KeymouseGo
GNU General Public License v2.0
7.15k stars 1.04k forks source link

可否添加动态加载 python 模块的功能? #113

Closed zhsunlight closed 2 years ago

zhsunlight commented 2 years ago

我发现 Issues 中很多提出希望添加一些个性化功能的要求,这些个性化功能很多不太通用,添加后确实会让本项目变得臃肿和难以维护。我建议增加支持插件的功能,最起码支持动态加载 python 模块,以类似现有 scripts 的方式添加,建立一个 codes 子目录,由用户自己选择载哪个模块。用户自己在自定义模块中添加需要的个性化功能。修改一下现有的 scripts 格式,添加一个 pyhton_function 的栏位,在执行到填写了 函数名称 栏位的行,就执行该栏位中填写的函数,直到返回结果,再继续执行下一行。这样,象前面提到的捕捉某个窗口标题,直到窗口标题出现时才执行下一行之类的要求就可以轻易实现,只需要在自定义函数中添加捕捉窗口标题和等待的逻辑就行了。

我最感兴趣的是,这样一来,就可以利用粘贴板来交换数据了,当运行到 scripts 中某一行时,执行自定义的函数,在自定义的函数中修改粘贴板的内容,这样就可以在之后的某行 ctrl + v 粘贴,实现表单填写时允许内容变化的功能。

参考资料: python动态加载模块、类、函数 https://cloud.tencent.com/developer/article/1568138

Monomux commented 2 years ago

令人耳目一新的想法😃,我对此功能实现的想法是,程序本身提供一个功能扩展类,其结构可以像这样:

class Extension:
    def __init(self)__:
        self.runtimes = 1
        # 初始化内容

    def onrunbefore(self, event):
        pass
        # 每行脚本执行前需要做的事

    def onrunafter(self, event):
        pass
        # 每行脚本执行后需要做的事

    def oneachloop(self):
        self.runtimes = self.rumtimes + 1
        # 每次脚本全部执行完后需要做的事

每次要执行的操作及相关信息会封装成event,执行前后分别调用Extension或其子类对应的方法。该类的定义文件存在子目录/codes/plugins什么的里面,用户可以另写一个Extension类的子类,重载类的方法实现自己的需求。例如用户想要在第三行脚本执行后做特定操作,可以这样实现:

class MyExtension(Extension):
    def __init(self)__:
        super().__init__()
        # 初始化内容

    def onrunafter(self, event):
        super().onrunafter(self, event)
        if event.currentindex == 3:
            # 需要做的事

假设Extension类定义在Extension.py中,用户自己实现了自己的需求,用户定义的插件子类写在MyExtension.py中(如果实现需求依赖其它库也可以在里面import)。实际脚本执行前,如果用户在脚本开头显式指定了自己实例化的子类,比如脚本结构变成了这样

[
  "MyExtension.py",
  // 以下为录制的脚本
]

则动态加载模块MyExtension并实例化子类;如果用户没有显式指定实例化的子类(没有插件需求),则脚本执行前会在开头隐式地加上了Extension.py

[
  "Extension.py",
  // 以下为录制的脚本
]

这样则会动态加载Extension并实例化之,其对脚本执行本身并无影响。

taojy123 commented 2 years ago

很好的想法,KeymouseGo 的初衷是尽量简易。 基于这种拓展机制,可以在保持软件本身极简的情况下,满足使用者的个性化需求。

Monomux commented 2 years ago

经过 #115 关于新增功能的调试与讨论,目前最新合入的代码存在无法加载自定义扩展的问题,解决方法为: 修改UIFunc.py第4行

from importlib.machinery import SourceFileLoader

第91-92行

copyfile(get_assets_path('plugins','Extension.py'),
                     '{0}/plugins/Extension.py'.format(os.getcwd()))

第587行

module = SourceFileLoader(class_name, os.path.join(os.getcwd(),'plugins','%s.py' % module_name)).load_module()

自定义扩展第一行引入默认扩展改为

from assets.plugins.Extension import Extension

此外,对于完善扩展还有以下需求

zhsunlight commented 2 years ago

经过 #115 关于新增功能的调试与讨论,目前最新合入的代码存在无法加载自定义扩展的问题,解决方法为: 修改UIFunc.py第4行

from importlib.machinery import SourceFileLoader

第91-92行

copyfile(get_assets_path('plugins','Extension.py'),
                     '{0}/plugins/Extension.py'.format(os.getcwd()))

第587行

module = SourceFileLoader(class_name, os.path.join(os.getcwd(),'plugins','%s.py' % module_name)).load_module()

自定义扩展第一行引入默认扩展改为

from assets.plugins.Extension import Extension

此外,对于完善扩展还有以下需求

  • [ ] 扩展返回值对程序执行流程的控制
  • [ ] 加入日志记录以便调试
  • [ ] 提供录制过程的扩展接口

@Monomux 看到新的PR了,赞。

我用上一个PR的修改版本运行4天,效果很好。我有将近10万条不规则数据需要通过一个没有提供接口的系统中处理,并将处理结果保存到本地硬盘。处理过程中,需要录入三项数据(账号和密码、待处理数据),然后执行多个操作,通过粘贴板复制三项运行结果,执行完成后保存获得的数据。使用没有插件接口的旧版 KeymouseGo,需要很多技巧才能实现自动化,而且运行过程中不能有一点差错,否则数据会乱,数据弄乱后,又要人工重新设置才能再次执行,所以虽然录了脚本,但并没有提高处理效率,因为需要不断人工干预。而使用有插件接口的新版 KeymouseGo 后,我把输入数据和获取运行结果的任务交给插件完成,通过粘贴板实现数据交换。在插件中判断获得的运行结果是否符合正确结果的特征,如果不符合,就丢弃该次运行结果,并将出错的信息保存在日志文件中。这样处理后,错误率大幅降低,大约每500个循环才出现一个错误,而且出错时,设置了声音报警,效率大大提升。出错时,也并不需要人工干预,直接丢弃该次运行结果就行。之前安排一名同事手工处理,两周一共处理不到3000条数据,按此效率,需要一年多才能处理完全部数据。但现在一台电脑一小时就能处理260多条数据,安排4台电脑同时工作,预计未来两周就能处理完。

目前脚本执行插件接口已经做得很好(比我原先设想还要灵活和强大),这部分暂时没有改进建议。

居于让 KeymouseGo 按用户预期方式运行的需求出发,对于 KeymouseGo 的其它部分提几点建议供参考: 1、脚本录制扩展接口:可以让用户自己实现接口抓取何种数据,然后将用户插件返回的数据记录在 scripts 的扩展栏中。KeymouseGo执行scripts 脚本时,原封不动地将用户数据(甚至将整行 scripts 数据传送给运行接口插件,由用户决定如何使用这些数据。(比如在录制时,用户插件记录了窗口或控件的名称、标题、大小、位置等信息,在 KeymouseGo 执行脚本时,运行接口插件收到这些信息后,可以作出正确的判断,然后控制下一步动作)。

2、将 KeymouseGo 现有的执行脚本的代码细分成各个执行单元,提供指令来操作不同的执行单元。比如说可控制KeymouseGo跳转到某一行scripts脚本的指令;暂停执行当前script文件,去执行另一个script文件的指令:让 KeymouseGo 将当前script 压栈,执行另一个 script,待另一个 script 执行完成后,出栈上一个 script 继续执行;重置当前script,重新开始执行script的指令;设置KeymouseGo各项参数的指令;执行用户插件返回的 scripts 代码片段的指令(也可以简化成发送指定字符串的键盘指令,移动鼠标和点击的指令等)。

具备这些能力后,就可以控制 KeymouseGo 完全按预期方式执行了。比如录制脚本时,录制了主流程,在KeymouseGo执行脚本时,理想状态下完全按主流程执行,但出现分枝流程时(比如网页出现了 403 Forbidden),可以通过临时执行一次分枝流程的script脚本(比如清除浏览器 cookies)让系统恢复到正常状态。

Monomux commented 2 years ago

1、脚本录制扩展接口:可以让用户自己实现接口抓取何种数据,然后将用户插件返回的数据记录在 scripts 的扩展栏中。KeymouseGo执行scripts 脚本时,原封不动地将用户数据(甚至将整行 scripts 数据传送给运行接口插件,由用户决定如何使用这些数据。(比如在录制时,用户插件记录了窗口或控件的名称、标题、大小、位置等信息,在 KeymouseGo 执行脚本时,运行接口插件收到这些信息后,可以作出正确的判断,然后控制下一步动作)。

目前在新的PR中录制接口的处理方法是为封装的事件提供一个扩展属性(addon),录制完一个事件时扩展属性默认为None,调用录制接口时,用户可以通过修改event.addon加入自己想要记录的内容(可以是任何数据类型,但需要实现__str__方法以便保存),甚至可以修改录制的内容,同时可以通过接口返回值决定该次录制的内容是否要保存。执行时每行脚本的内容被封装成一个事件event,扩展属性也会被加载event.addon,对于默认扩展来说没有影响。

2、将 KeymouseGo 现有的执行脚本的代码细分成各个执行单元,提供指令来操作不同的执行单元。比如说可控制KeymouseGo跳转到某一行scripts脚本的指令;暂停执行当前script文件,去执行另一个script文件的指令:让 KeymouseGo 将当前script 压栈,执行另一个 script,待另一个 script 执行完成后,出栈上一个 script 继续执行;重置当前script,重新开始执行script的指令;设置KeymouseGo各项参数的指令;执行用户插件返回的 scripts 代码片段的指令(也可以简化成发送指定字符串的键盘指令,移动鼠标和点击的指令等)。

目前只实现了跳转到某一行的操作,因为更加复杂的流程控制实现起来有点麻烦。

如果需要实现更复杂的自定义功能,我有个纯粹的想法:提供更高一级的扩展支持,让用户可以直接修改脚本的执行逻辑,从而达到定制流程控制的目的。现在的单次脚本执行在RunscriptClass.run_script_once函数中,可以写个RunscriptClass的子类实现相关逻辑。

zhsunlight commented 2 years ago

目前在新的PR中录制接口的处理方法是为封装的事件提供一个扩展属性(addon),录制完一个事件时扩展属性默认为None,调用录制接口时,用户可以通过修改event.addon加入自己想要记录的内容(可以是任何数据类型,但需要实现__str__方法以便保存),甚至可以修改录制的内容,同时可以通过接口返回值决定该次录制的内容是否要保存。执行时每行脚本的内容被封装成一个事件event,扩展属性也会被加载event.addon,对于默认扩展来说没有影响。

这个方案应该和我说的具有相同的效果。我测试一下新PR。

如果需要实现更复杂的自定义功能,我有个纯粹的想法:提供更高一级的扩展支持,让用户可以直接修改脚本的执行逻辑,从而达到定制流程控制的目的。现在的单次脚本执行在RunscriptClass.run_script_once函数中,可以写个RunscriptClass的子类实现相关逻辑。

你这个方案也挺好,由用户自己实现特定用途的控制逻辑。我提的方案只是想降低用户编写脚本的难度,将一些通用、常用的控制逻辑在核心层进行标准化实现,供用户插件调用。

Monomux commented 2 years ago

目前计划使用raise Exception的方法驱动较为复杂的流程控制,提供几个基本控制逻辑。

控制KeymouseGo跳转到某一行scripts脚本的指令;重置当前script,重新开始执行script的指令;

这部分功能都可以用跳转实现。

比如说可暂停执行当前script文件,去执行另一个script文件的指令:让 KeymouseGo 将当前script 压栈,执行另一个 script,待另一个 script 执行完成后,出栈上一个 script 继续执行;

这部分功能可以在触发相应逻辑后直接执行另一脚本,执行结束后即可从当前位置继续执行。

设置KeymouseGo各项参数的指令;

这个可以通过修改传入的event内容修改当前行的执行内容,如果要修改其它行的内容,考虑在扩展初始化时传入整个脚本的事件集合。

执行用户插件返回的 scripts 代码片段的指令;

可以在插件内将操作封装为事件或事件集返回,程序随即执行集合内操作。

此外还想到了终止本次执行和终止所有执行。

zhsunlight commented 2 years ago

这个可以通过修改传入的event内容修改当前行的执行内容,如果要修改其它行的内容,考虑在扩展初始化时传入整个脚本的事件集合。

我指的是KeymouseGo画面上那些参数,可以在插件代码中修改并立即生效。(主要是想修改执行次数,其它参数在代码中修改的意义不大。)

此外还想到了终止本次执行和终止所有执行。

这个是很有必要的。我前几天录制了一个脚本,将执行次数设置为2000次,由于我的执行插件会将不正确的运行结果丢弃,所以每次运行大约能得到1700个正确结果(当然可以设置为大一点的执行次数,比如2400次而得到一个接近2000的正确结果数)。我就想,能不能将执行次数设置为0(无限执行次数),达到2000个正确结果后在插件代码中强制停止执行。结果没有找到实现方法,只好作罢。

目前提供的扩展插件事件能满足大部分情况了。如果有可能,请加上一个全部循环执行完成后的事件,主要用于记录全部循环的执行情况,目前只能在每次循环中记录,需要反复读写文件。

最新PR版本已经测试完成,录制插件及执行插件接口都能正常工作,日志输出功能也完美,对于绝大部分应用场景,通过编写插件已经能完美实现。现在就差改进控制部分的功能了,完善控制部分的功能后,几乎所有应用场景都能全方位满足。

其实现在也可以在执行插件中实现额外的延时效果,再加目前版本跳功能,也能在一定程度上控制KeymouseGo了。比如我今天录制的一个脚本,在录制时,浏览器一秒钟完成打开网页。而在实际执行时,虽然大部分时间是在一秒内打开网页,但偶尔会出现不能在一秒内打开的情况,此时能看到鼠标在空白屏幕上点击,后续执行结果错误。后来我在执行插件中检测窗口标题,发现标题不正确时,就延时0.5秒( time.sleep(0.5)),之后极少出现在空白屏幕点击的情况。

zhsunlight commented 2 years ago

@taojy123 @Monomux PR#119 已超额实现本工单所提需求,具备支持插件运行所需的一切条件,具有里程碑意义,建议PR119版本号为5.0

@Monomux 创造性地解决我提出的所有问题,你的高效工作和非凡智慧,使得本项目取得重大突破,跃上新的台阶。非常感谢!

我随后将分享一些使用插件的经验。

zhsunlight commented 2 years ago

示例1:辅助脚本录制 功能:录制脚本时,规范点击、按键的延时;记录点击区域的图像,方便确定该脚本行的作用。 参考脚本:在插件的 onrecord 中添加如下代码: image

录制脚本后,会在 img 子目录下存放录制时捕捉到的图像: image

录制到的脚本: image

zhsunlight commented 2 years ago

示例2:监控脚本执行结果 功能:在脚本执行过程中,有些步骤故障率极高,比如下载文件,由于受文件大小、网络速度、服务器负载波动等因素的影响,下载时间很难预估,通常的做法是设置一个远大于所需要的下载时间,确保下载成功。但这样一来,原本1分钟能执行完一个循环的脚本,就可能要设置成5分钟执行完,脚本执行效率大幅下降。用插件监控执行情况,可以做到不浪费1秒的等待时间,一下载完成,立即执行下一行脚本。本示例以示例1录制的脚本为例,说明如何监控脚本执行结果。

直接上代码,在插件的 onrunbefore 添加如下代码: image 当脚本执行到示例1脚本中最后一个红框处的脚本行时(即有4-1654576713835.png文件名的行,文件是在录制脚本时,由于点击 打开文件而捕捉到,具体看示例1),搜索屏幕截图中是否存在4-1654576713835.png目标图片【由自定义函数 location=find_image_wait(image, 10)负责搜索,其中参值10表示最多连续搜索10秒钟,一旦搜索到,立即返回,如果没有在10秒内搜索到,也返回一个空的 location】。如果搜索到目标图片,说明下载已完成,返回 location 表示目标图片所在屏幕位置;如果不存在,说明下载还没有完成,此时跳回脚本中每二个红框处的脚本行,重新开始下载。(这里的故障处理逻辑只是示例,实际的脚本中,还可以通过运行分支处理流程脚本等多种方式来处理故障。)

zhsunlight commented 2 years ago

@Monomux 使用新版本已经有一段时间,之前无法用 KeymouseGo 完成的重复工作,现在都能用了,一些复杂的工作流程,通过编写插件,可完美地实现自动化。有插件加持的脚本,适应性更广泛,解决问题的能力更强大。

制作脚本时,做一个父流程脚本和若干子流程脚本即可,在插件中根据条件判断使用哪个子流程,与常规编程无异。举一个父流程例子: image

在这个例子中,子流程包括打开Chrome浏览器子流程,安装Chrome扩展子流程等等。举一个安装Chrome扩展子流程脚本例子: image 相信以后类似这样使用KeymouseGo的方式会变得很流行,原因是这种方式可解决的问题更广泛,通用性和适应性更强。

但随之而来的一个问题是:在插件代码中会大量使用 time.sleep()函数实现延时、等待。time.sleep()这个函数是以阻塞方式工作,即使按下停止脚本执行的快捷键,也得等当前设置的等待时间执行完成。所以在调试脚本和执行脚本时,无法立即停止脚本执行,相当多时间消耗在等待脚本停止执行上面。

可否提供一个非阻塞式的延时函数,供插件调用?如果提供这样的函数,请在编写时,同时考虑执行速度 speed 要添加在计算等待时间的公式里,以便调整执行速度时,插件中的延时与脚本中的延时有一致的表现。

zhsunlight commented 2 years ago

我们这个程序也没有啥额外的线程吧, 目前有的就是调用提示音开了一个额外的线程

可能我上面的表述不正确,也许无法立即停止脚本的原因并不是由于time.sleep()本身导致,但确实当插件代码在运行时,按下停止键并不是马上响应的。我举一个具体的例子,如下这个查找屏幕图像的函数, 在 while 循环中使用 time.sleep(),如下图: image

一旦进入 While循环,即使已经按下停止键,也得等到该循环执行到 timeout 的时间。 由于上图中,time.sleep()的时间只有0.1秒,这样看来,说应该是进入 while循环后,没有收到和响应停止执行通知导致的。 另外,如果 time.sleep()的延时时间比较长,是否也有相同问题?比如 time.sleep(180)

Monomux commented 2 years ago

一旦进入 While循环,即使已经按下停止键,也得等到该循环执行到 timeout 的时间。 由于上图中,time.sleep()的时间只有0.1秒,这样看来,说应该是进入 while循环后,没有收到和响应停止执行通知导致的。 另外,如果 time.sleep()的延时时间比较长,是否也有相同问题?比如 time.sleep(180)

和 #124 中关于延时的问题相同,因为time.sleep是阻塞式的,目前可以参考event.execute部分的延时实现,采用设置线程事件的标志和非阻塞式的thd.event.wait解决。

if self.thd:
    self.thd.event.clear()
    self.thd.event.wait(delay)
    self.thd.event.set()
else:
    time.sleep(delay)

问题是命令行形式运行时并不会创建thread对象,这个方法目前不适用命令行模式,之后还需要修改一下RunScriptClass