情绪稳定不稳定的心路历程 AHK监听微信群回复
其实我们都知道,情绪有时是只薛定谔的猫,你不问还稳定点,你一问就不稳定了。然而,我还得问,你还得答,我还得知道你答了稳定。如果解析一下这个过程,觉知其中的细节,放到宏观的技术背景中,也许能感觉情绪更稳定一些吧。
一、问题的提出
上回书说到,每天 1024 pm,AHK脚本向微信群发消息,应@尽@,问每位同学情绪是否稳定。然后仅仅这样并不是关心询问,而是通知。通知的意思是,你得知道,至于你是不是知道了,那我不关心,也就是说你爱知道不知道。这怎么能行呢。我得问,你得答,我还得知道你的回答。如果我不知道你的回答,就是光运行程序,不关系代码的运行结果。所以,得想办法 监听微信群发消息的回应。
以下是心路历程,想过几种技术路线,以及对这几种技术路线的讨论。按理说,对技术路线的讨论应该只包括(1)该方案的优点,(2)该方案的缺点,(3)当前问题的需求、条件、限制。从而帮助选定技术方案。然后,实践操作的时候,还要考虑 可行性。比如,你不会,资料缺少,还有代价。
二、技术路线讨论 心路历程
考虑过几下几个方案,有的还走了两步。最后一个方案实施成功了。
1. appium
这是田HX同学的方案。在上一版本中,他同时发布的不仅包括群发,也包括监听回应。他使用了夜神仿真器和真手机两种方案。夜神仿真器中跑微信,被微信识别出来,锁了两次账号,我们不敢再尝试第三次了。手机,需要他插着usb线,保持debug模式。然而我觉得占用他的手机,而且还要插着线,对学生利益侵占过多,已经超过技术训练的范围了。
他的方案在这里[https://www.cnblogs.com/ourshiningdays/p/16023291.html],实测效果非常不错。在我完成监听回应脚本以前,每天就是田同学的脚本在跑,大大降低了我一个个查"稳定"的工作量。我的脚本,不仅对监听回应的报告,还有群发消息,都模仿了他的文本。
如果仿真器和真机都不适合当前问题,那么appium这条技术路线就不可行了。
到这时,我一行代码还没写呢。
2. OCR
要分析回应的消息,先要取得微信群中的历史消息。appium通过遍历控件或先验的知道从哪些控件中取得文字。如果不使用appium,那么如果取得学生回复的"稳定"或"不稳定"或无回应呢。
还是在windows操作系统下使用AHK脚本 读 微信客户端的群消息。WindowSpy.ahk表明,右侧的群消息与左侧的通讯录等,在同一个窗口之中,不可拆分和遍历控件。
既然不能拆分和遍历控件,我们可以向 第一性原则 走得更近一些,更接近问题的底层或者本质。这种程度的简单和朴素需求,无非是计算机对人类动作的模仿,包括模仿人类的传感器 (视、听)和动作 (鼠标、键盘)。在这个问题中,人类是如何监听微信群消息的呢?
用眼睛,图像识别。
所以最初想到是OCR。先对窗口截屏,然后OCR。这样就得到了群消息的文本。至于视域以外的历史消息,可以用AHK向上滚屏,这个技术风险不高,虽然没实测过,但是估且不必更多考虑。
不要!找个OCR接口就开始编程。这是不少初学者的习惯,发现个钉子,看淘宝上有个锤子,就赶紧下单了。万一不好使呢,这些时间投入就白费了。虽然你可能觉得在探索的过程中"学到了",但是……说起来话长,此节估且搁置。
找这个编程接口的demo成品,测试一下效果,以确定技术可行性。我们测了几种。位JY同学报告他用过微软的库,马HB同学报告他以前用过微软的OneNote,效果不错。我实测识别错误不少,不能达到实用的程度。原因猜测可能是微信群消息截图中有浅色的文字。我找了几个在线的OCR,上传截图,错误率同样不低。OCR效果最好的是 手机 讯飞输入法 的拍照输入,错误率最低,可以容忍。讯飞也提供在线API,需要认证等准备工作,可能涉及到免费限额和经费支出。这条线索暂时搁置。
到这时,我一行代码还没写呢。
3. OpenCv 模板匹配
回到OCR技术路线的第一性原则,我们需要不一定是OCR图像到文字,而是图像的发现能力。如果能找到头像,在微信里点击头像,在弹出的窗口(WindowSpy.ahk表明有单独的窗口)中可以选择和复制文字。这样就知道了是谁在回复。回复的消息,可以通过鼠标选择和复制得到。
在后面的进一步实验,并非OpenCv的实验,在进一步的实验中想到并实现,不必击头像得到文字,只需要通过图像发现头像,自然就知道这个头像是谁的。这是后话。
OpenCv如何找到头像呢。要么有OpenCv能力的知识结构,要么想想网上的贴子和手册会如何描述这个功能,搜索。
这个功能叫做 模板匹配,在大图中找到小图,在窗口截图中找到头像。
查了一下网上的贴子和手册,模板匹配,需要查以下几点。
(1)什么样的入口auguments,如何向模板匹配算法/函数传递大图和小图。在查之前有猜测,要么是数组一类的,要么是URL或者文件地址,要么是图片对象。要么就是没猜出来得细读探索,而不是验证猜想,麻烦会大一点。如果是数组或对象,要考虑跨语言编程的类型映射或转换。如果是文件地址,要考虑当前目录以及文件不可达时的错误处理或者约定。
(2)什么样的出口return。小图在大图上的坐标,以何种方式传出。是二维坐标对象,还是rect,还是两个数字,是float (是的,没写错,像素可能是float),还是int。如果有多次匹配,如何传出,用线性表么,如何排序,按匹配的程度由高到低,还是按大图中的坐标从左到右从上到下。编程的过程中,如何检验出口值正确,是打印出来,还是叠画在大图上。
(3)工作过程,模板匹配有几种不同的算法,哪个最适合当前问题。最大的可能是,由于当前问题如此朴素,所以哪个都行,都差不多,都足够好。可能需要担心的是,这么多种算法,每种算法的门限这一类参数parameters,需要通读文档,确定所有这些参数的物理意义,别写错了。
找了几个例子,读代码。这几个例子都是python的。要不要ahk调用python,通过python调用OpenCV,从而降低跨语言类型映射和转换的工作量?查了下ahk调用OpenCv,需要用到COM,入口和出口的类型转换都不少行代码。当然可以抄,然后不优雅。
到这时,我一行代码还没写呢。
4. AHK ImageSearch
在上述过程中,偶然发现一个贴子,提到AHK可以匹配 (原文用的似乎是"搜")图像,在大图里查小图,这个函数是ImageSearch。这就是我知识结构残缺、读手册不完整的恶果。居然不知道,或者知道过然而忘了。以前试用过一个日本人做的AHK类软件,能在屏幕上匹配图像,移动鼠标过去做点击一类的操作。也忘了是哪个软件,再也没找到。现在,AHK也具备了这个功能。应该是后加的功能,我曾经通过读AHK手册,当时还没有这个功能。
与OpenCV对比,ImageSearch的缺点是,不能匹配"差不多"像的图。算法不够复杂,估计就是像素比对的,开个二维窗口,KMP之类的。然而这足够解决当前问题。从OCR到OpenCV到ImageSearch,由功能强大而具有不确定性到功能弱小而结果确定。ImageSearch要求像素完全匹配,当前问题的条件和约束是在窗口截图里找头像截图,在无损压缩的情况下,输入足够好。至于俩人头像特别像,ImageSearch比OpenCV解决得会更好一些。至于俩人头像一样……有个老乌鸦教小乌鸦如何防范人类的故事,以后讲给你。
到这时,我一行代码还没写呢。向同学们报告,我就选这条技术路线了。偶尔在群里做实验,打扰各位。开始实验。
截YP同学的头像,截微信群消息窗口的图。写AHK技术原型代码,调用ImageSearch,找到了。再写技术原型,多次匹配也找到了。第3个技术原型,从特定坐标向下找,方便遍历,也找到了。32行代码。
到这时,针对当前项目的需求,我一行代码还没写呢。
分解需求。
(1)需要确定分辨率
因为ImageSearch要求大图中的目标与小图源图的大小相同,又鼠标点击操作,甚至包括偏移多少像素,都对屏幕分辨率敏感。所以要么针对不同的分辨率做标定,要么简单粗暴的做法就是,如果分辨率不符合要求,就要求用户改。这是给我自己用的脚本,冻结这一需求,就用我的当前分辨率。 最大化窗口,这样能少滚动几次。
(2)往上翻到所有的回复消息之前
往上翻到 ,多翻几下,假设能超过 询问消息 “今天你情绪还稳定么?"。由此向下截图。
进入群以后,把鼠标光标移至文本光标的上方一点儿 (这个一点是根据分辨率标定的,比如100像素),鼠标光标就进入了历史消息。此时可以上翻历史消息。
怎么往上翻,用鼠标滚轮,还是PageUp。如果滚轮,每次会滚动多大,按比例,还是按像素。如果是PageUp,我没想到,PageUp和PageDown不是对称的,每次PageUp翻动的长度也不稳定。
为什么要对超过 询问消息 “今天你情绪还稳定么"做假设,而不是判断是否到达呢。因为这时还没有技术原型支持取得询问消息。只能知道那是我的头像,却不能确定这个头像发出了这条消息。如果我在这条消息以后发出过别的消息,那么由向下向上找到的第一个我的头像就不是这条消息。如果要求我在发出这条消息以后不得发出其他消息,那么就是对用户需求做限制。这个限制有点过份,用户/我不会妥协的。
找特定的人。由下向上,找到一个头像,看看消息是不是想要的。如果不是想要的消息,再往上找下一个头像。如果一直没找到,那么滚屏。下面是这个需求的代码片断。因为写着写着发现比我想得复杂,所以加了不少调试信息,"正找呢""没找到""找到头像了",这些调试信息注释以后也相当于……相当于注释了。
: ; 鼠标移到 历史消息,操作滚轮向上
: gap = 100 ; 由 群消息编辑 到 群消息历史 的偏移像素,根据分辨率校准
: MouseMove, A_CaretX, A_CaretY-gap, 0
:
: ; 在历史消息中找今天群发询问的位置
: ; 找到 群发消息询问人 头像,根据消息确认
: not_found :=0
: y := 0 ; 当前可见范围内如果有若干个头像符合,从y开始找下一个
: loop {
: ImageSearch, OutputVarX, OutputVarY, 0, y, Width, Height, 杨贵福.png ; %sender%
: ; 未找到,上滚,继续找 100次以内
: msgbox ,,, 正找呢
: if( ErrorLevel!=0 && not_found<100) {
: msgbox ,,, 没找到,滚屏再从上面重新找
: not_found += 1
: y = 0
: Send {WheelUp 50} ; 根据分辨率校准
: Sleep 1000
: continue
: }
: ; msgbox %OutputVarX% %OutputVarY%
: ; 找到头像了,根据发出的消息确认
: ; 消息文本在 鼠标向左1个头像宽度,向下1/2个头像高度,根据分辨率校准
: ; 假设群发消息的是"我",因此消息在头像左侧
:
: ; 取消息文本
: w := OutputVarX - avata_w
: h := OutputVarY + avata_h / 2
: MouseMove, w , h , 0
: ; msgbox ,,, %w% %h%
: clipboard := ""
: ; 不仅可能是文字,
: ; 可能是图片,没有想到关闭的方法,所以复制不用 ^c,而用鼠标右键复制
: ; 还可能是链接、表情,右键菜单都不同,因此鼠标右键也不行
: Click , Right
: MouseMove, 0 , 10 , R
: Sleep 1000
: Click
: Sleep 1000
: msgbox ,,, %Clipboard%
:
: if(InStr(Clipboard, question)) { ; 消息匹配
: msgbox ,,, 终于找到消息的开头了
: break
: }
: else
: {
: msgbox ,,, 消息不对,继续向下找
: y := OutputVarY + 1
: continue
: }
: }
:
:
: ; 在历史消息中,从今天群发询问的位置开始向下滚动
(3)消息文本
在上一需求的代码编写中,发现了一个悲剧。有个技术原型没有测试,原以为风险不高,结果不可或缺。
如何复制出消息文本。人眼是能看到的,就在头像旁边。问题还可以更简单和确定。我的在窗口右侧,消息在头像左边。同学们的回复在窗口左侧,消息在头像右侧。偏移量确定。
但是!消息不仅可以是文本,还可能是图片或链接。鼠标双击的如果是文本,那么会选中,接下来可以复制。鼠标双击的如果是图片,会打开图片,并且不自动关闭。鼠标双击的如果是链接,会打开浏览器 (在我的机器上这样配置的)。难点是,AHK不知道要双击的是文字、图片,还是链接。当然,你可能会说,可以用OpenCV什么的判断那是文字、图片、链接。然后,技术原型阶段已经过去了,现在是写代码实现功能,这样的意外会导致计划变更,还需要评估这部分的工作量、可行性、对需求和执行环境施加的新约束。
换个操作方式呢?鼠标右键,弹出的上下文菜单中,有时是复制,有时是删除(如果是未下载的图片)。如果消息是文本,消息的长度 (?)不同会导致有时上下文菜单在左,有时再右。再用OpenCV判断上下文菜单的位置?
没有把握的技术路线,需要的时间是无穷无尽的。这种不确定性大大降低了技术工作的愉悦。没把握,这是第二不爽。第一不爽是被监督和被指导。
复制不出来文本,就没法识别回应的消息是稳定还是不稳定。这条路线有事先未考虑到的细节,要完。
此时,不算技术原型,对需求实现产生了120行代码。两句三年得,一吟泪双流。有点累了,而且感觉前途渺茫。缺肉吃,饿。联系社区组织团购,没人理。自己组织,商家说不敢卖。运输也是问题,社区没功夫,自己运不让出门。所以停下来休息睡觉。
5. AHK 全选 + 正则表达式
午夜令人警醒,因此也倾向于过高估计困难的危险。天一亮就感觉好多了。
再次回到第一性原则,发现人手的操作还有别的路线。可以鼠标按下,上推鼠标光标,选中几屏头像、图像、文字等,然后复制。粘贴出来就是纯文本的了。
这么大一片纯文本,大可不必像上面找头像的方式,找一个头像检查一次文本,而是可以启用伟大的工具 正则表达式,匹配 "某同学 一堆字符 稳定",再去除"不稳定"。
有两个技术难点。
(1)稳定地选中内容。试了几种方案,不都稳定,故障现象也无法重现。有时选不上,有时选的内容有遗漏。一种方案是拖动。鼠标左键接下不抬起,向上拖动;鼠标到最顶,触发滚屏。保持几秒,假设能到达今天的第一条回应。不能用AHK MouseClickDrag,这个函数的结束时会自动抬起鼠标左键,因此无法触发滚屏。变换各种角度操作鼠标,现象无法重现或者不稳定。我甚至一度以5次连续正确为现象稳定的标准,后来想想这不就跟有些同学调超参数一样了么。跌入玄学?又感觉希望渺茫了。
后来想到鼠标左键按下以后,向上滚动鼠标。现象稳定了。
滚动一次,复制,分析文本,遇到的第一次 “今天你情绪还稳定么?",那就是到地方了,从这里向下都是我想要的。这样解决了滚到到什么时候停下来这个需求。
(2)正则表达式。这个单独写了一个AHK脚本,作为一个模块。这个模块与复制模块的接口,可以是文件,可以是进程间通信,我选择了剪贴板。前一脚本运行完毕,复制的历史消息在剪贴板里。 (正式部署deploy以前) 再运行后一脚本,从分析剪贴板中的文本,遍历每位同学,看他是否答复过稳定。要么稳定,要么不稳定,要么没吱声。
以上两部分技术难点,刚好是两部分需求中的技术重点,分别解决了,分别通过了单元测试。
集成测试出毛病了。
稳定选中内容,眼睛能看到。复制到的东西,作为检查点,从剪贴板里粘贴出来,到文本文件,或者到正则表达式工具 (见附录) ,做第三方检验,都是对的。保持这个剪贴板,立即运行第二个模块,即正则表达式的AHK,匹配结果不对。异常如何不是这里讨论的重点,略过,总之就是要么有人稳定报成不稳定,要么不稳定报成稳定,要么有人不稳定报成全员稳定。
单元测试正确,集成测试错误。毛病一定在模块间的接口上。但是,剪贴板这么简单的东西能有什么错。正则表达式写错了?把AHK手册正则表达式三章的英文和中文分别读了,见附录。还是感觉没毛病。按AHK手册,我特意写了一行代码,clipboard := clipboard 表示把剪贴板中可能的各种格式转为 plain text。
但是,我应该如此相信手册么?
做个实验。第一步,从第一个模块的输出得到剪贴板;第二步,手动把剪贴板粘贴到记事本,再从记事本全选、复制;第三步,跑第二个模块。成功了。
我知道为什么clipboard := clipboard不好使,但是它显然没有得到与向记事本
粘贴再复制出来相同的效果。
算了,简单粗暴非常有效。既然通过记事本可以,那么就通过记事本吧。所以,我的脚本跑的时候,第一模块结束,会运行记事本,转完剪贴板的格式以后,再把记事本关了。因为脚本是自用的,我甚至没有禁用鼠标和键盘,也免得万一执行有问题夺回操作权困难。
测试了几个晚上。同学们帮助测试,有时回复"我还对付",有时拖过10分钟等我监听完群消息再回复。测试通过了。
事实上,正则表达式就是对付写的,能应对简单的情况。稍微复杂点的……再说吧。就是下面这行。
: needle := ".*" element ".*:\R([^不:]*?)稳定"
某位同学,他回复了,回复的消息里包括"稳定",并且不是"不稳定"。这个"稳定"是他说的,而不能错误地匹配到他后一位同学的回复。
此时,去除 ImageSearch 版本不算,我又写了210行代码。
三、后续
GYB同学曾说,如果某位同学用脚本回复呢?这是个非常值得一试的问题。得识别教师的发言 (或者以时刻1024pm作为依据?)。然后呢,教师发言时附带个问题,比如"一加二等于几""从以下图像中找到所有的 火车""把下面拼图中的小块向右拉到正确位置"?然后呢,学生写个脚本自动回答。
计算机与计算机流畅地交流着,留痕也合规。
此时,我们终于可以有时间培养一下师生情谊,顺便讨论一下技术了。
附1:
AHK手册中文版
[https://wyagd001.github.io/zh-cn/docs/AutoHotkey.htm]
正则表达式 在线的测试工具
[https://regex101.com/]
代码如下。
: ; 在规定时间 10:34 启动
: ; 代码参考 [https://blog.csdn.net/liuyukuan/article/details/53860122]
: ; 检测回应的时刻, 22:34:00,格式223400
: 定时= 223400
:
: ;--------------------------------
: ;---------------------------------
: time_out = 5
: msgbox , , 信息 , %time_out%秒钟后AHK脚本开始倒计时, 启动对回应的监听, %time_out%
:
: StringLeft,当前日期,A_Now,8
: 提醒的时刻 = %当前日期%%定时%
: 提醒的时刻 -= %A_Now%, Seconds
: if (test_mode != "true"){
: ; debugmode ...
: Sleep 提醒的时刻*1000
: }
:
: ; part 1 模拟鼠标键盘 操作微信 复制聊天历史
: ;----------------------
: ; 微信群名
: group_name = 软件所-现役
: group_name_temp = 玫瑰花园
:
: ; --------------debugmode todo
: ; BlockInput, On
: ; BlockInput, MouseMove
:
: ; 找微信窗口
: ; 最大化,以方便滚动
: Run, C:\Program Files (x86)\Tencent\WeChat\WeChat.exe
: Sleep 500
: WinActivate, ahk_class WeChatMainWndForPC
: WinMaximize , A
: WinGetActiveStats, Title, Width, Height, XXX, YYY
:
: find_group(group_name, group_name_temp)
:
: Sleep 1000
: ; debugmode
: ; Send 开始检测情绪稳定的回应
: ; Send {Enter}
: ; Sleep 1000
:
: ; 往上翻到 ,多翻几下,假设能超过 询问消息 “今天你情绪还稳定么?"
: ; todo 分析复制得到的文本,如果未能达到询问消息的位置,就再向上翻
:
: ; 鼠标移到 历史消息
: gap = 50 ; 由 群消息编辑 到 群消息历史 的偏移像素,根据分辨率校准
: x := A_CaretX
: y := A_CaretY-gap
: ; 拖动鼠标,复制消息,向上滚动10秒,假设复制到足够多的消息
:
: Clipboard = ""
: MouseMove, x-15, y, 0 ;向上,到达历史消息栏,比文字光标向左一点,避开头像
: Click ; 如果已有选中,清除
: Sleep 1000
: Click, Down ; 鼠标左键按下
: Sleep 1000
: count := 0
:
:
: loop {
: count += 1
: MouseClick , WheelUp, , , 50 ; 保持鼠标左键不抬起,向上滚动鼠标轮
: Sleep 500
: ; 复制,检测是否有 当前日期标志,如果有,那就是到了我发的消息。
: Send ^c ;
: ClipWait
: StringLeft,当前日期,A_Now,8
: if ( InStr(clipboard, 当前日期) || count > 10) ; 找到了(或者滚动10次),停止滚动 todo 日期时刻 改为变量
: {
: if (clipboard == "") ; 没有复制到文本,重来
: {
: find_group(group_name, group_name_temp)
:
: Clipboard = ""
: MouseMove, x-15, y, 0 ;向上,到达历史消息栏,比文字光标向左一点,避开头像
: Click ; 如果已有选中,清除
: Sleep 1000
: Click, Down ; 鼠标左键按下
: Sleep 1000
: count := 0
:
: continue
: }
: break
: }
: }
: Click, Up ; 抬起鼠标左键
: Sleep 500
:
: ; debugmode
: ; msgbox ,,, %clipboard%
:
: ; todo debugmode
: ; WinRestore , A
:
: ; debugmode
: ;BlockInput, Off
: ;BlockInput, MouseMoveOff
:
:
: ;-------------------------------------------
: ; part 2 在聊天记录中匹配回复信息 以及 发送提醒消息
:
: message_receiver := ["位军营", "杨萍", "张宵", "2020-史志腾", "杜蕾", "韩亚光", "田洪轩", "唐一钦", "李娜", "马洪博", "王涛"]
:
: ; 需要监听这些人员的回答
: check_list := ["位军营", "杨萍", "张宵", "史志腾", "杜蕾", "韩亚光", "田洪轩", "唐一钦", "李娜", "马洪博", "王涛"]
:
: ; 未答复或情绪不稳定的人员清单,值是 平行数组 message_receiver 或 check_list 的下标
: mute_or_unstable_list := []
:
: Clipboard := Clipboard
: run, notepad
: sleep 500
: send ^v
: sleep 500
: send ^a
: sleep 500
: send ^c
: ClipWait
: send !{f4}
: send n
: ; debugmode
: ; msgbox ,,, ok
: hay := Clipboard
: ; msgbox ,,, % hay
:
: check(hay, check_list, mute_or_unstable_list)
:
: ; ----debug mode
: ; 找微信窗口
: Run, C:\Program Files (x86)\Tencent\WeChat\WeChat.exe
: Sleep 500
: WinActivate, ahk_class WeChatMainWndForPC
:
: ; 找群
: find_group(group_name, group_name_temp)
: ; --------------------
:
: for index, element in mute_or_unstable_list ; message_receiver 平行数组
: {
: Send {@}
: Sleep 500
:
: receiver_label := message_receiver[element]
: Send %receiver_label%
: Sleep 500
: Send {Enter}
: if (index < mute_or_unstable_list.Count() ) {
: Send {、}
: }
: else {
: Send {,}
: }
: }
:
: if (mute_or_unstable_list.Count() ==0 )
: {
: Send 全体稳定。
: ; debugmode ...
: Send {Enter}
: Sleep 500
: exit
: }
:
: Send ^{Enter}
: Send 以上同学回复未检出到或者情绪不稳。
: Send {@}
: Sleep 500
: Send 杨贵福,请关注。
: ; debugmode...
: Send {Enter}
:
:
:
: ; -----------------------------------------------------------
: check(hay, check_list, m_or_u)
: {
: for index, element in check_list
: {
: ; 未匹配 不带 不 的稳定,此人未回复 或 不稳定
: needle := ".*" element ".*:\R([^不:]*?)稳定"
: FoundPos := RegExMatch(hay, needle , OutputVar)
: ; debugmode
: ; msgbox ,,, %element% %FoundPos%
: if(FoundPos == 0 || ErrorLevel != 0)
: {
: m_or_u.push(index)
: ; msgbox ,,, %element%
: }
: }
: }
:
: ; 找群
: find_group(group_name, group_name_temp)
: {
: Send ^f
: Send %group_name_temp%
: Sleep 1000
: Send {Enter}
: Send ^f ;并显示群的历史消息最下方
: Send %group_name%
: Sleep 1000
: Send {Enter}
: Sleep 1000
: }