改出一个 豆瓣好友列表导出工具

豆瓣网络资源菊 提到 望晴 提到 “有没有批量导出微信好友通讯录的方法或小工具?”

如下,支持 导出 [头像链接、人名、此人主页的链接、签名 到excel/csv]文件。

https://pic4.zhimg.com/v2-9163f26e3b4e63c0843202aea3d591ef_r.jpg

需要在浏览器安装 Tampermonkey 插件,

然后安装下述脚本。

访问豆瓣 我的关注 或 我的豆瓣|我的关注,然后 成员,会增加一个链接“导出好友”。点击它。

// ==UserScript==

// @name 豆瓣好友列表导出工具

// @namespace https://younggift.net

// @version 0.1

// @description 将豆瓣好友导出到文件。启用本脚本,进入豆瓣个人页面后,在『我的关注|成员』里会有一链接『导出』,点击即可。

// @author younggift

// @copyright 2022, younggift; 2018, KiseXu (https://kisexu.com)

// @license MIT

// @match https://www.douban.com/contacts/list*

// @require https://unpkg.com/dexie@latest/dist/dexie.js

// @grant none

// ==/UserScript==

// ==OpenUserJs==

// @author KiseXu

// ==/OpenUserJs==

(function() {

‘use strict’;

// 页面触发部分

if (location.href.indexOf(‘//www.douban.com/’) > -1) {

// 加入导出按钮

var export_link = ‘https://www.douban.com/contacts/list?tag=0&start=0&export=1’; //列表URL 及’&export=1’标记

$(‘#db-timeline-hd ul li.last’).after(‘<li><a href=”‘+export_link+'”>导出好友</a></li>’) //根据css定位

}

if (location.href.indexOf(‘//www.douban.com/’) > -1 && location.href.indexOf(‘export=1’) > -1) {

// 开始导出

getPage();

}

// 获取当前页数据

function getCurrentPageList() {

var items = [];

$(‘li.clearfix’).each(function(index) {

items[index] = {

icon:$(this).find(‘img.face’).attr(“src”).trim(),

name:$(this).find(‘h3’).find(‘a’).text().trim(),

link:$(this).find(‘h3’).find(‘a’).attr(“href”).trim(),

motd:$(this).find(‘span.signature’).text().trim(),

// 学习了 https://www.w3school.com.cn/jquery/jquery_ref_traversing.asp

};

// alert(items[index].pub);

// alert(items[index].mark_date);

// alert(items[index].link);

});

return items;

}

// 采集当前页数据,保存到indexedDB

function getPage() {

const db = new Dexie(‘db_export’);

db.version(1).stores({

items: `++id, icon, name, link, motd`

});

var items = getCurrentPageList();

db.items.bulkAdd(items).then (function(){

console.log(‘保存成功’);

// 获取下一页链接

var next_link = $(‘span.next a’).attr(‘href’);

if (next_link) {

next_link = next_link + ‘&export=1’;

window.location.href = next_link;

} else {

exportAll()

}

}).catch(function(error) {

console.log(“Ooops: ” + error);

});

}

// 导出所有数据到CSV

function exportAll() {

const db = new Dexie(‘db_export’);

db.version(1).stores({

items: `++id, icon, name, link, motd`

});

db.items.orderBy(‘id’).toArray().then(function(all){

all = all.map(function(item,index,array){

delete item.id;

return item;

})

JSonToCSV.setDataConver({

data: all,

fileName: ‘contacts_list’,

columns: {

title: [‘头像’, ‘人名’, ‘链接’,’签名’],

key: [‘icon’, ‘name’, ‘link’, ‘motd’]

}

});

db.delete();

});

}

//以下younggift未修改

// 导出CSV函数

// https://github.com/liqingzheng/pc/blob/master/JsonExportToCSV.js

var JSonToCSV = {

/*

* obj是一个对象,其中包含有:

* ## data 是导出的具体数据

* ## fileName 是导出时保存的文件名称 是string格式

* ## showLabel 表示是否显示表头 默认显示 是布尔格式

* ## columns 是表头对象,且title和key必须一一对应,包含有

title:[], // 表头展示的文字

key:[], // 获取数据的Key

formatter: function() // 自定义设置当前数据的 传入(key, value)

*/

setDataConver: function(obj) {

var bw = this.browser();

if(bw[‘ie’] < 9) return; // IE9以下的

var data = obj[‘data’],

ShowLabel = typeof obj[‘showLabel’] === ‘undefined’ ? true : obj[‘showLabel’],

fileName = (obj[‘fileName’] || ‘UserExport’) + ‘.csv’,

columns = obj[‘columns’] || {

title: [],

key: [],

formatter: undefined

};

ShowLabel = typeof ShowLabel === ‘undefined’ ? true : ShowLabel;

var row = “”, CSV = ”, key;

// 如果要现实表头文字

if (ShowLabel) {

// 如果有传入自定义的表头文字

if (columns.title.length) {

columns.title.map(function(n) {

row += n + ‘,’;

});

} else {

// 如果没有,就直接取数据第一条的对象的属性

for (key in data[0]) row += key + ‘,’;

}

row = row.slice(0, -1); // 删除最后一个,号,即a,b, => a,b

CSV += row + ‘\r\n’; // 添加换行符号

}

// 具体的数据处理

data.map(function(n) {

row = ”;

// 如果存在自定义key值

if (columns.key.length) {

columns.key.map(function(m) {

row += ‘”‘ + (typeof columns.formatter === ‘function’ ? columns.formatter(m, n[m]) || n[m] : n[m]) + ‘”,’;

});

} else {

for (key in n) {

row += ‘”‘ + (typeof columns.formatter === ‘function’ ? columns.formatter(key, n[key]) || n[key] : n[key]) + ‘”,’;

}

}

row.slice(0, row.length – 1); // 删除最后一个,

CSV += row + ‘\r\n’; // 添加换行符号

});

if(!CSV) return;

this.SaveAs(fileName, CSV);

},

SaveAs: function(fileName, csvData) {

var bw = this.browser();

if(!bw[‘edge’] || !bw[‘ie’]) {

var alink = document.createElement(“a”);

alink.id = “linkDwnldLink”;

alink.href = this.getDownloadUrl(csvData);

document.body.appendChild(alink);

var linkDom = document.getElementById(‘linkDwnldLink’);

linkDom.setAttribute(‘download’, fileName);

linkDom.click();

document.body.removeChild(linkDom);

}

else if(bw[‘ie’] >= 10 || bw[‘edge’] == ‘edge’) {

var _utf = “\uFEFF”;

var _csvData = new Blob([_utf + csvData], {

type: ‘text/csv’

});

navigator.msSaveBlob(_csvData, fileName);

}

else {

var oWin = window.top.open(“about:blank”, “_blank”);

oWin.document.write(‘sep=,\r\n’ + csvData);

oWin.document.close();

oWin.document.execCommand(‘SaveAs’, true, fileName);

oWin.close();

}

},

getDownloadUrl: function(csvData) {

var _utf = “\uFEFF”; // 为了使Excel以utf-8的编码模式,同时也是解决中文乱码的问题

if (window.Blob && window.URL && window.URL.createObjectURL) {

csvData = new Blob([_utf + csvData], {

type: ‘text/csv’

});

return URL.createObjectURL(csvData);

}

// return ‘data:attachment/csv;charset=utf-8,’ + _utf + encodeURIComponent(csvData);

},

browser: function() {

var Sys = {};

var ua = navigator.userAgent.toLowerCase();

var s;

(s = ua.indexOf(‘edge’) !== – 1 ? Sys.edge = ‘edge’ : ua.match(/rv:([\d.]+)\) like gecko/)) ? Sys.ie = s[1]:

(s = ua.match(/msie ([\d.]+)/)) ? Sys.ie = s[1] :

(s = ua.match(/firefox\/([\d.]+)/)) ? Sys.firefox = s[1] :

(s = ua.match(/chrome\/([\d.]+)/)) ? Sys.chrome = s[1] :

(s = ua.match(/opera.([\d.]+)/)) ? Sys.opera = s[1] :

(s = ua.match(/version\/([\d.]+).*safari/)) ? Sys.safari = s[1] : 0;

return Sys;

}

};

})();

 

牛顿法解方程-使用浏览器(Geogebra可选)做实验

提起牛顿法解方程,大一以上的同学都听说过。但是如果要求做实验写代码,不少同学可能会心生畏惧,会不会很难很麻烦。事实上,核心代码只有一行而已。本文演示的实验只需要浏览器,可选Geogebra。

1.计算机问题求解的三类方法

裘宗燕老师把计算机问题求解的方法归结为三类,https://zhuanlan.zhihu.com/p/58943925

。例如就解二元一次方程

a * x * x + b * x – c = 0

而言,求解的方法可以从以下角度分为三类。

第一类方法是公式,

x1=(-b +(b^2-4*a*c)^1/2)/ (2*a)
x1=(-b -(b^2-4*a*c)^1/2)/ (2*a)

(在解存在的情况下,)我们把a,b,c代入公式就能求出方程的根。这种方法适用于 我们对于方程的规律全部都了解。对于计算机学生而言,这个公式相当于领域知识或者先验知识。

如果我们对方程的全局知识并不了解,比如五次及五次以上的方程已证明没有求根公式,那么就需要第二类方法,计算机的核心思路——搜索。我们可以想办法穷举/枚举解空间中的所有可能性,把这些x逐一代入方程a * x * x + b * x – c。如果哪个x代入方程以后得到的结果是0,那么当前的x就是一个解。

搜索问题需要考虑以下问题。1.是否可以穷举。有理数集可穷举,实数集不可穷举。二次方程有些解并非有理数,所以严格地说,第二类方法搜索不能得到方程的根。不过,在工程和技术领域,我们可以不那么严格,并不一定得到精确的实数解,而是可以允许在一定误差内(比如,方程代入x以后求得的值与0间的差的绝对值)的就算命中。2.如何穷举,按什么顺序枚举。从小大到?3.如何以更快的速度穷举。二分查找?

第二类方法搜索,需要了解问题的局部规律。方法的应用正是对局数规律在全局上的推广。

第三类方法甚至连局数规律也不必知道,只需要了解个别(也许很多)问题的实例和解的实例。你可能已经知道了,接下来是采用统计学的或神经网络或深度学习或机器学习的手段,把个例推广到一般(并且在一定范围内检验)。

牛顿法是第二类搜索方法,基于……一系列原理,简而言之,对牛顿的信仰,搜索速度飞快,可以采用。

2. 牛顿法 算法步骤的几何解释

假设我们要解的方程是y=(fx),要求误差小于0.01。

抛物线在平面直角坐标系里是这样的。

image2假设我们初始猜测的值是10。

此时误差不小于0.0.1。

过 (10, f(x))点做切线,红色的。

image3

以 切线与横轴交点 作为新的x1,过 (x1, f(x1))点做切线,绿色的。

image4

此时误差不小于0.0.1。

放大一下,是这样的。

以 切线与横轴交点 作为新的x2,过 (x2, f(x2))点做切线,蓝色的。

此时误差不小于0.0.1。

放大一下,是这样的。

image5

以 切线与横轴交点 作为新的x2,过 (x2, f(x2))点做切线,棕色的。

此时误差不小于0.0.1。

放大一下,是这样的。

image6

以 切线与横轴交点 作为新的x2,过 (x2, f(x2))点做切线,青色的。看起来比青色暗一些,是因为在这个分辨下与原方程的黑线重叠了。

此时误差不小于0.0.1。

放大一下,是这样的。

image7

青色的线与方程黑色的线几乎重叠,放大一下。

黑色的线是方程,青色的线是最后一次的切线。

此时的误差为F1E1=0.0014,小于0.01。搜索到了符合要求的解。

image8

总结,每个步骤都有一些共同的特征。

步骤1 猜测x_k,得到(x_k, f(x_k));

步骤2 过点(x_k, f(x_k))做方程的切线,交于(x_k+1, 0),这样得到x_k+1;

步骤3 检测f(x_k+1) 与 f(0) 间的差是否符合要求。

 

缩小看全局,可以看到 从T1(10, f(x))开始,切线 红-绿-蓝-棕-青 的倾斜过程,以及切线与横轴的交点迅速逼近方程的根。

image9

3. 递归公式推导

 

因为每个步骤有共同特征,因此可以抽象为递归或迭代过程。

我们根据几何解释求出递归公式。

图示为几何解释,其中(x_k, f(x_k))的高度为 f(x_k)。

image10

我们根据导数定义得到公式,其中f(x)和f'(x)根据方程由程序员手动求出。

公式在此!

image11

如果不会求导,可以借助符号运算工具,比如 Mathmatica,或者https://www.wolframalpha.com/。如果像我一样访问不到,再如 https://www.derivative-calculator.net/https://zh.numberempire.com/derivativecalculator.php

image12

或者

image13

3. JavaScript代码

为什么想起来用 JavaScrtip实现呢。因为正在重读 SICP(计算机程序的构造和解释,JavasScrtip版本),发现这个开发环境真是友好便利。本文即重现SICP中的例题,再次推荐SICP。

在浏览器里按F12,然后Ctrl+o(相信用菜单打开文件也一样)打开 newton.js 文件。还可以不编译调试,随手改随手运行。

代码很短,核心部分只有一行。以下分段解释代码。

// In Geogebra
// f: y=2 x^(2)+3 x-4
// NSolve(0=2 x^(2)+3 x-4)
// = {x = -2.35, x = 0.85}
a = 2;
b = 3;
c = -4;
epsilon = 0.01;

以上,要解的方程是y=2 x^(2)+3 x-4。为增加通用性(和趣味性),a,b,c三个参数可以修改。Epsilon是误差的最大值。

function f(x)
{
    return a*x*x+b*x+c;
}
function df(x) // derived function
{
    return 2*a*x+b;
}

方程,以及方程的切线。其中f(x)是方程,df(x)是方程的切线/导数。

以下就是核心部分了。

function guess(x)
{
    return Math.abs(f(x)) <= epsilon ? x : guess( x - f(x)/df(x));
}

这一行就是核心

Math.abs(f(x)) <= epsilon ? x : guess( x - f(x)/df(x) )

如果误差小于预期,那么当前的x就是方程的根;

如果误差大于预期,那么继续猜下去,猜测的x_k+1<= x_k – f(x)/df(x),即上面 递归公式推导中的“公式在此!” 手写公式中的f'(x)就是代码中的df(x)。

guess(0); //0.8517236753856471
guess (-1); //-2.350915564067371

这两行是测试运行,分别由左边和右边逼近得到两个不同的根。

4.验证

求解的根代入方程,可以确认f(x)误差小于预期。

也可以通过Geogebra算一下。在左边输入,求出方程的近似解。

image14

4.递归有多快

程序员会思前想后很多因素,也许在某个具体的问题中根本用不上,但是万一墨菲法则发作了呢。比如,牛顿法收敛有多快,递归需要多少次,会不会暴栈。

道理上,收敛非常快,非常~是多快。我们看一下感性认识。修改上述js代码,把guess改成下面这样。

function guess(x)
{  
    console.debug(x);
    return Math.abs(f(x)) <= epsilon ? x : guess( x - f(x)/df(x) 
);
}

加了一行调试,把浏览器按以后F12的debug设置为允许显示。

image15

猜测一下,以10作为初始值,guess执行6次。

image16

如果猜测得糟糕一些呢,比如10000?guess执行16次。

image17猜测的初始值为原来的1000倍,guess增加了10次。

收敛有多快呢,我看把x系列放到excel里看一下。

无标题

猜得再离谱一些,1000000000。33次。33次这种量级,对于计算机而言,就是瞬间。

33

而且我们观察上面两图可以看出,最初的收敛非常陡峭,也就是说,极快地逼近目标值。如果容忍的误差大一些,会有更好地表现。

当然,牛顿法有适用范围,或者说有不适用的范围。然后,没有一种工程方法是完美的,只有不完美的工程师把某种工程方法应用在不适合的问题上。所以,牛顿法(以及Geogebra,SICP,JavaScriipt,Excel等等)真是强大啊。

微信图片_20220814182439

抽象与权衡:用appium打卡百词斩和扇贝

用appium做个了小工具,用来每天打卡百词斩和扇贝。本贴总结一下其中一些设计的动机,特别是抽象和权衡。

1. 需求

手机上有 百词斩 和 扇贝单词 两个app,要求在PC上运行appium,每天完成在这两个APP中的打卡。

v2-3f1b54f6b4d3cd50e78133ae8a8de425_r

百词斩和扇贝分别需要这样几个步骤,包括 点击打卡、分享到特定的几个微信群、领取因分享带来的铜板之类的奖励。接下来,百词斩要进入小班里领”铜板”,扇贝要进入听力计划中领”贝壳”。铜板和贝壳分别是对应APP中的”货币”,攒着主要是习惯,其次可以在万一中断了的时候购买”补打卡”。

v2-ac7ccebf71b33c839d5ac2278def17d2_rv2-309dd1f6227d6356af91bb3481246500_r

2. 技术原型

THX和YP同学已经分别做过充分尝试,珠玉在前,在此不做赘述。参见
THX [https://www.cnblogs.com/ourshiningdays/p/16023291.html]
YP,系列博客 [Appium实现百词斩 – 萍2樱释 – 博客园](https://www.cnblogs.com/ping2yingshi/p/16206816.html)
微信图片_20220626134608
3. 抽象 与 权衡

能容易出来,PC需要向手机发出七八个或者更多的请求,点击控件,或者通过输入微信群的名字搜索,这一类的操作。如果抽象出这些动作的共性,就可以把动作视为代码,写个引擎跑这些代码。这一类抽象的思路参见《黑客与画家》,作者提到程序有三类,大意是,一种是手写业务逻辑;第二种是编译器或解释器,即引擎,跑代码,即数据;第三种是LISP。这里我实现的抽象就是第二种。

3.1 动作类别

打卡中的动作有哪些共性呢?在 Appium Inspector 中可以看到,可做的动作包括 tap, send keys,查找控件/元素可以根据 xpath, 各种 id 等。事实上,这并非*打卡*特有的动作,而是PC可以向手机发送的所有动作。考虑到打卡并无太多的特性,因此经过权衡,姑且认为 (打卡与一般的动作) 相同。

Appium Inspector 操作

这样,动作的类别包括: (1) tap, (2) sendkeys。前者用于点击按钮,后者用于向文本框发送文字。

PC与手机通信是异步非阻塞的,确保同步的最简单方便是 等待。考虑到在打卡的动作中,手机对不同步骤操作的响应时间有长有短,所以不同的步骤需要的等待时间可能不同,因此再加一种类型, (3) sleep,也算一个动作类别。

可能需要取得控件上的文字,作为下一步或进一步判断的依据。比如打卡多少天了,比如是不是达到某个条件。这个动作类别是(4)getText,在当前版本中没有实现,作为保留了。

某个特定动作是否可以执行,可能需要前置条件,例如只有当今天的单词全都背完了,才可以打卡。背完单词就是打卡的前置条件,在界面中通过检测到”打卡”而不是”开始复习吧!”来确认。这个动作类别是 (5)precondition。

在实现和测试的过程中考虑到,如果前置条件未满足,即”打卡”未出现,打卡程序应该如何呢,弹出个对话框提示我自己?不如抛异常崩掉实现起来更方便,而效果是相同的。给自己用的小工具与给别人用的程序的重要区别就在这里,友好的提示统统不必,前置条件统统可以认为满足了。要知道,所有这些都需要花时间才能写出来,如果效益不大,那就偷懒好了。

在设计的过程中,我只考虑到以上可能。其中的 precondition 可能用不到,只是隐约的感觉,还不清晰。

在代码过程中,发现自己漏掉了两种动作。 (6)back,退后一步,是个常用动作,相当于语法糖,不必获取和点击回到上一步按钮; (7)tapFirst,点击符合条件的第一个控件/元素。在搜索微信群这一类操作中,还有领铜板和贝壳界面中,手机APP的实现是类似数组中的多个元素,运行时动态生成的。所以搜索的时候会有多个匹配的,在当前问题中,只需要点击第一个。

enum ActionType
{
tap,
sendKeys,
precondition,
getText, //reserved
sleep,
back,
tapFirst, //只支持id,不支持xpath
}

3.2 元素/控件 类别

如何查找到要点击的元素/控件呢?支持了根据 (1) xpath和根据 (2) id查找。

类似 sleep 这种类型的动作,是不需要元素和控件的,此时 元素/控件 类别就是 (3) nop。

enum ElementType
{
xpath,
id,
nop; //不做任何动作
}

不支持按坐标点击或swipe,如果堕落到那种程度,就不如使用 AHK (鼠标键盘脚本) + Anlink (在PC上操作和显示手机)完成了,所占磁盘空间比 appium + appium client + android库 + jvm 要小得多。

3.3 动作

这样,动作的共性就包括 哪一种动作 (动作类型)、如何定位到控件 (元素类型)、控件地址 (无论xpath还是id,用字符串;同时复用这个属性,如果动作类型是sleep,那么代表单位为毫秒的duration)。

属性枚举型变量WhenNotFound的值有三种可能,abort,retry,ignore,分别代表当未找到控件/元素时的后续动作,是终止程序,再试一次,还是做动作序列中的下一个动作。

public class Act {

public ActionType action; //动作类型
public ElementType element; //元素类型
public String control; //控件地址

public String text;
//在procondition时作为判断条件,
//在sleep时是整数,单位毫秒,
//在其余情况下作为注释

public WhenNotFound notfound;

构造函数这样重载,在没有WhenNotFound参数的构造函数中为WhenNotFound指定了默认值abort。这也代表了小工具的典型思路,如果条件不符合要求,就终止运行,由我手动设置环境条件达到满足要求再跑。

public Act (ActionType a, ElementType e, String c, String t,    WhenNotFound f){

public Act (ActionType a, ElementType e, String c, String t){

3.4 动作序列

若干动作的”组合”,以及对这一组合中动作的遍历,就构成了整个打卡过程。需要支持 动作之间的哪些关系呢?

首先想到的是 符合条件,一切满足要求,那么从头到尾执行一遍就行了。这是顺序执行。

其次想到,如果存在不符合条件的情况呢?例如,因为单词没背完或者听力时间不足,因此不能打卡。例如,APP今天多了一条广告,因此在特定步骤要找的控件/元素不存在,需要关了广告才行。例如,手机响应慢,sleep时间不足,需要再等3秒才能到下一动作期待的界面。例如,由于不明原因,appium java client在第一次搜索某个控件/元素的时候找不到(THX同学确认在python中没有类似现象),第二次搜索就能找到了,而且并非在第一次搜索以前sleep一会儿能够解决。这是要支持选择执行。

再次,要不要支持循环呢?向3个微信群发送打卡图片,用1组动作 (比如4个动作) 的3次循环,而不是展开成12个动作,这样好不好?这是要支持循环持行,也可以通过选择执行来实现。

每次APP发生变化时,改动以上组合中的动作,也可能需要修改 动作间的组合,引擎不应修改。

这里的其次和再次,都是对首先的更深层次的抽象。但是要考虑到,并非越抽象越好。我选择在当前版本只支持顺序执行,平坦的自前向后的执行。如果有必要,后续版本再做更深的抽象。先跑起来再说,最好是更好的敌人。

把动作放在了一个数组中,并且计划支持自前向后遍历,因此称为 动作序列。

// 初始化动作序列
Act actseq[]={
new Act (ActionType.sleep, ElementType.nop, “”, “5000”),
// —- 打卡
// new Act (ActionType.precondition, ElementType.id, “com.shanbay.listen:id/progress_bar”, “41.0”),
new Act (ActionType.tap, ElementType.id, “com.shanbay.listen:id/check_status”, “去打卡|已打卡”),
//  —- 微信分享
new Act (ActionType.tap, ElementType.xpath, “//*[@text=’微信好友’]”, “微信好友”),
new Act (ActionType.tap, ElementType.id , “com.tencent.mm:id/ebr”, “搜索”),
new Act (ActionType.sendKeys, ElementType.id , “com.tencent.mm:id/cd6”, “玫瑰花园”),//群
new Act (ActionType.tapFirst, ElementType.id , “com.tencent.mm:id/kpx”, “玫瑰花园”),
new Act (ActionType.tap, ElementType.id , “com.tencent.mm:id/gv3”, “分享”),
new Act (ActionType.tap, ElementType.id , “com.tencent.mm:id/gup”, “返回扇贝听力”),
// //———————-
new Act (ActionType.tap, ElementType.xpath, “//*[@text=’微信好友’]”, “微信好友”),
new Act (ActionType.tap, ElementType.id , “com.tencent.mm:id/ebr”, “搜索”),
new Act (ActionType.sendKeys, ElementType.id , “com.tencent.mm:id/cd6”, “喵”),//群
new Act (ActionType.tapFirst, ElementType.id , “com.tencent.mm:id/kpx”, “喵”),
new Act (ActionType.tap, ElementType.id , “com.tencent.mm:id/gv3”, “分享”),
new Act (ActionType.tap, ElementType.id , “com.tencent.mm:id/gup”, “返回扇贝听力”),
// //———————-
new Act (ActionType.back, ElementType.nop , “”, “back”),
// —- 听力计划
new Act (ActionType.tap, ElementType.id , “com.shanbay.listen:id/mine”, “我的”),
new Act (ActionType.tap, ElementType.xpath, “//*[@text=’听力计划’]”, “听力计划”),
new Act (ActionType.sleep, ElementType.nop, “”, “3000”),
new Act (ActionType.getText, ElementType.id, “com.shanbay.listen:id/plan_day”, ” / 天 听力计划”),
};

以上是扇贝的动作序列,百词斩的与此结构相同,差异只在动作序列中的每个元素不同。

3.5 引擎

引擎的作用是遍历动作的组合,在遍历到每个动作时执行这个动作。既然只支持顺序执行,那么就是从前向后依次访问。

引擎的触发是这样的。

//跑动作序列,loop-遍历
ci.runActionSeq (actseq);

引擎的作用是 (1)遍历,即 for (Act act : q), (2)执行序列中的当前动作,即根据动作类型 (以及参数)操作APP,即

switch (动作类型)
{   case 特定类型1:
操作APP;
break;
case 特定类型1:
操作APP;
break;

}

这是个标准的路子,类型于CPU执行指令。所不同者,这里因为只有序列,不支持跳转,所以没有修改下一动作下标的必要。为什么不支持跳转,因为假设所有环境条件都满足打卡要求,由我手动操作,比写代码的利益更高。这可以根据代码将 执行的次数*每次执行的时间 – 节省的手动操作时间 估算出来。

private void runActionSeq (Act q[])  throws Exception
{
AndroidDriver d = this.driver;
org.openqa.selenium.WebElement e = null;
for (Act act : q)
{
switch (act.action)
{
case tap:
e.click();
break;
case sendKeys:
e.sendKeys ( act.text);
break;
case precondition:
assert e.getText().equals (act.text);
break;
case getText:
//assert false;
System.out.println ( “==================” + e.getText() + ” ” + act.text + “====================”);
break;
case sleep:
Thread.sleep( Integer.parseInt(act.text) );
break;
case back:
d.pressKey(new io.appium.java_client.android.nativekey.KeyEvent(io.appium.java_client.android.nativekey.AndroidKey.BACK));
break;
case  tapFirst:
java.util.List<org.openqa.selenium.WebElement> L = d.findElements(By.id(act.control));
e = L.get(0); //index从0开始
e.click();
break;
}
//默认每个动作后sleep 几秒
Thread.sleep( 2000 );
}
System.out.println (“-AFTER finally——————————————-“);
}
}

无论百词斩还是扇贝,以上引擎是完全相同的,二者的差别只在动作序列不同。不过在开发中,我又偷懒,没有把引擎单独做成class文件,编译和部署了两次。

这个版本的引擎,对调用者暴露了内部结构。更深层抽象的一个角度是更好的封装。可以提供一个 动作序列类,而不是 动作 (序列)数组。动作序列类提供一个方法,next或者execute_current_action,调用者在循环中不再遍历数组的元素,而是不断 next并执行当前动作,直至halt动作 (或者容器的迭代器到达 end) 。

3.6 复用

我只打卡两个APP,写第一个打卡工具的时候,连引擎带动作序列都需要写,花了较多时间。第二个打卡工具,只需要变更动作序列 (以及连接参数之类的),只几分钟就完成了。

如果有更多打卡工具要写,复用的利益可以更明显。

对于DIY这个原则,有人提到第二次遇到相同的代码就应该抽象 (成函数或者类) ,按复用的要求去写。我的耐受力更强一些,或者说更能忍,一般第三第四次遇到,才会动手抽象。并且抽象的深度要控制在这样的程度–在可预见的未来 (包括扩展和维护等) ,(节省时间的) 利益大于 (设计和写代码以及因为抽象带来的调试麻烦的)支出。

抽象/复用带来的一个直接副作用是对每个操作个体的单独处理的灵活性降低了,而只能顾及到共性。如果在共性的遍历中加上些if来强调个性,那么很快就会充满if,不如不抽象展开循环的好。

事实上,我在跑了一两个月以后,才遇到广告。后来又遇到当前单词书背完了,界面又有变化。这种小概率事件,没有必要总结,没有必要为它们变更代码做出应对。不值得。想起《额尔古纳河右岸》里,部落每到遇到什么苦难,当前做的事情就再也不做了,也不知道是为了避免悲剧再次发生,还是为了纪念。这会导致后来做什么都缚手缚脚,正月不能剪头,过年不能说不吉利的话,蒜要叫义和菜,醋要叫做忌讳,民部要改名叫户部,写代码前不能抠脚趾头,读博客前不能喝水?

微信图片_20220626134619

4. 其他讨论

还有些与工程技术有关,而与抽象/模型无关的。

4.1 环境安装,不用maven

我用appium依赖的是 java client,又不想用 maven 把挺大的库都装上,所以费了些时间。不想用maven的原因,是当时头脑不太清楚,但是基于的原则是对的。我当时误以为写完代码要部署给别人,那么运行环境与开发环境不同,不应该要求用户也装个maven。所以我花了些时间跑 appium java client,缺哪个依赖jar或class,就去找。

需要以下依赖,1.8 M。

slf4j.api-1.6.1.jar
java-client-8.0.0.jar
commons-lang3-3.12.0.jar
logback-core-1.2.11.jar
logback-classic-1.2.11.jar

以下是appium需要的,677 MB。

selenium\
appium-inspector\
appium\

还有以下是连接我的手机需要的,15.2 GB。这么大,所以,感觉节省的时间和
空间都不值得。装上maven也不会更大多少。

android-sdk-windows\

4.2 缺点

占用空间太大, 如前所述,不仅jar,class,还有android。感觉还不如用 AHK (图像搜索) + Anlink 来得轻量级(这个方案的缺点是1.对屏幕分辨率有依赖,2.语法我不如java熟悉)。就打个卡而已,16GB,太兴师动众了。

速度慢。网上有讨论,难以解决。而且速度不稳定,有时候快,有时候慢,即使环境条件毫无变化。确定的是,不用USB而用wifi连接手机,速度总是慢得不可容忍。

变更也不方便。一旦有变化,需要重新编译。在调试期间,我为此写了个bat,专门用于编译和运行。本着使用 发布环境/运行环境,而不是 开发环境 的原则,我没在IDE中开发。

编译:

set JAVA_TOOL_OPTIONS=-Duser.language=en

javac -cp %classpath%;c:\tools\selenium\*;c:\tools\appium_java_client\*;c:\tools\selenium\lib\* Act.java

javac -cp%classpath%;c:\tools\selenium\*;c:\tools\appium_java_client\*;c:\tools\selenium\lib\*CheckinBaicizhan.java

运行:

set JAVA_TOOL_OPTIONS=-Duser.language=en

java -cp %classpath%;c:\tools\selenium\*;c:\tools\appium_java_client\*;c:\tools\selenium\lib\* CheckinBaicizhan

adb -s 8KE0219819009788 shell settings put secure default_input_method com.iflytek.inputmethod/.FlyIME

运行中的最后一行,是重置手机输入法为讯飞。appium连接时有参数把手机输入法屏蔽了,为了输入文字。在此置回我常用的环境。

5. 为什么写这个贴子

首先,给学生留的作业,用appium做自动化测试的实验,比如打卡百词斩扇贝、打卡微信读书进度。我自己手痒也做了个打卡百词斩和扇贝的。本着发布是最好的保存这一原则,我也写贴子总结一下。其次,同学们经常提到的一个问题,或者在初学阶段容易陷入的一种状态,就是只有技术原型/可行性实验,然后就代码,编译并发布了。没有设计的过程,也觉得不需要模型,没啥可抽象的–老师,你看,都跑起来好使了,你还想咋样呢。我手痒写的这段代码,对打卡的动作序列稍微做了一些抽象,算是个有设计有模型的粗糙的演示。最后,在工程中,我本人也经常抽象上了瘾,停不下来,陷入到过度工程中,眼看着代码越来越抽象,越来越难维护和调试。什么时候停下抽象的脚步,什么时候妥协,是个一直需要讨论的问题。对这段小代码的讨论给出了我做妥协时的一些考虑。

同学们参与需求讨论,或者看着别的同学代码形成的过程,读起我的贴子来,可能更容易理解,也更容易挑出问题来。这也是为什么写作这一篇的一个原因。

成熟程序员读完上述贴子的感觉可能是:就这?出于对于教学的需要,简单的例子比大点的系统更容易接受,大系统里的宝藏过多,会让初学者淹灭在细节 (和各种难点) 里。而且,麻雀虽小五脏俱全,如果不能为初学者所用的技术,初学者就会感觉以后再掌握也不迟。最后,一个优秀的原则,难道不应该适用于非常广泛的领域么?按我们的所相信的生产,按我们所相信的生活,按我们所说地去做,按我们所做的去说。

———————–

代码不贴了,不止一个文件,也不太短,而且核心部分已经在正文中给出了。如果有感兴趣的同学,向我单独要吧,或者我发到 github 吧。

微信图片_20220626134622

geogebra求解二元二次方程一例

工作的时候每个人表现各自不同。我写代码的时候骂骂咧咧,写到兴头上“破玩意”“啥玩意”之声不绝于口。二猫聚精会神的时候嘟嘟囔囔,“怎么回事怎么回事,怎么会有俩解呢”。二猫妈就会出现,“是因为有俩解,你看这里……”两个开始嘀嘀咕咕解题,一会儿哈哈大笑,“对啊对啊,你看果然吧。”

我猜测应该是个二次方程,如果画图的话,两条曲线两个交点,这样讲解两个解会更一目了然。于是祭起强有力的法器 geogebra,问,“啥题这么有意思,给我看看呗。”

1. 题目

是这么道题。

image1

二猫口头讲解,x2就是x^2,y2就是y^2。也不知道从哪找来的题,排版可真够粗糙的了。

我看都没看,就往 geogebra 里录入。

右上解,选 CAS和绘图区两个视图(这玩意叫做视图么?)。

image2

两个方程

eq1: x*y + x + y = 10
eq2: x^(2) y + x y^(2) = 24
image3

对应XOY平面直角坐标系中的两条曲线, eq1设置成蓝色,eq2设置成红色。

image4

交点呢,没看到啊?放大一下。在第一象限,两条曲线贴得很紧,不过确实有两个交点。书中暗表,我的错误就从这里开始埋下伏笔。这是两个交点不假,但是题目所求的并不是交点,而是x^2+y^2. 此两解并非彼两解,我无意中偷换了概念。

image5

当时我并不知道此处有坑,就昂首阔步走下去了。

2. 解方程

精确解({eq1,eq2},{x,y})

此处忍不住跳出来评论一下。不知道是geogebra原生如此,还是国内做的本地化。用汉语指令“精确解”,确实能为使用者降低门槛,翻译也符合国内的使用习惯。然而,当用户想查细节去找手册的时候,却没有汉语只有英文的。如果不知精确解这条指令在英文的手册中是如何称呼的,就连条目都查不到。

solve({eq1,eq2},{x,y})
image6两个交点,两个解。而且这两个解在图中正是两条曲线的交点,横轴以3为中心对称,±根号5,纵轴以3为中心对称,±根号5,两个值刚好互换……

等等!互换?这道题求的是x^2+y^2,如果把x和y互换,那不就应该只有一个解吗?这时注意到了刚刚的坑,解非解。

3. 验算

二猫和二猫妈解出两个解,数值不等。

验证一下,我猜得对不对,看起来x和y可以互换啊。顺便,我求出来的是两个解中的哪一个呢?

element(element(solutions({eq1,eq2},{x,y}),1),1)^2+element(element(solutions({eq1,eq2},{x,y}),1),2)^2
image7

二猫和二猫妈的两组解中,确实有一组是28。然而另一组解不是28,我的另一组,交换x和y却仍然是28。

4. 再验算

把求得的x和y代回方程中,看左右两边是否相等。

x1:element(element(solutions({eq1,eq2},{x,y}),1),1)
y1:element(element(solutions({eq1,eq2},{x,y}),1),2)
x1^(2)*y1+x1*y1^(2)
x1*y1+x1+y1
image8

都与预期完全符合。

或者

x1:element( element(solve({eq1,eq2},{x,y}),1),1)
-> x = -sqrt(5) + 3
y1:element( element(solve({eq1,eq2},{x,y}),1),1)
-> y = sqrt(5) + 3

5. 咦?

我求得的两组x,y(以及再求得的一组x^2+y^2),代回方程没问题,只能证明这两组解是对的。但是并不能表明就没有别的解了。

6. 复数解

突然就对 精确解 这个术语所暗示的含义产生了怀疑。通常会觉得,与精确相对的是模糊吧,即,不用 3+sqrt(5),而是求解为 5.24 这样?

image9

跟二猫妈讨论,咋知道你们的另外一组x^2+y^2解对应的x和y是实数呢?二猫妈说,另一组x^2+y^2是4,是实数啊,而且是正的。

x^2如果是正的,那么x就是实数。但是x^2+y^2是正的,可能x^2是负的(因此x不是实数),但是y^2是个更大的正数呢。

再解一次。

csolve({eq1,eq2},{x,y})
image10

果然,坏人在这里!还有两个解,都是有i的。

验算略去,代回原方程也都对,x^2+y^2也是正的没错,并且不是28。

image11

矩阵一下,对换x和y以后确实应该相等。。

image12

7. 近似解

既然到了这一步,近似解也跑一遍吧。

image13

很好玩。

8.竞赛题

按题目要求,x^2+y^2到底几个解呢?回顾一下。

image1

你以为是两个解?并不。二猫和二猫妈辛苦解出的答案为4的那组解需要舍掉。因为题目里说“已知x,y都是实数”。表面上看,这半句是用来降低题目难度的,然而,这是用来提升题目难度的。因为这是一道竞赛题,而不是 geogebra演示题。所以,拿到这道题的人,如果不是像我一样傻乎乎地求出每个x和y,那么一定会换元,直接求出x^2+y^2,而不经过求出x和y这一步骤。非常容易地,就会以为这道题不过如此非常容易,忽略了对求解结果的delta的检验。明明多求了两个复数解,却不符合题目要求了。

这是一道竞赛题,竞赛题哪有不骗人的。

所以说,处处是坑。我最开始做的结果就是对的,然而我并不知道自己用错了函数,瞎猫碰到死耗子,盲人骑瞎马跨过去没踩到而已,并不是远远地躲开了坑。二猫和二猫妈用了高级的手法,但是没有注意到表面降难度的条件却是坑。掉进去又出来,出来以后发现踩在另一个坑里,再出来发现还有一个坑。这就是最好玩的地方。

微信图片_20220528215636

读书打卡和锻炼打卡,以及AHK脚本

1. 习惯

长期有阅读的习惯,经常同时读几本。有时候读着读着就搁下,过几年想起来再接着读,甚或忘记读过一部分,又重头来读。读着读着觉得似曾相似,抚掌大笑原来是你,老朋友。

好记性不如烂笔头,得记下来。除了读书笔记以外,也尝试过不少记录进度的方法。比如尝试过读完归类,分成工程、素材、文学、非功利性……因为兴趣广泛,并且多数书籍都既是文学,同时非功利,也许我看重的正是里面的工程思想可以当作讲课或科幻的素材。MECE不重不漏,太难做到了。

笔记和日志的范围,很容易就超出五年十年。时间流逝,由于兴趣变化,关注点迁移,后来不仅分类,我连标签都懒得加。就是流水账。

这样,除了读书笔记,页边吐槽,A4纸做的书签写满画满,读书的记录就完成了一行。有的读得酣畅淋漓,想来当初读的时候一定不舍昼夜。比如 黑客与画家 [2011-12-14 Wed]–[2011-12-17 Sat],只有短短三天。有的读得了很久,像 伯罗奔尼撒战争史 [2012-04-03 Tue]–[2012-07-04 Wed] ,足足三个月,当时应该是细细品味,不断掩卷追思来着。有的书读了又放下,捡起来又放下,如是者三,到现在也还在不断慢慢耗着。有的书记录的阅读时间非常短,但是训练内化的过程极其漫长艰难,个中滋味不足道哉,也就没有记到那一行里去。像 囚徒健身 [2013-11-13 Wed]–[2013-11-16 Sat],这很可能是第一轮阅读的记录。后来再无记录,但是每周的训练里仍然有这本书的影子,框架,循序渐近,尽可能避免受伤,阶段指标,这些影响还在。

像锻炼,还有其他需要训练的,数学、编程、英语、写作、美术、音乐,这些的训练时间远超过阅读的时间,也都没有记录,不算阅读。

说起锻炼,我也保持了相当长时间记录的习惯。本来断断续续写日志,后来在YK老师倡导打卡的群里打卡,索性每天记录,也充作打卡的内容。记录包括练了什么 (动作、重量、多少组,每组多少个,有时包括休息时长) ,什么感觉。感觉只记了较为客观的体验,受伤了,疼了,费劲,容易,什么的。事情上,在训练量大的时候,多数时间的感觉就是想骂人而已。我还记得一边卷腹,一边咒骂教练的智慧,是怎么想到这些折磨人的姿势的,刚好不容易发力。教练说,应该如何如何,要加油什么的,我就恶毒地隔空骂回去。这些过于个人化的情绪都没有记。

对仪式感我挺不能适应的,特别是别人施加给我的仪式感,哪怕是为了我好,哪怕我也认可真好的。不过,我也很意外,相信不少熟悉我的人也会非常意外,我对遵从合规居然能够接纳得还不错。

微信图片_20220520220212

长达几年的打卡,几乎每天不断,我就是用手写的,一个一个群里发过去。锻炼,现在一般是两个群,读书三个群。百词斩三个群,扇贝两个群。疫情打卡一个群。每天日志推git,手动执行 add,checkin,push 4年,才开始写个简单的脚本,改吧改吧,改动不多,用到现在。

想起群里有人问过我,发博客的时候,我一般都发在三四个地方,用了什么工具。我看到问题最初一楞,没有想到要问的是什么。因为,我并不以发在三四个地方,分别排版,不以此为苦。排版的时候,有时我还挑挑错别字,回味一下哪里写得不够好。

与博客发往多个站点一样,记录日志和群发打卡也是,我就一下一下,每天分别打的。没有自动化工具,难道就不干活了么。大刘先生在《全频道阻塞干扰》的结尾借美国将军之口说:我们也不是从来一直就有最好的武器,也并非没有最好的武器就不能战斗――对面已经全线压上,退后就是大海,他在简短的演讲后下达命令:士兵,上刺刀。

较早的锻炼记录是这样的,到现在也没有大的变化。

>* 俯卧撑[2011-01-16 Sun 20:21]
>50个。流汗不多,喘得不太厉害。
>事先做了左手腕康复训练,不过还是有些胀痛。
>后背和前胸舒服一些。
>
>查用锻炼治疗后背疼。用矿泉水瓶的那个教练的视频提到,俯身飞鸟有效,尤其
>是对肩胛骨下疼痛。

读书进度的记录和打卡晚近一些,像这样:

>[2020-03-22 Sun]
>* 读书打卡
>北野武的小酒馆 第四章
>莎士比亚戏剧集 喜剧I 仲夏夜之夜 第二幕
>瓦尔登湖 The Ponds
>Bodyweight Strength Training Anatomy 第二章 手臂

2. 关键技术,使用AHK脚本

微信图片_20220520220235

我知道DRY原则。有人说,如果要重复两次的,就应该写函数了。我能忍到三次。

在代码中,写作时重复三次的,运行时可能会重复几十万次。单单在后续代码的维护和变更中,可能就要有几十次修改涉及到重复的地方,所以抽象/重用,就有特别的必要。

在工作和生活中这些打卡动作都具有这样一些特点。1.涉及的动作种类不多。比如只有搜索群、输入群名、发送固定的内容,甚至不必检查群里的响应;2.重复次数不多。虽说”长期”打卡,你打过多少年?一年不过350多次,即使打卡十年不过3500多次而已。分布在许多天里,实在算不上什么负担。3.每次动作持续时间不长。我测量过时间,打卡百词斩和扇贝这类需要五六个动作的,也不足一分钟。打卡阅读和锻炼,可能操作的时间半分钟都用不上。写完日志或报告,剩下的操作时间,没有多长,可以全算作休息。

只有两种情况例外。一种是你对打卡这一富有仪式感的动作序列厌烦了,那么这时候自动化能降低心理的负担,有利于保持愉快。另一种是你闲着技痒,把做个打卡代码当成富有 (微小)创造性的游戏,好玩。

最简单的打卡,是只打卡,不看回应。

从需求到技术

1.系统显示文本框,用户在里面填写打卡内容。我就是把日志复制粘贴进去。比如今天的一部分:

>傍晚,跳一组心肺。从提膝跳膝下拍掌开始测心率, 137,140,13X,160。
>
>附:
>心肺一组
>开合跳 50
>提膝跳膝下拍掌 50
>提膝跳拍膝 50
>深蹲跳摸地 50
>交替箭步蹲跳 50

2.用户单击发送按钮。为了点击方便,我把点击按钮做得特别大,鼠标大致往那个方位一按就行,避免鼠标急走急停耗费的精力、腕力、时间。按钮大到你可能找不到它,就在文本框的下面,大小跟文本框差不多,占整个对话框一半,上面有个小小的“OK”。

OK

3.在微信里,向特定的几个群发送消息。这涉及到,找到微信窗口,找到群,向群里发消息。

找到特定控件,或者向特定控件发消息,AHK我用过几种方法。包括 图像匹配找到控件 (像素匹配,不能缩放) ,快捷键 (按键组合,比如 ctrl-f) ,鼠标按坐标点击 (需要根据显示器分辨率标定)。在这里,我只用快捷键,相当于全用键盘操作。ctrl-f 开始搜索,键入群名,回车就到了聊天窗口,发送文本框中的内容。

在寻找群时,我操作了两次。因为在其他项目 (情绪稳定,监听回复) 的需求中,需要跳到群聊天记录的最新一条,操作两次可以保证达成这一效果。在这里我复制了那段代码,操作两次并不带来负面效果,所以就没改保留了。

一个小技巧,AHK虽然功能强大,可能由于本地化/国别/输入法的关系,偏偏发送内容不太可靠。一个简单的方法是用剪贴板。把要发送的内容赋值给剪贴板,然后发送 ctrl-v 快捷键粘贴上去。

4. 星期几

打卡时有日期,还包括星期。我日志里长期保持使用英文缩写”Mon””Tue”,但是AHK按默认操作系统本地化以后,会显示中文的”星期一””星期二”。我写了一小段查表转成英文缩写,保持与我日志风格相同。

>Switch A_DDD
>{
>Case "周一":    ddd=Mon
>Case "周二":    ddd=Tue
>Case "周三":    ddd=Wed
>Case "周四":    ddd=Thu
>Case "周五":    ddd=Fri
>Case "周六":    ddd=Sat
>Case "周日":    ddd=Sun
>}
>current_day = %A_YYYY%-%A_MM%-%A_DD% %ddd%

微信图片_20220520220238

3. 代码

代码没多长,总计50多行。去除测试和注释,估计50行有效的?

以下。

#SingleInstance force

; 测试模式
test_mode = false

; 微信群名 列表

group_name := ["玫瑰花园", "天天向上"]

;---------------------------------

; 用户输入发送的文字
Gui, +Resize  ; Make the window resizable.
Gui, Add, Edit, vMsg WantTab W600 R20
Gui, Add, Button, default  w600 h200, OK
Gui, Show
return  ; End of auto-execute section. The script is idle until the user does something.

;--------------------------

ButtonOK:
    Gui, Submit
    out = %Msg%

; 时间戳
Switch A_DDD
{
Case "周一":    ddd=Mon
Case "周二":    ddd=Tue
Case "周三":    ddd=Wed
Case "周四":    ddd=Thu
Case "周五":    ddd=Fri
Case "周六":    ddd=Sat
Case "周日":    ddd=Sun
}
current_day = %A_YYYY%-%A_MM%-%A_DD% %ddd%


; 找微信窗口
Run, C:\Program Files (x86)\Tencent\WeChat\WeChat.exe
Sleep 500
WinActivate, ahk_class WeChatMainWndForPC

; 找群
for index, element in group_name
{
    Sleep 1000
    Send ^f
    Send %element%
    Sleep 1000
    Send {Enter}
    Sleep 1000

    Clipboard = [ %current_day% ]
    Send ^a
    Send ^v
    Sleep 500
    Send ^{Enter}
    Sleep 500

    Clipboard =  %out%
    Send ^v
    if (test_mode != "true"){
       Send {Enter}
    }

}

time_out = 5
msgbox , , 信息 , 完成,%time_out%秒钟后退出, %time_out%


ExitApp
return

稳定了,解决传输途径不畅的心路历程:AHK操作微信群第3集

微信图片_20220512152526

1.前情回顾

为……我写了个AHK脚本,每天在微信群里问同学们“你情绪稳定不”,同学们帮助我一起测试,也调侃我。调试的过程虽然不漫长但是曲折,值得一讲的故事写成了两篇博客。一篇是《写个脚本,问候你今天情绪稳定不》[https://zhuanlan.zhihu.com/p/488537616],介绍了如何用AHK脚本自动化定时向微信群发送消息,需要应@尽@。另一篇是《情绪稳定不稳定的心路历程 – AHK监听微信群回复》[https://zhuanlan.zhihu.com/p/496703430],介绍读取微信群中同学们的稳定回应,然后用正则表达式匹配找到不稳定或沉默的同学。image1在第二篇中报告了,当时有个相当对付的解决手法,活儿干得有点脏。背景是AHK操作在微信中选择聊天记录,复制到剪贴板。然后在AHK中按名单列表通过正则表达式匹配获取稳定不稳定。在这样的技术背景下,发现正则表达式匹配有问题,谁都能匹配到,无论他发言是不是“稳定”,并且匹配到的偏移量总是整个剪贴板文本字符串的最开头,索引下标是1。然而,保持同一个剪贴板的内容,贴到浏览器里,通过在线的 pcre online 工具匹配,没毛病,匹配的发言和位置都正确。又,把剪贴板粘到记事本里,再复制回剪贴板,也没毛病。 image2

如下图所示,从AHK操作微信得到的同一剪贴板,到正则表达式匹配,有毛病;过记事本,再到正则表达式匹配,没毛病;通过浏览器使用 PCRE regex online工具匹配正则表达式,也没毛病。

当时解决这一故障的手段挺对付,用AHK打开个记事本,把剪贴板粘到记事本里,再全选、复制到剪贴板中。对从记事本 过 过一遍的剪贴板做正则表达式匹配,达到了想要的效果。

然后终究是不优雅,意难平。

2.心路历程

故事的最终结局是 问题解决了,仅一行代码。难点是在哪里,加入一行什么代码。有趣的部分是定位故障的原因,就像侦探小说一样,到底谁是坏人不重要,重要的是寻找坏人的过程和论证、揭示的严密性。

那行代码在本文最后,接下来是过程。

2.1 验证性实验

要想解决问题,先要知道原因。不知道原理的实验,就是瞎试。瞎试的实验,说得好听一点,可以叫做“探索性实验”。这改改,那捅咕一下,看看会发生什么。婴幼儿和小猫对这类活动乐此不疲,借此认识世界。然而这不是干活的路子,因为一爪子下去是毫无反应还是天崩地裂,我也没谱。干活得有计划,至少要有预期。不然,就是纯玩儿了。

干活什么样呢。我们事先就知道一共就这么四五种方法,哪种方法有什么优缺点也知道,这个活有什么需要也知道。优缺点和需要匹配一下,就知道哪个方法最适合当前需求。解决问题也差不多。造成这个症状的可能一共就那么四五种,挨个试一下,有可能的话探测一下这四五种可能原因的特有现象,有哪个现象就是哪种原因。这是“验证性实验”。

严格地说,应该使用“证伪性实验”。所有的实验都不是为了验证你猜测的可能原因是符合事实的,而是尽可能设计刁钻的实验试图推翻猜测,如果没推翻,那么就可以暂时认为也许、大概、差不多就是这个原因,或者最不坏的就选它了。当然,证伪性实验的第一步与验证性实验相同,就是要先验地知道有哪些可能的原因。所谓”不重不漏”中的“不漏”在这里特别重要。如果你事先没想到,实验是万万什么也发现不了的,因为干活的成年人不探索。

微信图片_20220512152529

2.2 猜测-剪贴板里的不是文本

这就是瞎猜的,并非从知识体系中回忆起“此类情形有如下三种可能”这样的问答题。如果猜测正确,即剪贴板里不是文本,而是包括文本、格式,以及图像等在内的一些东西(也许不止富文本),就能解释为什么通过记事本以后能好使。事实上,我正是从记事本是纯文本想到了这种可能,也就是记事本与AHK得到的剪贴板的区别是什么,区别是记事本是纯文本的。

验证实验的话,第二篇博客的解决方案就是了。你看,好使,通过记事本把剪贴板转换为纯文本好使了,因此剪贴板原来并非纯文本就是问题的原因。

这个逻辑是错误的,“不重不漏”中有漏。漏了什么呢,把剪贴板转换为纯文本固然使症状消失了,A方法使症状消失,A方法就是原因么?用酒精擦身子能退烧,皮肤缺酒精是发烧(或不退烧)的原因么?喝咖啡心情好,缺咖啡是抑郁的原因么?

攻击的实验是这样的,不少博客说 “Clipboard := Clipboard”,这样一行AHK代码,或者类似的代码,可能把剪贴板转换为纯文本。我加了这行代码,不好使,经过记事本转化就好使。我们还可以补充猜测,这行代码不好使,博客不可信。但是,AHK官方手册中文英都这么说。手册也烂?有时候是这样,那么——有人可能接着想到的是如何解决呢,联系手册作者……但是故障现象这么漫长,而且是微信这种特定场景下,怎么描述呢。

接下来不是解决,而是分析原因。既然怀疑“把剪贴板转换为纯文本”可能不是原因,那么就该设计实验验证这一假说了。

微信图片_20220512152528

2.3 剪贴板里到底是什么

这是个验证性实验,探测(不是探索)一下剪贴板里到底是什么,什么格式,什么内容。我需要的是剪贴板内容查看工具。

找了几个,没有好的。在 sandboxie 中试了一下,有要要弹出广告,有的要安装服务,有的要常驻内存、修改启动项。Codeproject老牌好工具聚集地,要求注册。我的账号忘了,注册超时。

看不到。

2.4 穷人的必要技能,手动

 没有工具,创造工具吧。如果不能做到剪贴板“在线”时查看,那么,输出来。

image3

类似于电路的探针,在不干扰系统运行的情况下,我把剪贴板通过“写文件”操作,写到文件系统中。然后使用第三方查看工具看文件。

写出来了,写了两种。一种是 clipAll,AHK手册说是带格式的。文件内容是二进制,看不懂,得查MSDN看偏移量吧。搁置。另一种是AHK声称为纯文本的,也就是经过“Clipboard := Clipboard”变换过的 clipBoard。真的是纯文本! 而且第一种输出作为对比或者空白实验表明,AHK输出的文件可以是二进制的。那么,输出的东西是纯文本,只能是它原本就是纯文本,而不是在输出过程中转换了。

由验证性实验变成了证伪性实验,否定了我的猜测。我原本猜测“Clipboard := Clipboard”不好使,输出的东西不是纯文本。这样后面正则表达式匹配错误等现象就全都可以解释了。然而,这一猜测不能解释为什么输出了纯文本这一现象,或者说,输出了纯文本这一现象证否了猜测。

猜错了,也没有新猜测了,卡住了。

2.5 捋一捋

我们捋一捋都发生了些什么。

假设第一步输出的剪贴板与第二步输入的剪贴板是同一的,也就是说操作系统和AHK都没碰它,非易失的,这个暂不验证。

猜测过正则表达式匹配有问题,用PCRE regex onlin证否过了。把记事本里的文本复制到剪贴板里,在AHK代码里为字符串赋值,都表明正则表达式匹配没问题。

猜测剪贴板中的并非文本格式,刚刚证否了,就是纯文本。

探索!死马当活马医,再跑一步。把文件读回来。

image4

假设剪贴板写出的文件与读回的文件是同一的,那么
经过“Clipboard := Clipboard”剪贴板 = 文件 = 新的剪贴板

这样,

既然 经过“Clipboard := Clipboard”剪贴板 匹配正则表达式有问题,

那么 新的剪贴板 匹配正则表达式也会有问题。

这就是假设,验证一下。

再跑一步,把写出的文件读回来是探索,但是会发生什么,我有明确的预期。我猜测匹配还是不好使才对,这是验证/证伪。

神奇的事情发生了,意外。好、使、了。把不好使的剪贴板写出去,再读回来,好使了。就像经过记事本(末端相同)、经过浏览器(末端不同)一样,这中间发生了什么变换?我此前假设过记事本的作用是转换剪贴板为纯文本,但是这个假说被证否了,剪贴板本来就是纯文,差异(由匹配错误到匹配正确,由不好使到好使)的原因不是转换为纯文本。也就是说,在经过记事本,或者经过写读文件这个过程,中间除了转换为纯文本,还发生了点别的什么。

别的什么,是什么?

有的同学可能插话,好使了,那不就是解决了么。好使了,只是故障现象消失,我并不知道原因。莫名其妙消失的,也可能莫名其妙突然再次出现。就在你论文答辩现场,在你项目验收现场,在甲方大老板亲临现场的时候,在最重要的客户试用的时候,突如其来,让你措手不及。

微信图片_20220512152532

2.6 变小了?

我用 msgbox 作为探针把这几个东西都打出来,一个个看。由微信复制得到的剪贴板,经过“Clipboard := Clipboard”的剪贴板,写出的文件(用记事本查看),读回的文件……它们全都一样。

看起来都一样,但是表现不一样。说明,有我看不到的东西存在。

什么呢?一个字符一个字符打印出来……试了一会儿,太长了,我很快疲劳,鉴别能力下降,比对结论值得怀疑了。表面上这是探索,然后这是验证性实验,验证/证伪的猜测是“它们不一样”。

猜测,写出文件,应该是文件变小了,格式发生了什么变化,去除了 非文本 的什么东西。

我做实验,打印出这些东西的字节数。由微信复制得到的剪贴板,经过“Clipboard := Clipboard”的剪贴板,写出的文件(用记事本查看),读回的文件……

由微信复制得到的剪贴板最大,这是意料之中的。因为根据AHK手册,经过“Clipboard := Clipboard”变换,得到的 clipboard 去除了非文本元素,变小是对的。

意外又出现了。写出的文件,比写出前的剪贴板,大了。我以为会变小,这是猜测,实验结果证伪了这一猜测。好,所有的意外都提供了新的信息。

为什么会变大,增加的是什么,值得对比了。比对之前,我发现增加的字符数似乎与行数相同。有的同学到此可能已经猜到原因了,我当时没猜到。

我做了个短的剪贴板,1.用AHK转换为纯文本,假设转换成功。逐一打印出字符的ASCII。因为有东西看不见,所以ASCII更可靠;2。把写出的文件用 emacs hexl-mode (十六进制)打开。对比二者。

一目了然。拍大腿,这么简单啊。所有的bug,都是简单的,只是在定位以前我们不知道它在哪里。

以下是当时的日志截图。

image5

回车这个词是有二义性的。Windows操作系统下,回车是两个字符,CR Carriage Return 译为回车 13(0x0d),和 LF line feed 译为换行 0x10(0x0a)。Unix操作系统下,回车是单个字符,LF line feed 换行 0x10(0x0a)。

微信复制到剪贴板的文本,即使在windows操作系统下,也遵循了Unix的习惯,回车是单个字符。

2.7 解决

我写的正则表达式,匹配回车的时候只匹配1个字符。为什么在Windows下能工作呢?因为从微信复制到剪贴板以后,一直在AHK中工作,与操作系统无关。写入文件、贴到记事本里、贴到浏览器中,都涉及进程间通信,经过操作系统之手了。

既然知道了原理,就有了比写出文件再读回来更好的解决方法,当然比开个记事本不能碰机器更好。

只修改了一行代码,确切地说,增加了10个字符,匹配任意回车。

新的正确写法 needle := "(*ANYCRLF).*" element ".*:\R([^不:]*?)稳定"
旧的错误写法 needle :=           ".*" element ".*:\R([^不:]*?)稳定"

回顾一下整个实验和代码中的数据流,如下。在数据流图中,特别关注变换,数据的类型的变换。

image6

3.未来

修改以后,代码跑了一段时间了,代码稳定,我们也每天说自己稳定。

昨天,发现了两个bug,暂不修复。不过挺有意思,所以在这里讲一下。

都是剪贴板的问题,在转换成纯文本以前就出毛病了,不过仍然可以通过剪贴板贴到记事本里作为探针查数据来诊断。

第一个bug是有位WT同学明明回复了“稳定”,但是监听脚本仍然报告他不稳定或者未回应。有诸多可能的原因,我首先要排除 剪贴板的内容错了 这种可能性。选哪里开始,是另一个漫长的话题,以后再说。

对比粘到记事本里的剪贴板和微信聊天记录,可以发现,WT同学在微信聊天记录里回复了“稳定”,但是粘到记事本里的剪贴板中,他的发言刚好没了。前一名和后一名同学的发言都在,WT同学连人名带发言都消失了。猜测是昨天我们大家讨论的内容比较多,在AHK询问的时候仍然在频繁互动,所以攒下了不少聊天记录。聊天记录长到AHK由下向上滚动复制的过程,需要翻页才能到达询问的时刻。在触发翻页展开这个动作中,跳过了一些(这个案例中是一条)消息。

感慨 生产场景比测试场景更复杂,充满了我们没有预料到也因此未做约束的情况。

第二个bug是有位TYQ同学,他的回应刚好在22:34,这正是AHK开始操作微信群复制聊天记录开始的时刻。TYQ回复的时候,AHK代码已经开始向上复制内容,所以没有抓到他的消息。

第一步 我开始复制;

第二步 他回复了,我没复制到;

第三步 我的正则表达式匹配,稳定数组里没有他;

第四步 我发报告,稳定数组里没有他。

TYQ同学总结,这类似于线程安全问题,在AHK汇总的时候,有线程乱入了。我的AHK代码不是原子操作,也没有在开始汇总(复制、匹配、报告)之前冻结聊天现场,因此有微小的几率,就是在22:34:00 到 22:34:02 大约这么长的时间内,回应的消息不能被我的AHK代码采集到。

感慨 即使几率微小,在大样本的情况下,也必然发生。

微信图片_20220512152530

这样,做出发送模块,又做出了采集模块和匹配模块。最后解决了采集模块和匹配模块这两个模块间的接口剪贴板问题。解决最后这一行代码10个字符,过程曲折,我日志的部分标题如下。猜测,然后证伪,不断尝试,直至找到坏人。结果甚至是次要的,这个过程充满趣味,令我乐此不疲。

image7

听出打电话拨的什么号码,以及巫术/神迹/天赋/技艺/科学/技术

有些画面,有些声音,只有你具有那些知识,或者受过那些训练,才能体会其中的情感。只有这样,你才参与历史其间,它也才能融入为你的一部分。

  1. 缘起

曲同学分享了一段视频,感慨了一代老家伙的共同记忆。是一段声音的的频谱图,把时间通过在空间中的延展表现出来。时间从过去到未来,频率从低频到高频,构成平面。在这个平面上下凸起凹陷的,是特定时间、特定频率的声强,像山峰或者海浪。动态频谱图对应的声音,也同步播放出来。

image1

典同学说,能听出来这段声音是什么的,年龄也都不小了。

先是持续的”嘟–“电话拔号音。在手机没有流行以前,有一种叫做座机的电话,没有屏幕。与有屏幕的手机不同,需要先把电话听筒拿起来,响起表示线路空闲的”嘟–“,此时才能拨号。

接下来是拨号,滴滴嗒嗒嘟嘟。

最后是zizi~~gagadidi持续一顿响。这是你的调制解调器在与对端的调制解调器对话,然后就建立连接,可以开始传输数据了。当年的163、169、互联网,还有更古老的BBS就是这样通信的。在《魔鬼终结者》等电影里也能听到这个声音,当时还挺先进的。如今这种联网方式甚至电话本身,都已经成为了时代的陈迹。

我听了一会儿,感叹了一会儿,说出了这7位电话号码。

157 xxxx xxxx。归属地重庆,可能是刚刚或者最近两年做着玩儿的?

微信图片_20220427021403

  1. 巫术、神迹、天赋、技艺、科学、技术

以前有过新闻,说有个小伙在新闻里听到有人拨打某企业家的电话,他靠耳朵听出了号码,给那位企业家打电话,得到了不错的工作岗位。当时谁给我讲的这个段子来着,我可能没有表达出预期的惊讶。

当年有人做了个3d游戏,应该就是给游戏引擎加了些素材,可能脚本都不一定需要。当时NZG推荐给我,我看了以后也没有表达出应有的尊重。NZG同学说,你行你上啊。我觉得不值得,没意思,还挺花时间的,没有也做一个。

听电话号码这个,记得我当时讲了下原理,对方也没有表达我期待的认同。

你行你上。还是这样,如果我不展示一下我也行,挺容易的,你就会觉得我是吹牛。如果不是你花个十几分钟就能学会,你就会觉得这玩意是巫术、神迹。

原理非常简单,我一会儿说。

那小伙能用肉耳朵听出来,确实是需要一些技艺的。技艺,就得花些时间训练。受过音乐训练的人,学习这个应该很容易。米特尼克、乔布斯、莫里斯这些老一辈的黑客传记里提到过一个人,他也能听出电话号码,作者特意提到这家伙的家里有一架钢琴。还有一个人,能用口哨吹出早期电话的调试员的拨号音接通免费电话。

需要特定人物实施,无法由你通过常规办法检验的,我说了你就得信,那是巫术或者神迹。

需要特定人物实施,你可以通过常规办法检验的,是天赋或者技艺。天赋可以重现验证,是你可能永远学不会。技术能学会,可能要花些时间。肉耳朵听出电话号码,还有绝对音高听钢琴、小提琴,甚至能听到椅子腿摩擦地板的和弦。这种谁都能检验,但是除非训练你不能做到,甚至训练也做不到的,是天赋或者技艺。要么你也做不到,要么挺难的,就像知道生在伯努利家就有出息,或者苦学数十年就知道自己的数学天份不行了,所以知道也没有用处。

借助一些非常容易找到的工具,不要求是专用工具。或者没有这些专用工具,替代品也非常容易找到,借助这些,几分钟就能识别出电话号码,你也能,谁都能。这是科学或者技术。科学或技术与巫术、神迹、天赋、技艺的区别就是,借助(根据原理研发的或者现成的普通)工具,而不是借助信念、不可改变的素质、极难的训练,就能在短时间内由任何人类达成。

只要我行,你就也行。

微信图片_20220427021405

  1. 技术路线

操作是这样的,一共五步。如果工具装好了,就是几分钟的事。比肉耳朵慢,但是绝对有效,甚至更有效。

第一步 保存为mp4

文件命名为modem拔号音.mp4。

第二步 转换为mp3

ffmpeg -i modem拔号音.mp4 m.mp3

ffmpeg也可以替换为任意一款格式转换软件,用录音机也行。

image2

第三步 切一段

我用audacity(GNU开源、免费)在m.mp3上把拔号音那7个音切了下来,从时域的波形 (横坐标时间,纵坐标声强) 上,用视觉比听觉更很容易找到是7个音,在哪一段。用听力确认切得对。

audacity也可以换成任意一种声音编辑软件。这一步可以省略,也为了发博客的时候如果能贴声音,可以更容易说清楚是哪一段。

image3

第四步 频谱分析

audacity 菜单,分析|频谱分析,找到2个峰,记下这2个频率。如下图,左上角从7个拨号音中选择了一段拨号音,频谱分析的结果2个频率的尖峰分别是698Hz和1337Hz。

下图,选中的是左边的尖峰,测量显示为698Hz。

image4

下图,选中的是右边的尖峰,测量显示为1337Hz。

image5

这步操作,也可以换成origin、MATLAB、python之类的软件或语言完成,只要能做FFT的都行。Excel也行,麻烦一点。还有个办法,不需要工具,只需要耳朵,需要一点听力基础,参见下一步。

第五步 参考DTMF码表

698Hz和1337Hz,这两个频率构成“和弦”的声音决定了唯一的号码。

参见下表,接近698Hz的是697,接近1337Hz的是1336。697这一行和1336这一列的交点是2。这个“和弦”声音对应的拨号是2。

image6表格来自[https://www.cnblogs.com/xiangyuecn/p/13200894.html#%E4%BA%8Cdtmf%E9%A2%91%E7%8E%87%E6%8C%89%E9%94%AE%E5%AF%B9%E7%85%A7%E8%A1%A8]。如果链接失效,在网上搜索 “DTMF码表”,有很多公开的页面。

按下拨号盘上的每个按键,都会同时发出2个频率的声音,叠加在一起。按2这个数字,发出的声音就是697Hz和1336Hz。这个声音与我上一步从7个拨号音中选择了的那段拨号音“听”起来一样。是的,需要点听力,不完全一样,就像小提琴拉do和钢琴弹do,音色和音量都不同,但是你能听出来是同一个音。如果这有点难,听出来两个按键的音不同,你一定能。

所以,上一步我提到的不需要工具的方法来了。在网上找个 DTMF声音生成器 (DTMF tone generator),有许多在线版本。挨个键按一遍,听听哪个和这一段的声音“听”起来一样,就是它。或者挨个排除那些“听”起来不一样的,剩下的唯一那个就是了。一般地,你不需要上述16个数字,只试0~9就行了。

这样,我就得到了视频中的电话号码:157 XXXX XXXX。

第六步 复现

这一步对于听出电话号码并非必须,只是为了说服你真的有效。

用audacity测量,得到每个拨号音持续100毫秒时长,空白100毫秒。

下图是拨号时长0.96秒,约100毫秒。

image7

下图是空白时长0.96秒,约100毫秒。

image8

找几个拨号音生成器,发出声音来,你可以用肉耳朵听出来是不是一样的。这些拨号音生成器(DTMF tone generator)也可以用来在第五步中不经过频谱、仅使用肉耳机比对。

[https://www.venea.net/web/dtmf_generator]

可调节拔号间时长和空白时长。不能保存。可使用录屏,导出声音。

[https://www.audiocheck.net/audiocheck_dtmf.php]

可下载wav。

[https://aggemam.dk/code/dtmf]

php的,可下载au文件。

微信图片_20220427021406

  1. 原理

原理说起来也不复杂。每个拨号音都由两个频率的声音唯一标识,这就是DTMF中的DT(Dual Tone)双频所暗示的。频谱分析,或者FFT(快速傅里叶变换),可以由时域(横从标时间,纵坐标强度)向频域(横坐标频率,纵坐标强度,可以简单假设这小段声音期间频率不变)变换,从而方便测量出这两个频率是什么。然后查表就可以根据这两个频率知道对尖的按键。

image9

如上图所示,时间、频率(低频、高频)、强度,形成三维空间。线路空闲是双频长时间持续的,与第一个拨号音有部分重叠,用灰色文字标出。图中的红圈内,是3个拨号音,每个号码有两个频率。

事实上,人类的肉耳机是优秀的频谱分析仪,所以虽然脱口说出频率的精确数字有点困难,但是判断未知号码含有两个频率组分的声音 和 另外由生成器得到的9个样本中的哪一个相同,是较容易做到的。

即使有绝对音高听力,由于这些音与钢琴上的按键都不一样,听起来可能是某个键的跑调,大概不太容易识别。从这一点上看,使用工具的科学和技术不仅人人可用,也更加稳定和有效。

有的同学可能会说,如果科学与技术如此强大,磨练技艺无论多久,也比不上凝结了人类智慧的工具吧。练一辈子,在围棋上能战胜阿尔法狗么?如果功利的看,非常可能是这样,人生而有之的天赋和后来艰辛训练方能点滴进步的技艺,在几千年以来正在被人类发明的工具不断超越。似乎人类除了大脑,其余的都不足道哉。并非如此。发挥天赋,磨练技艺,让自己成为更快、更高、更强,更有趣的人,这些本身就很好玩,本身就是目的。并不是要让自己成为更强有力的工具,而是我们自身,就是目的本身。同样的,发挥智慧的天赋,磨练智慧的技艺,与锻炼肌肉、手指、眼力一样,也很有意思。

有些画面,有些声音,只有你具有那些知识,或者受过那些训练,才能体会其中的情感。只有这样,你才参与历史其间,它也才能融入为你的一部分。

微信图片_20220427021400

推荐什么书

一觉醒来看朋友圈和前辈留言才知道,今天是世界读书日。知道世界读书日,保持每天读书,却不知道世界读书日是哪一天。看不少公众号和朋友推荐了有少书,我是不是应该也应景推荐几本?

去年底今天初的时候,读书群主X老师说,大家推荐几本一年来喜欢的书吧。挺有仪式感的事,后来我没有响应。为什么呢?每一天,不过是个平常的日子,或者我装作如此吧。就像我和我的老学生、在读学生们背单词打卡,每周五 (就是最初打卡的那一天)发红包。有时候恰是个好日子,想庆祝一下,但是想到这样的好日子以后不知道还有多少,觉得小题大作,或者可能今天就是从此以后最好那天,心下黯然。照例在红包上写”一周单词”。每日读书,如此平常就像谁还会关注和纪念喝水、吃饭、喘气、父母的爱呢。

又,我喜欢的书,你可能十多年前早就读过了,我推荐的书,可能太低于你的层次过于基础。还可能,你推荐的书,确实需要相当基础才能领悟,我此刻读起来味同嚼蜡也说不定。

但是且住,果真人人如此么?

微信图片_20220423170550

这样不换位思考的看法,我确实见过一次,刚好当时屁股坐在换位的另一方,才意识到。所以读书这事,也不见得人人认为是平常。不换位思考的小故事,也刚好与书有关,今天可以当个段子讲来。

在豆瓣上看到的。有位网友说,根本就不应该存在市立、区立图书馆这种东西,里面的书都没有意思,反正喜欢读书的人家里肯定有个图书馆。作者是位嗜书如命的青年,年轻轻的已经坐拥书城了,而且还在加紧买。我读的不过是一本又一本,他研究的已经是这个版本和那个版本了。我也同意市立和区立图书馆的藏书对我没啥吸引力,要么没意思,要么我看过了。不过,除此以外的观点,我忍不住反对了。

并非人人生而有书可读。如果不是通化市图书馆及其少儿部,我的小学和初中能读到的书可真是非常有限了。半本《三国演义》、缺后半截的《水浒传上》、被我们当成教科书一样的小说《警犬黑豹》,还有小人书《铁腿红心》《人民的好医生李月华》《江南七怪》。还有《魔方大厦》,是一年六一的礼物,哭着喊着要来的。我妈说,你连故事内容都猜出来了,看着还有什么意思呢。如果没有图书馆,我的阅读大概就止于上述范围了。

书本,打开了通往另一扇世界的大门。我当时还看不懂的《丁丁历险记》让我神往了很多年,等自己识字。缺结尾的《倒长的树》,在同学家翻了半遍的《星球大战》,在WTD同学家看的计算机入门书,CJ家的《星际旅行》 (即星际迷航1)都带给我几十年的深刻影响。初中和高中学校的图书馆,更是展现一片阔野,万里风光。

微信图片_20220423170534

毛姆说得对,那是尘世艰苦生活的避难所。别人是如何生活的,历史上的那些英雄和巧匠,其他地方的风物,爱琴海的巨浪,极地的雪原,西伯利亚的莽林。格立高里直举的战刀第一次劈下,日本科普书里暗夜中的星座旋转。草原上的废弃建筑是小伙伴们的战舰。啊对了,还有叶圣陶的稻草人——世界上还有这么不温馨不柔情也不粉红的童话。但那是我喜欢的能看懂的第一篇童话故事。

这就说到推荐什么书了。

看你能看懂的,看你喜欢的。只要还在阅读,只要还在体味和感知这个外在的世界,而不是单纯沉湎于对自己内部的纠结,那么修行就还在继续,一切还不错。

微信图片_20220423170546

你喜欢什么呢?从别人的推荐上是看不出来的。

通常我们看到的推荐列表都是人文类的,历史、政治,如何认识人类社会,该当如何做人。这里推荐里深刻地包含着推荐人的世界观、价值观,他认为世界如何运转,世界应该如何。对于教我做人的,即使他身体力行,我也经常充满反感。我不想做自己反感的那种人,所以对推荐非常慎重。我经常想对推荐的人说,你推荐的我看过了,你说的我也”知道””了解”(经常地我还知道点别的你不知道的),然而我还是不认可。事实上,哲学之争论如此深刻,你拿出哪个被驳倒无数次的烂哲学家的观点,把我们打得体无完肤也都轻而易举。我只是懒得举例,如果讨论以扣帽子结束,”你这个异教徒”,归于信仰、信念、你是坏人,又有什么意思。

推荐人文类的可能有个好处,就是人人可读,大家都认为上面的字认识。即使懵懂如我,也可以说读过……我可别吹了,哪本也不敢说读懂了。但是,这不妨碍我一页页翻下去,和理工科的著作可不一样。我可以吹嘘说,康德的《实践理性批判》谈的是道德,是关于”头顶的星空和内心的道德律法”一样是实体,是实实在在的存在的。康德反对把人作为工具,认为人只能是目的本身。又怎么样,不影响我把别人当工具,不影响我看着别人把别人当工具而怯懦闭嘴,不影响我看着别人一边嘴里讲康德一边行动上反康德而别过脸去,甚至不敢别过脸去。如果能超脱书本与世事脱离的痛苦的话,那么倒是容易装作读过的样子推荐。我可以读一下邓晓芒的演讲,或者听听他的语录,或者读一下几十页的小册子、二三千字的公众号文章,精华俱在,似乎我已读完康德上身,推荐三大批判,年轻人,一定要读一下啊,不然你还能算是个人么。嗯。

推荐人文类可能还有个原因,就是推荐者希望你也成为他那样的人,或者他希望你相信他是他所声称的那样的人,那么善良、睿智、博学。

微信图片_20220423170525

至于他自己读过没读过怎么考核,有个办法,挺古老的:听其言,观其行。他会用他推荐的书解释他所看到的世界么,他按他推荐的书行事么,他除了这篇推荐还在别的地方讨论过他推荐的书么。除了过年,还有什么会期待家人团聚、国泰民安、如你所原?难道,不应该每天、每时、每刻。一个只有过年才会唠的嗑,有多么廉价。我以前一直觉得三毛没事就怀念追求过她而终身未婚等着她的男性(不止荷西),感觉她挺自恋的。后来才渐渐明白,这里的坏人不是三毛,而是那些男性。没事说一句”达令,我一直怀念你呢”有什么用处,有什么价值。你是夜夜睡不着的时候就怀念一下以至于更睡不着呢,还是除了这句达令以外,其实连怀念也没有。推荐给你的那些书,推荐人自己也像希望你做的那样,自己亲自再三阅读么?

考核他读没读过次一点的办法是,考试。略过。

微信图片_20220423170536

推荐人文类可能还有一个原因,就是他真的喜欢,只不过喜欢的刚好是人文类的。前几天在群里有人说,要捐一批书到监狱去,问什么比较好。刚开始大家开玩笑,《基督山恩仇记》,要归到教育类里,要不要配个铁勺子一起阅读。还是《圣经》挖洞藏小地质锤更适合。后来严肃的讨论,TY老师提到要书法类的,有老师提到要人物传记。我提到想看物理化学地理历史。

人文类的对我来说太难了,如果需要心眼儿和对人性的体悟,那就难上加难。这么说吧,色盲非要让天天挑红花绿叶,不喜欢抽象非天天让做数学,不喜欢逻辑非让你天天编程,先天性心脏病非要让锻炼锻炼去跑马拉松,你能高兴么。有些英文小说,翻译成汉语男生都看不懂,为什么非要那么难为自己呢。《简爱》《傲慢与偏见》,还有《红楼梦》。并非巨著不好,是我不好。

微信图片_20220423170538

本轮疫情开始时,我也立志正是读书的好时候。我低估了身边随时可能到来的危险对我的干扰,低估了饥饿对人的意志能削弱的程度,低估了领导对给你找足够的事儿做充满你的生活有多么自信和执着。加上疲劳,几乎摧毁了我长期保持的锻炼计划,更不用说阅读。我只读好玩的,不累的,容易读的。

照着书刻木头,刻了四只猫头鹰,一头猪。我想,集中注意力于物外,是不是就不被物所役了。后来有天,一刀削在手套上,刀刃贴着皮肤一掠而过,留下一道白茬。手指分毫未伤,但是我一下就想到现在出不了小区,去不了医院。刻木头的是重新开刃的瑞士军刀,每次雕刻以前都要再磨一次。这么飞快的刀,一刀切到骨头非常容易。想起了二猫妈告诉我最近不要锻炼对心肺、体能、技巧要求太高的动作,可能送不了医院,可能还没等核酸结果出来就结束。冷汗涔岑而下,呆坐半晌。

微信图片_20220423170532

群里讨论书的时候,我还提到不要励志的,正能量过多,看着闹心。最近,看电影都困难。不是因为片源,而是因为心境。看英雄主义的,怕自己冲动;看深刻反应人性的,太压抑了,他们怎么能那么坏啊;看现实主义的,可xxxx吧,看看窗外不就得了。看大部头,经常怀疑自己,有用么,知道不知道毕生所学就在此刻。

应该只读自己能读的,读起来轻松,因此能长期保持的。无须考虑有用。日有所进,无须顾及功会不会唐捐,无所考虑最后会是个什么结果。每天只有半小时一小时,你还想练出什么专业什么名堂么。享受这个过程吧,喜欢什么就读什么。

我就喜欢理工科的书,心烦的时候就只读这个。三两能完成代码的小项目,技术手册,中英文古汉语词典。一章一章的小说,每次只看一章。不分章节的书,每次看半小时一小时,不贪多。

做题,写笔记。痛骂作者写得烂,都很好。

微信图片_20220423170543

当年和计算机系的同事们一起去伪皇宫,大家感叹溥仪有个媳妇关冷宫后来疯了。我当时说,找本高数书,天天做题 (当时还没有刷题一词),有的是事可做,怎么会疯。如果有这样的体力,我并非指意志,如果有这样的体力,刷题挺好的。就像刻木头,画画,写小说,写代码,刻意锻炼某组肌肉,练琴,令人心静。《双城记》里那老头关巴士底狱里,难受就修鞋。后来他出狱了,难受也修鞋。挺好的。

专注于手头的这一本书,这一件事。不为了未来的功利,仅仅享受一页页一行行融在你生命里,这就是修行这就是生命本身。善哉。

微信图片_20220423170544

如果说非得推荐一本。上次你看别人吹牛读过,你自惭形秽没读过的那本是什么来着?就它吧,或者介绍它的书。

疫情期间买不到?不要违法犯罪,不要能从网上找到电子书,但是要始终保有这样的能力。

如果非得推荐一本,那么往往只是推荐者当时的心境,不见得是人类文明史上多么重要的作品。就像肉蛋奶才是最重要的营养,但是不少人拼命想喝的是不健康的可乐。如果非得推荐一本,我推荐读读《一九八四》,如果读过,不妨再读一遍。

微信图片_20220423170553

情绪稳定不稳定的心路历程 – AHK监听微信群回复

情绪稳定不稳定的心路历程 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,需要认证等准备工作,可能涉及到免费限额和经费支出。这条线索暂时搁置。

到这时,我一行代码还没写呢。
微信图片_20220411171333
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,入口和出口的类型转换都不少行代码。当然可以抄,然后不优雅。

到这时,我一行代码还没写呢。
微信图片_20220411171331
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行代码。两句三年得,一吟泪双流。有点累了,而且感觉前途渺茫。缺肉吃,饿。联系社区组织团购,没人理。自己组织,商家说不敢卖。运输也是问题,社区没功夫,自己运不让出门。所以停下来休息睡觉。
微信图片_20220411171320
5. AHK 全选 + 正则表达式

午夜令人警醒,因此也倾向于过高估计困难的危险。天一亮就感觉好多了。

再次回到第一性原则,发现人手的操作还有别的路线。可以鼠标按下,上推鼠标光标,选中几屏头像、图像、文字等,然后复制。粘贴出来就是纯文本的了。

这么大一片纯文本,大可不必像上面找头像的方式,找一个头像检查一次文本,而是可以启用伟大的工具 正则表达式,匹配 “某同学 一堆字符 稳定”,再去除”不稳定”。

有两个技术难点。

(1)稳定地选中内容。试了几种方案,不都稳定,故障现象也无法重现。有时选不上,有时选的内容有遗漏。一种方案是拖动。鼠标左键接下不抬起,向上拖动;鼠标到最顶,触发滚屏。保持几秒,假设能到达今天的第一条回应。不能用AHK MouseClickDrag,这个函数的结束时会自动抬起鼠标左键,因此无法触发滚屏。变换各种角度操作鼠标,现象无法重现或者不稳定。我甚至一度以5次连续正确为现象稳定的标准,后来想想这不就跟有些同学调超参数一样了么。跌入玄学?又感觉希望渺茫了。

后来想到鼠标左键按下以后,向上滚动鼠标。现象稳定了。

滚动一次,复制,分析文本,遇到的第一次 “今天你情绪还稳定么?”,那就是到地方了,从这里向下都是我想要的。这样解决了滚到到什么时候停下来这个需求。

(2)正则表达式。这个单独写了一个AHK脚本,作为一个模块。这个模块与复制模块的接口,可以是文件,可以是进程间通信,我选择了剪贴板。前一脚本运行完毕,复制的历史消息在剪贴板里。 (正式部署deploy以前) 再运行后一脚本,从分析剪贴板中的文本,遍历每位同学,看他是否答复过稳定。要么稳定,要么不稳定,要么没吱声。

以上两部分技术难点,刚好是两部分需求中的技术重点,分别解决了,分别通过了单元测试。

集成测试出毛病了。

稳定选中内容,眼睛能看到。复制到的东西,作为检查点,从剪贴板里粘贴出来,到文本文件,或者到正则表达式工具 (见附录) ,做第三方检验,都是对的。保持这个剪贴板,立即运行第二个模块,即正则表达式的AHK,匹配结果不对。异常如何不是这里讨论的重点,略过,总之就是要么有人稳定报成不稳定,要么不稳定报成稳定,要么有人不稳定报成全员稳定。

单元测试正确,集成测试错误。毛病一定在模块间的接口上。但是,剪贴板这么简单的东西能有什么错。正则表达式写错了?把AHK手册正则表达式三章的英文和中文分别读了,见附录。还是感觉没毛病。按AHK手册,我特意写了一行代码,clipboard := clipboard 表示把剪贴板中可能的各种格式转为 plain text。

但是,我应该如此相信手册么?

做个实验。第一步,从第一个模块的输出得到剪贴板;第二步,手动把剪贴板粘贴到记事本,再从记事本全选、复制;第三步,跑第二个模块。成功了。

我知道为什么clipboard := clipboard不好使,但是它显然没有得到与向记事本
粘贴再复制出来相同的效果。

算了,简单粗暴非常有效。既然通过记事本可以,那么就通过记事本吧。所以,我的脚本跑的时候,第一模块结束,会运行记事本,转完剪贴板的格式以后,再把记事本关了。因为脚本是自用的,我甚至没有禁用鼠标和键盘,也免得万一执行有问题夺回操作权困难。

测试了几个晚上。同学们帮助测试,有时回复”我还对付”,有时拖过10分钟等我监听完群消息再回复。测试通过了。

事实上,正则表达式就是对付写的,能应对简单的情况。稍微复杂点的……再说吧。就是下面这行。

: needle := ".*" element ".*:\R([^不:]*?)稳定"

某位同学,他回复了,回复的消息里包括”稳定”,并且不是”不稳定”。这个”稳定”是他说的,而不能错误地匹配到他后一位同学的回复。

此时,去除 ImageSearch 版本不算,我又写了210行代码。
微信图片_20220411171327
三、后续

GYB同学曾说,如果某位同学用脚本回复呢?这是个非常值得一试的问题。得识别教师的发言 (或者以时刻1024pm作为依据?)。然后呢,教师发言时附带个问题,比如”一加二等于几””从以下图像中找到所有的 火车””把下面拼图中的小块向右拉到正确位置”?然后呢,学生写个脚本自动回答。

计算机与计算机流畅地交流着,留痕也合规。

此时,我们终于可以有时间培养一下师生情谊,顺便讨论一下技术了。
微信图片_20220411171330

附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
: }

写个脚本,问候你今天情绪稳定不

用AHK (AutoHotkey) 写了个脚本,在PC/Windows操作系统中调用微信客户端,定时@ 特定人员 单发/群发 (?) 消息。

1. 问题及此前的工作

本轮疫情以来,我所在单位的不止一个有关部门发出了不止一次不止一个要求。其中一个要求,是要求导师每天向每名研究生询问情绪是否稳定。如何上报,等以后通知。不过,在如何上报出来以前,就应该有行动,不然一旦要求上报,报啥呢。我怕除了聊天和学术上技术上的讨论以外,万一忘了每件特别是这件重要的底线,又根据 DRY (Don’t Repeat Yourself) 原则,所以考虑应该自动化。

当然,我们就如何实现进行了反复实验和深入的讨论,成功地把这个重要而 的任务变成了有趣的技术问题。不仅执行要求,也分析要求,按 正念 的观点,觉知正在进行的任务以及感受,有效避免了抑郁。

田同学用 appium + 真手机 做了一个版本,在这里[https://www.cnblogs.com/ourshiningdays/p/16023291.html]。非常成功,到每晚1024这个时刻,就由 appium 通过USB线调用手机上的微信,把消息发出来。

他发现,”@杨贵福”这种写法不行。”@杨贵福”并不是一段文字,我用实验复现了他的发现,确实如此。”@杨贵福”能复制,但是不能粘贴到记事本中。根据DDE应该能进一步确认复制出来的到底是什么以及格式,不过,知道不行也就够了。田同学发现必须先”@”,等微信列出名单,再输入名字匹配才行,单纯输入”@杨贵福”或粘贴这段文字,不能起到@的”呼叫”效果。

田同学还做了加强版,等10分钟,然后解析同学们 (和我) 的回复,确认大家都表示了”稳定”而且不是”不稳定”。凡是没有回应或不稳定的,@导师提醒,并给出名单。

完美。我问,你咋不用夜神一类的仿真器呢,是因为微信在仿真器里登录以后,会踢手机上的微信下线,而你没有另一个微信号么?他说,他有另一个微信号,也在仿真器上试了。被微信侦测出使用仿真器,已经封号两次了,再封的话号就没了。

于是,有了我今天介绍的这个版本。

微信图片_20220327201946

2. 技术路线

AHK是个脚本工具,语法有点像VB。本轮安装以前,我没有想到,这东西如此友好,甚至不必安装,解压就行。因此可以认定干净并且不烦人。烦人,指安装全家大礼包、修改开机自动运行和服务、特别大个,等等。AHK这点不错,解压完以后9M。

AHK可以模仿键盘和鼠标的动作,可以窗口和屏幕上的控件,可以读颜色。这么说吧,你能干的事,它都能干。因此,也可以作为GUI的测试工具,而不仅是个自动化工具。

因为在微信上回消息,用手机实在太慢了。回消息的时候除非手头没有PC,我不用手机。所以我的PC微信客户端本来也经常长时间登录在我的座机上,虽然经常隐藏在我当前工作桌面以外的另一个桌面中。随时唤起,也可以被AHK唤起。

思路大致如下,用AHK模仿人手的动作。我先把下面的步骤想清楚,写在代码里,加上注释,算作大纲。然后开始从上向下写代码。

第一步,找到微信窗口

用ahk的小工具WindowSpy找到微信窗口,可以根据窗口的标题、进程名 (程序名)或进程号、win class之类的区分。我用 ahk_class。

: WinActivate, ahk_class WeChatMainWndForPC

实测时发现,如果微信窗口被ESC消隐,就不能 WinActivate 切换到前台。所以在此之前尝试运行一下。

: Run, C:\Program Files (x86)\Tencent\WeChat\WeChat.exe

然后sleep 500毫秒,等待万一微信需要启动。后面还要经常sleep,即使我们肉眼以为瞬间完成的,也需要等一下。下略。瞬间这种事,在计算机中不等价于同时。

微信图片_20220327201927

第二步,找群

找群这个动作,微信支持用快捷键 ctrl+f,然后输入群名,回车。

所以用AHK模仿键盘,不操作鼠标。

: Send ^f
: Send %group_name%
: Sleep 1000
: Send {Enter}

group_name是个变量。考虑到别人用这段代码时群名可能变化,所以在脚本最开头,定义群名。

微信图片_20220327201936

第三步,清空原有消息

此时已进入群聊的窗口中,光标在输入文本框中。考虑到一个场景,在此之前脚本的使用者可能正在使用微信,也许正在此群中,并且已经输入一些文字。但是这个场景并非MVP (Minimum Viable Product),所以推迟到以后的版本 (如果还有的话)再实现。

在这里,为避免原有文字干扰发送的问候,仅做简单粗暴处理——全选,清空。

: Send ^a
: Send {Del}

微信图片_20220327201943

第四步,循环,应@尽@

这一步构造要发送的消息,@每一位需要@的同学。一方面因为群里有已毕业的同学,不宜打扰。另一方面,”大家没事注意点儿群里的消息”或者”@所有人”都很烦人。

在脚本最前面把每一位需要@的同学的 群昵称 放到数组中作为一个元素,在此处遍历数组。

对于数组中的每个元素,先发送 @,sleep,然后发送群昵称一个,sleep,发送回车。这样就@完一位。每轮循环的最后根据是否最后一位同学决定发送句号还是逗号。

遍历数组以后,发送问候的消息。我还顺便附带了当前时间。

再发送个回车,把这条消息发送出去。

微信图片_20220327201940

第五步,定时

这是最后考虑的,但是不是在最后执行的。

定时可以像田同学那样,使用windows定时任务,或者用 linux cron这一类的机制。也可以按当年李记者告诉我的更常用的手法,轮询,跑死循环,在每次循环里检查是否要运行的时刻,然后sleep节省CPU。

也可以像我这次这样,抄了一段代码。从这里抄的,[https://blog.csdn.net/liuyukuan/article/details/53860122]。

在程序运行之初算一下还差多少秒到要运行的时刻,sleep这么长时间。

: StringLeft,当前日期,A_Now,8
: 提醒的时刻 = %当前日期%%定时%
: 提醒的时刻 -= %A_Now%, Seconds
: Sleep 提醒的时刻*1000

这就行了。在预定的时间每晚1024,应@尽@,发送问候。

微信图片_20220327201948

3. 实验结果及改进

白天试了两下,通知同学们不必理我。大家看完消息以后,讨论了一会儿,表达过和表达了对一些问题的担心/估算。比如要sleep,要sleep多久。万一同学也用脚本回复怎么办。也许应该讨论的”怎么办”不是如何应对,而是如何达成?、

当天晚上,AHK脚本如约运行。第一位同学的名字前面没有@。这位同学指出我一定是没有sleep。正是,改。

改的过程中发现,要测试好几次。要避免定时sleep,而希望马上执行看效果;要避免消息构造完成以后不慎回车把消息发出去。所以,我定义了 测试模式。

: test_mode = false ; true
: …
: if (test_mode != “true”){
:    Sleep 提醒的时刻*1000
: }
: …
: if (test_mode != “true”){
:    Send {Enter}
: }

4. 展望

工作总是可以更美,不过留到以后吧。当前版本就可以对付用了,就这样发布吧。

以后要工作:
(1) 发出消息后10分钟后查回应,如果没回复需要提醒

我们讨论到的方案是截屏(可能需要滚几屏),OCR,然后解析。

(2) 保存现场和恢复:发送消息前,由系统暂存未发出的消息,群发消息以后再粘回来

5. 附录 代码

; 要在以后的版本中再增加的功能
/*
1. 发出消息后10分钟后查回应,如果没回复需要提醒
2. 保存现场和恢复:发送消息前,由系统暂存未发出的消息,群发消息以后再粘回来
*/

#SingleInstance force

; 测试模式
test_mode = false ; true

; 微信群名
group_name = 软件所-现役

; 人名清单
;message_receiver := Object()
message_receiver := [“位军营”, “杨萍”, “张宵”, “2020-史志腾”, “杜蕾”, “韩亚光”, “田洪轩”, “唐一钦”, “李娜”, “马洪博”, “王涛”]

; 问侯语
question = 来来来,请回复一下,今天你情绪还稳定么?我还对付。

; 在规定时间 10:24 启动
; 代码参考 [https://blog.csdn.net/liuyukuan/article/details/53860122]
; 群发消息的时刻, 22:24:00,格式222400
定时= 222400

;——————————–
;———————————
; 通用AHK脚本启动
time_out = 5
msgbox , , 信息 , 运行中,%time_out%秒钟后AHK脚本开始倒计时, %time_out%

StringLeft,当前日期,A_Now,8
提醒的时刻 = %当前日期%%定时%
提醒的时刻 -= %A_Now%, Seconds
if (test_mode != “true”){
Sleep 提醒的时刻*1000
}

;————————–
; 通知用户不要手动操作计算机
time_out = 5
msgbox , , 警告 , 请暂停手动作操作,%time_out%秒钟后AHK脚本将通过微信群发消息。`n如有必要立即保存当前消息。 , %time_out%

; 找微信窗口
Run, C:\Program Files (x86)\Tencent\WeChat\WeChat.exe
Sleep 500
WinActivate, ahk_class WeChatMainWndForPC

; 找群
Send ^f
Send %group_name%
Sleep 1000
Send {Enter}

; 清空原有消息
Sleep 1000
Send ^a
Send {Del}

; 循环
; 在群中@每一位用户
; 发消息
;Send {当前时刻%A_Now%,}

for index, element in message_receiver
{
Send {@}
Sleep 500
Send %element%
Sleep 500
Send {Enter}
if (index < message_receiver.Count() ) {
Send {、}
}
else {
Send {,}
}
}
Send %question%
Send {^Enter}
Send %A_Now%
if (test_mode != “true”){
Send {Enter}
}

; 下一版本再实现
; 10分钟后查回应
; 找群
; 选文字?读

ExitApp
return