设计思想
从本质上来说这个项目不算机器人,因目前不能提供自动回复、自动对话的业务场景。
需求说明
公司大部分业务都是ToB的,运营管理着好几百个微信群,产品跟运营私交甚好,因此,在产品的耳边吹了一口气,“我们做一个微信机器人,帮助我们管理微信群吧,最好加上群聊,也可回答一些客户的问题”。产品听了,觉得这事可以有,想想,快到年底了,KPI考核也没什么补救,说不定这也就是一部分。
时间倒退到2017年8月底,产品开始设计一款名叫“微信机器人”的项目。嗯,看名字就觉得很高大上。BTW,设计稿呢???没有,就是口头产品,没进入评审环境,直接告诉我我们需要一款这样的…,能干…最好…的产品,Balabala说了一堆,其实就是做一个能够收发群消息,能够管理群成员,能够干微信能干的事情,最后,这些要干的事情,必须在我们的运营管理后台可操作,消息发送状态可查看 。
这项殊荣交给我了,限定要在9月初上线。
服务设计
拿到需求之后,并没有急着开发,而是先度娘问问,有没有现成的,如果有的话,直接copy一份也就成了(毕竟人家说我们是搬砖的)。调研后得知,大家都基于微信web版开发的,在GitHub上发现有一堆微信机器人项目,下载了一份Python的demo,直接在本地运行,发现没问题。但是业务肯定要修改,而且,我对Python并不是很熟悉,于是找了一份Java版本的。
读Java版本的源码,再加上自己使用Charles抓取微信的web版,发现流程都一致(图1)。但是处理业务的逻辑有很大的不同。
图1 web微信流程图
![][1]
网络上存在的微信机器人项目,都是开启单个守护线程,只能有一个用户登录,且不能通过提供API或者RPC接口。因此,根据业务的要求,重新设计了一套流程(图2)。
图1 web微信流程图
![][2]
服务设计也相对简单,和调用web API的流程基本也保持一致,最多也就是增加了一些自己的业务的逻辑设计。设计的过程也是几经变化逐步修改过来的。刚开始,微信反爬没有那么严格,就在第一个版本上线之后,发现微信的安全策略做的着实好,无论我发消息的频率阈值范围设置自我感觉多么合理的时候,微信服务器依然能够判定我就是机器人,web版登录还是被封,此是其一。其二,频繁的登录,或者说登录间隔在5分钟之内,都是synccheck接口返回的retcode为1101,所以,在监听事件处理方面的设计做了优化。
本月初(2017-12),微信封杀web版机器人的政策开始变得严格起来。线上项目基本还没怎么用,就出现被封的情况。眼看,此项目就要流产了,leader建议用类似按键精灵这种方式来群发消息。hn~项目也应该由“微信机器人”变为“微信群发助手”。写文章的这几天,看了wetool这样一款软件,写的很不错,但是没办法拿到源码或者说没有API调用,否则我就用它了。
设计思路也就需要改变了,从调用API变成了操作UI,可以说处于一个UI自动化测试的流程阶段(我还是有点气愤的说,老子不是搞测试的)。通过操作微信客户端界面,在搜索框中,输入群名,等待匹配群名,点击群名进入群聊窗口,在光标处粘贴剪贴板中的消息内容,点击发送(或者按Enter键) 。最最重要的多用户登录解决方案是 在Windows server2012中安装虚拟机,每个虚拟机中装win7,在win7中安装微信客户端以及运行环境,每隔虚拟机配置网桥,绑定独立内网IP,运营和ip做绑定调用即可 。至于,这些的实现,需要根据要求自己去琢磨。按键精灵可以快速的实现该功能,但是按键精灵不提供API,无法通过API调用。Sikulix是一款图像编程脚本,有API且有Java版本。
功能实现
因为我们需要直接在运营后台调用接口,发送服务消息。所以,我选择了sikuli脚本编程。开发环境是Mac,部署环境是Windows,所以代码兼容了Mac和Windows。
打开微信界面
openWechatDesktop
将已经打开的微信客户端窗口置于最前面,并判断是否有客户端存在。对于Windows系统而言,App.focus方法并不一定能让微信窗口置于最前面(测试发现,属于概率事件),所以直接返回true。getAppWindow
判断是否有微信的窗口,微信登录窗口和微信聊天窗口对于App.focus而言,都是运行中的窗口,因此,凭借App.focus不能判断微信聊天窗口是否打开,因此getAppWindow
方法对于已经存在的窗口做判定。但是对于Windows系统,调用Windows的消息处理句柄来判断窗口的存在。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 private boolean openWechatDesktop () { boolean isHasWindow = false ; if (Settings.isMac()) { App.focus(APP_NAME); isHasWindow = true ; } else if (Settings.isWindows()) { isHasWindow = true ; } return isHasWindow; } private boolean getAppWindow (String appName, String formClass, String formTitle) { if (Settings.isMac()) { String scanLogin = "scanlogin.png" ; String confirmLogin = "confirmlogin.png" ; Screen screen = new Screen (); return screen.exists(scanLogin) == null && screen.exists(confirmLogin) == null ; } else if (Settings.isWindows()) { return TryWithHwnd.setForegrundWindows(formClass, null ); } return false ; }
setForegrundWindows
调用Windows的user32接口,通过窗口类名或者窗口标题来获取句柄,并前置窗口。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public static boolean setForegrundWindows (String className, String windowName) { if (StringUtils.isEmpty(className) && StringUtils.isEmpty(windowName)) { return false ; } WinDef.HWND hwnd; if (StringUtils.isEmpty(className)) { hwnd = User32.INSTANCE.FindWindow(null , windowName); } else if (StringUtils.isEmpty(windowName)) { hwnd = User32.INSTANCE.FindWindow(className, null ); } else { hwnd = User32.INSTANCE.FindWindow(className, windowName); } if (hwnd == null ) { Debug.info("应用没有运行" ); return false ; } else { User32.INSTANCE.ShowWindow(hwnd, 9 ); User32.INSTANCE.SetForegroundWindow(hwnd); User32.INSTANCE.ShowWindow(hwnd, SW_MAXIMIZE); return true ; } }
微信客户端是否运行
isRunning
方法主要是执行Linux或者Windows命令,从进程列表中货物微信是否在运行。当然,这地方Windows的命令wmic是有坑点,至于是什么问题,在坑点说明中详细解释以及称述解决办法。
execWinCmd
和execCmd
用于执行Windows的命令和其他命令。其实另个的作用基本一样,但是传入的参数可能会有所不同,以及对wmic命令的Java执行有点不同(也就是上面说的坑点)。
runWeChat
打开微信的登录窗口。主要用于当我们检测微信没有打开或者微信进程不存在的时候,我们通过命令直接调用可执行文件或者脚本,启动应用程序。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 private void isRunning () { List<String> cmd = new ArrayList <>(); if (Settings.isMac()) { cmd.add("/bin/sh" ); cmd.add("-c" ); cmd.add("ps -ef | grep WeChat | awk '{print $8}'" ); } else if (Settings.isWindows()) { cmd.add("cmd.exe /c wmic process where caption='WeChat.exe' get commandline /value" ); } else { cmd.add("/bin/sh" ); cmd.add("-c" ); cmd.add("ps -ef | grep WeChat" ); } List<String> result = Settings.isWindows() ? execWinCmd(cmd.get(0 )) : execCmd(cmd); if (!existsProcessor(APP_NAME, result)) { Debug.info("微信应用没有打开" ); runWeChat(APP_NAME); } } private void runWeChat (String appName) { if (StringUtils.isEmpty(appName)) { Debug.error("不支持的操作系统" ); return ; } App app = new App (appName); app.open(); }
输入群名匹配群
和设计原理中说明的一样,我们是通过输入群名称来查找群名。但是如何输入群名称,以及在什么地方输入,鼠标是不知道,我们必须找到输入框,通过点击确定光标聚焦之后,才能输入文本。
或许有Windows开发经验的同学会说,怎么不用Windows的组件的消息句柄呢?!是的,一开始也是这么想的,但是经过调试发现,微信的Windows客户端UI是基于DirectUI封装的(Windowsless或者Handleless,也就是无句柄窗口),除了登录窗口以及微信登录之后的主窗口之外,其他均为DirectUI,Soga,没办法通过句柄定位输入框。
既然我们选择的sikulix,那么我们就通过图片识别来确认搜索框的位置,找到该区域之后再做点击、光标聚焦等事件操作。当然了,处理一般的按键类操作,都需要等待设备的响应,也就是需要我们等待几百毫秒。
输入群名称,匹配到群之后,我们单击第一个匹配的群,打开聊天窗口。这地方可能需要注意,群名支持最小匹配,所以,必须要想办法让匹配的群名称是唯一的且不是最小匹配单元。什么意思呢,就是说输入“测试群1”和输入“测试群123"打开的群都有可能是测试群123。
Mac和Windows的处理方式也有点不同,原因是在Windows中,我们打开微信客户端聊天界面的时候通过主窗口句柄执行了最大化,所以可以直接根据绝对坐标值搜索输入框,但是mac没有实现最大化,只能通过相对位置定位搜索输入框的位置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 private boolean findSearchInput (String groupName) { Screen screen = new Screen (); Region region = Region.create(0 , 0 , getScreenX(), getScreenY() / 2 ); screen.capture(region); Match target = null ; if (Settings.isMac()) { target = screen.exists("max.jpg" ); if (target == null ) { target = screen.exists("search2.jpg" ); } } else if (Settings.isWindows()) { return findGroupForWin(groupName); } if (target != null ) { Location location = target.getTarget(); Debug.info("找到微信搜索框x轴:%s" , location.x); Debug.info("找到微信的搜索框y轴:%s" , location.y); return findGroupForMac(groupName, target); } return false ; } private boolean findGroupForMac (String groupName, Match target) { if (StringUtils.isEmpty(groupName) || target == null && target.getScore() > 0.8 ) { return false ; } Location location = target.getTarget(); Location loc = location.right(55 ); Match match = new Match (target); match.setLocation(loc); match.setY(location.getY() - 10 ); match.click(); match.type(Key.TAB); sleep(1000 ); match.paste(groupName); match.setX(match.getX() + 35 ); match.setY(match.getY() + 80 ); match.setW(200 ); Region region = Region.create(match.x, match.y, match.w, match.h); return OpenChatRoom(region, groupName); } private boolean findGroupForWin (String groupName) { if (StringUtils.isEmpty(groupName)) { return false ; } Location location = new Location (75 , 25 ); Screen screen = new Screen (); screen.setLocation(location); screen.setH(25 ); screen.setW(190 ); screen.click(); sleep(100 ); sleep(1000 ); screen.paste(groupName); sleep(2000 ); screen.setY(screen.getY() + 60 ); screen.setH(60 ); screen.setW(200 ); return OpenChatRoom(screen, groupName); }
打开微信群聊天窗口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 private boolean OpenChatRoom (Region region, String groupName) { if (region == null || StringUtils.isEmpty(groupName)) { return false ; } if (Settings.isMac()) { Screen screen = new Screen (); screen.setRect(region.x - 10 , region.y - 10 , 90 , 30 ); sleep(1000 ); Match target = screen.exists("no_match.jpg" ); if (target != null ) { Debug.error("没有匹配到群:【%s】" , groupName); return false ; } } else if (Settings.isWindows()) { LOGGER.info("查询微信群: {}" , groupName); region.click(); WinDef.HWND hwnd = TryWithHwnd.getWindowHwhd("FTSMsgSearchWnd" ); if (hwnd != null ) { LOGGER.error("微信群不存在:{}" , groupName); TryWithHwnd.closeWindowHwhd(hwnd); return false ; } } region.click(); return true ; }
通过句柄关闭搜索群,弹出的未找到群的搜索框:
1 2 3 4 5 6 7 public static void closeWindowHwhd (HWND hwnd) { if (hwnd != null ) { User32.INSTANCE.PostMessage(hwnd, WM_CLOSE, new WinDef .WPARAM(0x00 ), new WinDef .LPARAM(0x00 )); } }
发送消息
打开聊天窗口之后,我们把传入的字符串交给sikulix,通过sikulix的paste方法粘贴。但在粘贴之前,我们需要找到鼠标光标的位置(其实这一步可以不需要),消息粘贴之后,执行enter键发送,注意发送的频率,过快依然存在强制退出的情况。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 private boolean sendMsg (String msg) { if (StringUtils.isEmpty(msg)) { return false ; } if (Settings.isMac()) { int x = MouseInfo.getPointerInfo().getLocation().x; int y = MouseInfo.getPointerInfo().getLocation().y; Screen screen = new Screen (); screen.setRect(x, y, getScreenX() - x, getScreenY() - y); Match target = screen.exists("input_emotion.jpg" ); if (target != null ) { target.y = target.y + target.h; target.click(); target.paste(msg); sleep(getRandom(1000 , 5000 )); target.keyDown(Key.ENTER); target.keyUp(); } } else if (Settings.isWindows()) { WinDef.HWND hwnd = TryWithHwnd.getWindowHwhd(FORM_CLASS); if (hwnd != null ) { Screen screen = new Screen (); screen.setRect(315 , getScreenY() - 140 , getScreenX() - 315 , 140 ); screen.highlight(3 ); screen.paste(msg); sleep(getRandom(1000 , 5000 )); screen.keyDown(Key.ENTER); screen.keyUp(); } } return true ; } public static HWND getWindowHwhd (String className) { if (StringUtils.isEmpty(className)) { return null ; } return User32.INSTANCE.FindWindow(className, null ); }
打包部署
项目基于Maven构建,使用maven-compiler-plugin
插件编译,使用的是maven-war-plugin
插件打包,其他插件用于单元测试以及对项目clean等。我们这里只说组装包到部署阶段的一些处理方式。
这里面涉及了几个变量,如果后面遇到了,其代表的意思和此处表达的一样,替换的值也是保持一致的:
${environment.dir}
指的是配置文件的目录,我们在父工程下面创建了一个目录,存放各种环境的配置文件
${environment}
指的是环境变量(dev,test,production),但是这里我们是把配置文件properties和环境关联在一起的,比如开发环境是dev.properties。
${java.version}
Jdk的版本,设置的是1.8
依赖配置
这里面我们在parent pom.xml中增加部署包的组装,组装插件maven-assembly-plugin
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 <build > <filters > <filter > ../${environment.dir}${environment}.properties</filter > </filters > <pluginManagement > <plugins > <plugin > <groupId > org.codehaus.mojo</groupId > <artifactId > cobertura-maven-plugin</artifactId > <version > 2.7</version > </plugin > <plugin > <groupId > org.apache.maven.plugins</groupId > <artifactId > maven-compiler-plugin</artifactId > <version > 3.0</version > <configuration > <source > ${java.version}</source > <target > ${java.version}</target > </configuration > </plugin > <plugin > <groupId > org.apache.maven.plugins</groupId > <artifactId > maven-surefire-plugin</artifactId > <version > 2.18.1</version > <configuration > <includes > <include > **/*Test.java</include > </includes > </configuration > </plugin > <plugin > <groupId > org.eclipse.jetty</groupId > <artifactId > jetty-maven-plugin</artifactId > <version > 9.3.0.v20150612</version > </plugin > <plugin > <groupId > org.apache.maven.plugins</groupId > <artifactId > maven-war-plugin</artifactId > <version > 2.6</version > </plugin > <plugin > <groupId > org.apache.maven.plugins</groupId > <artifactId > maven-assembly-plugin</artifactId > <version > 2.6</version > </plugin > <plugin > <groupId > org.apache.maven.plugins</groupId > <artifactId > maven-clean-plugin</artifactId > <version > 3.0.0</version > </plugin > </plugins > </pluginManagement > </build >
新建assembly模块
在我们的项目中增加一个子模块叫new-wxbot-assembly,我们按照maven-assembly-plugin的配置来写相关的代码。
下图是该模块的工程结构图(图3),assembly是插件的配置xml,bin使我们的相关脚本,启动和关闭Tomcat的脚本,config目录是一个配置目录,包含一个deploy.properties文件,保存了本地Tomcat的路径,加jdk的路径,还有一些Tomcat的端口的配置,这些配置都是用于在构建脚本的时候替换里面的参数。tomcat目录是Tomcat的虚拟目录配置。
图3 assembly结构图
![][3]
在assembly模块的pom.xml中做一些清理工作,并指定maven-assembly-plugin的描述文件等相关信息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 <?xml version="1.0" encoding="UTF-8" ?> <project xmlns ="http://maven.apache.org/POM/4.0.0" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation ="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd" > <parent > <artifactId > new-wxbot</artifactId > <groupId > com.zhoujunwen</groupId > <version > 1.0-SNAPSHOT</version > </parent > <modelVersion > 4.0.0</modelVersion > <artifactId > new-wxbot-assembly</artifactId > <version > 1.0-SNAPSHOT</version > <packaging > pom</packaging > <build > <plugins > <plugin > <groupId > org.apache.maven.plugins</groupId > <artifactId > maven-clean-plugin</artifactId > <configuration > <filesets > <fileset > <directory > ../deploy</directory > <includes > <include > **/*</include > </includes > </fileset > <fileset > <directory > ..</directory > <includes > <include > deploy.tar.gz</include > </includes > </fileset > </filesets > </configuration > </plugin > <plugin > <groupId > org.apache.maven.plugins</groupId > <artifactId > maven-assembly-plugin</artifactId > <configuration > <finalName > deploy</finalName > <outputDirectory > ../</outputDirectory > <filters > <filter > ../${environment.dir}${environment}.properties</filter > </filters > <appendAssemblyId > false</appendAssemblyId > <descriptors > <descriptor > src/assembly/release.xml</descriptor > </descriptors > </configuration > <executions > <execution > <phase > package</phase > <goals > <goal > single</goal > </goals > </execution > </executions > </plugin > </plugins > </build > </project >
assembly的描述文件是一个集合描述符,每一项都指定了源文件的目录,输入目录,以及过滤的文件名称。指的注意的是,pom中的finalName配置,该配置项指定了最终组装的包的名称,我们这里是deploy,而assembly描述符中生成的输出目录都在改目录下面 。
现在我们看看assembly文件的配置描述,directory为.
的描述是把assembly模块中的所有文件都放在tomcat/temp
目录中,src/bin
是把当前目录下src/bin
中的文件输出到bin
目录,以此类推。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 <assembly > <id > dist</id > <formats > <format > dir</format > <format > tar.gz</format > </formats > <includeBaseDirectory > false</includeBaseDirectory > <fileSets > <fileSet > <directory > .</directory > <outputDirectory > tomcat/temp</outputDirectory > <excludes > <exclude > */**</exclude > </excludes > </fileSet > <fileSet > <directory > src/bin</directory > <outputDirectory > bin</outputDirectory > <includes > <include > **/*</include > </includes > <fileMode > 0755</fileMode > <filtered > true</filtered > </fileSet > <fileSet > <directory > src/conf</directory > <outputDirectory > conf</outputDirectory > <includes > <include > **/*</include > </includes > <filtered > true</filtered > </fileSet > <fileSet > <directory > src/tomcat/conf</directory > <outputDirectory > tomcat/conf</outputDirectory > <includes > <include > **/*</include > </includes > <filtered > true</filtered > </fileSet > <fileSet > <directory > ../new-wxbot-web/target</directory > <outputDirectory > target</outputDirectory > <includes > <include > new-wxbot.war</include > </includes > <filtered > false</filtered > </fileSet > <fileSet > <directory > ../build/images</directory > <outputDirectory > sikuli/images</outputDirectory > <includes > <include > **/*</include > </includes > <filtered > true</filtered > </fileSet > </fileSets > </assembly >
mac系统
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 cd `dirname $0 `BIN_DIR=`pwd ` cd ..export LOG_PATH=/Users/zhoujunwen/data/logs/new-wxbotexport APP_DEPLOY_HOME=`pwd `export JAVA_HOME=`sed '/java\.home/!d;s/.*=//' ${APP_DEPLOY_HOME} /conf/deploy.properties | tr -d '\r' `export TOMCAT_HOME=`sed '/tomcat\.home/!d;s/.*=//' ${APP_DEPLOY_HOME} /conf/deploy.properties | tr -d '\r' `export APP_PORT=`sed '/app\.port/!d;s/.*=//' ${APP_DEPLOY_HOME} /conf/deploy.properties | tr -d '\r' `export TOMCAT_LOG=$LOG_PATH /tomcat.logexport CATALINA_OUT=/dev/nullexport PATH=$JAVA_HOME /bin:$PATH PRODUCTION_MODE=`sed '/env/!d;s/.*=//' ${APP_DEPLOY_HOME} /conf/deploy.properties | tr -d '\r' ` APP_DEBUG_PORT=`sed '/debug\.port/!d;s/.*=//' ${APP_DEPLOY_HOME} /conf/deploy.properties | tr -d '\r' ` APP_REMOTE_PORT=`sed '/remote\.port/!d;s/.*=//' ${APP_DEPLOY_HOME} /conf/deploy.properties | tr -d '\r' ` if [ ! -e $JAVA_HOME ]; then echo "********************************************************************" echo "**Error: $JAVA_HOME not exist" echo "********************************************************************" exit 1 fi if [ ! -e $TOMCAT_HOME ]; then echo "********************************************************************" echo "**Error: $TOMCAT_HOME not exist." echo "********************************************************************" exit 1 fi JAVA_OPTS_EXT=" -Djava.awt.headless=true -Djava.net.preferIPv4Stack=true -Dfile.encoding=UTF-8 -DdisableIntlRMIStatTask=true -Ddubbo.application.logger=slf4j" JAVA_DEBUG_OPTS=" -Xdebug -Xnoagent -Djava.compiler=NONE -Xrunjdwp:transport=dt_socket,address=$APP_DEBUG_PORT ,server=y,suspend=n " inet_ip=`/sbin/ifconfig|grep inet|grep -v inet6|grep -v 127.0.0.1|awk '{if(substr($2,1,5)=="addr:"){print substr($2,6)} else{print $2}}' |head -n 1` JAVA_JMX_OPTS=" -Dcom.sun.management.jmxremote.port=$APP_REMOTE_PORT -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.authenticate=false -Djava.rmi.server.hostname=$inet_ip " JAVA_GC_LOG_OPTS=" -XX:+PrintGCDateStamps -verbose:gc -XX:+PrintGCDetails -Xloggc:$LOG_PATH /gc -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=10M " JAVA_DUMP_OPTS=" -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=$LOG_PATH /dump_\${date}.hprof " JAVA_ERROR_OPTS=" -XX:ErrorFile=$LOG_PATH /hs_err_pid%p.log " if [ $PRODUCTION_MODE = "new-wxbot" ]; then JAVA_MEM_OPTS=" -server -Xmx4096m -Xms2048m -Xmn1g -XX:PermSize=256m -XX:MaxPermSize=512m -Xss256k -XX:+UseConcMarkSweepGC -XX:+UseFastAccessorMethods -XX:+UseCMSInitiatingOccupancyOnly -XX:CMSInitiatingOccupancyFraction=70 -XX:+PrintGCApplicationStoppedTime -XX:+PrintGCApplicationConcurrentTime " JAVA_OPTS=" $JAVA_MEM_OPTS $JAVA_GC_LOG_OPTS $JAVA_DUMP_OPTS $JAVA_ERROR_OPTS " else JAVA_MEM_OPTS=" -server -Xms512m -Xmx512m -XX:PermSize=256m -XX:SurvivorRatio=2 -XX:+UseParallelOldGC " JAVA_OPTS=" $JAVA_MEM_OPTS $JAVA_DEBUG_OPTS $JAVA_JMX_OPTS $JAVA_GC_LOG_OPTS $JAVA_DUMP_OPTS $JAVA_ERROR_OPTS " fi JAVA_OPTS=" $JAVA_OPTS -Djsse.enableSNIExtension=false" echo "********************************************************************" echo "**ENV: $PRODUCTION_MODE " echo "********************************************************************" export JAVA_OPTS=" $JAVA_OPTS $JAVA_OPTS_EXT " export CATALINA_BASE=$APP_DEPLOY_HOME /tomcatexport CATALINA_PID=$CATALINA_BASE /tomcat.pidif [ ! -d "$CATALINA_BASE /conf/Catalina/localhost" ]; then mkdir -p $CATALINA_BASE /conf/Catalina/localhost fi chmod -R +x $APP_DEPLOY_HOME /bin/$APP_DEPLOY_HOME /bin/tomcatctl start
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 DATE=`date +%Y-%m-%d` OS=`uname ` export LOG_PATH=/Users/zhoujunwen/data/logs/new-wxbotstart (){ if [ -f "$CATALINA_BASE /tomcat.pid" ]; then echo "warn: App is already run, please check." exit ; fi STR=`netstat -an | grep "$APP_PORT " | grep LISTEN` if [ ! -z "$STR " ]; then echo "warn: Tomcat port is already used, please check." exit ; fi if [ ! -d "$LOG_PATH " ]; then mkdir -p $LOG_PATH fi $TOMCAT_HOME /bin/startup.sh >$TOMCAT_LOG 2>&1 & starthttpd } stop (){ if [ ! -f "$CATALINA_BASE /tomcat.pid" ]; then echo "App process is not exist!" exit 1; fi if [ ! -d "$LOG_PATH " ]; then mkdir -p $LOG_PATH fi TIMESTAMP=`date +%Y_%m_%d_%H:%M` KILL_LOG=$LOG_PATH /kill.log echo "`hostname` was stopted at $TIMESTAMP " >>$KILL_LOG pid=`cat $CATALINA_BASE /tomcat.pid` kill -9 $pid sleep 5 str=`ps -p $pid |grep $pid ` if [ -z "$str " ]; then echo "APP $pid Shutdown is ok!" rm -f $CATALINA_BASE /tomcat.pid else echo "APP $pid Shutdown is failed!" echo "Please kill pid $pid manually ,and romove file $CATALINA_BASE /tomcat.pid" fi } starthttpd (){ STARTTIME=`date +"%s" ` COUNT=0 sleep 5 while true do RESULT=`curl --connect-timeout 1 -s http://127.0.0.1:8088/ok.htm` ENDTIME=`date +"%s" ` COSTTIME=$(($ENDTIME - $STARTTIME )) if [ -z "$RESULT " ]; then sleep 1 echo -n -e "\rWait Tomcat Start: $COSTTIME seconds" continue fi COUNT=`echo $RESULT | grep -c -i ok` if [ $COUNT -ge 1 ]; then pid=`cat $CATALINA_BASE /tomcat.pid` echo "APP $pid Start in $COSTTIME seconds." return else echo "ERROR: Start APP Failed!!!" exit fi done } usage () { echo "Usage: xxx {start|stop|restart}" exit 1; } case "$1 " in start) start ;; stop) stop ;; restart) stop sleep 5 start ;; *) echo $ACTION usage ;; esac
win系统
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 @echo off rem Windows启动服务脚本 rem --------------------------------------------------------------------------- rem Start script for the CATALINA Server rem --------------------------------------------------------------------------- setlocal rem 处理环境变量,替换配置文件 set "CURRENT_DIR=%cd%" cd ..set "LOG_PATH=${deploy.log.path} " set "APP_DEPLOY_HOME=%cd%" for /f "tokens=1* delims='='" %%a in (%APP_DEPLOY_HOME%\conf\deploy.properties) do ( echo ;%%a|find "java.home" >null&&set JAVA_HOME=%%b echo ;%%a|find "tomcat.home" >null&&set TOMCAT_HOME=%%b echo ;%%a|find "app.port" >null&&set APP_PORT=%%b echo ;%%a|find "env" >null&&set PRODUCTION_MODE=%%b echo ;%%a|find "debug.port" >null&&set APP_DEBUG_PORT=%%b echo ;%%a|find "remote.port" >null&&set APP_REMOTE_PORT=%%b ) if "%JAVA_HOME%" == "" ( echo ******************************************************************** echo **Error: JAVA_HOME is empty echo ******************************************************************** exit /b ) if not exist "%JAVA_HOME%" ( echo ******************************************************************** echo **Error: %JAVA_HOME% not exist echo ******************************************************************** exit /b ) if "%TOMCAT_HOME%" == "" ( echo ******************************************************************** echo **Error: TOMCAT_HOME is empty. echo ******************************************************************** exit /b ) if not exist "%TOMCAT_HOME%" ( echo ******************************************************************** echo **Error: %TOMCAT_HOME% not exist. echo ******************************************************************** exit /b ) set "JAVA_HOME=%JAVA_HOME:\\=\%" set "TOMCAT_HOME=%TOMCAT_HOME:\\=\%" set "TOMCAT_LOG=%LOG_PATH%\tomcat.log" set "CATALINA_OUT=null" set "PATH=%JAVA_HOME%\bin;%PATH%" rem "设置JVM相关参数" rem "配置环境参数变量" set "JAVA_OPTS_EXT=%JAVA_OPTS_EXT% -Djava.net.preferIPv4Stack=true -Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF8 -DdisableIntlRMIStatTask=true -Ddubbo.application.logger=slf4j" set "JAVA_DEBUG_OPTS=%JAVA_DEBUG_OPTS% -Xdebug -Xnoagent -Djava.compiler=NONE -Xrunjdwp:transport=dt_socket,address=%APP_DEBUG_PORT%,server=y,suspend=n" ver|findstr "5.1" >nul && ( set "m=ipconfig^|findstr /i " ip address"" )|| ( set "m=ipconfig^|findstr /i " ipv4"" ) for /f "tokens=14* delims=: " %%1 in ('%m%' )do ( set "IPV4=%%2" goto okIpNet ) :okIpNet set "inet_ip=%IPV4%" set "JAVA_JMX_OPTS=%JAVA_JMX_OPTS% -Dcom.sun.management.jmxremote.port=%APP_REMOTE_PORT% -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.authenticate=false -Djava.rmi.server.hostname=%inet_ip%" set "JAVA_GC_LOG_OPTS=%JAVA_GC_LOG_OPTS% -XX:+PrintGCDateStamps -verbose:gc -XX:+PrintGCDetails -Xloggc:%LOG_PATH%\gc -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=10M " set "JAVA_DUMP_OPTS=%JAVA_DUMP_OPTS% -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=%LOG_PATH%\dump_\%date:~0,4%.%date:~5,2%.%date:~8,2%.hprof " set "JAVA_ERROR_OPTS= -XX:ErrorFile=%LOG_PATH%\hs_err_pid%p.log " if "%PRODUCTION_MODE%" == "octopus-new-wxbot" ( set "JAVA_MEM_OPTS= -server -Xmx4096m -Xms2048m -Xmn1g -XX:PermSize=256m -XX:MaxPermSize=512m -Xss256k -XX:+UseConcMarkSweepGC -XX:+UseFastAccessorMethods -XX:+UseCMSInitiatingOccupancyOnly -XX:CMSInitiatingOccupancyFraction=70 -XX:+PrintGCApplicationStoppedTime -XX:+PrintGCApplicationConcurrentTime " set "JAVA_OPTS=%JAVA_MEM_OPTS% %JAVA_GC_LOG_OPTS% %JAVA_DUMP_OPTS% %JAVA_ERROR_OPTS% " ) else ( set "JAVA_MEM_OPTS= -server -Xms512m -Xmx512m -XX:PermSize=256m -XX:SurvivorRatio=2 -XX:+UseParallelOldGC " set "JAVA_OPTS=%JAVA_MEM_OPTS% %JAVA_DEBUG_OPTS% %JAVA_JMX_OPTS% %JAVA_GC_LOG_OPTS% %JAVA_DUMP_OPTS% %JAVA_ERROR_OPTS% " ) set "JAVA_OPTS=%JAVA_OPTS% -Djsse.enableSNIExtension=false" echo ********************************************************************echo **ENV: %PRODUCTION_MODE%echo ********************************************************************set "JAVA_OPTS=%JAVA_OPTS% %JAVA_OPTS_EXT%" set "CATALINA_BASE=%APP_DEPLOY_HOME%\tomcat" set "CATALINA_PID=" CATALINA_BASE\tomcat.pid" set " CATALINA_HOME=%TOMCAT_HOME%" if not exist " %CATALINA_BASE%\conf\Catalina\localhost" ( md " %CATALINA_BASE%\conf\Catalina\localhost" ) call " %APP_DEPLOY_HOME%\bin\tomcatctl.bat" :end
调用Tomcat的启动命令:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 setlocal rem Guess CATALINA_HOME if not defined set "CURRENT_DIR=%cd%" if not "%CATALINA_HOME%" == "" goto gotHomeset "CATALINA_HOME=%CURRENT_DIR%" if exist "%CATALINA_HOME%\bin\catalina.bat" goto okHomecd ..set "CATALINA_HOME=%cd%" cd "%CURRENT_DIR%" :gotHome if exist "%CATALINA_HOME%\bin\catalina.bat" goto okHomeecho The CATALINA_HOME environment variable is not defined correctlyecho This environment variable is needed to run this programgoto end :okHome set "EXECUTABLE=%CATALINA_HOME%\bin\catalina.bat" rem Check that target executable exists if exist "%EXECUTABLE%" goto okExececho Cannot find "%EXECUTABLE%" echo This file is needed to run this programgoto end :okExec rem Get remaining unshifted command line arguments and save them in the set CMD_LINE_ARGS=:setArgs if "" %1"" =="" "" goto doneSetArgsset CMD_LINE_ARGS=%CMD_LINE_ARGS% %1shift goto setArgs :doneSetArgs call "%EXECUTABLE%" run %CMD_LINE_ARGS% :end
Windows设置
默认情况下,当用户没有在 Windows 上执行任何输入(没有鼠标键盘等的输入)并保持一定时间后,Windows 会自动切换到锁屏模式(或屏保模式),甚至待机。
一般情况下,这样不会有任何问题,而且也是推荐的设置(出于安全和节能的角度)。但是,如果这台电脑被用于进行一些自动化的测试,尤其是涉及到 UI 的交互操作时(比如,用脚本操控鼠标来模拟点击一个按钮),这将会是个很大的问题:鼠标键盘失效了!
解决这个办法的方案很简单:设置 Windows 的电源模式,让 Windows 不要自动锁屏和待机,同时去掉屏保。
基础概念阐述
我们需要了解3个在Windows操作系统中的经常用到却鲜为人知的对象:Session(会话)、Windows Station(工作站) 和 Desktop(桌面)。
Session
session表示用户会话,每个登录操作系统的用户都会分配一个唯一的登录会话,用于标识该用户。操作系统(Vista 及以上)保留0号会话给一些系统服务及用户态的驱动使用,第1个登录系统的用户使用的 Session ID 为1,该用户执行的所有应用程序都在 Session 1 下执行。
我们可以打开任务管理器,切换到进程列表,然后在菜单->视图->选择列中,勾选 Session ID 列。
![][4]
如果有其它的用户登录到系统,就会看到 Session ID 大于1的情况,比如远程桌面。
Windows Station
Station可以理解为工作站,它被认为是桌面和进程的安全边界。因此,每一个 session 都会包含多个 station,而每一个 Station 又包含1个或多个 Desktop。
但是,多个 Station 中,只有名字叫 Winsta0 的 Station 才是交互式的 station,也就是说只有它才能有 UI 并接受用户输入。所以每个 Session 都有一个叫 Winsta0 的用户进行交互。
Desktop
这里的Desktop并不是我们进入系统后所看到的那个蓝底的桌面(,我们看到的这个桌面,实际上只是一个窗口)。而是逻辑上的一个显示对象,它包含可显示的 GUI 对象(比如窗口、菜单、钩子等)。一般情况下,WinSta0 包含至少三个Desktop:登录(WinLogon)、屏保(ScreenSaver)和默认(Default,能看到所有应用程序的地方)。
同一时刻只能有一个Desktop处于激活状态(而能激活的 Desktop 只能属于 Winsta0)。用户还没登录的时候,登录桌面处于激活状态。登录之后,默认的Desktop处于激活状态。当达到系统电源设置的某个点的时候,系统切换到屏保,此时屏保Desktop处于激活状态。当用户按下 Ctrl + Alt + Delete 时,切换到登录Desktop,此时登录Desktop处于激活状态。
激活状态的Desktop才能接收用户输入,钩子才能获取其中的某个窗口消息。
如果你已经体验过 Win10,那应该就会知道 Win10 提供了很方便的创建多个Desktop的方式。
问题点处理
最小化问题
最小化会让远程桌面的会话切换到无图形界面的模式,这自然就无法继续接收鼠标、键盘的指令了。
断开远程桌面问题
关闭远程桌面会让系统切换到登录Desktop的界面,而在该Desktop上并没有我们打开的其它窗口,因此会导致 UI 自动化测试失败。
解决办法
最小化
通过设置注册表的值可以阻止切换到无图形界面。
32位系统
找到 HKEY_LOCAL_MACHINESoftwareMicrosoftTerminal Server Client,创建一个 DWORD 类型的值,名字叫做RemoteDesktop_SuppressWhenMinimized,然后设置值为 2。
64位系统
找到 HKEY_LOCAL_MACHINESoftwareWow6432NodeMicrosoftTerminal Server Client,然后和 32 位一样创建一个DWORD 类型的值,名字叫做 RemoteDesktop_SuppressWhenMinimized,并设置值为 2。
上面的改动会应用于整个机器,如果只想应用于当前的用户,把 LOCAL 替换成 USER。
关闭远程桌面
在远程桌面(被连接到的电脑)中先执行 query session 来查看当前登录到的 session,(远程桌面的 sessionName 都以 rdp-tcp 开头)。
![][5]
然后用管理员用户打开命令行工具,并执行 “tscon rdp-tcp#0 /dest:console”,其中 rdp-tcp#0 为该该命令会关闭远程桌面的连接,然后把连接返回给远程的那台电脑(绕开登录过程)。这里的 console 只是一个 session 的名字,而这个名字的意思并非是 C# 中 “控制台” 的意思,而是指带有输入输出设备的机器,一般直接登录电脑的会话就是 console。
假设电脑A执行 mstsc 连接到电脑B(,连接成功后,电脑B黑屏),此时在电脑B上执行上述命令后(替换对应的session名字),电脑A中的远程连接窗口会被关闭,并提示远程连接会话已经终止。电脑B(假设运行在另一台物理机上)会恢复到已经登录的状态,如果需要重新让电脑B恢复锁屏状态,可以在电脑B上执行如下命令:
rundll32.exe user32.dll,LockWorkStation
坑点说明
sikulix依赖的lib包
sikulix涉及到操作系统环境相关操作,因此在不同的环境会生成不同的lib包。在mac下,依赖文件中可以看出有这么一个依赖:com.sikulix:sikulixlibsmac:1.1.1
。该依赖中,有很多.dylib
文件,这些文件就是sikulix操作mac系统中相关应用的依赖库。同理,在Windows中,依赖变为:com.sikulix:sikulixlibswin:1.1.1
,该依赖包中有许多.dll
文件,这些文件就是与Windows环境相关的库文件。
在mac上,默认路径是:/Users/${yourName}/Library/Application Support/Sikulix
,在win中,默认是获取环境变量%APPDATA%
的目录,该目录默认为:C:\Users\${yourName}\AppData\Roaming\Sikulix\
,其中${yourName}为你的电脑当前用户名。
这些信息我们从哪里获取到呢?在Settings类中,找到getSikuliDataPath
方法,里面就有相关信息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public static String getSikuliDataPath () { String home, sikuliPath; if (isWindows()) { home = System.getenv("APPDATA" ); sikuliPath = "Sikulix" ; } else if (isMac()) { home = System.getProperty("user.home" ) + "/Library/Application Support" ; sikuliPath = "Sikulix" ; } else { home = System.getProperty("user.home" ); sikuliPath = ".Sikulix" ; } File fHome = new File (home, sikuliPath); return fHome.getAbsolutePath(); }
但是很遗憾,没有类似setter方法,用于用户指定其lib目录。对于win用户而言,可以通过修改环境变量%APPDATA%可以指定自己想要存放的目录。
这里,如果打好的war包直接部署在Tomcat中启动会报错。因为所需要的lib包不存在。在开发环境中,我们通过mvn exec:exec
执行一个main方法可以生成这些lib文件。但是部署环境我们没有maven环境,此时,最好在系统环境一致的情况下,将开发环境生成的相关文件复制到部署环境,路径就是上面说的getSikuliDataPath
指定的路径。
sikulix图片位置设置
sikulix是一款图形编程语言,因此,对图像的依赖是不言而喻的。比如我们查找一个图片的位置find(String imagePath)
,就需要指定图片的绝对路径。每个需要图片的方法,我们都填充一段路径前缀,看起来也很繁杂凌乱,尤其对于代码审美要求严格的人来说,是一件很苦恼的事情。当然我们可以将其前缀设为一个静态常量,做字符串拼接。不过,sikulix非常人性化的,在Settings中增加了一个全局配置图片路径的函数:
1 2 3 4 5 6 7 8 9 10 11 /** * create a new PathEntry from the given absolute path name and add it to the * end of the current image path<br> * for usage with jars see; {@link #add(String, String)} * * @param mainPath relative or absolute path * @return true if successful otherwise false */ public static boolean add(String mainPath) { return add(mainPath, null); }
我们借助该函数,可以设置我们的图片路径的前缀。这样我们不用在find、exists等函数中做字符拼接了,直接填写图片的文件名即可。
开启OCR图片识别中文识别失败
OCR (Optical Character Recognition,光学字符识别)是指电子设备(例如扫描仪或数码相机)检查纸上打印的字符,通过检测暗、亮的模式确定其形状,然后用字符识别方法将形状翻译成计算机文字的过程;即,针对印刷体字符,采用光学的方式将纸质文档中的文字转换成为黑白点阵的图像文件,并通过识别软件将图像中的文字转换成文本格式,供文字处理软件进一步编辑加工的技术。
百科词条
说白了,就是利用该技术识别图片区域的文字,将其保存为文本。sikulix默认是关闭OCR,开启是需要执行下面的操作:
1 2 3 Settings.OcrTextSearch = true; Settings.OcrTextRead = true; Setting.OcrLanguage = "chi_sim";
OcrLanguage默认识别英文,对于中文,需要安装中文训练数据包:chi_sim.traineddata,网上教程很多,我配置之后依然不能正确识别中文,命令行中倒是能识别中文,但准确率不高。
因此,不建议大家使用Sikulx中的OCR功能。
Java执行Windows的wmic命令不起作用
Java执行命令行的代码无非就是:Runtime.getRuntime().exec(cmd)
和new ProcessBuilder(cmd).start()
这两种。接收参数可以是字符串,也可以是字符串数组。
什么时候使用字符串,什么时候使用数组呢?
一般而言,如果命令中只有简单的单条命令,就可以使用字符串。如果命令行中出行管道流(grep)等,需要使用数组,具体的命令作为参数传给执行程序。
举个栗子:
1 2 3 4 5 6 7 8 9 10 11 12 List<String> cmd = new ArrayList<>(); if (Settings.isMac()) { cmd.add("/bin/sh" ); cmd.add("-c" ); cmd.add("ps -ef | grep WeChat | awk '{print $8 }'" ); } else if (Settings.isWindows()) { cmd.add("cmd.exe /c wmic process where caption='WeChat.exe' get commandline /value" ); } else { cmd.add("/bin/sh" ); cmd.add("-c" ); cmd.add("ps -ef | grep WeChat" ); }
上面例子中,先不要看win下的命令,mac下执行程序为/bin/sh
, 选项为-c
, 参数为ps -ef | grep WeChat | awk '{print $8}'
。
Windows中命令中,其他命令和Unix中类似,除了wmic。比如cmd.exe /c wmic process where caption='WeChat.exe' get commandline /value
这条命了,在Windows的命令窗口是可以执行,但是Java中执行报错或者无结果。
具体为什么会这样,我目前不清楚。不过,在stackover flow中找到了一个解决办法,在打开命令窗口之后,用输入流的方式将命令出入到命令窗口。具体看我这边写的一个方法,里面if (cmd.contains("wmic")) {
条件中的内容则为处理wmic特殊命令的方式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 public static List<String> execWinCmd (String cmd) { if (StringUtils.isEmpty(cmd)) { return Collections.emptyList(); } Runtime runtime = Runtime.getRuntime(); List<String> result = new ArrayList <>(); BufferedReader br = null ; String message; try { if (cmd.contains("wmic" )) { cmd = cmd.substring(cmd.indexOf("wmic" ) + 4 ); Process process = runtime.exec("wmic.exe" ); OutputStreamWriter out = new OutputStreamWriter (process.getOutputStream()); out.write(cmd); out.flush(); out.close(); br = new BufferedReader (new InputStreamReader ( process.getInputStream())); } else { Process process = runtime.exec(cmd); br = new BufferedReader (new InputStreamReader (process.getInputStream())); } if (br != null ) { while ((message = br.readLine()) != null ) { result.add(message); } } } catch (IOException e) { e.printStackTrace(); }finally { if (br != null ) { try { br.close(); } catch (Exception e) { e.printStackTrace(); } } } return result; }
文章写完了,感谢大家!如果大家有什么意见或者建议,请发Email:me@zhoujunwen.win。
注:Windows设置这一节的内容基本来自:《关闭远程桌面后如何使UI自动化程序仍处于可交互状态 》,感谢作者:Flicker 。
参考文章:
关闭远程桌面后如何使UI自动化程序仍处于可交互状态
Sikulix官方文档
tessdata
使用Google开源tesseract OCR用语言库报allow_blob_division解决方案
windows中文文档