稳定了,解决传输途径不畅的心路历程: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

改出个豆瓣书籍导出工具

1. 属主

多少年以来,就不断传说豆瓣要倒掉了,大家都担心里面辛苦记录的看过哪本书看过哪部电影,就都化为飞灰。所以我也关注过豆坟这样的工具,能把你走过的路导出来。后来,听说这些工具被封的被查的,各种说法,也都是传说。只是豆瓣还没有倒掉,也许正如比萨斜塔,倒掉的过程漫长到不知不觉它已经变了,早已低于你一次次定下的底线,而你居然仍能接受。这应该不算忘却初心,算与时俱进吧。

虽说,只有忘掉了以后还在深刻影响你的,才是真正的修养。然而,当你准备吹牛说某某书在你成长的过程中曾经让你喝了三天大酒每天早晨逃课,但是那位作家那本书的名字就在嘴边却偏偏想不起来了。"就是那本,你知道的,那本特有名的,叫什么什么,哎,一时没想起来。"说服力大打折扣,真是尴尬。那个令你夜不能寐泪湿枕套的人,居然忘了名字,说起来谁能信有过这几滴泪呢。

再说,你走过的路,你标过的电影和书,说到底,它是谁的呢。在饭店结账,有一次我不知怎么犯了混——经常有,每次不同,其他的以后再讲给你——非想把小票拿走。起因可能就是对方那句"这个我们要留着",我就一定要拿走。他一句我们有规定,又一句这个得入账,又一句给你个礼物,又一句你可以拍照,还有啥来着。他的每一句后面,我都只有一个问题,"这是你的还是我的?"

你标过的电影和书,说到底,它是谁的。

教师上课,领导不必打招呼随便进课堂检查,我也挺想知道,这课堂是谁的。谁,才应该是主人,谁,才有责任有主人翁意识。我就猜有人会说,课堂是学生的,学生才是主人。那么,你经过主人同意了么。得这么请示,"某同学,受我的上级领导委派,明天我不得不去旁听您所在班级的某门课,就是教师小那谁那堂。仰慕已久,正好可以当堂观摩您当堂指出教师授课问题时的风彩。您看是否合适?"如果有闲空,也可以对教师指出,"小那谁,明天本大人要去听你的课啊,提前耳提面命令尔知哓,是莫大的恩情呐!"我等即应"着"。如果没有闲空,发个通知,"本座可能突然莅临监听尔等任何一人任何一堂,望周知",即可。甚至不必等"着"这个确认。要知道如果你有了"着"的责任,就意味着还有不"着"的权利。鲁迅先生所说的有钱人对我喊完"滚",可是无须我表达听到或同意的。多么威风。

微信图片_20220318004330

当然,一切都是为了属主。属主喜欢什么就是什么,喜欢谁就是谁。

鉴权系统中有个概念,叫做 属主。属主真是说删就删,如果只读,我就把你的只读改掉还是要删。但是,得是属主。属主成天担心标过的书记过的电影正上着的课,这能算属主么。

非单身的同学请注意,你的收入可不是你一个人的。即,你并非属主。根据法律规定,你的收入归你和你的配偶共同所有。共同所有,你并非唯一的属主,因此也就不是属主。并非独占,即并非拥有。

出于对于并非独占而自以为拥有的战战兢心理吧,所以,爬豆瓣,把属于自己的数据取回来,这样的工具、方法、操作层出不穷。也是奇观。说起来,爬数据违法,爬自己的数据违法不?

微信图片_20220318004333

2. 爬

怎么爬呢。

正则表达式?这绝对是个好办法。正则表达式,等价于乔姆斯基3型文法,描述能力相当于有穷自动机。那是相当强悍。也算小众,不然乔姆斯基应该已经封杀了。说起来,如果乔姆斯基封杀了,正则表达式是不是不株连不足以平民愤呢。

不过,有比正则表达式更适合于 爬 这个具体需求的。杀鸡焉用牛刀,图灵机肯定行,图灵机干啥都行。但是回答"如果发贴子",你不能说"用图灵机",得用浏览器区别于微信,才好。

从网页或者类似的结果数据,而不是理解为可执行的代码,从结果数据中抽取文本的时候,匹配的特征描述可以用xpath获取dom节点。其关键思想是在 内容(如html中的字符串) 与 形式 (如css的字体)分离 的情况下,通过形式 (css符合哪个class,什么颜色,第几个li标签或者第几个p标签)找到内容 (一段文字)。

我们可以 (随便什么方法) 写个爬虫。然后

第一步,读到网页。

第二步,用xpath分析dom,从中提取到想要的信息,比如 我读过的书目列表,从中取出 书名、作者-出版社-出版时间-码洋、标注时间。记录这些信息到本地。或者放在你的脑子里,总之,你确定无疑地具有属主权限的地方。

不要放在云端!童话故事里恶魔把自己的心脏交给善良的小媳妇保管,坏人总是有那么一刻心软了,唉。后来你看到有几本书消失了,或者成了"未知书籍",就像有的"我的网盘"上的东西突然没了,我一按键盘,那不是"我的"网盘么?难道,这就像"我的理发师""我的老师""我的母校"那种,其实它并不像"我的缺点""我的负债""我未完成的任务"那样是"我的"?这书没了的时候,你却在吹牛的时候刚好想不起来那几本对你影响深远的书的名字是什么,那就是单纯吹牛了。

第三步,还是刚才那个网页,用xpath分析dom,找到"下一页"那个链接。

第四步,http get "下一步" 的 url。

第五步,跳转到第一步。直到找不到"下一页"了,那么就是到了最后一页。

无标题也可以到这时,把前面每一页的每个你想保留的字段存储到本地。不要放在云端!

3. 工具

以上步骤,可以用 python,也可以用世界上最好的语言 php。凡是支持 http get的,并且支持 xpath dom 的,一般也都支持写入本地,都可以。

我前两天翻 firefox 优秀插件 tampermonkey 的脚本时,发现了一个好东西,豆瓣电影标注的导出工具。名字是 豆瓣电影导出工具,[https://kisexu.com/]这位大侠写的,发布在这里 [https://openuserjs.org/scripts/KiseXu/豆瓣电影导出工具]。

我想,有导出电影的,肯定还有导出书籍的吧。没找到。都这么懒呢。后来到了每日三省吾身的时候想,圣人日,行有不得 (并、不是这个意思),反求诸己 。对啊,找不到东西,自己写啊。正好有这么像的,动手改啊。我就改了一个。

Kisexu大侠的代码流畅,容易读懂,与上述如何爬的思路一致,不必修改。kisexu大侠用了 jquery 求 xpath。电影和书籍页面的xpath不一样,我得每页看看,改改。对 jquery 语法不熟悉,还顺便复习/预习了一下。在这里学的,[https://www.w3school.com.cn/jquery/jquery_ref_traversing.asp]。

微信图片_20220318004336

写完了跑了一下,效果还不错。刚好1234本书,有意思的数,算是个纪念。

4. 使用

需要用 firefox 浏览器,安装 tampermonkey 插件。

在 tampermonkey 的 dashboard 中新增脚本,删除原有示例代码(ctrl-a del),然后把以下代码贴进去。保存。

接着在访问豆瓣时,"我的豆瓣",我读 的右边除了 在读、想读、读过、书单,多了个"导出读过书籍"链接。

微信图片_20220318004343点这个链接,firefox 会翻你标记了读过的书,每页15本,一页页翻下去。全翻完以后,产生一个excel (后缀csv)请你下载。这个excel就是你读过的书籍了。
微信图片_20220318004339

微信图片_20220318004341

tampermonkey 脚本如下。再次感谢kisexu大侠。

// ==UserScript==
// @name         豆瓣已读书籍导出工具
// @namespace    younggift.net
// @version      0.1
// @description  将豆瓣已读图书导出为csv文件。启用本脚本,进入豆瓣个人页面后,在『我读』部分会有一链接『导出读过书籍』,点击即可。无需登录,支持导出任意用户已读书籍。
// @author       younggift
// @copyright 2022, younggift; 2018, KiseXu (https://kisexu.com)
// @license MIT
// @match        https://book.douban.com/people/*/collect*
// @match        https://www.douban.com/people/*
// @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 people = location.href.slice(location.href.indexOf('/people') + 8, -1);
        var export_link = 'https://book.douban.com/people/' + people + '/collect?sort=time&start=0&filter=all&mode=grid&tags_sort=count&export=1'; //列表URL 及'&export=1'标记
        $('#book h2:first .pl a:last').after('&nbsp;·&nbsp;<a href="'+export_link+'">导出读过书籍</a>') //根据css定位
    }

    if (location.href.indexOf('//book.douban.com/') > -1 && location.href.indexOf('export=1') > -1) {
        // 开始导出
        getPage();
    }


    // 获取当前页数据
    function getCurrentPageList() {
        var items = [];
        $('li.subject-item').each(function(index) {
            items[index] = {
                title: $(this).find('h2').find('a').text().trim(),
                pub: $(this).find('.pub').text().trim(),
                mark_date: $(this).find('.date').text().replace(/读过/, '').trim(),
                link: $(this).find('h2').find('a').attr('href').trim(),
                // 学习了 https://www.w3school.com.cn/jquery/jquery_ref_traversing.asp
            };
//             alert("here");
//             alert(items[index].title);
//             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, title, pub, mark_date, link`
        });
        var items = getCurrentPageList();
        db.items.bulkAdd(items).then (function(){
            console.log('保存成功');
            // 获取下一页链接
            var next_link = $('span.next a').attr('href');  //todo 是否正确?
            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, title, pub, mark_date, link`
        });
        db.items.orderBy('mark_date').toArray().then(function(all){
            all = all.map(function(item,index,array){
                delete item.id;
                return item;
            })

            JSonToCSV.setDataConver({
                data: all,
                fileName: 'book',
                columns: {
                    title: ['书名', '出版信息', '标记时间','链接'],
                    key: ['title', 'pub', 'mark_date', 'link']
                }
            });
            db.delete();
        });
    }

    //以下未修改 todo
    // 导出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;
    }
  };

})();

疫情数据简单抽取:Emacs正则表达式一例

某天,长春市发布了疫情数据,内容相当长,而格式有规格。所以我想到用正则表达式抽取出来,看着方便一些。

1. 原始数据,特征,正则表达式

原始数据有很多行,看起来形式大概这样。

: 新增确诊病例:
: 确诊病例1-35:现住九台区。系确诊病例的密切接触者,隔离期间核酸检测阳性,3月11日转入长春市传染病医院,诊断为确诊病例。
: 新增无症状感染者:
: 无症状感染者1-47:现h住九台区。系无症状感染者的密切接触者,隔离期间核酸检测阳性,3月11日被确定为无症状感染者。
: 无症状感染者48-63:现住九台区。系确诊病例的密切接触者,隔离期间核酸检测阳性,3月11日转入长春市传染病医院,被确定为无症状感染者。

能够看到明显的规律:

(1)数据由若干 行 组成;

(2)在每行中,需要提取的数据有共同特征,

比如下面这一行。

> 确诊病例1-35:现住九台区。系确诊病例的密切接触者,隔离期间核酸检测阳性,3月11日转入长春市传染病医院,诊断为确诊病例。

按原文与特征的对比如下。

| 原文                 | 特征                   |
| 无症状感染者1-47:现  | 不需要的任意字符序列... |
| 住                   | 住                     |
| 需要的任意字符序列... | 九台区                 |
| 。                   | '。'                   |
| 系无症状感染者的密切接触者,隔离期间核酸检测阳性,3月11日被确定为无 | 症状感染者。|不需要的任意字符序列... |

这样就得到了正则表达式,"住.*。",可以匹配需要的字符序列。比如上面这行,可以匹配得到"住九台区。"然后删除第一个字和最后一个字,得到"九台区"。对每一行均使用这一正则表达式,得到只有符合匹配要求的字符串。整行中都不符合特征的,这一行不会被选出来。

正则表达式可以用 命令行工具grep,程序设计语言python、js等。

我平时用emacs,支持正则表达式。

在emacs里,把上述数据粘贴到buffer(相当于文件)中。执行命令 M-x occur,

给出正则表达式,得到类似如下结果,不完全符合我的要求。下面的结果,是我对当前文档执行的结果,匹配的部分是高亮的。

图1

2. lazy

可以看到,匹配到的部分比我们预期的要长。不是在第一个遇到的句号 (。)停止,而是在最后一个句号,即尽可能长地匹配。

我隐约想起emacs正则表达式还有个 lazy 模式,查了一下,用?代表。

把正则表达式改为 住.*?。

好多了。

3. 仅保留匹配的部分

如果把匹配的结果复制出来呢。我只想要匹配的部分,不想要其余的文字,而现在的效果是给出了 包含匹配文字 的整行。

在网上查到一些emacs的lisp代码。还有一个简单的办法。

: As of Emacs 24, occur does in fact provide a simple solution:

: C-u M-s o .*pattern.* RET

[Emacs copy matching lines]

其中的"C-u M-s o" 表示 ctrl-u alt-s 再按 o。

怎么这么稀奇古怪的。

我事先知道 C-u 是前缀,后面的才是命令本身。用 f1 ? k 查询,按 M-s o 查

到原来就是 occur 啊。

: M-s o runs the command occur, which is an interactive compiled Lisp

我一直喜欢命令序列,可以减少硬记的工作量。所以,我用 C-u M-x occur。

C-u 有什么用呢?

occur 的手册说:

: When NLINES is a string or when the function is called interactively
: with prefix argument without a number (`C-u' alone as prefix) the
: matching strings are collected into the `*Occur*' buffer by using
: NLINES as a replacement regexp.

会把匹配的字符串 (而不是整行)收集到一个buffer (估且理解为文件) 里。

4. 去除首尾的字符

上述结果在一个只读的buffer中,复制出来,放到一个可写的buffer中。选中区

域,执行下面的指令。

: M-x replace-regexp
:  ^.
: 
: M-x replace-regexp
:  .$

得到以下结果。

: 九台区
: 九台区
: 九台区
: 九台区
: .*
: 九台区
: .*?

5. 去重

选中区域,执行下面的指令。

: M-x delete-duplicate-lines

得到以下结果。

: 九台区
: .*
: .*?

除"九台区"以外的两行,是匹配到了上文中我写的正则表达式的。

6. 排序

选中区域,执行下面的指令。

: M-x sort-lines

得到下面的结果。

.*
.*?
九台区

可以看到九台区被排到了正则表达式那两行的后面。

以上是在本文中运行的效果,真实数据的运行结果像下面这样。

: 九台区
: 公主岭市澜洋豪庭
: 公主岭市阳光首府
: 南关区十三局宿舍
: 南关区南城家园
: 南关区卫星商城
: 南关区星城国际大厦B座
: 双阳区太平镇桦木村9社
: 宽城区上台花园
: 德惠市中央公馆
: 德惠市国丰壹号院
: 德惠市幸福里岸
: 榆树市闵家镇东升村
: 汽开区东方之珠龙翔苑
: 经开区中海寰宇天下C区
: 经开区亚泰梧桐公馆
: 绿园区标记大厦
: 绿园区金色欧城
: 长春新区修正大厦
: 长春新区修正大学培训基地
: 长春新区北湾新城四期
: 长春新区恒盛豪庭

7. 后续工作

接着可以批量标注在地图中,看起来更方便。我没有使用方便,比如不需要身份认证的,没找到这样的工具。所以没再继续。

此外,emacs有个小工具 M-x re-builder,可以实时地在你修改正则表达式时,显示匹配的结果。适合正则表达式初学者探索。

手动测量经纬度,只使用简单工具

当年读儒勒·凡尔纳的《神秘岛》,以及此后反复阅读,每一次都为其中的智慧而击节赞叹。美国南北战争时期,五个人乘坐热气球从南方囚禁中逃出来,在一位远离人烟的荒岛上从一无所有到烧陶、制铁、电报机,几乎重建了工业。流落荒岛的第一件事,他们要确定自己的位置。是靠近陆地,还是孤岛。进而,才能决定要做长久打算,还是有可能尽快返回人类社会。

在夜里,工程师用六分仪测量南十字(?)的高度角,确定了纬度;在接近正午,用保持出发地时间的手表和当时时间之差确定了经度。由经纬度和回顾地图,他们确定远离陆地,缺乏远航的条件。

不用手机GPS,使用尽可能简单的工具,我们能何种程度精确地测量自己所在的经纬度呢?我试了一下。

1.原理

本节讨论实验所依据的原理,即如何由 测量的物理量 推算出 待求的物理量经度和纬度。

1.1 纬度

太阳的直射点以一年为周期,在北回归线和南回归线之间往复移动,导致正午太阳的高度角每天持续变化。因此,可以根据 正午太阳高度角、当天日期(确定了太阳直射点的纬线),可求得当地的纬度。

1(1)公式为 太阳高度角=90°- | 当地纬度 – 太阳直射点纬度 |

或 绝对值导致正负号对应的两个解,分别是太阳在赤道以南和以北的情况。

太阳在赤道以南或以北,可根据当天日期在春分或秋分前后确定。

涉及到的变量包括 太阳高度角,测量得到;太阳直射点纬度,根据当天日期查表或公式得到;当地纬度,待求的量。

(2)太阳直射点纬度

太阳直射点纬度 方法1 查表

以下表格都可以

Declination table

https://astronavigationdemystified.com/survival-declination-table/

https://moontracks.com/daily-declinations.html

https://tieba.baidu.com/p/5381726760

根据当天日期,可查表得到 赤纬,与太阳直射点纬度相同。

太阳直射点纬度 方法2 公式

太阳赤纬角 = 0.39795 * cos ( 0.98563 * ( n – 173 ) )

其中 n 是积日,即日期在一年中的序号,以1月1日为1。

(3)太阳高度角

置坚杆垂直于地面,测量正午前后日影的长度与杆的长度。日影长度/杆的长度,是太阳高度角的余切值,或者等比例画出日期的长度和杆的长度,用量角器测量。

2

(这里在实验还有需要讨论的细节,比如 如何确定正午,如果以北京时间为准,那么当地经度并非东八区,比如在南太平洋,带来的误差有多大;,如何保证竖杆垂直于地面,不那么垂直的误差会对测量精度带来多大影响。)

1.2 经度

根据 测得的当地时间 与 标准时间(已知经度)的时间差,可以求得当地的经度。当地时间的测量不以手机或广播或国家授时中心为准,而以太阳高度角最高点为 正午12:00。

环地球一周经度变化范围360度,时间变化范围24*60分钟。因此每度合4分钟。例如,长春在北京以东大约5度,因此正午12:00即太阳高度角最大比北京早大约4分钟/度*5度=20分钟。这样,我们可以根据测得的所在地正午时间与北京时间(东经120度经线的正午时间,不是北京的经线的正午时间)之差,求得所在地的经度。公式为

当地经度 – 东经120 = 时间提前分钟数 / 4

2.实验装置

朝南的窗台,假设水平良好。

硬质活页夹,纸。把硬质活页夹的三个角用铅笔标记在窗台上,以确保不慎移动后可以复位。

一支笔。笔帽,当作竖杆。在纸上标记竖杆的中心位置。

用手机作为手表。用笔在纸上标记竖杆顶端日影和时间。如图所示,竖杆是笔帽有弹性夹持部分(这东西叫什么名字?)。

3数据分析时使用 geogebra和excel,并非必须。

3.实验过程

2022年2月20日,11:26起大约每4分钟标记一次竖杆顶端影子的位置,同时记录时间。每4分钟太阳会移动大约1度(24*60分钟/360度=4分钟/度),更高的精度难以标记。

其间,因为玻璃较脏,有时会挡住阳光,竖杆影子的顶端看不清。在这之前,在纸上标记竖杆影子的顶端,然后把整个硬质活页夹移动到光线更好的地方,保证竖杆影子顶端与标记处仍然重合。

整个实验在12:07结束。后来在14点以后还测了几组数据,第二天21日上午又测了几组数据。对实验结果没有帮助,因此忽略这些数据。照片如下图所示。

4

4.实验数据

我们只需要角度,因此只需要知道杆和影子的长度比例作为余切,需要时间差,而不需要长度的绝对值。所以我并没有测量竖杆的长度数值,而是在geogebra中测量角度。

4.1 太阳高度角

把竖杆放倒,在纸上标记出底端到顶端的距离,如图中线段CD。

在geogebra中导入测量结果的照片。在图中把竖杆底端中心标记E点。令EF长度=CD长度。

可以看出,G点附近的竖杆顶端影子基本在同一条直线上,并与E点向下的直线大致垂直。因此G点选取对高度角精度影响不大。

角度FGF即太阳的高度角,用geogebra测量得34.95度。

5

4.2 当地正午与北京时间正午的时间差

此处实验设计的精度不好。我本以为,日影会是个大圆。拟合出圆以后,连接圆心和竖杆底端,这条直线与圆交点对应的时刻就是正午。

我没有考虑到,日影与杆间的关系是余切函数,而余切函数在90度附近变化缓慢,过零点不明显。所以,实验测得的竖杆末端几乎是一条直线,观察不到弧,因此找不到圆心。我需要测量更多离90度即正午较远的点,才能形成明显的弧降低误差。

6注意,不能仅测早晚几个点来确定正午。因为当时时间是未知的,不能根据北京时间确定数据的“中间”即正午的位置。事实上,正午,正是要测量的物理量。

所以,我补测了14点以后的几组数据和第二天的数据。但是,弧形仍然并不非常清晰,并且需要较长时间测量。

我粗糙地按如下方式处理,得到了测量值。把所有竖杆影子的末端大致拟合成直线LK,过竖杆末端E作LK的垂线,垂线与LK交于M。

7方法1 观察M点在记录的时间范围,在11:45附近,比这个时间略早。

方法2 线性映射

LK长度对应11:26到12:07即41分钟,求KM长度对应的分钟数。

LK/KM = 41/x  => x = 21(约)

求得x后,11:26加x分钟,得到正午时刻对应的北京时间11:47。

5.数据分析

根据公式(或查表)以及测量值,求得经度和纬度。

5.1 纬度

公式 太阳高度角=90°- | 当地纬度 – 太阳直射点纬度 |

2月20日

(1)测得正午太阳高度角34.95度。

(2)查表太阳直射点纬度 -11.21666667 (南纬)

求得当地纬度为 南纬66.26666667,或 北纬43.83333333。

根据指南针所示,太阳从南侧而不是北侧经过,因此长春纬度为 北纬43.83。

又,

公式 太阳高度角=90°- | 当地纬度 – 太阳直射点纬度 |

2月20日

(1)测得正午太阳高度角34.95度。

(2)根据公式0.39795 * cos ( 0.98563 * ( n – 173 ) ),积日n为51,得到赤纬为-11.47025014。

求得当地纬度为南纬66.52025014,或北纬43.57974986。

根据指南针所示,太阳从南侧而不是北侧经过,因此长春纬度为 北纬。

算得长春在北纬43.58。

5.2 经度

根据测量值当地正午为北京时间11:47,或者北京时间11:45。

分别比北京时间提前13分钟或15分钟。按4分钟/度,即0.25度/分钟计算,当地在东八区中央120经线以东3.25度或3.75度。

算得长春经度为东经123.25或东经123.75度。

6.误差讨论

6.1 对照标准

在网上搜索一下,长春市 经度 : 125.32 纬度: 43.90。

或者如下。

8在高德网页版上得到我所在的位置东经125.35,北纬43.8。

6.2 误差

(1)绝对误差和相对误差

我算得的数据 纬度为北纬43.58,经度为东经123.25或东经123.75度。此处应有表格,略。

纬度误差,约向南0.2度;经度误差约1度(最差3度左右)。相对误差都小于1%,尚可。

(2)误差换算成公里数

根据地球大圆或赤道4万公里(”坐地日行八万里”),经度和纬度都是360度,得4E4 km/360度 = 111.11公里/度。这样,我的测算误差大约100公里。

100公里看起来误差不小,相当于在如图所示的范围附近,即长春偏西100~300公里,略向南公里左右。在向通辽一带,可能比图中红圈更靠近长春一些。

9

这个结果貌似有点糟糕。然而在更大的尺度,如下图所示的范围,纬度几乎没错,经度从长春偏到沈阳。考虑到长春使用北京时间,相差达5个经度,甚至北京也与北京时间相差-4个经度,1~3度并不算太多。

a如果我们像《神秘岛》一样随热气球飘到一个荒芜人烟的地方,测算得到如下图所示的位置,判断是在陆地、陆地附近,还是大洋中心,结论还是有价值的。图中,红色地标是测得的结果,其左侧“家”的标志是长春。

b

7 热爱动手

一个硬质活页夹,一支圆珠笔,手表,得到下面这张粗糙的纸,进而推算出100公里以内范围。炫酷吧?

c也可能有人觉得,看下手机的GPS不就得了么,哪用得到这么麻烦。而且这也就是初高地理课水平的实验,说不定对高中生的精度要求更高呢。

当年我请教本科的导师李树杰老师,为什么要做实验。根据牛顿第二定律实验报告上的数据我都能编,连噪音都能随机啊。类似的疑问还有,高等数学为什么要证明,我信还不行吗?李老师教导我,做实验的目的不是为了验证实验结论,而是为了锻炼实验的能力。这段话我至今铭记,并且二十多年来令我受益良多。

微信图片_20220306173449

如果长白山天池火山喷发,影响方圆有多大,顺便讨论估算和查资料

长白山主峰白头山有湖泊,名为天池。天池下面是座活火山。就是随时可能喷,或者最近,指人类历史上,最近喷过的。事实上,不止天池下面是火山,附近有100多座火山,叫做长白山火山群。

从小就知道长白山自古著名,与千山山脉在东北的东部连成一线。小时候就知道长白山是活火山,有部电视剧讲长白山气象工作人员的,提到在风口差点被吹飞了,下着大暴雨,一个小年轻的喊:沉睡了三四百年,这长白山是不是又要喷发了。

但是我从来没怕过。也看过一些火山相关的片子,比如罗马庞贝古城、韩国电影《白头山》、BBC完美星球的第一集火山这一类的,感觉还是离得太远,对高温、火山灰、岩浆缺乏感性认识吧。贴近的接触是跟单位去日本旅游的时候见识过地面是温的,远处有烟,空气里有硫磺味。还有东汤镇的温泉。这些都不足以构成威胁,感觉远着呢。

1 近些年,看到些报道,提及韩国、朝鲜、日本也参与研究长白山火山群研究,还有韩国学者有耸人听闻之语,什么什么时候可能爆发。前文提到的韩国电影《白头山》的情节就是在这样的假设下展开的。哈哈一笑,离我还远。

后来看到了一些科普,提到长白山火山在16xx年,17xx年,190x年均有不同规模爆发。稍微引起我关注的是 火山灰影响远及日本,似乎还有人提到在日本能听到喷发的声音。

长白山到日本,比长白山到长春还要远。得关注一下了,这样的心理。

1. 估算

2 还有文章提到[如果长白山再次爆发,附近100平方千米的内的160万人将要遭殃][ https://zhuanlan.zhihu.com/p/328401889]。

我的思路就此发散。

160万人?里面有没有我。160万覆盖多大范围?作为对比参考,北京有多少人,3000万?长春有多少人,将近1千万?通化有多少人,以前是30万。长白山天池附近多大范围有160万人呢。

100平方千米,方圆有多大。等等,方圆是什么意思。方圆百里,有人说方圆是直径百里,有人说是半径百里,有人说是面积百平方里,有人说是周长。周长可笑么,据说朝鲜半岛三千里江山就是以周长计,我量了下,南北长750公里,合1500里,南北狭长,两个来回约合3000里,还真能对上。 3

原文用的是100平方千米而不是方圆这么模糊的字眼,更容易讨论一些——因此更可信。然而一旦喷发所影响的100平方千米……面积也太小了吧。

100平方千米乍一听好像挺大,但是,这是面积不是长度。按正方形计算,要开平方根,所以100平方千米就是10千米*10千米这么大。千米这个词咱们平时用得少,就是10公里见方。

10公里*10公里有多大,或者说,10公里有多远。没多远,打车20多块钱。 4

从东北师大自由校区,或者村上春树提到过的老虎公园或者金志文唱过的平泉路,到东北师大净月校区,大概就这么远。

几千年来整个地球都排得上第二的长白山火山群,就这么大点范围?我不信。而且,这么大范围不能有160万人吧。长春1千万人口,如果平均分布,那么长春的1/10面积上有大约100万人。这点范围,与长春的1/10相比也太小了。

下图中,红圈是长春的大概范围,绿圈是10公里*10公里。 5

也许需要考虑人口密度均匀,并且,只考虑长春市区。1/10倒是有可能。下图,红色的3*3格子,是我当时脑袋里想的9份,接近10份;绿圈,是10公里*10公里。 6

那意味着,1千万人口绝大部分挤在长春市区。1/10刚好差不多吧,至少数量级上。

然而,长白山火山附近,会有这么大的人口密度么。

假设长白山火山就在天池下面。这个假设有点依据,就是天池是长白山火山喷发的结果。那么,在长白山火山附近,10公里*10公里有多大范围呢?

710公里*10公里的范围内,看起来草木不生,不应该有大密度人类聚集才是。有人可能想到,山下的旅游区万达什么的。山下二字,作为对比,我小时候在通化,一般形容我们就在长白山的脚下,长白山山麓,距离天池大约200公里。另外,我们可以看一下行政区划图。

最近的度假村,将近50公里。

8著名的二道白河,50公里。

9长白山天池10公里*10公里的范围内,就不应该有几个人。

我把上述图示(不是原图,本文中的图是重新截的)发给了文章的编辑,说是不是数据有误啊。编辑很热心,提出了还有朝鲜呢以及类似的可能。如果有人口密度图就好了,讨论可以更直接一些。

以上,是估算部分。接下来是查资料。

  1. 查资料

根据估算,我不相信” 如果长白山再次爆发,附近100平方千米的内的160万人将要遭殃”这一论断。那么,这个论断从哪里来的呢?科普文章通常不给参考文献,不过总有蛛丝马迹可以查。

我最先查到了这个。

2.1 长白山保护开发区

长白山保护开发区管理委员会辖区

[https://mp.weixin.qq.com/s/rHg6cyHAuQx89Gt88pZqtQ]

: 全区南北最大长度为128公里,东西最宽达88公里。

仅长白山保护开发区管理委员会辖区就达到约100公里*100公里,比10公里*10公里大多了,是100倍。有多少人口呢,查

长白山保护开发区第七次全国人口普查公报[http://cbs.jl.gov.cn/shjj/tjxx/202106/t20210625_168941.html],

全区总人口为61146人。

6万多人,距离160万差很远。

这不是160万的数量来源。

2.2 新闻

查到一篇新闻。

: As of yet, scientists aren’t sure how immediate any threat might be, but with 1.6 million people living within 100 kilometers of Mount Paektu, safety is a major concern.

出处是新闻,链接如下

[https://advances.sciencemag.org/content/2/4/e1501513]。

新闻援引的论文没有查到提及人口数和范围。论文链接为 [https://advances.sciencemag.org/content/2/4/e1501513#ref-26]。

这条新闻中的用词是 100 kilometers,是复数的100公里直线距离,不是100平方公里面积。我怀疑是100公里直径或半范围内1.6 million即160万人口受到威胁。

2.3 数据库?

又一篇。

: 另有网页提到100公里160万人这一数据

: According to the database there are 160 million people that live within 60 miles (or 100 km) of the 245 mapped glacierized volcanoes.

: 链接如下

: https://www.forbes.com/sites/davidbressan/2020/11/30/fire-and-ice-new-database-maps-and-classifies-the-dangers-of-glacierized-volcanoes/?sh=4711364b281b

提到了 the database,看来数据是从这里出来的,有245座冰川(化?)火山,符合100公里内160万人口。100公里,是半径还是直径?

2.4 数据库

刚刚这个网页提到标准来自下述数据库,提到

: glaciers (2584 total) with a 5 km radius.

: Glacierized volcanoes occur predominantly (74%) in subduction zone settings.

: 20,000 people live within 5 km of a glacierized volcano, 160 million within 100 km.

提到了半径5公里,以及5公里有2万人,100公里160万人。看来是指半径100公里。

报道数据库的论文链接如下.

: 850 ± 290 km3 of glacier ice is within 5 km of volcanic vents globally.

: [https://www.sciencedirect.com/science/article/pii/S0921818120302472?via%3Dihub]

半径100公里有多大范围?接近临江、靖宇、抚松。白山市距离天池也不过137公里。

a

100公里*100公里,与半径100公里处于同一数量级,差不多吧,160万人口。

2.5 衍文的最初来源?

知乎的玛莉蓓尔提到“下面这个文字中的人口数,似乎不是160万人,而是1亿6千万人?[发呆]
According to the database there are 160 million people that live within 60 miles (or 100 km) of the 245 mapped glacierized volcanoes.
这数字好夸张啊。”

我把160 million,160百万,当成了160万。 想想吉林省才才2690.73万人,怎么会有160百万人口受到影响。

查了原文Global-mapping-of-future-glaciovolcanism_2020_Global-and-Planetary-Change。原文提到“in all, approximately 6.7 million people live with 30 km of a glacierized volcano, and 163 million people live within 100 km globally.” 是全球所有符合条件的冰川火山每一座100km直径以内受到影响的人口为160百万。

这,可能就是衍文的源头吧。就像我,把160万读成了160百万,可能还有人把100km读成了10km,把受全球所有这样火山影响的人口当成了任意一座?

  1. 修订,结果

我把上述资料发给编辑,对方说“谢谢!”。后来我看到微信公众号[https://mp.weixin.qq.com/s/5EjAXbHuPiqPnnLwKWZD5w]里的文字修改为” 如果长白山再次爆发,附近1万平方千米的内的160万人将要遭殃”。1万平方千米,100*100平方公里。

b写这篇博客的时候我发现,知乎上的同一文章还保留着100平方千米。[https://zhuanlan.zhihu.com/p/328401889]

c

  1. 缘起

为什么想起来回顾一下这件事呢。以一阵和大哥、WG喝酒的时候,我提到 梅河口的酒精生产占全国的90%,被他俩一顿嘲笑。大哥提到,要有常识。他说的是 common sense,包括论断内部的逻辑自洽、以及论断与现有证据之间不矛盾。如果梅河口的酒精生产占全国的90%,那么我们早就应该知道了,通过各种渠道佐证。我记得的这个数字,是2020年初疫情其间封省时在哪里读到的。回家没有查到。

查到了些其他的数字,看来我记错了,远没有90%那么多。

不要没事就瞎编或者瞎信,要自洽,要与所有已知的证据吻合。要主动、充分提供证据,以帮助别人检验。

吉林省酒精产量占全国的

200/984 约1/5 (2017年吉林省/2014年全国 发酵酒精)~ 上限75.90%(东北+华东),

-------------------------------------------------------

1.

全国没查到,只查到啤酒和葡萄酒。

 | total | per capita

beer | 38.12 million t/yr (metric tons per year) | 26.7 kg/person/yr (kilograms per person per year)

wine | 1.922 million t/yr (metric tons per year) | 1.346 kg/person/yr (kilograms per person per year)

(2018 estimates)

[https://www.wolframalpha.com/]搜索 alcohol production of china

 2.

2017年上半年,东北、华东地区酒精产量占到国内酒精行业总产量的75.90%。

 [http://www.siacn.org/index.php?optionid=1113&auto_id=27638]

 3.

根据国家统计局数字,2014年全国发酵酒精产量984.27万千升(年主营业务收入在2000万元及以上的工业法人企业)

 [www.alcoholnet.com/zxlm/179076.htm]

 约6百万吨

  2017 2018 2019

发酵酒精(折96度,商品量)(千升) 2,005,561.00  1,641,408.00  1,649,242.00

饮料酒(千升) 1,992,760.00  1,140,674.00  953,390.00

# 白酒(折65度,商品量)(千升) 777,787.00  193,901.00  22,631.00

啤酒(千升) 1,095,943.00  921,728.00  905,608.00

葡萄酒(千升) 98,804.00  21,393.00  21,369.00

果酒及配制酒(千升) 1,703.00  960.00  1,371.00

[http://tjj.jl.gov.cn/tjsj/tjnj/2020/ml/indexc.htm]

吉林省统计局

d

微信图片_20220216143930

指针和数组,到底哪个快,顺便讨论写实验报告

1个月又10天以前,MYL同学给我看了2段代码,是C语言的字符串接接函数strcat的实现。一段代码用的方法是数组,另一段用的是指针。问题是,一般使用指针实现,而不用数组,是因为指针更快呢,还是仅仅出于习惯/习俗/惯用法。

代码大致是这样的。

用指针实现的 strcat,如下。
: void str_cat_pointer(char* from, char* to)
: {
:     while(*to != 0)
:         to++;
:     while(*from != 0)
:         *to++ = *from++;
:     *to=0;
:
: }

用数组实现的 strcat,如下。

void str_cat_array(char from[], char to[])
{
int i,j;
for(i=0; to[i]!= 0; i++)
;
for(j=0; from[j]!=0; i++,j++)
to[i] = from[j];
to[i]=0;
}

插叙:MYL同学的代码有bug,并且我没有问她要授权,所以不宜发布在这里。她的代码有小bug,逻辑错误,对于当前讨论的问题是个题外话,所以不讨论了。不过,对于写实验报告这个主题,这是个有意义的话题。在实验中,不能假设原材料是干净的,不能假设工具是可用的,也不能假设仪器是校准过的。需要在实验前先"测空白"。就像在哪吒闹海里,祭童男童女就下雨 这个论断,需要先测空白,即不祭童男童女就不下雨,然后再去尝试伪原论断。如果不祭童男童女也下雨呢,那么原论断就不知道真假了。MYL同学的代码有毛病,得改成能跑、跑得对,才能进一步讨论。

插叙:当晚我做了实验答复她,但是为什么花了一个多月才写这篇博客呢。这也是实验报告写作中的一个问题,那就是大家都不乐意写,懒。实验比报告有意思。不过,即使不写实验报告,也应该按实验报告的大纲和要求设计实验、做实验并保证结论严谨。

上述代码,学过C语言的同学都很熟悉。一般教材在这里会指出 *(p+i) 与 p[i]是"等价"的。

诉诸权威,用教材来回答当然也是个路线,这样就不必做实验了。不过,那样需要找到教材,查到原文。不然的话,万一对原文理解错误,小细节没记清楚,被追究到以后就说"哎呀,我没记清啊",真是不严谨认真的人。这也是我不主动不积极回答同学们疑问的原因,特别是事涉人生发展的,因为真是累啊。也有的同学可能说,我对你的意见也不会那么重视,你为啥这么大压力呢。如果你不重视我的意见,还问我羞辱我干嘛,不如去扔个色子。

我当时手头刚好没教材,需要花点时间找。而且,找到教材,就有说服了么?在我的记忆里,教材里没有提到,这是习惯用法,还是效率就是高。即使教材提到"等价",也是指在这样不同的语法下,二者的语义是等价的。语义在这里包括什么呢,非常可能,只包括函数映射意义上的执行结果,而不包括对效率的讨论。即使教材说"习惯上"这么写,我也会认为作者在瞎扯。

好了,所以,需要做个实验看看 二者的效率 是不是一样。如果效率一样,我们就可以继续假设 单单是习惯上的原因。

还有一种可能,你读到这里已经早已按捺不住拍案而起,"我知道,不就是……"这时,要么,我们需要证据和论证过程,而不是拍胸脯;要么,你是权威,我们诉诸你。写实验报告还不利索的同学,你不是权威。或者你是权威,但是早有一天,你会遇到不信你的人,得给出证据和论证过程。这就是学数学的时候,我们哭丧着脸说"我信,我信,我服了还不行么"也不行的原因。早晚有一天,会有不信的人,只有理性、逻辑、证据和论证过程方能说服。

实验是这样设计的。

1. 准备材料,两个数组/指针字符串。

:     char f[11]="0123456789";
:     char t[30]="abcd";

2. 操作

用其中一个函数拼接字符串,拼接 1000000 次。

:         for(int i=0; i<1000000L; i++)
:         {
:             strcpy(f, "0123456789");
:             strcpy(t, "abcd");
:             str_cat_pointer(f, t);
:             //printf("\n%s\n", t);
:         }

为什么拼接这么多次呢?别笑。确实有不止一位同学在做实验的问过我,为啥我测不出来呢。因为次数不够、速度太快、时间太短,所以没测到。我们如何称量1粒米的重量?准备1kg米,然后数有多少粒,用1kg除以米粒的数量。

所以要拼接 1000000 次,为了 明显地可观察到结果。如果你的机器格外快怎么办,再拼接几次。如果 i 的大小超过了整数64位宽,开始回卷了怎么办?好问题,请阅读 CSAPP。

在回到主线——如果 拼接 1000000 次 就可以测到——之前,有的同学可能会问,你怎么知道拼接 1000000 次就够呢。我不知道,我瞎编的,我测了一下。在我机器上40多毫秒,能测出来了。如果时间太长,我就准备减小这个数;如果时间短到测不出来,我就准备增加这个数。二猫他们有一套计算方法,能大致估算出迭代多少次的数量级,我不会,又没请教,所以就靠实验方法了。

3. 测量

在拼接操作之前,测一下时刻。

:     clock_t begin, duration;
:         begin = clock();

在拼接操作之后,再测一下时刻。求开始与结束的时刻之差,这段时间间隔,就
是拼接1000000次所花费的时间,是我们要测量的物理量。

:         duration = clock() - begin;
:         printf( "\n%d ms in str_cat_pointer.", duration*1000 / CLOCKS_PER_SEC );

4. 多测几次

别笑。我确实见过不止一位同学,测了一次就给出结论。类似于,我看到过隔壁张大妈家小子从来不学习还能考上清华,因此我也能。计算机科学学生,误解了算法的可重现性。只有算法具有可重现性,算法在真实世界中的应用,其效率并不可重现,或者说是理论值的基础上叠加了噪音。即使在算法所应用于其上的数据是一致的,在CPU上跑的时候,运行的环境也*无法*保证一致。有没有别的进程,操作系统心情好么,CPU温度如何,对结果时间间隔这个物理量都有影响。

真实客观世界的物理量,如果测量与理论不符,那么就只能是理论错了。即使测量的方法有误,也是我们对测量的方法的理论理解有误。真实客观世界本身从来不会错。

所以外面套个循环,跑10次。

:     for(int k=0; k<10; k++)

5. 对比

跑10轮数组的版本,再跑10轮指针的版本。得到2*10组数据。

此外有图chart和表格table。

无优化

并且! 还要有结论啊同学们。经常有同学把数据拿出来,不给结论。那意思就是傲慢地说: 你看吧,结论多么显然。如果你没有看到这结论如此显然,就是你傻得如此显然。或者,那意思就是,尊敬的老师,证据我已经给出了,结论得由您才有资格尊贵地给出。

结论得写实验报告的人给。如果结论符合假设、实验目的,就把假设或实验目的再说一遍,并且说,实验证据支持 (事实上,波普尔说并不能证实)实验目的。如果实验证据与假设矛盾,就把实验目的再说一遍,然后说,证据与假设矛盾。

得说,明确说。

实验数据展示了,指针的写法比数组的写法 所花费的时间更短,即运行速度更快。拼接1000000次,重复10轮,每轮实验中指针版"大约"比数组版快5毫秒~9毫秒。我到这里就满意了,更严格的实验还应该给出误差范围和来源、方差分布、置信度什么的。

实验表明,在字符串拼写函数的实现中,指针的写法比数组的写法效率更高。结论是假设指针效率高,实验证据展示的是 (在这些特定的跑过的实验中) 指针的效率高,结论与证据间有联系——并且这一联系是否合理也公开出来接受别人的检验。

6. 反转

给出证据和结论,我顺手查了一下理论。是不是在语义上,指针和数组还是有微小的区别。请知道得受不了拍案而起的同学,把解释留在评论中供其他读者膜拜,在这里不是主线,我略过。

看这些资料时,我注意到有人提到 编译优化。我面无表情地做了个"吼"的表情,还是考虑不周。

按下述两种编译参数分别编译了代码跑一遍,发现故事有反转。

: "c:\Program Files (x86)\CodeBlocks\MinGW\bin\mingw32-gcc.exe" point.c

: "c:\Program Files (x86)\CodeBlocks\MinGW\bin\mingw32-gcc.exe" -O2 point.c

此外有图chart和表格table。

有优化

由于优化,代码运行的速度更快了。还是这些数据,还是这些代码,材料和工具都没变,测量方法也没变。原来35毫秒的指针,跑到了10毫秒,快了3倍有余。

更吸引我们注意的是,原来的数组版本受到优化的正面影响更大。数组版本跑到9毫秒,由优化前比指针版本慢一跃达到比指针版本更快了。

往上看,不仅要有图,还要有对图的解读。

优化以前,指针更快;优化以后,数组更快。所以,之前的结论是错的,也许写成指针,只是习惯吧。或者当年尚不能这样优化的时候,指针曾经更快过。

7. 未尽事项

实验"大致"得出了结论,至少比原始的猜测要更深入了。但是,还有些遗留问题,只是通常对结论没有影响,我们就没有讨论。这与"我的证据是假的"但是不影响我的结论不同。而是即使深入讨论,我们也已经准备好了答辩的应对。

比如,在指针和数组实验中,计时都包括了 strcpy 的时间。我们假设,strcpy的效率是稳定的,因此在1000000次计数中,我们可以剃除这部分时间。

诸如这类的假设还有很多,例如我们假设拼接更长或更短的字符串,不影响我们的结论。甚至可能进一步假设接接所花的时间会随字符串之一的长度成线性增长。我们假设 后++ 与 前++ 所花费时间区别不大,因此没有做 前++ 的实验。我们假设传参的时候用数组还是指针对时间没有影响。我们假设先做数组还是先做指针版的拼接,对于结果没有影响。

就像我们假设了物理定律的在宇宙中是普适的,我们假设材料各向同性。

这些假设可能被其他人指出"是错误的假设",或者这些假设并不总成立,或者假设成立是有条件的。我们的结论就要再修正。

公开实验证据,公开实验设计和实验数据;明确说明论证过程,明确给出结论和证据间的逻辑联系。期待别人指出其中的失误。如果暂时没有人指出,要么咱们暂时继续假设结论是对的吧,要么做的东西不值得讨论,不值得别人花时间否定。

全部代码是这样的。

/*
>"c:\Program Files (x86)\CodeBlocks\MinGW\bin\mingw32-gcc.exe"  point.c

>"c:\Program Files (x86)\CodeBlocks\MinGW\bin\mingw32-gcc.exe"  -O2 point.c
*/

#include <stdlib.h>
#include <stdio.h>
#include <time.h>
#include <string.h>

void str_cat_pointer(char* from, char* to)
{
while(*to != 0)
to++;
while(*from != 0)
*to++ = *from++;
*to=0;

}

void str_cat_array(char from[], char to[])
{
int i,j;
for(i=0; to[i]!= 0; i++)
;
for(j=0; from[j]!=0; i++,j++)
to[i] = from[j];
to[i]=0;
}

int main()
{
clock_t begin, duration;

char f[11]="0123456789";
char t[30]="abcd";

for(int k=0; k<10; k++)
{
//---------------------------
begin = clock();
for(int i=0; i<1000000L; i++)
{
strcpy(f, "0123456789");
strcpy(t, "abcd");
str_cat_pointer(f, t);
//printf("\n%s\n", t);
}
duration = clock() - begin;
printf( "\n%d ms in str_cat_pointer.", duration*1000 / CLOCKS_PER_SEC );
}

for(int k=0; k<10; k++)
{
//---------------------------
begin = clock();
for(int i=0; i<1000000L; i++)
{
strcpy(f, "0123456789");
strcpy(t, "abcd");
str_cat_array(f, t);
//printf("%s\n", t);
}
duration = clock() - begin;
printf( "\n%d ms in str_cat_array.", duration*1000 / CLOCKS_PER_SEC );
}

return 0;
}