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

用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