MoreRSS

site iconRamsay Leung修改

软件工程师,蚂蚁金服 - 微信 - AWS,使用Emacs 与Linux 
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Ramsay Leung的 RSS 预览

关于破解加拿大航空飞机网络限制的一件小事

2025-10-10 06:53:00

1 序章

前段时间,坐飞机从加拿大飞回香港,全程大概12个小时,坐的是加拿大航空(Air Canada)的飞机。

有趣的是,飞机上竟然有 Wifi:

但是 Wifi 做了限制,对于Aeroplan的会员,如果未付费,就只提供 Free Texting 的功能,即只能使用即时通信软件,比如 Whatsapp, Snapchat,微信发送文本信息,但无法访问其他网站。

如果想要无限制地访问其他网站,那么就是付 30.75 加元;

如果想要在飞机上看视频,那么就是付 39 加元;

我就在想,对于 Free Texting 服务,我是否可以绕过只能使用即时软件的限制,无限制地访问其他网站呢?

即相当于还是免费会员,但是可以享受30.75加元付费用户的服务,反正长途漫漫,总要找点有趣的事情消磨一下12个小时的时间。

又因为可以使用微信的文字聊天服务,那么我还可以在天上呼叫外援来帮忙一起处理,而我的室友恰好是个安全+网络专家, 当时在家休假,我一提这个想法,他觉得好玩就一拍即合,我们就直接地空连线开搞。

2 流程

在飞机上选择完 acwifi.com 这个唯一的 wifi 后,就像其他需要登录的Wifi一样,会弹出一个 acwifi.com 的网页,要求验证我的 Aeroplan 会员的身份,验证通过之后即可上网。

有个非常经典的软件开发面试题:在浏览器输入一个网址之后,按下回车键之后,会发生什么事情。

比如输入的是 https://acwifi.com, 如果只关注的网络请求部分,整个过程大概是:DNS查询 -> TCP连接 -> TLS握手 -> HTTP请求与响应

我们把需要访问的目标网站当作是 github.com, 现在就来看下要怎么才能突破网络的限制,成功访问 github.com

3 思路1: 伪装域名

既然 acwifi.com 可以访问,而 github.com 不可以访问,那么是否有可能是网络在DNS服务器做了限制,只解析白名单内的域名(即时通信的域名)

如果是这种情况的话,那么我是否可以修改 =/etc/host=,把我的服务器伪装成 acwifi.com, 所有的请求流量都经过我的服务器,再去请求目标网站(github.com) 如:

想法大概是我修改DNS记录,把我们的代理服务器的IP 137.184.231.87 绑定到 acwifi.com 上,因为本地的 /etc/host 的优先级是高于DNS服务器的,然后再用证书进行自签名,告诉浏览器,这个IP 和这个域名是绑定的,你要相信它。

我先验证下一个想法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
> ping 137.184.231.87
PING 137.184.231.87 (137.184.231.87): 56 data bytes
Request timeout for icmp_seq 0
Request timeout for icmp_seq 1
Request timeout for icmp_seq 2
Request timeout for icmp_seq 3
Request timeout for icmp_seq 4
^C
--- 137.184.231.87 ping statistics ---
6 packets transmitted, 0 packets received, 100.0% packet loss

只是没想到,IP直接 ping 不通,相当于是IP大概率直接就连不上了。

试了下其他的著名IP,比如 Cloudflare 的CDN IP, 也是连不通:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
> ping 172.67.133.121
PING 172.67.133.121 (172.67.133.121): 56 data bytes
Request timeout for icmp_seq 0
Request timeout for icmp_seq 1
Request timeout for icmp_seq 2
Request timeout for icmp_seq 3
Request timeout for icmp_seq 4
^C
--- 172.67.133.121 ping statistics ---
6 packets transmitted, 0 packets received, 100.0% packet loss

看来这条路子是走不通的,这个思路只有满足两个条件才能走得通:

  1. texting only 的限制是在DNS 解析时做的,只有特定的域名(比如WhatsApp, 微信的域名)才会被解析 (这样的维护成本比较低)
  2. 网关允许任意的IP发起网络请求

但实际是IP都直接拦截了,怎么伪装都没有用了,这个网络大概率维护了某个IP白名单(比如WhatsApp,微信的出口IP), 只有在白名单的IP才可以访问。

此外,舍友还建议我做了个额外的测试:

  • 舍友:我想看看TLS有没有被拦截。万一只拦截了ICMP没拦截TLS, 那就是虚惊一场。
  • 我:想得有点美好😂
  • 舍友:做安全的就是要每个都试试
1
2
3
4
5
6
7
8
> curl -Lkv https://172.67.133.121
*   Trying 172.67.133.121:443...
* Connected to 172.67.133.121 (172.67.133.121) port 443
* ALPN: curl offers h2,http/1.1
* (304) (OUT), TLS handshake, Client hello (1):
* LibreSSL SSL_connect: SSL_ERROR_SYSCALL in connection to 172.67.133.121:443
* Closing connection
curl: (35) LibreSSL SSL_connect: SSL_ERROR_SYSCALL in connection to 172.67.133.121:443

不出意外,IP都被封了,TLS也没法建立连接。

4 思路2: 53端口伪装DNS

在第一条路子走不通的时候,室友提供了第二条路子:尝试利用DNS服务作为突破口:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
> dig http418.org

; <<>> DiG 9.10.6 <<>> http418.org
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 64160
;; flags: qr rd ra; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;http418.org.			IN	A

;; ANSWER SECTION:
http418.org.		300	IN	A	172.67.133.121
http418.org.		300	IN	A	104.21.5.131

;; Query time: 3288 msec
;; SERVER: 172.19.207.1#53(172.19.207.1)
;; WHEN: Sat Oct 04 14:18:24 PDT 2025
;; MSG SIZE  rcvd: 94

这是个好消息,说明还是有路子可以请求到外部网络的,DNS就是其中一个路子。

看上面的记录,说明我们查询 http418.org 这个网站的DNS 纪录成功,也就意味着DNS请求是成功的。

4.1 任意DNS服务器

室友再随意找了个 DNS 服务器,看下这个网络是否对DNS服务器有白名单:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
> dig @40.115.144.198 http418.org

; <<>> DiG 9.10.6 <<>> @40.115.144.198 http418.org
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 58958
;; flags: qr rd ra; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1224
;; QUESTION SECTION:
;http418.org.			IN	A

;; ANSWER SECTION:
http418.org.		275	IN	A	104.21.5.131
http418.org.		275	IN	A	172.67.133.121

;; Query time: 1169 msec
;; SERVER: 40.115.144.198#53(40.115.144.198)
;; WHEN: Sat Oct 04 14:24:25 PDT 2025
;; MSG SIZE  rcvd: 72

竟然可以使用任意的DNS服务器,机会又大了不少

4.2 TCP查询

任意的DNS服务器都能请求成功, 这就是个非常好的消息, DNS 默认是走的UDP 协议,那么走TCP 协议的DNS请求是否会被拦截呢?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
> dig @40.115.144.198 http418.org +tcp

; <<>> DiG 9.10.6 <<>> @40.115.144.198 http418.org +tcp
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 30355
;; flags: qr rd ra; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1224
;; QUESTION SECTION:
;http418.org.			IN	A

;; ANSWER SECTION:
http418.org.		36	IN	A	172.67.133.121
http418.org.		36	IN	A	104.21.5.131

;; Query time: 4679 msec
;; SERVER: 40.115.144.198#53(40.115.144.198)
;; WHEN: Sat Oct 04 14:28:24 PDT 2025
;; MSG SIZE  rcvd: 72

DNS TCP查询也能通过!这说明飞机网络的过滤策略相对宽松,为我们后续的DNS隧道方案提供了可能性。

4.3 53端口的代理服务

说明飞机网络限制也不是完全密不透风的,我们发现这堵墙上有个「狗洞」。

那么我们就有了个巧妙的想法:既然飞机网关对DNS请求没有拦截,那么理论上我们可以把代理服务器伪装成DNS服务器, 暴露DNS服务的53端口,所有的请求都经过代理服务器,伪装成 DNS 请求,那么就可以绕过拦截了。

舍友就用 xray 1服务花费了一个小时,架设了一个暴露 53 端口的代理服务器,把配置信息通过微信发给我:

室友用Xray搭建的代理服务器配置包含了如下的示意配置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
{
  "outbounds": [
    {
      "tag": "proxy",
      "protocol": "vless",
      "settings": {
        "vnext": [
          {
            "address": "our-proxy-server-domain",
            "port": 53,
            "users": [
              {
                "id": "some-uuid",
                "flow": "xtls-rprx-vision",
                "encryption": "none",
                "level": 0
              }
            ]
          }
        ]
      },
      "streamSettings": {
        "network": "tcp",
        "security": "tls",
        "tlsSettings": {
          "allowInsecure": false,
          "allowInsecureCiphers": false,
          "alpn": [
            "h2"
          ]
        }
      }
    },
    {
      "tag": "direct",
      "protocol": "freedom"
    },
    {
      "tag": "block",
      "protocol": "blackhole"
    }
  ]
}

而我电脑上就有个 xray 的客户端,不需要额外的软件就能建立连接。

万事具备,激动人力的时刻到了,按下回车,访问 github.com

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/Users/ramsayleung [ramsayleung@ramsayleungs-Laptop] [18:28]
> curl -v github.com -x socks5://127.0.0.1:10810
*   Trying 127.0.0.1:10810...
* Connected to 127.0.0.1 (127.0.0.1) port 10810
* SOCKS5 connect to 172.19.1.1:80 (locally resolved)
* SOCKS5 request granted.
* Connected to 127.0.0.1 (127.0.0.1) port 10810
> GET / HTTP/1.1
> Host: github.com
> User-Agent: curl/8.4.0
> Accept: */*
>
< HTTP/1.1 301 Moved Permanently
< Content-Length: 0
< Location: https://github.com/
<
* Connection #0 to host 127.0.0.1 left intact

/Users/ramsayleung [ramsayleung@ramsayleungs-Laptop] [18:28]
> curl -v github.com -x socks5://127.0.0.1:10810
*   Trying 127.0.0.1:10810...
* Connected to 127.0.0.1 (127.0.0.1) port 10810
* SOCKS5 connect to 172.19.1.1:80 (locally resolved)
* SOCKS5 request granted.
* Connected to 127.0.0.1 (127.0.0.1) port 10810
> GET / HTTP/1.1
> Host: github.com
> User-Agent: curl/8.4.0
> Accept: */*
>
< HTTP/1.1 301 Moved Permanently
< Content-Length: 0
< Location: https://github.com/
<
* Connection #0 to host 127.0.0.1 left intact

竟然请求成功了,github.com返回成功结果啦!

这意味着我们真的破解网络的限制,可以访问任意的网站啦!

此次,我们之前并没有意识到 xray 还有此种妙用 :)

这里我们就利用了一个简单的思维惯性:不是所有使用53端口的服务都是DNS查询请求。

5 终极思路: DNS Tunnel

如果思路2还是无法成功,我们还有一个终极大招可以用。

现在网关是只通过端口是否是53来判断是否是DNS请求, 但是如果网关更严格一些,比如检查DNS请求包的内容,就会发现我们的请求是「伪装」成DNS查询请求,而非真正的DNS查询请求:

既然伪装的DNS请求会被拦截掉,那么我们就把所有请求都塞成DNS请求包里面,做成DNS TXT query,我真的是要查 DNS 了,只是还在里面加了些料:

但是这个终极方案需要有一个DNS Tunnel 的客户端(比如 https://github.com/yarrick/iodine) 来把所有的请求都封装起来,我电脑里面没有这样的软件,所以这个就变成理论上的终极方案,实际也无法验证。

6 结语

长途漫漫,我和室友花费了大概4个小时的时间远程把网络限制给破解了,玩得不亦乐乎,证明我们解决问题的思路着实是可行的。

方案能最后实施成功,主要是归功于我室友这个网络专家,远程连线提供技术和思路支持。

美中不足的是,虽然我们破解了网络限制,可以访问任意网站,但是飞机上的带宽实在是太小的, 打开网页实在是费劲,所以我也没有花太多时间上网冲浪。

剩下的几个小时时间,我是重温了一下八十年代的经典穿越科幻电影:《回到未来(Back to Future)2三部曲,相当好看.

最后,我在此严正声明:

这种技术探索仅限于学习和研究目的,我们严格遵守相关规定和服务条款。

7 后话:可以突破限速么?

有限速 你再怎么折腾也不可能把限速破了

free texting的带宽才是最大限制。再怎么bypass不解决带宽问题也没办法像付费Wi-Fi一样用😭

这是来自于读者的评论,这个是真的么?

也不尽然。

其实还是有法子可以Bypass的,对于网络链路,通信的时候并不会带有「已付费用户」的业务标记信息, 所以换我来设计这套付费系统,我会在用户付费之后,把付费用户的设备唯一标记,一般是 Mac 地址加入到网关白名单中,那么所有来自该Mac 地址 的流量都可以走更高带宽的线路;

此外,也因为白名单的存在,即使绕过free texting 的限制,这些免费用户也无法享受更高的带宽,一举两得。

猜到这个原理之后,我就可以「伪装成付费用户」。

因为Mac 地址算是电脑自行指定的,然后用ARP 协议告诉网关的.

所谓的ARP(Address Resolution Protocol)协议,就是将一个IP地址解析成对应的MAC地址。而ARP协议的工作原理就是在网络内广播ARP请求:

网关:谁的IP地址是192.168.1.100?请告诉你的MAC地址。 拥有该IP的设备:我是192.168.1.100,我的MAC地址是XX:XX:XX:XX:XX:XX。

ARP协议本身没有安全验证机制——它无条件信任收到的ARP应答,我可以也告诉网关的,我的IP 地址对应的也是已经付费的Mac 地址,这样所有从我这里来的流量,都是可以享受到付费线路,这个即所谓的 ARP欺骗(ARP Spoofing)

最后一个问题是,怎么知道哪个是付费用户的Mac地址呢?

很粗暴,把网络中所有知道的Mac地址都尝试一次,如果有付费的Mac 地址,来自该Mac地址的请求绝对是能访问类似 Youtube/Netflex 这样的网站的,这样用脚本非常容易来自动化检测到。

我此前的伪装DNS 服务端口的方案不会对飞机网络有任何影响,也不会入侵任何的飞机系统,本质和在电脑上开个端口是53的VPN 是一样的。

但是ARP欺骗这个方案就很「刑」,已经入侵飞机的网络系统,我就把思路分享出来就够了,我可不想去吃免费的皇家饭 :)

推荐阅读

qrcode_gh_e06d750e626f_1.jpg 公号同步更新,欢迎关注👻

Telegram频道精华贴检索机器人

2025-10-02 12:55:00

1 引言

我有时总会觉得自己是个「奇葩」: 在现在各种算法推荐大行其道的当下,我却偏偏不喜欢算法推荐,因为这种「千人千面」的模式,总让我担心自己会陷入「信息茧房」的焦虑之中,此外我也希望可以看到一些我兴趣之外的内容。

我算是很多年的 Twitter 老用户了,使用 Twitter 已经超过十年了,只是在马斯克收购 Twitter,新增了一个 “For You” 的推荐流之后,越来越没有兴趣看,不是吵架就是借极端观点/话题引流。

于是,我转向了Telegram频道,并为此打造了一个专属的「挖掘好帖」工具。

在最近的一年时间,我订阅了越来越多的 Telegram 频道,频道主可以向所有的订阅者广播内容,因为 Telegram 频道都是由频道主发布的,这给了我一种主动发现、主动阅读,而非被动接受算法推荐的感觉。

有频道主分享 twitter 上的内容时,也可以算是某种意义的「编辑精选」了。

当我订阅越来越多的频道之后,发现有些频道主太能聊了,一天能发几十个帖子,有些只是碎碎念,有些却是思考和精华。

所以我就在想,是否有个好用的挖宝藏(挖坟)工具,可以把频道的热贴,精华帖都列出来呢?

2 灵感

另外一个我高频使用的网站就是 Reddit, 不了解的 Reddit 的朋友可以理解成它是网站的百度贴吧,可以有不同的子版(subreddit),相当于不同的吧, 然后每个子版都有一个 top 的功能,可以按照过去一小时,过去24小时,过去一周,过去一年,历史全时段按帖子的顶贴数进行排序。

类似 rust 这个子版的历史精华帖:https://old.reddit.com/r/rust/top/

那么,我是否也可以对 Telegram 频道也实现类似的功能呢?

我可以把某个频道所有的帖子都爬下来,然后按点赞数,转发数,点击数按不同的时间段进行排序,

支持不同的时间段:包括一周内,一个月内,一年内,和历史所有帖子

相当于根据频道的订阅者的用手投票,挑选出不同时段最精华的帖子

3 技术挑战

仔细分析之后,我发现检索频道并构建热榜是个相当有趣的技术问题,也与频道帖子本身的产品属性有关。

在我爬取某个频道的所有帖子后,如何与频道的最新动态保持同步更新呢?

毕竟像阅读数,转发数这些数据也是一直会更新的。

但是大部分历史的帖子的阅读数据都是不怎么会变化的,比如半年前的帖子可能就不会有人去翻看,订阅者大多只会关注最新的帖子。

每次都所有帖子都重新爬取一次固然可行,但是效率太低,毕竟绝大部分的帖子的数据都是不怎么变动的,怎么平衡性能与数据的及时性呢?

有点像超简化版本的搜索引擎问题了。

查询的时候要支持不同的时间段,按不同的指标进行排序,如何保证查询的性能呢?

总不能每次查询都重新计算一次吧,这样在帖子非常多的热点频道,就会出现查询的性能瓶颈。

4 解决方案

在仔细考量,权衡利弊之后,我取了个折衷的方案:

  1. 第一次爬取某个频道时,全量爬取并索引
  2. 每天定时重新爬取最近30天的帖子数据,作增量更新
  3. 每周再爬取和索引一次全量数据

就这样兼顾性能,成本以及数据及时性,又不会造成过多的重复爬取。

而对于查询性能,我选择了建立物化视图(material view)的策略, 每次爬取成功之后就重新计算,更新一下 material view, 耗时可能比较长,每个频道更新视图大概需要个十几秒。

但此后所有的查询都直接指向这个预计算好的视图,数据库还可以利用缓存优化,以空间换时间,查询性能因此得到保障

5 使用示例

使用方法非常简单:

  1. 点击链接打开机器人:https://t.me/tele_ranker_bot
  2. 在对话框中输入 /rank <频道链接> ,例如 /rank https://t.me/pipeapplebun
  3. 首次检索一个频道时,机器人需要一些时间来建立索引(如下图所示),完成后会通知用户:

完成之后就会发消息通知用户

然后用户就可以按照点赞数,转发数,点击数查看帖子

通过这个工具,我甚至发现了一些有趣的现象:

某些频道看似有十几万订阅者,但近期帖子的点击数仅有一千多,还不到百分之一,数据真实性令人存疑。

6 结语

有了这个频道检索工具,我终于能在一个频道里高效「挖坟」了,再也不用担心错过沉淀在时间线里的精华。

不管是想回顾精华,还是挖掘隐藏好帖,甚至是做简单的数据挖掘,这个机器人都能搞定,举个例子:

  • 找最近一个月点赞最多的帖子
  • 看看历史上点击最高的内容有哪些

这次,我总算为自己、也为可能有同样需求的你们,打造了一个称手的工具。

推荐阅读

qrcode_gh_e06d750e626f_1.jpg 公号同步更新,欢迎关注👻

基于贝叶斯算法的Telegram广告拦截机器人(二):上线半月的故障、挑战与优化之路

2025-09-14 05:28:00

1 引言

半个月前,我发布了一个基于贝叶斯算法的Telegram广告拦截机器人 @BayesSpamSniperBot (https://t.me/BayesSpamSniperBot)

项目地址:https://github.com/ramsayleung/bayes_spam_sniper

系列文章:

尽管项目代码开源,但我始终以产品思维运营它。上线半个月以来,经历了故障、用户反馈与持续优化,现将这段经历分享出来。

2 上线即故障

没想到我的产品的第一个线上故障来得这么快,发布的时候直接不可用,把正常消息都给删了,用户在各种途径都向我反馈:

故障的原因是我当时一直在收集垃圾广告的数据,太专注于垃圾广告数据,而忽略了收集的正常数据, 导致垃圾广告数据过多,消息都被认为是垃圾广告,被误删了。

通过补充大量正常消息数据,重新平衡训练集,模型逐渐恢复正常识别能力。

3 挑战

3.1 邮件与即时消息的差异

我在《基于贝叶斯算法的Telegram广告拦截机器人(一):从问题到产品》里面提到过:

常见的 Telegram 广告机器人是大多是基于关键字的,通过匹配关键字进行文本拦截,非常容易被发垃圾广告的人绕过。

这不禁让我想起了保罗.格雷厄姆在《黑客与画家》一书在2002年介绍的情况:

当时电子邮件兴起,也有非常多的垃圾邮件,常见的垃圾广告拦截方式是关键字匹配+邮件地址黑名单,但是既低效也容易被绕过。

保罗.格雷厄姆就创造性地使用贝叶斯算法(Bayes Theorem)实现了一个广告拦截器, 效果竟然出奇地好。

但产品上线之后,我发现聊天软件消息和Email虽然都是文字,还是有很大差别的:

Email 大多时候都是长文的,内容较长,并且大多情况,一封邮件上下文本身也很完整,就有较多的内容,较高的准确度来判断是否是广告。

而 Telegram, 微信这类的即时聊天软件,聊天消息大多都不长,可能把内容分成多条消息来发,就没有完整的上下文,比如:

换U

找我

单条消息很较难准确判断是否是广告,所以对即时消息做广告拦截本身就更难, 「短文本+无上下文」是NLP中的经典难题,也是本项目最大的技术挑战。

3.2 漏删与误删

漏删与误删是广告拦截中不可避免的矛盾权衡。

若想提高拦截率(召回率),就需降低置信度阈值,将更多疑似广告的消息拦截,但这也会增加误删正常消息的风险。

反之,若想避免误删(提高精确率),则必须提高置信度阈值,但这又会导致更多广告被漏掉。

在即时消息短小、上下文缺失的特性下,想同时实现零误删和零漏删几乎是不可能的。

权衡之下,我选择优先保证用户体验: 宁可漏删,不可误删

因为漏掉的广告,群友可以举报或由管理员手动删除;但误删的正常消息却无法恢复,对用户的伤害更大。

因此,我将拦截阈值设置为95%,即仅当模型有极高把握(>95%概率)判定为广告时才会删除。

这虽然会放过一些疑似广告,但最大程度地保障了正常聊天不被误删。

4 优化之路

4.1 自动删除消息

产品上线之后,很快就有用户来试用了,然后其中一个用户就提了一个非常好的优化建议。

这个警告的消息不会自动删除,如果有很多人在群里发广告,那么群里就会有一堆这样的消息,也算是对群消息的污染。

所以用户建议:

可以发这个提醒,但在几分钟后也把这个提醒消息删除掉

我觉得这是个非常好的优化体验,因为就把这个功能给加上了,提醒消息本身会在5分钟后自动删除。

倾听用户的声音是非常重要的,他们可能就会从他们的角度提出非常好的建议。

但是不要盲目听从用户的建议,比如也有用户建议:

我觉得还应该有以下功能.

  1. 恢复消息, 恢复用户. (让管理员恢复误删的消息和用户)
  2. 主动投喂正常消息. (让管理员主动投喂一些消息. 比如, 群里面昨天 的消息, 随便选一些正常的, 投喂给机器人)

恢复消息这个功能没有太大必要,并且也不实用,因为恢复消息这个功能本身就很微妙,是直接恢复被删除的消息呢,还是重新发一条新消息?

如:

  • 2025-09-09 10:01:00 张三: 我今天吃了鸡翅
  • 2025-09-09 10:02:00 李四:鸡翅有啥好的(被误删消息)
  • 2025-09-09 10:03:00 王五:人家就喜欢吃,你管得着嘛

如果是直接恢复被删除的消息,当前时间是 2025-09-09 11:00:00 ,把消息恢复之后,还有人会手动刷历史消息,查找旧消息么?

Telegram客户端不一定支持会跳转被恢复的旧消息,这意味着,你恢复误删的消息,也没人看得到。

假如是重新发一条新消息 鸡翅有啥好的, 因为缺失了上下文,群里的人反而会疑惑,你在说什么。

解决误删问题本质是提高拦截的准确率,而非考虑如何恢复被误删消息,准确率提高了,误删就会减少, 自然就不需要考虑如何恢复消息,用户体验还会更好.

而主动投喂消息这个想法有点理所当然了。

没有任何群管理员有意愿帮忙训练这个机器人,对用户而言,他们只想要一个好用的广告拦截机器人,至于怎么开发,训练出来的,用户并不在乎。

所以用户不会有意愿和动力来优化这个机器人,不好用就再换一个好了,更何况,逐条消息收集的效率实在太慢太慢了, 所以我后面想出了一个比手工收集数据提效至少100倍的主意。

4.2 过滤重复消息

发现人难免会有误区,总会以为别人会和自己一样,之前看到发垃圾广告的人的时候,总会觉得他们是正常的用户手工发。

但是最近几天发现了一些规律,有用户把同一条消息反复发,不同的群还是发同样的内容 即使是复制粘贴也难免会多个或者少个空格,然后消息被删了还一直发同样的内容。

此外,还有一些群,内容的聊天内容都是广告,我还很奇怪,大家都在发广告,正常用户不都跑了嘛?

此时,我才意识到,发消息的都是机器人。

所以我加了个优化,计算消息内容的 hash 值,保存到数据库,并为这个字段建立索引。

后面检测消息的时候,先根据 hash 值查询,检查是否存在已有的消息,如果消息已经存在且已经被标记成广告或者正常消息,那么就无需再使用模型检测,可以直接返回之前的检测结果。

这样既提高了准确度,也优化了性能,也减少了人工干预的成本。

同一个用户如果在同一个群发了三条广告,那么就会自动被封禁掉,也就是相同的广告只要发三条,就会马上被自动封禁掉。


为什么是计算 hash 值并为该Hash值建立索引而非对完整的文本消息建立索引?

因为文本消息是变长的,并且聊天消息可能会很长,对这样的 TEXT 建立索引会产生非常大的索引结构,占用大量的磁盘空间,每次进入查找,插入和排序操作,速度都会较慢。

而 hash 值是定长且非常短(相对原始消息而言),建立索引速度非常快,此外 hash 函数保证只有相同的输入一定会产生相同的输出,而即使一个字符不一致,其计算出来的 hash 值就会不一致,就能判断内容文本不一致。

4.3 自动收集数据

使用机器学习算法来实现一个类似的垃圾广告过滤器并不难,困难的持续收集高质量的训练数据,训练数据是非常宝贵的,毕竟数据才是核心资产。

而对于我这个产品来说,最难的是冷启动时的训练数据问题:

因为没有训练数据,模型就不准确,模型不好用就不会有人使用,自然也无法通过用户来收集垃圾广告数据,就无法良性循环, 存在一个鸡生蛋,还是蛋生鸡的问题。

所以冷启动时,我是手动加了非常多的 Telegram大群,然后人工在里面收集垃圾广告.

但是这个效率实在是太低了,我收集了快一周才只有几百条数据, 一个是我无法一直盯着各个群,另外是这种20w的大群,一般都会有几个管理员,会手工删除广告,一会没有看垃圾广告数据就会被删掉了。

这样手工收集数据实在在太痛苦了,我就在想有没有什么办法自动收集数据呢?

我本来想的是直接把我的机器人拉到这些大群里面,即使没有管理员权限无法删除消息,也可以收集数据嘛,后面才意识到 Telegram 有个规定,只有群管理员才有权限加机器人,因为我不是管理员,所以自动没有权限添加机器人。

但是 telegram 的客户端是开源的,他们提供了 tdlib 1这个跨平台的 C++ 库便于社区构建第三方的 Telegram 客户端,那么我自然可以使用这个库来登录我自己的账号,然后使用我的模型来过滤消息,然后把疑似广告的数据都收集起来,我再人工确认下。

(顺便说一下,tdlib 和 telegram-bot-api 2这两个库竟然都是同一个作者 Aliaksei Levin 3在维护,实在是太强了。)

我现在需要做的就是添加各种大群,然后程序就会自动监听并收集数据,我再人工批量确认下。

实现起来也不复杂, 200行代码就实现了这个监听消息,分析,并且收集的功能。

得益于这个自动化的数据收集程序,我1周不到就收集了近上万条的高质量训练数据了,效率实在高太多太多了。

懒惰真的是程序员的美德, 这个经历再次证明:自动化工具往往能成倍提升效率,这正是工程师价值的体现.

5 推广

所谓酒香也怕巷子深,没有用户使用,代码写得再好也没有意义。从产品角度,运营推广至关重要。

作为个人开发者,我没有大量粉丝关注,也没有营销预算,因此采用了传统的推广方式:撰写博客并在相关社区分享。

我撰写了两篇双语博客文章,中文版本分享至:

英文版本发布至:

虽然推广效果有限,但这些努力为项目带来了最初的用户关注。

6 成果与数据

上线半个月,截止到目前为止, 已经有超过80个群使用过这个机器人,用户数已经比我预期要多了:

指标 数值
GitHub Stars 106
使用群组数 83
训练数据量 10543

最开心的是看到我自己的程序在这些群成功拦截垃圾广告,就很有成就感,证明我做的东西真的能用户解决问题。

7 结语

这半个月的运营让我深刻体会到:产品不是代码写完就结束,而是从用户反馈中不断迭代的开始。

产品是需要持续运营的,而写代码只是产品生命周期的其中一个环节,甚至不是最耗费时间的环节。

下一步,我计划进一步优化模型准确率,并探索多语言支持,也欢迎关注我的频道或提交Issue一起讨论。

qrcode_gh_e06d750e626f_1.jpg 公号同步更新,欢迎关注👻

推荐阅读

基于贝叶斯算法的Telegram广告拦截机器人(一):从问题到产品

2025-08-29 14:45:00

English Version

系列文章:

1 序言

我花了一周末时间,写了一个自学习的 Telegram 广告拦截机器人 @BayesSpamSniperBot (https://t.me/BayesSpamSniperBot),项目开源在:https://github.com/ramsayleung/bayes_spam_sniper

1.1 Telegram

Telegram 是一个流行的即时通讯软件,类似微信,Whatsapp,已有超过10亿用户,支持许多强大的功能,如聊天记录云存储,支持Linux, Mac, Windows, Android, IOS, Web 多个平台,客户端都是开源,类似微信公众号的频道功能(Channel),还有我见过的最强大的机器人系统。

2 缘起

平时我跑步和做饭都习惯会听播客,而《软件那些事儿1是我最喜欢的播客之一,主持人是栋哥 2, 我也因为喜欢栋哥的节目,趁机加了栋哥的电报频道。

栋哥的电报频道汗牛充栋 3主要是用来发布播客信息, 之前打开过一段时间的留言功能,没有想到引来了一堆的币圈的用户来发广告,因此将评论功能就关了:

另外一个我关注的频道 Ray Tracing 4也在吐槽币圈的广告,不堪其忧:

3 黑客与画家

常见的 Telegram 广告机器人是大多是基于关键字的,通过匹配关键字进行文本拦截,非常容易被发垃圾广告的人绕过。

被绕过的话主要是靠管理员人工删除。

这不禁让我想起了保罗.格雷厄姆在《黑客与画家》一书在2002年介绍的情况:

当时电子邮件兴起,也有非常多的垃圾邮件,常见的垃圾广告拦截方式是关键字匹配+邮件地址黑名单,但是既低效也容易被绕过。

保罗.格雷厄姆就创造性地使用贝叶斯算法(Bayesian Theorem)实现了一个广告拦截器 5, 效果竟然出奇地好。

对于 Telegram 的垃圾广告而言,这不是类似的问题嘛?

那我岂不是可以用类似的解决方案来解决 Telegram 广告的问题嘛

3.1 贝叶斯定理

提起概率算法,最经典的例子莫过于「抛硬币」这一古典概率——每次抛掷都是独立事件,前一次的结果不会影响下一次的概率。

然而,现实中的很多场景并不能像抛硬币那样无限重复,事件之间也往往并非相互独立。

这时候,贝叶斯定理就显示出其独特的价值。

它是一种「由果溯因」的概率方法,用于在已知某些证据的条件下,更新我们对某一假设的置信程度。

换句话说,贝叶斯算法能够根据不断出现的新证据,动态调整对某个事件发生概率的估计。

简单来说,就像人脑的学习过程:我们原本有一个初步认知,在获得新信息之后,会据此修正原有的看法,进而调整下一步的行动。

保罗·格雷厄姆就是通过贝叶斯定理,不断地根据已被标记为垃圾广告或者非垃圾广告的邮件,对新出现的邮件进行分类,判断其是否为垃圾广告。

如果想更直观地理解贝叶斯定理,推荐两个讲解清晰、生动易懂的视频:

4 架构设计

Telegram Bot 支持两种与 Telegram 服务器交互的模式,分别是:

  1. Webhook: Telegram 服务器会在 Bot 收到新消息时主动回调此前 Bot 注册的地址,Bot Server 只需要处理回调的消息

  2. Long Polling: Bot Server 一直轮询 Telegram 服务器,看是否有新消息,有就处理,本机器人使用的是此模式

4.0.1 消息分析

Bot Server 收到消息之后,会派发到单独的 telegram_bot_worker 处理,然后根据预训练的模型判断是否是垃圾广告,如果是,调用 Bot API 删除消息。

4.0.2 封禁并训练

Bot Server 收到消息之后,会派发到单独的 telegram_bot_worker 处理, telegram_bot_worker 会调用 bot API 删除消息并封禁用户,并插入一条训练数据,标记为垃圾广告(spam)

保存训练数据会触发 hook, 创建一个训练消息,投递到消息队列 training, 会有另外的 worker classifier_trainer 订阅 training 消息,并使用新消息重新训练和更新模型

使用队列和后台进程 classifier_trainer 来训练任务而非直接使用 telegram_bot_worker 主要是为了返回 Bot请求与训练模型解耦,否则随着模型规模的增大,训练时间会越来越长,响应时间会越来越长。

解耦后就易于水平扩展了,在设计上为后续性能优化和扩展预留空间。

5 Why Rails

看了我项目源代码的朋友,难免会浮起疑问,为什么使用 Ruby on Rails 实现的?

因为我工作中会有用到JVM系的编程语言(Java/Kotlin/Scala)和 Rust, 所以我对 Java/Rust 相当熟悉,又觉得模型训练可能对性能要求很高,所以最开始的原型 8我是用 Rust 实现的,大概就花了半个多小时。

但是当我想把原型扩展成 Telegram 机器人时,就发现需要处理相当多与机器人交互的逻辑,主要涉及到 API 与数据库操作,其中大部分都是和模型无关的,因此我又想到了 Ruby on Rails。

论单个工程师做产品原型,就我个人而言,实在是没有比 Ruby on Rails 更高效的框架了,因此我就切换到 Ruby on Rails 去。

Rails 8 的新特性,把 Rails 向所谓的「一人全栈框架」又推进了不少,通过关系型数据库内置对消息队列 Solid Queue 的支持,甚至不再需要类似 Redis 这样的存储来支持队列实现。

架构设计中的队列和后台进程,只需要几行代码就实现了,甚至不需要额外的配置,如果队列不存在,框架会自动创建:

1
2
3
4
5
6
7
8
class ClassifierTrainerJob < ApplicationJob
  # Job to train classifier asynchronously
  queue_as :training

  def perform(group_id, group_name)
    SpamClassifierService.rebuild_for_group(group_id, group_name)
  end
end

得益于 Rails 强大的 ORM 框架,内置各种生命周期的 hook, 对新插入训练数据后触发后台进程重新训练模型的代码也只有寥寥几行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class TrainedMessage < ApplicationRecord
  # Automatically train classifier after creating/updating a message
  after_create :retrain_classifier
  after_destroy :retrain_classifier

  def retrain_classifier
    # For efficiency, we could queue this as a background job
    ClassifierTrainerJob.perform_later(group_id, group_name)
  end
end

在 Rails 各种内置强大工具的加持下,我只用了一天时间就把整个机器人的功能给实现出来了。

看到这里,有朋友可能会担心性能,觉得 Ruby 性能不行,并且还是动态语言,不好维护。

我持有的观点还是和之前的博文《编程十年的感悟9一样:

先跑起来再说,先做个原型跑起来,有用户愿意用你的产品再说, 当运行速度成为瓶颈时,你的业务肯定非常大了,肯定有足够的资源招一打程序员把项目优化成 Rust/C++, 甚至是汇编。

没有用户,谈性能只是个伪命题。

至于动态语言一时爽,代码维护火葬场,我也是相当认同的。

因此我在为团队选型时我绝对不会考虑动态语言,只会上编译型的语言, 甚至是Rust这种强类型,但是现在只有我一个人来做原型,我自己是什么顺手就用什么的。

5.1 Vibe Coding?

Vibe Coding等AI编程概念可谓是铺天盖地,甚嚣尘上,难免会有朋友好奇我这个项目是否 Vibe Coding生成的。

答案是,我尝试了几个小时之后,直接放弃了, Claude 4 和 Gemini 2.5 Pro 都试过了。

开始是使用 Rust + Cloudflare Worker 的技术栈,Rust + Cloudflare Worker 是个相当小众的领域,训练语料少,Vibe Coding 出来的代码编译无法通过

后面换成 Ruby on Rails, 问题还更严重了,Ruby 是弱类型的动态语言,语法写起来和英语一样,Rails 又还有很多黑魔法,所以到运行时才报错,代码生成省下来的开发时间,debug过程全补回来了。

另外一个是 Vibe Coding 生成的代码很多都是没有设计的,比如把 ClassifierTrainedMessage 的类耦合在一起,在 Classifier 里面持久化 TrainedMessage

又直接在 telegram_bot_worker 进程里面,接收到训练信息马上同步训练新模型,训练完再返回调用命令的结果,完全没考虑解耦接收训练语料和模型训练。

只能说 Vibe Coding 非常适合 Rust 这样的强类型编译型语言,生成的出来的代码起码要编译通过,保证质量的下限。

而对于那些说「一行代码都不用写/改,就能做出一个APP」的言论,此时我脑海不禁升起疑问?

究竟是代码好到一行都不用改?还是开发者看不出症结所在,所以一行都不改?

6 设计理念

开发完原型,在机器人整体功能可用之后,脑中又有不少的想法冒出来,当时就马不停蹄地给机器人加上, 因此机器人就支持快十个命令,还支持私聊和群聊的不同模式。

加着加着,连我自己都疑惑起来:这么多的功能,有点像国内的各种大而全的App了,我不禁对此产生疑问:

真的会有用户用这么多功能么?真的有用户会用这些功能嘛?太多功能不是也会有额外的心智负担嘛?

我最喜欢的广告拦截器 Ublock Origin 10拦截效果非常好,但是使用起来却非常简单,易上手。

想起《软件设计的哲学11里面提到的设计理念,接口应该是简单易用的,但是功能可以是复杂丰富的。

因此我只能忍痛把此前新增的,但与核心功能无关的命令都删掉;

此外考虑到可能绝大多数的用户都没有技术背景,也可能不知道命令怎么用,因此将命令尽可能地优化成按钮,用户可以直接点击,改善易用性:

我还希望可以支持多语言,比如根据用户的系统语言,自动切换到中文或者英文,这个就需要不同语言的文案。

telegram_botter.rb 这个核心服务类里面有超过60%的代码都是为了此类易用性改进而引入的。

简单留给用户,复杂留给开发

6.1 如何使用

只需两步,机器人就可以自动工作。

  • 将机器人(@BayesSpamSniperBot)添加到您的群组
  • 给予机器人管理员权限(删除消息(delete message ),封禁用户权限(ban user ))

完成这两步后,机器人不仅会自动开始工作,自动识别群内广告,然后删除文本消息,如果发送垃圾广告超过3次,将会被封禁;

还会随着社区的使用(通过 /markspam/feedspam ),变得越来越智能

此机器人的设计理念就是最小化打扰管理员与用户,提供简单的操作命令,并最大可能地自动化, 所以本机器人只提供以下三个命令(支持"/“开头自动补全):

6.1.1 /markspam

删除垃圾消息并封禁用户, 需要管理员权限。

在某条你想封禁的信息下回复 /markspam, 机器人就会自动把该条消息删除被封禁用户.

(消息也被删除)

与常见的群管理机器人不同,这条命令不仅会删除垃圾消息并封禁用户, 因为这条消息还被管理员标记成垃圾广告,有非常高的置信度,所以系统就会以这条垃圾广告为训练数据,对模型进行实时更新。

下次类似的发言不仅会被识别,所有使用本机器人的群组都会受益,也会把类似的文本标记成垃圾广告

6.1.2 /listbanuser

查看封禁账户列表, 需要管理员权限。

查看已封禁的用户列表,并主动解封。

6.1.3 /listspam

查看广告消息列表, 需要管理员权限。

查看被标记为广告的消息列表,并可标记为正常。

6.1.4 /feedspam

投喂垃圾信息来训练,无任何权限要求,可私聊投喂或在群组内投喂.

7 Eating your own dog food

在软件开发领域,有这么一句俗话,Eating your own dog food(吃你自己的狗粮),大意是你自己的开发的东西,要自己先用起来。

因此我建了一个自己的频道:菠萝油与天光墟 12用于测试,可惜订阅者寥寥, 就吸引不来太多的发垃圾广告的用户,所以欢迎大家订阅或者进来发广告,以吸引更多的发垃圾广告的用户。

在我这个频道,每个人都有自由发言的权利(美中不足只是次数受限)

既然没有人来我的频道发广告,苦于没有训练数据,我只能主动出击,赤膊上阵,割肉喂鹰去加了各种币圈群,黄色群,主动去看各种广告了:

自从开发了这个机器人之后,我对广告的看法就变了,以前在别的群看到广告就烦,现在在别的群看到广告就很开心, 这都是宝贵的训练数据,要趁着还没被删,赶紧记录下来。

7.1 八仙过海的垃圾广告

别人故事里的算法效果总是出奇的好,到自己实际运行的时候,总是发现会有这样那样的 case 没有覆盖,总有各种意外惊喜

许多在 Telegram 发广告的用户都是久经考验的反拦截器斗士了。

虽然关键词封禁效率不高,但是那些能让我们见到的广告说明已经是绕过关键词拦截的。

比如:

在 币圈 想 赚 钱,那 你 不关 注 这 个 王 牌 社 区,真的太可惜了,真 心 推 荐,每 天 都 有 免 费 策 略

又或者

这人简-介挂的 合-约-报单群组挺牛的ETH500点,大饼5200点! + @BTCETHl6666

前者通过空格分隔来绕过关键词,后者通过添加标点符号来绕过关键词。

与英文等基于拉丁字母的语言天然通过空格分词不同,中文使用贝叶斯算法进行统计时,需要先进行分词

the fox jumped over the lazy dog

我们的中文就不一样了

「我们的中文就不一样了」就会被分词成「我们 | 的 | 中文 | 就 | 不 | 一样 | 了」, 然后才能对词频进行统计。

但是像广告 在 币圈 想 赚 钱,那 你 不关 注 这 个 王 牌 社 区,真的太可惜了,真 心 推 荐,每 天 都 有 免 费 策 略 , 空格除了会影响关键字匹配,也会影响分词,这句话的分词结果就会变成:

在 | | 币圈 | | 想 | | 赚 | | 钱 | , | 那 | | 你 | | 不 | 关 | | 注 | | 这 | | 个 | | 王 | | 牌 | | 社 | | 区 | , | 真的 | 太 | 可惜 | 了 | , | 真 | | 心 | | 推 | | 荐 | , | 每 | | 天 | | 都 | | 有 | | 免 | | 费 | | 策 | | 略

这人简-介挂的 合-约-报单群组挺牛的ETH500点,大饼5200点! + @BTCETHl6666 也会被分词成:

这人简 | - | 介挂 | 的 | | 合 | - | 约 | - | 报单 | 群组 | 挺 | 牛 | 的 | ETH500 | 点 | , | 大饼 | 5200 | 点 | ! | | + | | @ | BTCETHl6666

未经处理的训练数据就会影响模型的结果,可见训练数据的质量也非常重要,因此我就对训练语料做了相应的预处理:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Step 1: 处理 anti-spam 分隔符
# 把中英文之间的非中英文及数字去掉,即 "合-约" -> "合约"
previous = ""
while previous != cleaned
  previous = cleaned.dup
  cleaned = cleaned.gsub(/([一-龯A-Za-z0-9])[^一-龯A-Za-z0-9\s]+([一-龯A-Za-z0-9])/, '\1\2')
end

# Step 2: 处理中文字符 anti-spam 空格
# 处理 "想 赚 钱" -> "想赚钱" case
previous = ""
while previous != cleaned
  previous = cleaned.dup
  # 匹配中文汉字之间的一个或多个空格,然后删除掉
  cleaned = cleaned.gsub(/([一-龯])(\s+)([一-龯])/, '\1\3')
end

# Step 3: 增加汉字与英文之间的空格
# 以及帮助分词算法如(jieba)更好地分词, e.g., "社区ETH" -> "社区 ETH"
cleaned = cleaned.gsub(/([一-龯])([A-Za-z0-9])/, '\1 \2')
cleaned = cleaned.gsub(/([A-Za-z0-9])([一-龯])/, '\1 \2')

# Step 4: 删除多余的空格(多个空格缩减个一个)
cleaned = cleaned.gsub(/\s+/, ' ').strip

预处理之后, 在 币圈 想 赚 钱,那 你 不关 注 这 个 王 牌 社 区,真的太可惜了,真 心 推 荐,每 天 都 有 免 费 策 略 就会变成 在币圈想赚钱那你不关注这个王牌社区真的太可惜了真心推荐每天都有免费策略 (这里把合法的逗号也去掉了,我觉得相较过多标点符号对分词的影响,把标点去掉分词结果反而是能接受的), 分词结果是:

在 | 币圈 | 想 | 赚钱 | 那 | 你 | 不 | 关注 | 这个 | 王牌 | 社区 | 真的 | 太 | 可惜 | 了 | 真心 | 推荐 | 每天 | 都 | 有 | 免费 | 策略

这人简-介挂的 合-约-报单群组挺牛的ETH500点,大饼5200点! + @BTCETHl6666 就会变成 这人简介挂的合约报单群组挺牛的 ETH500 点大饼 5200 点! + @BTCETHl6666 ,分词结果是:

这 | 人 | 简介 | 挂 | 的 | 合约 | 报单 | 群组 | 挺 | 牛 | 的 | | ETH500 | | 点 | 大饼 | | 5200 | | 点 | ! | | + | | @ | BTCETHl6666

7.1.1 广告新花样

广告看多了,不得不感慨发广告的人的创造力。

因为在消息发垃圾广告会被广告拦截器拦截,他们创新性地玩出了新花样:

消息发的都是正常的文本,但是头像和用户名都是广告,这样广告拦截器就无法工作了,真的是太有创意了。

对手这么有创意,我也因地制宜地建立对用户名的训练模型,检测的时候消息文本的模型和用户名的模型都过一次, 只要有任何一个认为是垃圾广告,那就禁掉。

更进一步的可以对头像做OCR提取文本,再增加一个对头像的训练模型,不过OCR成本挺高的,就先不搞了。

7.2 优化

没有用户的话,做啥优化也没有必要,毕竟过早的优化是万恶之源, 因此我就把想法先做成原型,搞出来再说,但这不意味着这个原型没有优化的空间。

脑海中还是有不少优化的点的:

  1. jieba 分词的效果可能不是最好的,后续可以使用效果更好的分词器进行优化;或者是添加自己的词库。
  2. 每次有训练消息都进行重新训练,效率稍低,可以增加 batching 机制:有新消息时,等待5分钟或者等到100条消息再处理
  3. 现在整个模型都是在内存中计算,计算完就持久化成 DB, 可以在内存和数据库之间增加一层缓存来优化性能
  4. 贝叶斯算法可能效果不够好,换个复杂的机器学习模型

但是这些优化点都算是 Good to have, 不是 Must have, 后面遇到实际问题再进行优化好了。

8 实战效果

使用变换之后的垃圾广告词进行发送:

成功被检测出来,自动删除了:

有朋友可能会说,这只是卖家秀,为什么别人在我群里发的广告还是没有被识别?

因为贝叶斯算法本质是个概率算法,如果它没有见过类似的广告,那么它就没法判断是否垃圾广告 :(

稍安勿躁,你需要做只是使用 /markspam 删除消息并封禁用户,就可以帮助训练这个bot, 所有使用这个 bot 的用户都会因此受益

9 结语

我相当享受这种从发现问题、灵光一现,到构建原型,再到最终打磨出一个完整项目的创造过程。

虽然这完全是「用爱发电」——代码开源,还得自掏腰包租服务器,物质上毫无回报。

但每当看到机器人成功拦截广告的那一刻,那种创造的喜悦,就足以令我回味无穷。

推荐阅读

qrcode_gh_e06d750e626f_1.jpg 公号同步更新,欢迎关注👻

一本读了八年还没读完的书

2025-08-05 01:00:00

1 缘起

正如我在之前博客文章《这些年走过的路:从广州到温哥华1提到的那样,我在大二暑假的时候因缘际会,获得了去一家在深圳的初创公司实习的 Offer。

实习的两个多月时间也快就过去了,我也顺利拿到了 Return Offer,公司也非常有人情味地给实习生办了个欢送典礼。

当时实习的导师,也是这家公司的副总裁,加州州立大学的刘颖教授2,在欢送典礼上给我们几个实习生每人都赠送了一本书作为临别礼物。(可惜换了几次手机,已经找不回当初手捧着书的合照了)

他说这是一本可以帮助我们了解程序本质,以及学习抽象的好书,这本书就叫《计算机程序的构造和解释3(Structure and Interpretation of Computer Programs, 简称 SICP,下文使用 SICP 代称)

收到这本书时,我并未料到它会成为一场长达八年的拉锯战。

2 好书不愉悦

我在2016年收到这本书,从2017年开始阅读,中间中断了好几次又重新拾起,而时至今日也只读了一半,即五个章节中的前三章。

翻看自己一直以来阅读这本书的日记,总有种看胡适先生一直在打牌的留学日记一样的感受:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
** DOING Read The Structure And Interpretation of Computer Programs

   Read SICP everyday, at least 1 hour

   :LOGBOOK:
   CLOCK: [2017-04-03 Mon 20:26]--[2017-04-03 Mon 21:25] =>  0:59
   CLOCK: [2017-03-14 Tue 23:15]--[2017-03-14 Tue 23:40] =>  0:25
   CLOCK: [2017-03-14 Tue 22:45]--[2017-03-14 Tue 23:10] =>  0:25
   CLOCK: [2017-03-12 Sun 15:46]--[2017-03-12 Sun 16:11] =>  0:25
   CLOCK: [2017-03-12 Sun 15:13]--[2017-03-12 Sun 15:38] =>  0:25
   :END:

   <2022-10-08 Sat>
   #+begin_comment
   读了6年了,还是没有读完,重新开始读
   #+end_comment

   <2025-05-25 Sun>
   读了8年,还是没有读完,又开始读

看到这里,可能没有读过 SICP 可能会奇怪,为什么读一本书要这么久,如果蜻蜓点水,水过鸭背那样子读完一本书,自然只需要不停地翻页即可。

而 SICP 为了帮助你掌控书中讲解的知识和要点,会有大量的习题,并且把非常多额外的知识点都嵌入到习题中,以练带学。

就数量而言,章节一有46道习题,章节二有97道习题,章节三有82道习题。 如果跳过这些习题,这本书的内容不仅少了一半,而且也失去其精髓,可谓是买椟还珠。

此外,习题不仅数量多,还有相当难度,我每天花一到两个小时阅读,只能完成1-2道习题。

习题完成情况:

  • 章节一: 43/46
  • 章节二: 88/97
  • 章节三: 72/82
  • 章节四: TODO
  • 章节五: TODO

我把所有的题解都放到了 GitHub 项目: https://github.com/ramsayleung/sicp_solution, 并为大部分的题解都配套了单元测试,以验证其正确性,还加上了 GitHub Action 作 CI.

经年累月,我的题解代码和笔记都接近一万行了,这也能侧面说明我为什么读得这么慢了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
> tokei .
===============================================================================
Language            Files        Lines         Code     Comments       Blanks
===============================================================================
Markdown                1           65            0           46           19
Org                    70         2757         2163            0          594
Racket                 71         3976         3086          256          634
Scheme                 77         2479         1898          110          471
===============================================================================
Total                 219         9277         7147          412         1718
===============================================================================

毕竟大部分真正让人进步的阅读,读起来都不是愉悦的:

世之奇伟、瑰怪、非常之观,常在于险远,而人之所罕至焉,故非有志者不能至也。

—-王安石《游褒禅山记》

3 主旨

如果笼统地概括整本书,“无非”是「抽象」,通过使用一门非常简单的语言 Scheme, 以及几个非常简单的操作 cons(构造一个序对), car(取出序对的第一个值), cdr(取出序对的第二个值):

1
2
3
4
5
6
> (cons 1 2)
'(1 . 2)
> (car (cons 1 2))
1
> (cdr (cons 1 2))
2

构造出各种数据结构,如链表,队列,哈希表以及更复杂的组合数据结构; 探寻各种概念,如递归,闭包,高阶函数,赋值,流,程序优化等;

而后两章更进一步,第四章介绍如何实现一个 Scheme 简单的解释器,一个简单的 Prolog 解释器;而第五章介绍计算机体系结构的 CPU 设计,编译器,垃圾回收等。

从括号中的几个简单函数,到最后造出整个计算机体系,有种《道德经》里道生万物的感觉:

道生一,一生二,二生三,三生万物。

4 计算机科学与工程

我本科专业读的是软件工程(Software Engineering ),翻看当初的专业培养计划,从计算机导论开始入门,到程序设计基础,面向对象程序设计基础,数据结构,操作系统,计算机组成原理,再到计算机网络,汇编与编译原理,数据库原理到软件工程等。

学习完这些课程,可以成为一个合格的软件工程师,但广义的计算机专业还有一门专业,叫计算机科学(Computer Science),我一直很疑惑两者之间的差别是什么,就我所见过的不同学校的培养计划里面,两者的课程都非常相似。

而在阅读这本1984年麻省理工就出版的计算机科学的教材时,我找到了我想要的答案。

最初的计算机科学是数学,电子工程和软件设计的交叉学科,计算机科学的学生需要兼备这三者的专业知识。

而三位作者也是高屋建瓴,在数学,电子工程和软件领域旁征博引,各种知识信手拈来, 如练习3.59关于微积分的内容,通过流来处理幂等数积分,练习3.60-3.62都是关于积分的内容。

如3.5章里面,通过流来描述信号处理系统中的「信号」, 练习3.73用程序的流(stream)来表示电流或者电压在时间序列上的值,用以模拟电子线路。

如 3.3章里面,通过程序来建立数字电路的反门,与门,或门,再通过这样的电子元件建立起半加器, 再通过多个半加器实现全加器,实现二进制的加法,从程序到模拟电路,再用模拟电路来构造计算机的处理器。

不同学科的知识在一本书中融会贯通,再配合这个 eval-apply 表达式的配图,总有一种太极的感觉,难免让我有种读计算机哲学书的感觉:

5 优美的括号

书中那些非常有趣或者优美的代码

5.1 图形构造:从点到面的抽象

第二章介绍了复合的数据结构时,就提到了如何去画图,先画点,再画线,然后要求完成习题连线成面,构造出图形。

新的习题再要求通过变换,组合图形,构造新的复杂图形:

最后把类似的变换应用到图片上:

5.2 蒙特卡罗模拟来计算 π

所谓的蒙特卡罗模拟(Monte Carlo Simulation)是一种通过随机采样和统计计算来求解的数值方法,通过大量随机实验模拟不确定性,从而估算复杂系统的可能结果。

用人话来说就是不断地试,在试错的过程中逼近确定解,试的次数越多,结果越准确。

书中就介绍了一种通过蒙特卡罗模拟来计算 π 的方法, 就像通过随机撒豆子估算圆的面积,概率统计将抽象的π转化为可计算的实验:

举例来说,6/π^2 是随机选取的两个整数之间没有公因子(也就是说,它们的最大公因子是1)的概率。我们可以利用这一事实做出π的近似值。

完全读不懂这段话,没理解是怎么可以算出π的近似值的。

查阅资料后得知:

随机选取两个正整数,它们互质(即最大公约数GCD为1)的概率是 \(\frac{6}{\pi^2}\) , 所谓的 互质指两个数没有公共因子(如8和15互质,但8和12不互质,因为公约数为4)。

这一结论源自数论中的经典定理(涉及黎曼ζ函数),我们只需利用其概率公式反推π即可。

而用蒙特卡罗模拟步骤来计算 \({\pi}\):

随机实验:重复多次随机选取两个整数,检查它们的GCD是否为1。

例如:

  • (3, 5) → GCD=1(计数+1)
  • (4, 6) → GCD=2(不计数)

统计概率:

若总实验次数为 N,其中 k 次GCD=1,则互质概率的估计值为 \(\frac{k}{N}\)

关联π:

根据数论结论 \(\frac{k}{N} \approx \frac{6}{\pi^2}\),解得 \(\pi \approx \sqrt{\frac{6N}{k}}\)。

当直接计算π困难时,可通过概率实验间接逼近。

这里利用了数论中的概率规律,将π与随机事件联系起来。(高等数学对于我来说已是雪泥鸿爪,更遑论数论的知识了)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#lang racket

(define (estimate-pi trials)
  (sqrt (/ 6 (monte-carlo trials cesaro-test))))

(define (cesaro-test)
  (= (gcd (rand) (rand)) 1))

(define (monte-carlo trials experiment)
  (define (iter trials-remaining trial-passed)
    (cond ((= trials-remaining 0)
           (/ trials-passed trials))
          ((experiment)
           (iter (- trials-remaining 1) (+ trials-passed 1)))
          (else
           (iter (- trials-remaining 1) trials-passed))))
  (iter trials 0))

这里的蒙特卡罗实现真的是优雅,它将数学理论(互质概率)转化为寥寥数行的递归实验。

而基于流的实现更是优美:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
(define (monte-carlo experiment-stream passed failed)
  (define (next passed failed)
    (cons-stream
     (/ passed (+ passed failed))
     (monte-carlo
      (stream-cdr experiment-stream)
      passed
      failed)))
  (if (stream-car experiment-stream)
      (next (+ passed 1) failed)
      (next passed (+ failed 1))))

6 尾声:括号里的计算机哲学

直到今天,我也只读完前三章。有时我会问自己:这本书究竟给了我什么?

它没有教我实用的编程技巧,也无关面试刷题。

但是我好像又抓住了一些东西,尤如用手拢过一团烟雾,张开手,并未见留下什么,但是手上还残留着它的气味。

如今,MIT 的课程已用 Python 替代 Scheme,但 SICP 的价值从未褪色。

SICP 不是一本教你「如何编程」的书,而是一把钥匙,是一座桥,连接着工程的实用与科学的纯粹。

那些括号中的表达式,最终在我脑中化成了某种朦胧却持久的气味:

一种对抽象本质的直觉

推荐阅读

qrcode_gh_e06d750e626f_1.jpg 公号同步更新,欢迎关注👻

从在加拿大退货失败的一件小事思考系统设计

2025-06-01 02:00:00

1 前言

前天刚写完《软件设计的哲学》,满脑子还萦绕着模块耦合和接口抽象, 结果昨天就撞上一个现实中的“设计陷阱”——一次耗时数小时却无解的「退货」噩梦。

今天趁着周末,决定把这场荒诞遭遇拆解出来,既当吐槽,也当案例分析.

2 来龙去脉

前段时间搬了家,自然就需要重新办理宽带,一直用的是 Telus 家的家庭宽带服务,他们家的宽带服务也支持从一个住址迁移到另外一个住址, 就预约了 Telus 技术人员上门安装。

技术人员上门安装完宽带之后,就需要测试一下 WI-FI 能否正常使用,就问我们的路由器在哪,他接上处理一下。

问题就来了:

我们的路由器之前是舍友设置的,还不是常见的一体路由器,而是分体式路由器,有三个不同的组件。

而舍友在搬完家后就回国休假了,我还真不知道怎么搞这路由器,各个接口尝试了小半个小时也没反应,师傅也没见识过,自然也不晓得弄。

这个又是一个非常经典的软件开发问题:

「在我的机器上能跑,换个环境就挂了」

但是一直没网也不是办法,然后师傅建议我可以把他随身带的 Telus 路由器买下来,等我舍友回来后把网络设置好,再把路由器还回来,Telus支持30天无理由退货。

听起来也只能这么搞了。

舍友休了几周假回来之后,几分钟不到,很快就把这个路由器就设置起来了:

剩下的就是把路由器还给 Telus, 已经过了几周,30天的免责退货时间所剩不多了。

3 退货流程

因为设备不是通过网购买的,没法直接在网上退单,也不是门店买的,无法直接拿去门店退,退货的流程是打电话给 Telus 的客服,问他们要退货指引。

我就给 Telus 的客服打电话,解释清楚情况后,客服说给我账户对应的邮箱发个邮件,里面有指引和退货码,我需要去 Canada Post(加拿大邮政)把路由器寄回去。

电话里客服说已经给我发邮件了,但是我说没有收到(此处为后面埋下伏笔),于是我提供另外一个邮箱,成功收到了。

因为 Canada Post 最近在为涨薪闹罢工,客服提到我需要去另外一家快递公司 Purolator 寄快递。

剩下要做就是把路由器打包,然后寄出来(这么容易就好了), 再把快递单号告知 Telus, 退货流程就算结束了。

4 坑来了

4.1 邮政罢工

因为加拿大邮政罢工,所以只能去 Purolator 寄,但是去到 Purolator后,人家反馈:

你这个退货码是给加拿大邮政的,我们不认哦,你要给个我们家的退货码。

我只能去再打电话给 Telus 客服要退货码,花费了15分钟,终于打通了,解释完一番之后,他们说给我的邮箱发了新的 Puralator 退货码,我等了一分钟,说没有收到,然后让给我另外的一个邮箱也发一次指引,还是没有收到,然后客服说邮件会在24-48小时内到达..

但挂电话后再等了一个小时还是没有收到.

4.2 邮箱收不到email

只能再打电话给 Telus 的客服,又等了10几分钟终于接通了,这次换了个客服,这位客服说我们不支持 Purolator,你可以等加拿大邮政罢工结束之后再寄。

我也很无语,怎么你们的回复还不一致的,就和客服说,我怎么知道罢工什么时候结束呢,30天马上就要到了嘛。

客服说,的确很有道理,这样吧,你可以去尝试使用用加拿大邮政寄下,然后我把情况记录一下,到时超过30天也可以免责退款。

然后我追问到,那罢工结束时退货也是用相同的退货码么?这个退货码有过期时间么?邮件没写哦。

客服说,那以防万一,我再给你邮箱发个新的退货码吧。

我着实是怕了,不知道为什么一直没有收到邮件,就让客服把我账号对应的邮箱地址读出来, 客服就把我邮箱的逐个地址读出来。

前面部分听着没问题嘛,我还在寻思是什么问题,只是听着听着,怎么我邮箱还有我不认识的部分,就打开 Telus 的APP 修改, 然后被气得差点要吐血了:

我的邮箱地址是 [email protected], 然后为了标记不同的公司,我用了《两个鲜为人知的Gmail地址技巧》 提到的加号技巧来注册 Telus 账号:

[email protected]

之前用了一年多还是好好的,不然我也无法注册和验证邮箱成功。

但是现在 Telus 作了变更,直接把邮箱地址中的加号去掉了,变成了 [email protected], 变成一个完全不同的邮箱, 肯定是不可能收到邮件的。

花费了近一下午,打了5-6次电话,和不同的客服沟通和练习口语,最后的结果就是隔天再去加拿大邮政试试,不行就等他们罢工结束再寄。

5 糟糕设计的代价

这次经历虽然令人沮丧,但也印证了软件工程的一条铁律:

糟糕的设计最终会让所有人付出代价——无论是用户还是开发者。

讽刺的是,人们总希望通过「学习别人的错误」来避免踩坑,但现实中,我们往往被迫为别人的设计缺陷买单。

5.1 单点故障与「Happy Path」陷阱

电话退货这个操作虽然看似落后,但是总体来说还是可以用的,在不出问题的前提下。

Telus 的退货流程设计暴露了一个典型的系统脆弱性:

强依赖单一服务提供商(Canada Post) ,且未设计降级方案(如备用物流或线下门店退货)。

这种「Happy Path Only」的思维,本质上是对分布式系统设计原则的违背:

任何外部服务都可能失败,而系统必须对此容错。

让快递直接成为业务系统的「单点」故障,只考虑 Happy Path, 没有考虑异常场景,甚至发过来的退货邮件指引,都可以看出他们是把 Canada Post 写死在邮件。

5.2 向后兼容性:一个被忽视的底线

退货强依赖加拿大邮政这个还可以说成是产品设计的问题,但是直接把我邮箱地址给改掉这个,就一定是程序员的锅了。

此外,我的邮箱地址在 APP 中显示的是 [email protected], 只有在修改邮箱地址的时候,才会显示出 [email protected] 这也是我一直没有发现的原因。

但最令人匪夷所思的是邮箱地址的非兼容性变更:系统直接静默移除了存量用户邮箱中的加号:

[email protected] -> [email protected] ,导致邮件发送失败。

这种粗暴的修改方式违反了最基本的向后兼容性原则,而问题的暴露方式(APP显示与修改界面不一致)进一步说明:

其系统内部还存在的数据状态不一致性问题

合理的变更方式应该是:

  1. 增量控制:
    • 禁止新用户注册或修改时使用特殊符号,但保留存量数据, 保证增量用户地址正确
    • 存量用户修改邮箱地址时,禁止使用带特殊符号的邮箱地址
  2. 存量迁移:
    • 通过离线数仓,查询出所有带特殊符号的邮箱地址,通过异步任务批量通知受影响用户(避免阻塞主流程)
    • 提供自动清理特殊符号的“一键修复”功能(需用户确认)。
  3. 监控兜底:
    • 建立异常邮箱地址的监控或者报表,直到存量问题归零。

虽然这做法非常繁琐,但是却可以保证系统升级绝对不影响用户。

系统设计与维护就是如此:开始做的时候成本很低,越到后期成本越高。

6 个人感悟

除去别人的设计错误之外,我还有些额外的个人感悟:

虽然 Gmail 支持邮箱地址中增加一个 + 这样的功能,但是并不是所有的公司都支持这特性的,重要的邮件还是不能使用这个「奇技淫巧」。

此外,我另外提供的邮箱也无法收到邮件,可能是我的邮箱太长了,导致客服没有拼对我的邮箱,所以最好还是准备一个短的,包含数字的备用邮箱地址,方便电话沟通时提供给对方。

整个故事再次印证了《软件设计的哲学》中的道理:

所有偷懒的设计,终将以更高的成本偿还

当然, 谁来还就是后话了

qrcode_gh_e06d750e626f_1.jpg 公号同步更新,欢迎关注👻