FreshRSS

🔒
❌ About FreshRSS
There are new articles available, click to refresh the page.
Today — October 15th 2021个人博客

科技爱好者周刊(第 179 期):AR 技术的打开方式

October 15th 2021 at 00:37

这里记录每周值得分享的科技内容,周五发布。

本杂志开源(GitHub: ruanyf/weekly),欢迎提交 issue,投稿或推荐科技内容。

周刊讨论区的帖子《谁在招人?》,提供大量程序员就业信息,欢迎访问或发布工作/实习岗位。

封面图

济宁市美术馆2019年建成,由日本建筑师设计。上图中,上方的不规则白色屋顶,就是美术馆的展厅。下方是一个公园,左侧是一个回廊步道,右侧是咖啡馆。(via

本周话题:AR 技术的正确打开方式

前两天,我看到一条新闻,德国杜塞尔多夫开张了一家 AR 公园(增强现实公园)。

表面上,这就是一家普通的公园,草地、绿树、小溪流......

但是,当你打开手机 App,使用摄像头,就会看到所有场景上面,都叠加了一个虚拟层。

天空漂浮着奇怪的生物。

道路上有充气路障。

长椅上坐着一个不存在的人。

游客可以探索这些虚拟对象,也可以根据提示,完成指定任务,解锁关卡。

新闻说,这个公园一共部署了35个 AR 应用,其中13个是免费作品,其余22个必须付费4.99欧元才能激活,相当于公园门票了。

我觉得,这个主意太好了,值得借鉴。

城市公园改建为 AR 游戏场,这才是 AR 技术的正确打开方式。 公园还是那个公园,什么都没变,但是加入了电子游戏的成分。这里最关键的一点是,AR 就是应该在室外玩。

仔细想一下,你会发现,绝大多数电子游戏只适合在室内玩,但是 AR 可以在室外玩,而且室外比室内好玩得多。

这是因为 AR 是部分虚拟、部分现实,虚拟层需要叠加在现实层之上,如果现实空间太小、太单调,就不可能设计出好玩的 AR 应用。目前,国内影响最大的 AR 应用,就是支付宝"集五福"的室内找"福"字,这个游戏非常乏味,原因就在这里,室内很难想出好玩的玩法。

相反的,室外可以叠加寻宝、解谜、打卡等多种手段,还能跟虚拟对象互动,可玩性大大增强。国外的 AR 游戏《Pokemon Go》就出现过万人街头寻宝的热潮。

游戏公司真的应该多开发针对室外空间的 AR 游戏。如果政府同意把公园改造为 AR 游戏场,就解决了游戏场地的问题。

最后提一下,最近很热门的"元宇宙",指的是一个与真实世界平行的虚拟世界。但是,目前的元宇宙实现都是基于 VR,也就是全部虚拟的宇宙。我在想,有没有可能出现基于 AR 的元宇宙呢?

在 AR 元宇宙之中,世界本身依然是真实世界,但是玩家的各种活动发生在虚拟层里面。举例来说,如果要举办一个晚会,地址在人民路,那么 VR 元宇宙需要构建一条虚拟的人民路,而 AR 元宇宙则是在真实的人民路上构建一个虚拟会场,不仅开发难度低,而且真实感更强。

Go 语言基础学习

我们常说的"互联网开发",其实包括前端和后端两个部分。前端是客户端开发,后端是服务器开发。

Java 语言是后端开发的老大,企业级的互联网后台大部分都是用 Java 写的。但是,这几年 Go 语言上升很快,目前市场占有率可能仅次于 Java,比如视频网站 B 站的后台就完全是用 Go 写的。

Go 语言有几个显著的优点。首先从一开始,它就是为互联网并发环境设计的,特别适合互联网应用,其次它是跨平台的,同样的代码可以运行在不同的操作系统,再次它背后有谷歌这样的大公司支持。语法上,它比 Java 和 C++ 都要简洁,新人一周左右就能入门上手,而性能丝毫不落下风。

目前招聘市场上,Go 语言的行情非常好,招聘的公司远多于应聘的程序员。根据一份2021年的调查,Go 语言位列最想学习的编程语言的前三名。

如果你有打算服务器开发,想为前端程序做一个后台,Go 语言肯定是值得学习的。

本周介绍的课程,就是一门来自极客时间的学习资料 《Go 基础冲刺班》。它通过四节课 + 三个实战项目,帮助大家入门 Go 语言,讲解基础语法,理清学习难点。

这份学习资料适合下面三种同学:

  • 已经有其他语言基础、但还没上手过 Go
  • 想从其他技术栈转到 Go 技术栈的
  • 已经在做 Go 研发,想要巩固基础

四节课的学习内容如下。

每节课都提供配套源码和 PPT。想学习 Go 语言的同学可以尝试一下,不花一分钱就得到一份基础教程。

微信扫描上方二维码,添加班主任,即可免费获得课程链接和配套源码。

科技动态

1、《鱿鱼游戏》的官司

韩国电视剧《鱿鱼游戏》最近成了 Netflix 平台观看次数最多的电视剧,但是却为 Netflix 带来了官司。

韩国的网络服务商 SK Broadband 起诉 Netflix,理由是太多观众通过网络收看电视剧,要求后者支付网络流量增加和维护工作的费用。证据之一是 Netflix 在美国为了提高流媒体速度,向宽带提供商康卡斯特公司支付网络维护费用,已经超过七年。

这种指控其实有点奇怪,因为内容提供商不会产生流量,而是观众产生流量,但是观众已经向网络运营商付费了。另一方面,如果 SK Broadband 设立网内的缓存服务器,那么所有的观看流量都是网内流量,几乎没有成本。

2、加油机器人

国内首款加油机器人,在南宁投入试运行。只要把小轿车停在加油机前,手机下单以后,加油机器人就会伸出"手臂",打开油箱盖,插入加油枪。整个过程仅需3分钟,司机不需要下车。

这台机器能够自动判断车型、定位油箱盖位置、自动打开和关闭油箱内外盖,实现了"开关盖+插拔枪"动作的智能化。

3、疫苗贴片

美国科学家发明了一种 3D 打印的疫苗贴片,只要贴在皮肤上,就会溶解到皮肤中,达到疫苗注射的效果。

这种疫苗贴片表面有许多微针头,可以刺破皮肤的表皮细胞,进行给药。相比传统的疫苗注射,这种方式的优点是无痛、侵入性更小,并且可以自我给药,不需要护士。(@jijiah 投稿)

4、ABBA 虚拟演唱会

瑞典著名乐队 ABBA 最近录制了新专辑,距离上一张专辑已经过去了40年。1982年乐队解散时,四个成员还是年轻人,现在都已经成了老人。

专辑的两首新歌定于今年11月5日首发,同时还会在网上举行一次虚拟演唱会。四名成员以 3D 人物的形式在舞台上演唱20多首歌。虽然他们的演唱会化身是计算机生成的,但是舞台上的动作确实是本人的。他们为了这次演唱会,穿上了感应服装,进行动作捕捉。

5、木制大钟

一个日本大学生,为了自己的毕业设计,制作了一个木制大钟,一共有400个零件,全部手工制作,令人叹为观止,简直是一件艺术品。

它的最神奇之处,莫过于显示时间的表盘是一块白板,每当时间发生变化,机器会自动抹去原来的时间,用笔写上新的时间。

请看下面的视频

6、城市太阳能装置

一个法国艺术家发明了一种新的太阳能装置,可以装在城市楼房外侧。它将两块太阳能交叉在一起,从而不管太阳在哪个角度,都能获得光照。

而且,每块太阳能板还可以做成不同的形状,将照射在墙上的阴影,变成各种文字和图案。

文章

1、两个电动车主的长假出游遭遇(中文)

十一假期,很多人开电动车出游,都遭遇到了充电困境。本文讲述了两位车主的亲身经历。

2、钍燃料反应堆试验(中文)

核反应堆目前都使用铀燃料,《自然》杂志报道中国正在甘肃省试验钍燃料反应堆。钍比铀有一些显著的优点,比如核废料更少、不需要大量的水,但是成本比较高,技术还不成熟。本文介绍了钍反应堆的背景知识。

3、NASA 的交互式图像小说(中文)

美国宇航局 NASA 在官网发布了一部交互式小说,用户可以下载 PDF 文件阅读。它的最大特点就是里面有很多二维码,手机扫描后可以看到背景材料、插图和视频。但是与其这样做,为什么不直接做成网页发布呢?

4、我在通用电气工作的一件往事(中文)

作者2018年底加入了美国通用电气在英国剑桥的软件部门,发现公司内部使用的是一种自己发明的语言,但是这种语言没有 while 语句。(@DoctorLai 投稿)

5、如何用复选框实现图片、动画和游戏(英文)

在视觉上,复选框的选中状态不同于未选中状态。作者突发奇想,利用这一点将图片、动画、游戏转成复选框。(@abel533 投稿)

6、中位数为什么比平均值有用(英文)

这篇文章的前半部分使用图形,通俗地解释了为什么中位数(medium)比平均值(average)更好地反映了总体状况。

文章后半部分讲解了数据库如何查询中位数,不感兴趣的同学可以只看前半部分。

7、Envoy 开源五年的回顾(英文)

这是一篇长文,作者是开源项目 Envoy 的创始人,讲述了这个项目是如何诞生和发展的,抒发了很多个人感想,值得一读。网上有中文翻译

8、JavaScript 如何遍历树结构(英文)

本文介绍了树结构在 JavaScript 语言里面如何遍历,写得浅显易懂,解释了广度优先、深度优先等多种方法的实现。

9、GitHub Markdown 现在支持脚注(英文)

这篇是 GitHub 的官方博客,宣布该网站的 Markdown 语法现在支持脚注了,可以看看脚注怎么写。(@jwenjian 投稿)

工具

1、htmlq

一个命令行工具,可以从 HTML 代码里面提取内容,类似于 jq 从 JSON 里面提取内容。

2、Notion Avatar Maker

一个网页工具,可以在线制作 Notion 风格的个人头像。(@Mayandev 投稿)

3、DeepFaceLive

一个桌面软件,进行深度学习训练后,可以在视频直播和视频会议里面实时换脸。

4、FSNotes

苹果设备的笔记软件,支持 Touchbar 和 iCloud 同步,还可以 Git 版本控制和备份。

5、Windows 11 in React

该项目将 Windows 11 的桌面移植到网页上,使用 React 开发。

6、PoW! Captcha

一个开源的网页机器人识别服务(Captcha),通过工作量来识别。服务器会发送一个计算难题,要求客户端进行计算,使得入侵脚本的计算成本很高。

7、youtube-dl-gui

youtube-dl 的图形界面封装,支持多个视频网站的视频下载。

8、Ozzillate

一个很有意思的声波传送工具,将上传的文件转成声波,其它电脑从麦克风"听到"以后,再将声波转回文件。

9、BeMyEars

MacOS 应用,可以对系统播放的声音(比如浏览器、视频播放器、音乐播放器)进行语音识别,然后实时生成字幕。(@yujinqiu 投稿)

10、message-box

一个 Web Component 的消息框组件。(@kingcc 投稿)

资源

1、云媒体速览

该网站收集了中国333个地级市的报纸电子版和电视直播网址。(@DiamonWoo 投稿)

2、公司作息时间表

一个民间众包项目,用户通过腾讯文档的表格,提交自己所在部门的上下班作息时间,可以用来了解不同公司的加班情况。(@erdong 投稿)

3、中等代数

一本英文的数学教材,讲解中学代数,包括线性方程、指数函数、二项式定理等等,免费阅读。

4、教娃编程视频300天

一个工程师给娃讲了很多数学和计算机知识的短视频,详见说明文章。(@DoctorLai 投稿)

5、路径算法的动图演示

这个仓库提供常见路径算法的动画演示,一共有20多个算法演示。(@DoctorLai 投稿)

图片

1、高濑川

1608 年,一个名叫角仓了以(Suminokura Ryōi)的日本商人,得到了一份合同,为京都的一座寺庙提供建筑材料。

这件事的最大困难,就是无法把巨大数量的建材运到市中心。于是,他出钱请人挖了一条运河,从港口通到市中心。他用当地采石场的石头铺设岸堤和河底,由于他的船吃水大约15厘米,河的深度就翻一倍,定为30厘米。

1614年,这条长9.7公里、宽7米的运河挖好了,称为"高濑川"。

这条运河一直沿用到1920年,基本上不需要维护,可靠地发挥着航运功能。1920年以后,停止了航运,变成了纯粹的观光河流,直到今天还保留着400年前建造时的样貌。

2、《解析数论导论》的封面

1976年出版的美国高等数学教材《解析数论导论》,封面是一张很奇怪的图片。

很多人以为,这张图片只是一张普通的对称图片,但其实是有含义的。

它画了一个坐标轴,x 轴和 y 轴都是0到17,每个点的坐标(x, y)就是一个数值对。只要数值对的最大公约数不等于1 (gcd(x, y) ≠ 1),该点就会画上一个方块,否则为空。

举例来说,原点(0, 0)的最大公约数为0,所以有一个方块;而它旁边的两个点(0, 1)(1, 0),可以认为最大公约数为1,所以为空。

对角线上除了(1, 1),其它点的最大公约数都不等于1,所以这些点都有一个方块。

文摘

1、谷歌如何收购 Android

Android 是谷歌公司的操作系统,但并不是谷歌从头开发的,而是从一家小公司收购而来的。

最近,美国出版了一本新书,作者采访了几十位当事人,介绍 Android 的诞生内幕。

Android 团队最早的创业想法,是为照相机开发一个操作系统。但是,后来意识到手机会取代相机,成为主流的拍摄设备,就转而为手机开发操作系统。

2005年初,Android 团队陷入困境,急需现金,开始寻找外部投资。

工程师就为当时的按键手机写了一个演示程序,包括一个主屏幕和几个应用程序,不过大部分都不能运行。毫不奇怪,这个演示程序与今天的 Android 相去甚远。

有了演示程序,他们又准备了一个 PPT,解释他们的愿景,然后就开始向潜在投资者推销自己,试图获得投资。

这个 PPT 保存到了今天,一共15页。

他们首先解释了为什么手机市场即将发生巨变。

2004年,全球个人电脑出货量为1.78亿台,手机出货量为6.75亿部,几乎是 PC 数量的四倍。这意味着,手机市场已经远远超过 PC 市场,并且还在高速发展。

但是,2004年手机的处理器和内存只相当于 1998 年 PC 的性能(200MHz 的 CPU 和 32MB 的内存),因此手机需要一个真正的、功能强大的操作系统。

而且,手机的硬件成本正在下降,但是软件成本没有下降,手机制造商迫切需要一个廉价的操作系统,从而降低软件成本。

手机制造商并不是软件开发专家,没有能力也没有兴趣,持续改进软件,因此需要外部的软件公司为他们提供操作系统。

Android 将是一个免费的手机操作系统,以开源的形式提供给手机制造商。手机公司能够自由使用这个操作系统,不必依赖软件公司,也无需自己来开发。

2005年的市场上,手机操作系统寥寥无几。

  • 微软的系统要得到许可后,才能移植。
  • 塞班(Symbian)系统主要由诺基亚使用,其它手机公司虽然可以用,但是无法与诺基亚竞争。
  • 黑莓的系统不能用于其他手机。

对于那些想要制造一款功能强大的智能手机的制造商来说,根本没有操作系统可用,要么自己从头开发,要么支付高额许可费,使用那些专有的操作系统。然后,就会面对一个更大的问题:根本没有应用程序生态,谁来为这些操作系统开发应用程序呢?

塞班系统允许外部应用程序,但是通用性很差,为一种型号手机写的程序,无法运行在另一个型号上,即使两部手机出自同一个制造商,它们的应用程序也不是通用的。

Android 希望提供世界第一个完整的开源的手机操作系统。它建立在 Linux 之上,为应用程序开发者提供单一的编程模型,同一个应用程序可以运行在使用该系统的所有设备上,从而简化开发。

接着就是最困难、也是最重要的问题,Android 如何赚钱呢。如果这个开源手机操作系统,不能让发明者赚到钱,怎么吸引外部投资呢,又如何保证能够持续开发呢?

Android 团队设想,通过为手机制造商提供云服务、以及应用程序的基础设施(比如认证和加密)来盈利。后来的事实证明,谷歌果然是通过谷歌服务框架和 Play 应用商店来盈利。

创始团队与很多风投进行了接触,其中就包括了谷歌。谷歌邀请他们到总部来会谈,两位谷歌创始人拉里和佩奇都参加了。那次会议非常随意,但是谷歌显然对 Android 很感兴趣。会议结束时,谷歌说:"我们想帮助你们。"

接着,Android 团队去了亚洲,访问了三星和 HTC,了解手机制造商的态度。三星说,如果美国的无线运营商能够接受 Android,他们就愿意推出基于 Android 的手机。HTC 则说,希望独家合作,这个系统只授权给他们使用。团队不愿答应这个要求,但是后来推出的世界第一部 Android 手机,就是 HTC G1。

从亚洲回来以后,谷歌又找 Android 团队开会。这次会议来了更多的人。团队原以为,谷歌想了解项目开发进展,但是演讲进行到一半,谷歌打断了他们,直接说:"我们想收购你们。"

谷歌表示,如果 Android 愿意被收购,而不是接受风险投资,情况会好得多。这样可以不必理会风险投资家的要求,也不必向客户和运营商收取专门服务的费用,一心一意将操作系统免费提供给手机制造商即可。而且,手机内部还可以绑定谷歌的搜索服务,谷歌会从搜索获得收入,这些收入可以与手机制造商分享,从而手机制造商会从 Android 获利,因而有动力使用这个系统。

2005年7月,Android 正式加入谷歌。

几周后,团队再次在谷歌内部,向高管进行了宣讲。当讲到如何将 Android 货币化时,拉里打断了演讲者:"不用考虑这个,我只希望你们造出最好的手机,其余的事情以后再说。"谷歌收购这个团队的原因很简单,就是希望通过它可以让谷歌进入移动设备市场。

言论

1、

飞行汽车结合了两种完全不同的工程体系:它必须既能飞行,又能适应地面运输的狭窄空间限制。

同时满足飞行和地面运输的各种安全和效率要求并非易事,这就是为什么至今没有公司能造出飞行汽车,最多就是造出点对点的飞行汽车,这种车辆通常没有车轮,更像小飞机。

--《技术成功的规则》

2、

程序员没有必要局限在一种语言或工具。工具每天都在变化,只要新工具适合你的用例,就可以选择它。你的标准应该是,选择最能够快速有效地解决问题的任何语言或工具。

-- 《下一个项目要用哪些工具》

3、

一个可运行的复杂系统,总是从一个简单系统演变而来的。似乎可以因此推断:从头开始设计一个复杂系统,永远不会奏效,必须从一个简单系统开始设计。

-- 《加尔定律》

4、

大师并不是一开始就是大师。你把他们早期第一阶段的作品找出来看看,就会了解他们取得了多大的进步。

-- David Perell

5、

不要对技术钻牛角尖,要对如何获取客户钻牛角尖。

-- Hacker News 读者

历史上的本周

2020年(第 128 期):这个社会是否正在变成"赛博朋克"?

2019年(第 76 期):任何爱好都能变成职业,只要你会拍视频

2018年(第 25 期):安卓手机十周年的感想

订阅

这个周刊每周五发布,同步更新在阮一峰的网络日志微信公众号

微信搜索"阮一峰的网络日志"或者扫描二维码,即可订阅。

(完)

文档信息

  • 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证
  • 发表日期: 2021年10月15日
Before yesterday个人博客

科技爱好者周刊(第 178 期):家庭太阳能发电的春天

October 8th 2021 at 00:38

这里记录每周值得分享的科技内容,周五发布。

本杂志开源(GitHub: ruanyf/weekly),欢迎提交 issue,投稿或推荐科技内容。

周刊讨论区的帖子《谁在招人?》,提供大量程序员就业信息,欢迎访问或发布工作/实习岗位。

封面图

10月5日,俄罗斯宇宙飞船将一名女演员(左一)和一名导演(右一)送入了国际空间站。他们将在那里停留12天,拍摄一部电影。电影讲述一个宇航员突发疾病,生命垂危,地面控制中心决定紧急派遣一名医生去治疗。(via

本周话题:家庭太阳能发电的春天

最近,全国供电紧张,很多省的工业用电不够了。

我在想,会不会因为这件事,家庭太阳能发电就要流行了。

理由是这样的,这一次的供电紧张,主要原因是煤价涨了,从2021年初到现在翻了一倍。大家看下图,最后一个月(刚刚过去的9月份)的价格简直是飞涨。

但是,电价没涨,火力发电厂因此陷入亏损,每发一度电都会亏钱。偏偏又遇上用电量猛增,今年前八个月,全国用电量增加了13.8%,相比之下,这个数字去年是3.1%,前年是4.5%。用电量增长太快,电厂又无力多发电,所以电就不够了。

有的同学可能会说,火电不行,不是还有风电和光电吗?问题是它们只是一个零头,不到全部发电量的10%,根本替代不了火电,火电是绝对的大头,占到70%以上。

而且,风电和光电不稳定,英国的北海今年突然风停了,风电急剧减少,英国的能源就紧张到90%的加油站没有汽油了。

总之,要解决供电紧张,还是离不开煤炭,最简单的方法就是让煤价下跌。但是这一次,煤价很可能不会下跌。"碳达峰、碳中和"是国家战略,这种背景下,政府不会大量投资去增产煤炭,煤炭供应不足可能会持续下去。

另一方面,以后用电量肯定持续增长。别的不说,全国每年新增那么多电动车,都需要用电啊,单单一台特斯拉 Model 3 的功率(220kw)大约是家用空调的100倍。马斯克最近说,美国的电力生产需要增长一倍,才能满足电动车时代的需要。

我认为,如果煤价短期内不下跌,为了避免火电厂亏损,电价很可能会上调。

这样的话,岂不是家庭太阳能发电的大好时机。一方面可以保证家庭供电不中断,不担心电网限电;另一方面可以减轻电价调整带来的支出上升。

太阳能发电设备的价格,这几年一直在下跌。一套 3000W 的光伏系统,也就不到3万元人民币。假定冬天工作4个小时,每天就是12度电,夏天工作8个小时,每天24度电,对于小家庭应该够用。根据各地的电价,不难算出大概七八年可以回本。

即使不架设全屋系统,只在窗外放一块光伏板,至少也能满足电脑和手机的用电,等于家里多了一个不间断电源。

以前是电力公司集中式发电,但是随着太阳能发电的价格下跌,今后的趋势大概是家家户户的分布式发电,每家的屋顶都有光伏板,这也许就是解决中国的能源问题,达到碳中和的有效方法。

Vue 3.0 辅导视频

前端开发的学习路径,一般分成三阶段。

  • 第一阶段,学习 JavaScript 和 CSS 的基础语法,包括浏览器的 API。
  • 第二阶段,学习框架和周边工具,现在一般都学 Vue.js 或 React 框架。
  • 第三阶段,实际开发,积累经验。

很多同学卡在第二阶段,因为第一阶段啃语法书,可以熬过去,但到了第二阶段需要自己写代码,问题就全出来了。一方面,前期的语法基础不牢固,实际应用时,API 不会用,别人的代码也看不懂;另一方面,虽然跟着文档学了一遍框架,但不理解为什么这样做,遇到问题时,不知道怎么把框架套上去。

总之,第二阶段的框架和工具学习,非常磨人。但只有过了这个阶段,前端开发才算真正入门,后面才能去解决实际的开发任务。

本周的学习资料就是现在最热门的前端框架 Vue.js 3.0 的一个辅导视频,帮助你实现 Vue 的进阶,顺利通过第二阶段。

Vue 3.0 发布已经一年多了,国内的推广速度非常快。它的 Composition API 和 React 的钩子,是业内当前的两大热点技术。面试时往往会问到相关问题,比如 Vue 技术栈和它的核心设计。

这次的辅导视频,内容包括三个部分:(1)Vue 原理和源码剖析,(2)认识 Composition API,(3)实现一个"飞机大战"游戏。

整个视频总计450分钟,重点是第三部分,主讲老师在讲解框架之后,带大家从头写一个飞机大战游戏,通过实战练习,快速上手 Vue 3.0。同时也让你有一个成果,可以写在简历中,为面试加分。

微信扫描上方二维码,了解课程详情,0 元领取这份资料。保证没有任何套路,先到先得。

科技动态

1、贝多芬第十交响乐

伟大的德国音乐家贝多芬,一生中写过九部交响乐。1827年,他去世时,正在着手准备第十交响乐,只留下了一些草稿(下图),包含大约250个小节。

2019年,为了纪念贝多芬诞生250周年,奥地利音乐研究所的所长找到了一个人工智能专家,询问能否使用 AI 技术生成第十交响乐。

那位专家就先写了一个 AI 模型,使用贝多芬所有的作品进行训练,熟悉他的风格以后,再根据现存的手稿,生成整部作品,长度接近一个小时。

AI 版第十交响乐的完整录音将于2021年10月9日发布,同一天还会在德国波恩举行全球首演。下面就是 AI 生成的第十交响乐片段。

2、VR 编程环境

一个国外程序员发文,声称他都在 VR 环境编程,每周超过40小时,整个过程都戴着 VR 头盔。下面是他的编程环境示意图

他的实际编程还是在笔记本里面,使用实体键盘和鼠标,但是程序窗口会通过视频流,投射到 VR 头盔。这意味着,任何可以在笔记本完成的工作,都可以在 VR 环境里面做。

在 VR 环境,他将5个命令行窗口并排,感觉就像坐在 IMAX 影厅编程。

VR 环境的一个好处是,可以任意变换虚拟工作地点,一会在蓝天白云的山顶,一会又在海滩上工作。

3、人工合成淀粉

中国科学家全球首次实现"二氧化碳生成淀粉"的全过程。淀粉是粮食的主要成分,目前都是通过植物的光合作用产生。一旦能够实验室生成,就为人类的粮食生产创造了另一条途径。

科学家首先利用太阳能电解水,获得氢气;然后,利用氢气将二氧化碳还原为甲醇。接着,从62种生物酶里面选出10种,将甲醇转化为淀粉。

据称,1立方米大小的生物反应器年产淀粉相当于5亩玉米地的淀粉产量。这种技术虽然可以节省大量土地和淡水,但是目前的成本太高,并不具备商业价值。(@pipi32167 投稿)

4、胖熊周

每年秋天,大批的棕熊都会来到美国阿拉斯加州的布鲁克斯河吃红鲑鱼,为即将到来的冬眠积累脂肪。

最早的时候,当地的国家公园架设了摄像头,直播棕熊的动态。很多人一边看直播的时候,一边会在网上讨论,哪一头熊最大最胖。

国家公园看到了这些讨论,从2014年开始,就跟一家网站合作,在每年的9月底10月初举办"胖熊周",每天让网友投票,把来吃鱼的棕熊进行两两 PK,直至评选出最后的"胖熊冠军"。去年收到了将近60万张选票。

今年的胖熊周是9月29日到10月5日,经过投票,747号熊获得了今年的冠军。

5、Mac 应用商店的吸引力越来越小

苹果公司有两个应用商店:iOS 商店和 Mac 商店。区别是 iOS 设备只能从官方商店安装 app,而 Mac 设备可以从第三方下载安装 app,不一定要通过应用商店。

统计数据显示,今年开始,开发者对于 Mac 商店的兴趣显著下降,越来越倾向于让用户直接从自己的网站下载安装。

以往 Mac 应用商店每月上架新 App 大约400款左右,但是今年6月份,这个数字首次降至不到300款,预计8月份将只有大约200款上架。

文章

1、鸟类撞上玻璃幕墙怎么办?(中文)

玻璃幕墙建筑越来越多,撞上玻璃的鸟类也越来越多,导致大量鸟类死亡。本文是这个问题的调查报告。

2、Facebook 下线的原因(英文)

本周,Facebook 下线了七个小时,原因是它的 BGP 路由被撤销了,可能是内部人员更新 BGP 时出错了。本文是一篇简单的解释,什么是 BGP,为什么它会导致 Facebook 下线。

3、Service Worker 简介(中文)

谷歌官方的 Service Worker 教程,介绍什么是 PWA 应用,以及怎么开发,写得非常好,很容易懂。官方提供中文版翻译。

4、一个人能领导多少人?(英文)

人的精力是有限的,如果想做到有效领导,那么直线下属最好不超过5-7人。

5、异步函数如何使用缓存?(英文)

需要密集计算的函数,可以部署缓存(memoizing),用来提高性能。本文介绍了函数缓存的概念和实现。

6、Chrome 背景窗口的大小(英文)

Chrome 浏览器有一个 Bug,如果一个窗口在后台打开,这个窗口的大小(window.outerHeight)会是 0,但是内部视口(window.innerHeight)的大小却不是 0,这会导致一些奇怪的问题。

7、C23 的进展(英文)

C 语言的最新版本是 C17,这是2017年通过的标准。下一版本将在2023年发布,称为 C23。本文介绍 C23 会引入的一些新语法,关心 C 语言的朋友可以看看。

8、SourceMappingURL 的恶意使用(英文)

浏览器加载网页时,如果指定了 Sourece Map 文件网址,就会加载这个文件。但是,这个网址居然可以用脚本动态修改,因此恶意脚本可以用它发送用户信息。不过,这个技巧的生效条件,是用户必须打开开发者工具。

工具

1、listmonk

一个开源的邮件列表管理器,提供美观好用的管理界面,只要配上邮件发送服务(比如 AWS SES 或者开源的 Haraka)就可以使用。

2、sqlite-tui

命令行的 SQLite 数据库查看器。

3、OpenDesktop

该网站是一个开源的云服务套件,向用户提供各种免费的云服务,比如网盘、Office、聊天、地图、项目管理、社交等等。

4、Mithril.js

一个非常易用的轻量级前端框架,只做了 DOM 的包装层,没有加任何其他接口,体积小,加载快,并且支持组件和前端路由。开发小型页面,明显优于 React。

但是不知道什么原因,多年来它一直不流行,因为名字难记?......

5、Pollen

一个 CSS 变量库,提供一组常用的 CSS 变量(比如颜色、长度、字体大小等等)。开发者可以将这个库作为初始变量,以后通过调整变量来改变样式,比如从亮模式变成暗模式。

6、NETworkManager

Windows 原生的网络管理器非常难用,这是一个第三方的网络管理器,在图形界面中,提供各种方便的网络管理、调试功能。

7、x11docker

一个封装了显示模块的容器,可以用来通过 Docker 容器运行图形界面应用。

8、Arc

一个众包 CDN,宣称用户使用它们的服务可以赚钱。它跟以前的迅雷 CDN 是一样的,访问者打开安装了它的脚本的网站以后,就会分享一部分带宽,用来给其它用户作为 CDN 加速。

虽然不确定这样做是否合适,但从技术上看,是很有趣的实践。

9、git-cliff

一个根据提交信息,生成 Changelog 文件的命令行工具。

资源

1、深入计算机系统(Dive into Systems)

免费阅读的英文教材,介绍计算机系统的底层知识,内容包含 C 语言和汇编语言的教程。

2、JS 库的免费公共 CDN(英文)

网上有一些 JS 库的公共 CDN,可以从它们加载库脚本。本文列出了主要的名单。

3、Programming-Idioms

该网站列出主要语言的常用操作(比如创建数组、连接数组等),目前已经有250多种操作。可以指定任意两种语言进行对比,方便快速入门。(@jerrylususu 投稿)

4、OpenMoji

一套开源的 Emoji 图标,目前已经有将近4000个图标,还在持续增加中。

5、Mathlets

这个网站提供网页小程序,帮助学习各种数学概念,比如正弦曲线、复数运算、傅里叶变换等等。

图片

1、 大公司起家的地方

下图是苹果、谷歌、亚马逊等大公司,各自创业起家的地方。

2、奇特的转向灯

一家英国汽车公司推出了不一样的转向灯。

这种灯在右转时,显示向右的箭头;左转时,显示向左的箭头。为了显目,还会闪烁。

有些人觉得这个设计很好,另一些人觉得很愚蠢,你觉得呢?

3、地铁隧道的建设过程

下面是一组伦敦地铁隧道的建设照片,展示了隧道是怎么建设的。

第一步,先用盾构机打洞。盾构机的直径7米,每天可以前进38米。

第二步,洞壁涂上混凝土。

第三步,铺设各种线路,同时保证良好的空气流通。

第四步,铺设路面和墙面,具备开通条件。

文摘

1、乔布斯逝世十周年

乔布斯去世于2011年10月5日,今年是十周年。

著名 IT 作家史蒂文·利维发表了一篇回忆短文。

2011年时,作为一家科技媒体,妥善的做法是提前准备好史蒂夫·乔布斯的讣告,我们都知道他没有多少时间了。

将近有一年,苹果公司坚持说,它的创始人兼首席执行官能够成功康复,但是乔布斯本人的状况却是每况愈下。他的身体坚持不了多久了,他也认识到这一点。

那一年的早些时候,我和另一名记者要求对他专访。地点就在无限环路1号(One Infinite Loop)的苹果公司总部大楼四楼的一间办公室。专访没有指定议题,但是我们三人都知道这是为了告别。

那一天是工作日中午,整个苹果总部有数千人在上班,但是我们90分钟的谈话过程中,没有被一个电话或访客打断。就好像对于苹果公司来说,他已经是一个鬼魂了。

专访结束后,我还是没办法提前准备他的讣告。内心里,我不想承认他会很快逝世。

所以,2011年10月5日的下午,快要天黑的时候,接到乔布斯逝世的电话,我惊呆了。

那时,我手里什么文章也没有。于是,接下来的四个小时里,我开始写文章回顾乔布斯的一生,我使用的就是他创造的 Mac 电脑。我尽自己所能,讲述他的生平和遗产。

在讣告的最后一段,我写道:"乔布斯的全部遗产,在很长一段时间内都不会消失。"直到今天,我想我们还在整理他留下的遗产。不会再有像他这样特质的领导者和创新者,我们仍然生活在他的世界里。

言论

1、

开发人员是创建软件最宝贵的资源,但他们的时间往往被管理层低估和滥用。

管理者的作用应该是让开发人员能够工作,而不是拿走开发人员的时间。

-- medium.com

2、

一周的上班时间是40小时,但是我分配到了一个需要60小时的开发任务,我能怎么办呢?

-- medium.com

3、

唯一比量子力学更难的是解释量子力学。

--《理解量子革命》书评

4、

达尔文散步时,喜欢边走边思考问题。散步开始时,他会在起点放五块小石头,每走一圈就踢掉一块石头,这样就能在不打断思路的情况下,达到预定的圈数。

--《思想家与步行之间的关系》

5、

游戏分成两种。一种是有限游戏,另一种是无限游戏。有限游戏的目的是获胜,某方获胜就结束了,无限游戏的目的是继续游戏,你玩这个游戏是为了可以继续玩下去。

-- 《无限创业》

历史上的本周

2020年(第 127 期):未来人人开发软件,几乎没人编码

2019年(第 75 期):电子取代机械,对就业有何影响?

2018年(第 24 期):新人进入软件行业的建议

订阅

这个周刊每周五发布,同步更新在阮一峰的网络日志微信公众号

微信搜索"阮一峰的网络日志"或者扫描二维码,即可订阅。

(完)

文档信息

  • 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证
  • 发表日期: 2021年10月 8日

JavaScript 侦测手机浏览器的五种方法

September 29th 2021 at 01:19

有时候,前端网页需要知道,用户使用的是手机浏览器还是桌面浏览器。

本文根据 StackOverflow,整理了 JavaScript 侦测手机浏览器的五种方法。

一、navigator.userAgent

最简单的方法就是分析浏览器的 user agent 字符串,它包含了设备信息。

JS 通过navigator.userAgent属性拿到这个字符串,只要里面包含mobiandroidiphone等关键字,就可以认定是移动设备。


if (/Mobi|Android|iPhone/i.test(navigator.userAgent)) {
  // 当前设备是移动设备
}

// 另一种写法
if (
  navigator.userAgent.match(/Mobi/i) ||
  navigator.userAgent.match(/Android/i) ||
  navigator.userAgent.match(/iPhone/i)
) {
  // 当前设备是移动设备
}

这种方法的优点是简单方便,缺点是不可靠,因为用户可以修改这个字符串,让手机浏览器伪装成桌面浏览器。

Chromium 系的浏览器,还有一个navigator.userAgentData属性,也是类似的作用。不同之处是它将 user agent 字符串解析为一个对象,该对象的mobile属性,返回一个布尔值,表示用户是否使用移动设备。


const isMobile = navigator.userAgentData.mobile; 

注意,苹果的 Safari 浏览器和 Firefox 浏览器都不支持这个属性,具体情况可以查看 Caniuse 网站

此外,还有一个已经废除的navigator.platform属性,所有浏览器都支持,所以也可以用。它返回一个字符串,表示用户的操作系统。


if (/Android|iPhone|iPad|iPod/i.test(navigator.platform)) {
  // 当前设备是移动设备
}

二、window.screen,window.innerWidth

另一种方法是通过屏幕宽度,判断是否为手机。

window.screen对象返回用户设备的屏幕信息,该对象的width属性是屏幕宽度(单位为像素)。


if (window.screen.width < 500) {
  // 当前设备是移动设备 
}

上面示例中,如果屏幕宽度window.screen.width小于500像素,就认为是手机。

这个方法的缺点在于,如果手机横屏使用,就识别不了。

另一个属性window.innerWidth返回浏览器窗口里面的网页可见部分的宽度,比较适合指定网页在不同宽度下的样式。


const getBrowserWidth = function() {
  if (window.innerWidth < 768) {
    return "xs";
  } else if (window.innerWidth < 991) {
    return "sm";
  } else if (window.innerWidth < 1199) {
    return "md";
  } else {
    return "lg";
  }
};

三、window.orientation

第三种方法是侦测屏幕方向,手机屏幕可以随时改变方向(横屏或竖屏),桌面设备做不到。

window.orientation属性用于获取屏幕的当前方向,只有移动设备才有这个属性,桌面设备会返回undefined


if (typeof window.orientation !== 'undefined') {
  // 当前设备是移动设备 
}

注意,iPhone 的 Safari 浏览器不支持该属性。

四、touch 事件

第四种方法是,手机浏览器的 DOM 元素可以通过ontouchstart属性,为touch事件指定监听函数。桌面设备没有这个属性。


function isMobile() { 
  return ('ontouchstart' in document.documentElement); 
}

// 另一种写法
function isMobile() {
  try {
    document.createEvent("TouchEvent"); return true;
  } catch(e) {
    return false; 
  }
}

五、window.matchMedia()

最后一种方法是结合 CSS 来判断。

CSS 通过 media query(媒介查询)为网页指定响应式样式。如果某个针对手机的 media query 语句生效了,就可以认为当前设备是移动设备。

window.matchMedia()方法接受一个 CSS 的 media query 语句作为参数,判断这个语句是否生效。


let isMobile = window.matchMedia("only screen and (max-width: 760px)").matches;

上面示例中,window.matchMedia()的参数是一个 CSS 查询语句,表示只对屏幕宽度不超过 700 像素的设备生效。它返回一个对象,该对象的matches属性是一个布尔值。如果是true,就表示查询生效,当前设备是手机。

除了通过屏幕宽度判断,还可以通过指针的精确性判断。


let isMobile = window.matchMedia("(pointer:coarse)").matches;

上面示例中,CSS 语句pointer:coarse表示当前设备的指针是不精确的。由于手机不支持鼠标,只支持触摸,所以符合这个条件。

有些设备支持多种指针,比如同时支持鼠标和触摸。pointer:coarse只用来判断主指针,此外还有一个any-pointer命令判断所有指针。


let isMobile = window.matchMedia("(any-pointer:coarse)").matches;

上面示例中,any-pointer:coarse表示所有指针里面,只要有一个指针是不精确的,就符合查询条件。

六、工具包

除了上面这些方法,也可以使用别人写好的工具包。这里推荐 react-device-detect,它支持多种粒度的设备侦测。


import {isMobile} from 'react-device-detect';

if (isMobile) {
  // 当前设备是移动设备
}

(完)

文档信息

  • 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证
  • 发表日期: 2021年9月29日

科技爱好者周刊(第 177 期):iPad 的真正用途

September 24th 2021 at 00:31

这里记录每周值得分享的科技内容,周五发布。([通知] 下周五是十一假期,周刊暂停一期。

本杂志开源(GitHub: ruanyf/weekly),欢迎提交 issue,投稿或推荐科技内容。

周刊讨论区的帖子《谁在招人?》,提供大量程序员就业信息,欢迎访问或发布工作/实习岗位。

封面图

23岁的宁夏青年董书畅,今年6月份在西藏阿里地区,拍到了一张完美的日环食照片。最近获得了英国格林威治天文台评选的2021年天文摄影冠军照片。(via

本周话题:iPad 的真正用途

大家的平板电脑,都用来干嘛?

根据我的观察,第一位的用途都是看视频,其次是玩游戏。

如果有人说,平板电脑是生产力工具,那属于胡说。无论是学习和工作,它远远不如笔记本电脑好用和强大。

我曾经幻想,外出时使用平板电脑开发。结果发现根本不可行,要什么缺什么,开发体验极差,最多只能紧急时登录服务器,或者偶尔改一下仓库代码。

但是,我最近发现有一个用途,平板电脑大大强于笔记本,几乎可以说,是为这种用途量身定制的工具。

那就是普通人的视频处理。

专业级别的视频处理,平板电脑肯定是不行的。但是,普通人偶尔剪剪视频,它真的是利器,尤其是 iPad pro。

我使用 GoPro 拍摄旅游视频,每次要导入桌面设备处理,非常麻烦。要是身边没有笔记本电脑,就只能等到旅行结束再说。手机倒是可以剪视频,但是对于 2K 或 4K 分辨率,处理起来很慢。

今年上半年,苹果公司发布了新的 M1 架构的 iPad Pro。我眼前一亮,这个硬件配置跟 Mac mini 是一样的,而我们已经知道,后者的视频处理能力很强。

而且,iPad Pro 采用 Type-C 接口,可以直接连移动硬盘,导出文件。它的大屏也适合视频预览。总之,种种特点都表明这个设备特别适合视频处理。

入手以后,经过几个月的试用,它的表现跟我预想的基本一样,现场拍完就能剪,立刻就能分享给亲友,或者发上网。如果你也需要旅途处理视频,我推荐这个方案。

平板电脑诞生已经超过10年了,至今没有清晰的产品定位,不能不说是一件尴尬的事。我建议,厂商们可以把它的主要用途,定位在视频处理工具,真的很合适,没准能敲开更大的市场。

顺便提一下,iPad Pro 有一个地方,让我觉得很遗憾。虽然它跟 MacBook 笔记本是一样的硬件,但是无法安装 MacOS 应用,只能安装 iOS 应用。如果苹果公司愿意升级操作系统,它就是货真价实的笔记本平板了,百分之百的生产力设备。

前端工程化实战

正在学习前端的同学,有没有听说过 "前端工程化" 这个词?

它是这几年的热门领域,大厂招聘往往都有这方面的要求。

那么,"前端工程化"到底是什么呢?

我们知道,大学里面有《软件工程》这门课,如果把"软件工程"的做法用于前端项目,就是前端工程化。 比如,优化开发流程、提高项目可维护性、自动化测试、自动化构建、项目发布管理等等。

前端工程化可以大大提升项目的可维护性,降低维护成本,提供协作便利。这对于大型项目尤其重要,所以大厂才会如此看重前端工程化。

大部分的新人程序员,由于没有参与过大型项目,对于前端工程化的概念和实践都非常陌生。这对于正规化开发和应聘大厂,很不利。如果懂一点前端工程,面试可以加分不少,起薪也会比单纯的开发岗位更高一些。

本周的课程资料就是一个 《前端工程化实战》资料包,适合那些学会 React 或 Vue 框架后想更进一步提升的同学。

讲课老师通过视频,带大家动手构建一个前端组件库。以这个项目作为例子,重点讲解前端工程化的四个主要方面:测试、静态检查、代码打包发布、工作流程自动化。

同时,还会介绍目前最受关注的工程工具(Docker、GitHub Actions 和 Vite)。

对上面的内容感兴趣,希望掌握"前端工程化"知识的同学,可以听听看这门课程。

微信扫描上方二维码,就可以 0元 领取这份《前端工程化实战》资料包。

报名后,记得添加助教老师微信领取。数量不多,先到先得。

科技动态

1、电动车的超快充电

广汽的新能源汽车品牌 Aion,最近展示了超快充电技术,充电时间与加油时间差不多。

该公司的 3C 快充可以使车辆在16分钟内从0%充电至80%;另一种 6C 快充的速度更快,从0%充到80%只需要8分钟。

根据现场演示,它的充电功率达到 481kW,电压为 855.6V,电流为 562.8A。这种功率只要4分钟时间,就能充入35.1度电。但是问题在于,这样大的电压和电流,对于充电站的要求很高,需要专门建设,能否推广还有待观察。如果一个城市只有几个这样的充电站,并没有实际意义。

2、智能眼镜的隐私问题

上周,Facebook 与太阳眼镜制造商雷朋合作,推出了一副智能眼镜,允许佩戴者听音乐、接听电话,还可以拍摄照片和短视频。很多人担心这种眼镜可以用于偷拍。

爱尔兰的主管部门已经做出决定,如果这种眼镜要在该国出售,摄像头工作时,LED 指示灯必须常亮,让周围的人知道他们正在被拍摄或拍照。这可以防止偷拍,很多西方国家都有规定,任何有摄像头的公共场合,都应该有显目的提示,告知顾客会被录影。

3、世界最大的海上风力发电机

一家中国公司发布了,世界最大的海上风力发电机。

它的尺寸有点骇人,达到了242米高,配有三个长达118 米的叶片,叶片旋转后的覆盖面积比六个足球场还要大。每小时最多可以发电16000度。

由于风力发电机的发电效率与叶片尺寸成正比,所以厂家总是尝试制造越来越大的风力发电机,降低每度电的成本。

这种发电机预计2022年建造原型机,2023年试运行,如果一切顺利,2024 年上半年开始商业生产。

4、三星电视的远程屏蔽

三星公司的一批电视机,最近在南非仓库被窃。但是,这批电视安装了远程封锁功能,开箱后需要连接互联网激活。三星公司在收到激活请求后,根据电视的序号,远程禁用了电视的所有功能。

三星公司表示,该技术已经预装在所有三星电视之中。如果用户的电视被错误封锁,可以与官方联系解除限制。

这里的问题是,如果电视机可以被远程控制,那就可以用来监视用户,用户不会有任何察觉。所以,购买联网才能使用的电视机,是存在风险的。

5、四足鲸鱼化石

秘鲁发现了距今4300万年前的鲸鱼化石,奇特的是这种鲸鱼长有四条腿,还有蹄子和蹼足。

科学家认为,这证明了,鲸鱼原来在陆上生活,能够同时游泳和行走,后来回到海里,四条腿才逐渐消失。

生命起源于海洋,后来一部分动物从海里上岸,来到陆地生活,但是其中一些动物后来又离开陆地,回到海洋,鲸鱼大概就属于这种情况。

6、看书缩短刑期

巴西政府宣布,该国监狱里面的囚犯,每读一本书可以缩短刑期四天。书籍必须是文学、哲学、科学或经典著作,每年最多可减少48天的刑期,即读12本书。

每读完一本书,犯人必须撰写一篇读后感,该文章必须"正确使用语法,没有更正,保持页边距和清晰的笔迹"。监狱委员会根据读后感,来判断是否可以减刑。

巴西的监狱人满为患,相当一部分犯人是文盲。这个计划应该有助于推动犯人的学习和改造。

7、巴黎"无车日"

上个星期天(9月19日)是巴黎的"无车日"。自从2015年,该市规定每年这一天从上午11点到下午6点,城区全境禁止机动车,只保留公共汽车和紧急目的用车,违者将罚款135欧元(约1000元人民币)。

当天,香榭丽舍大街挤满了行人,平时那里都是挤满了车。人们发现城市变得安静,交通堵塞消失了,空气也干净了。

欧洲城市的中心城区,由于沿袭了历史上的布局,通常都非常狭小,不适合大量汽车通行,一直有禁止机动车的呼声。加上近年来防止气候变暖的考虑,"无车日"在欧洲城市变得越来越流行。

文章

1、杨振宁百岁生日讲话(中文)

9月22日,清华大学为杨振宁先生百岁诞辰举办座谈会。会上,杨先生发表了一个讲话。百岁老人回顾人生,思路依然如此清晰。

2、前端资源的完整性校验(中文)

网页的<link><script>标签有一个 integrity 属性,指定所加载内容的哈希值,防止被篡改。本文介绍这个属性的用法。(@dreamapplehappy 投稿)

3、最好的顶级域名(英文)

世界主要的顶级域名都被美国控制(比如 .com 和 .net),国别顶级域名则被各个主权国家控制(比如 .cn)。本文讨论有没有一个相对自由、可靠的顶级域名。

4、使用 C 语言编写 Shell(英文)

这篇教程讲解如何使用 C 语言,自己实现一个简单的 Shell。其实很容易,只要几十行代码就够了。

5、如何定制 nano 编辑器(英文)

nano 是 Linux 系统自带的文本编辑器,功能简单,用起来比 vim 和 emacs 都容易。虽然它的界面很简陋,但是只要稍加配置,就能看上去像 vim 一样。

6、如何使用 GitHub Actions 自动化开发流程(英文)

作者介绍他们公司如何使用 GitHub Actions,将各种开发步骤自动化,举了很多例子,演示了测试、代码格式化、构建、部署的配置文件应该怎么写。

7、Cloudflare Images 发布(英文)

CDN 服务商 Cloudflare 发布了 Cloudflare Images 服务,这是官方的介绍文章。该服务可以托管图片的存储,提供优化、剪切和 CDN,价格相当优惠。

8、CSS 的绝对长度单位都不准确(英文)

CSS 有几个长度单位属于绝对长度,比如像素px、厘米cm、英寸in、点pt。但是,这些单位都不是准确长度,比如1cm在屏幕上并不等于1厘米(上图)。

9、Steam 平台游戏引擎调查(英文)

本文调查了 Steam 平台上面的游戏使用什么游戏引擎,可以了解目前游戏开发工具的状况。

工具

1、DataStation

一个桌面软件,将数据库 SQL 查询、脚本编程、数据可视化结合在一起。用户可以在一个窗口里面,完成数据的查询、处理和呈现。

2、Speakeroo

这个服务将文章转成语音。用户通过浏览器插件或手机 App,把文章提交到服务器,服务器返回一个 RSS 链接,就可以在播客播放器收听该文章。

朗读质量相当高,接近真人朗读。目前支持英文、日文、韩文等,可能以后会支持中文。该服务收费,但每月有免费额度。

3、可视化代码执行

这个网站会将代码的执行流程,转成可视化图形,展示代码一步步是怎么执行的。目前支持 Python、Java、C、C++、JavaScript 和 Ruby 代码。(@WinterChenS 投稿)

4、TabHub

一个浏览器插件,可以定制打开新的标签页时,所要显示的内容,比如图片、RSS、或者其他网页。(@gingerhot 投稿)

5、Duplicate Searcher

Windows 软件,用来找出重复的文件,然后用硬链接取代它们,可以节省大量硬盘空间。

6、webcam-filters

Linux 软件,为摄像头的直播视频添加各种实时的图像过滤器(目前只有背景模糊这一个过滤器),适合用于视频会议。

7、MDcat

GitHub 有一个官方接口,将 Markdown 源码转为 HTML 代码。这个项目是一个简单的演示,使用 Python 代码去调用这个接口,完成 Markdown 的代码转换。

8、favicon pang

这个网页的 favicon 图标是一个乒乓球游戏,通过拖动网页滚动条来玩游戏。

9、FilesRemote

一个 SSH 图形界面的文件管理器,通过 SSH 登录远程服务器,然后像管理本地文件一样,管理远程文件。

资源

1、Cyber

该网站允许用户创造虚拟画廊,展示自己创作的或拥有的数字艺术品(需要 NFT)。体验非常出色,值得参观,比如这个画廊这个画廊

2、Apps for GNOME

Gnome 是 Linux 的桌面系统,这个网站是官方的 App 站,介绍自带的各种 App,制作得非常清新漂亮。

3、手机上如何 Python 编程(英文)

一个详细的指南,指导如何在手机上搭建一个 Python 开发环境。

4、数据库连接字符串大全

软件连接数据库,必须提供一个数据库连接字符串。这个网站收集各种数据源的连接字符串。

5、10 个 Python 聚类算法

聚类算法是常用的数据分析技术,这个教程介绍如何在 Python 语言中,使用10种流行的数据聚类算法。

图片

1、风向袋

你可能见过风向袋。它是一种锥形的管状纺织品,一头大,另一头小,用来确定风强和风向。

由于风向袋的尾部直径比头部小,受风时会自然在风的方向上膨胀起来,因此可以用来了解风从哪里吹来。

风越大,风向管就会飘扬得越水平。它分成五段,三段红色,两段白色。根据水平方向的段数,可以大概判断风的强度。一段代表三节风。

如果风向袋呈现完全的水平状,那么风力至少是15节,用级数表示,就是五级风。

2、没有打扫的地板

古罗马人经常制作各种玻璃和宝石的镶嵌画,称为马赛克画(mosaic),很多都保留到了今天。

罗马梵蒂冈博物馆有一幅著名的马赛克画《没有打扫的地板》,描绘了宴会之后地板一片狼藉,没有打扫的情景。

一个英国艺术家模仿这种风格,在花园里面创作了一个名为《没有打扫的落叶》的马赛克画。下面是原始作品,以及真正落叶掉在上面的情景。

文摘

1、浏览器标准被大公司左右

最近这些年,浏览器开发商(比如谷歌和苹果)从 W3C 手中夺取了网络标准的控制权。

网络标准正越来越多由大公司来决定,而不是由标准化组织 W3C 来决定。

大公司为了自己的业务,让浏览器的功能不断膨胀,目标是让 Web 网站做到原生应用可以做的一切,这是一个巨大的错误。

大公司明白,添加到 Web 的功能越多,浏览器之间的竞争就会越少!

谁能跟上谷歌推出新标准的脚步?Mozilla 不能,苹果不能,甚至微软也认输了,放弃自己开发浏览器引擎,直接采用了 Chromium 作为自家 Edge 浏览器的内核。

想象一下,现在有一家小公司试图从头开始编写一个 Web 浏览器。简直不可能!网络如此复杂,他别无选择,只能采用少数现有的浏览器引擎之一:Chromium、WebKit、Gecko。

这就是现在的状况,浏览器市场的竞争正在越来越少。"每个人都必须采用 Chromium",这正是谷歌的计划。

谁主导了浏览器引擎,谁就控制了互联网。即使 Web 标准是开放的,理论上允许任何人实现浏览器引擎。但是 Web 标准那么多、那么复杂,实际上没有人可以实现能与 Chromium 抗衡的浏览器引擎,那么这种开放也没有意义了。

现在的 Web 标准被大公司控制,已经沦为 Chromium、WebKit、Gecko 和它们各自怪癖的集合。以后怎么可能还会有新引擎?

如果没有新的人可以编写浏览器引擎,网络就不会是开放的。开放只是一种错觉。

言论

1、

失败很少是一种永久状态。失败后,继续前进就可以了。失败只是旅程中的一站,不要让它成为你的目的地。

-- 《首届 Delta Force 训练班》

2、

现在最成功的软件公司,都是一种模式:自力更生创立、业务产生现金流、获得风险投资、开始疯狂增长。

-- 《独角兽的崛起》

3、

SerenityOS 操作系统的目标用户,是这样一种程序员:他们最早在 Windows NT 系统接触到服务器,开始成长,但是最终在 Linux 和 BSD 系统上达到成熟。

-- SerenityOS 是一个 Linux 发行版,但是具有 Windows NT 的 UI。

4、

我认为,"去中心化"的网络要比"中心化"的网络更难建设,因为"去中心化"往往没有"中心化"所带来的商业激励,所以没有人真正推动它。

-- 《为什么没有大公司推动 P2P 文件分享》

5、

一家印度电力公司的高管告诉我,印度面临的最大问题将是缺电,电力需求以历史上从未有过的速度增长。

因为印度的夏天太热了,而且变得越来越热,印度的中产阶级也越来越多。印度家庭有钱以后,第一件要买的东西不是汽车、电脑或平板电视,而是一台空调。

-- 《如何提早发现宏观趋势》

历史上的本周

2020年(第 126 期):内卷化是什么?

2019年(第 74 期):信息的商业模式为什么不是收费

2018年(第 23 期):统计学上的人生最大决定因素

订阅

这个周刊每周五发布,同步更新在阮一峰的网络日志微信公众号

微信搜索"阮一峰的网络日志"或者扫描二维码,即可订阅。

(完)

文档信息

  • 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证
  • 发表日期: 2021年9月24日

俄罗斯总理的几何题

September 22nd 2021 at 00:16

9月1日是俄罗斯的知识节,因为这一天是各级学校的开学日,象征进入知识宝库的日子。

今年的知识节,俄罗斯总理米哈伊尔·米舒斯京(Mikhail Mishustin)来到莫斯科物理技术学院(MIPT)视察。该校是俄罗斯顶级的理工科大学,出过许多诺贝尔奖得主和著名科学家。

米舒斯京总理来到新生的教室,给学生们讲话。他看到黑板上写满了数学公式,一时兴起,就上前写了一道数学题,要求大家当场解答。

根据维基百科,米舒斯京生于1966年,大学专业是系统工程,主修计算机辅助设计。工作以后,还读过一个经济学博士。在担任总理之前,他是俄罗斯联邦税务局长。

他出的是一道几何题,题目如下。

给定圆上的一个点和一条直径,你能否找到一种方法,画出从该点到直径的垂直线(下图的绿线)。

这道题的难点在于,你不能使用任何测量工具,唯一可以用的就是一把不带有标记的直尺。

大家可以思考一下,这道题怎么解。

虽然它用不到高深的数学知识,初中的几何学课程就可以解答,但也不算容易。你必须知道两个基本的几何定理,才能想出答案。

第一个是泰勒斯定理,欧几里得《几何原本》提到过: 圆上任意一点与直径组成的三角形,是直角三角形。

第二个定理是: 锐角三角形的三条高交于一点。

如果你已经忘了这两个定理,可以再去看一下初中几何课本,这里就不给出证明了。

下面我根据一个数学家写给英国《卫报》的文章,介绍如何解答这道题。

如果你还想再思考一下,自己找到答案,那就暂时不要往下看了。我要讲答案了。

第一步,在相同的半圆上,任意再找一个点。将这两个点,与直径的相邻端点连起来,连线延长后可以形成一个三角形。

上图中,圆周上的两个点与直径组成的,都是直角三角形。它们可以看作直径的两个端点到绿边的两条高。

第二步,上一步的两条高产生了一个交点,将这个交点与三角形的外部顶点连起来,延长后与直径相交。

根据三角形的三条高交于一点,可以知道,上图的绿线是直径的一条垂直线。后面只需要找到它的平行线,穿过红点即可。

第三步,上一步的绿线与圆周有一个交点,将这个交点与红点连起来,延长后与直径相交(上图的第一个绿点)。

同时,将上一步的垂直线延长,与另一侧的圆周相交,产生一个交点(上图的第二个绿点)。

第四步,将上一步的两个绿点连起来,这条线会与圆周产生一个交点(上图的绿点)。

再将绿点与红点连起来(上图的黄线),这就是我们所要寻找的答案:红点到直径的垂直线。

这是因为上图的两条绿边与第二步的垂直线,形成了一个等腰三角形,原始的那条直径就是等腰三角形顶点到底边的高。这意味着,红点与绿点是对称的,它们的连线平行于底边,所以垂直于高(直径)。

至此,整道题解答完毕。

米舒斯京总理在黑板上画完解题过程后,对学生们说:

"你们会在大学里面,学到数学、物理、化学知识,但是不要忘了那些基础知识。基础知识与专业知识结合起来,你就能解决任何问题,不仅是科学问题,也包括商业问题。"

(完)

文档信息

  • 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证
  • 发表日期: 2021年9月22日

科技爱好者周刊(第 176 期):中国法院承认 GPL 吗?

September 17th 2021 at 00:34

这里记录每周值得分享的科技内容,周五发布。

本杂志开源(GitHub: ruanyf/weekly),欢迎提交 issue,投稿或推荐科技内容。

周刊讨论区的帖子《谁在招人?》,提供大量程序员就业信息,欢迎访问或发布工作/实习岗位。

封面图

2021 世界机器人大会在北京举行,一家公司展出了"爱因斯坦"人形机器人,使用中文向学生"授课"。(via

本周话题:中国法院承认 GPL 吗?

开源软件通常带有许可证。大家一直很想知道,如果国内用户不遵守许可证,可以去法院告他吗?

上周有一条新闻,深圳市中级人民法院一审判决,被告违反了 GPL 许可证,赔偿侵权费50万元。

很多程序员很兴奋,认为这代表 GPL 许可证得到中国法院的认可。

我仔细读了判决书,觉得跟大家想的不一样,这个案件很复杂,得不出法院认可 GPL 的结论。

我简单说一下案情。

济宁市罗盒网络科技有限公司(以下简称"罗盒科技")在 GitHub 开源了软件 VirtualApp,使用了 GPL 许可证。但是,他同时又声明,不得用于商业用途,除非购买商业授权。

大家觉得,这个声明有问题吗? 可以既采用 GPL 许可证,又不许用于商业用途吗?

回答是,这个声明有很大问题。GPL 许可证允许商用,只要你用了这个许可证,别人就可以把你的代码用于商业用途。

[开源小知识]

如果想禁止商用,就不能使用 Copyleft 许可证(比如 GPL),而是要添加一个自己的许可声明,比如"源码只供个人学习,不得商用,除非购买商业许可证"。

因此,罗盒科技一开始的许可证选择,就有问题,后来果然出了问题。

有一个叫做"点心桌面"的商业 App,使用了 VirtualApp。罗盒科技就控告开发商福建风灵公司侵权,要求赔偿2000万元。

罗盒科技起诉的理由,不是对方违反 GPL,没有开源"点心桌面",而是对方没有付款,就将代码用于商业用途。

所以,准确的说,这个案件跟 GPL 许可证没有直接关系,只跟风灵公司没有获得商业授权有关。法院最后判决被告败诉,也是这个原因。

但是前面说了,GPL 许可证允许商业用途,风灵公司就抓住了这一点,为自己辩护,宣称自己使用的是项目的 GPL 版本,不是商业版本,因此可以商用。

他们没想到的是,GPL 许可证的第八条明确写明,如果使用者不开源自己的代码,GPL 授权失效。

第八条 传播或修改代码时,如果不遵守规定的条件,授权自动终止。

法院因此认为,风灵公司不符合条件,不能使用 GPL 为自己的商业行为辩护。所以,这个案件不是法院认可了 GPL,而是法院认为被告不适用 GPL 许可证。

假如风灵公司遵守了 GPL,公开了"点心桌面"的源码,那还构成侵权吗?我的个人看法是,那样就不侵权。

总之,GPL 在国内维权还是很困难。它是按照美国法律写的,所有中文版都是网友翻译的,有没有准确表达含义都很难说。 中国法院就算认可 GPL,也是作为代码作者与用户之间的合同看待。

这意味着两点。

(1)只有代码作者才能提起侵权诉讼,其他人没法控告违反 GPL 的行为。

(2)就算胜诉,最多就是对方软件下架或者赔偿损失,很难强制开源代码,因为中国合同法没有这种先例,而且法律允许对方拒绝继续履行合同。

使用 Node.js 开发一个 CLI 工具

Node.js 诞生已经超过10年了。因为它,前端开发出现了翻天覆地的变化。

直到今天,你要学习前端的主流技术,首先就要掌握 Node.js,因为所有开发都要在它下面完成。

很多同学都想知道,怎么才能掌握 Node.js?

对于新人来说,其实只要掌握两个知识点,就能上手了:一个是 CommonJS 模块格式,另一个就是命令行环境 (command line,简写为 CLI)。其余部分就是普通的 JavaScript 语法。

上面两个知识点之中,CommonJS 比较简单,命令行有点难,因为内容多,需要了解操作系统,以及 Node.js 提供的各种系统接口,最常用的就是文件系统接口。

这一周介绍的课程,就是 《教你使用 Node.js 开发一个 CLI 工具》,教大家快速入门命令行开发。

这是一门视频课,手把手带你从零开始写一个 JS 脚本,把它做成命令行工具,并发布到 NPM 上面,可以给其它人下载使用。

它不同于那些直接讲述技术点的课程,不是生硬的文档解读,而是从具体的问题出发,带出知识点,立足于教会学习者,解决现实场景中的实际问题,属于"实务导向"。

课程的主讲老师崔效瑞,有丰富的前端开发经验,是 Vue 3 的代码贡献者,也是 Element3 技术负责人、mini-vue 作者。

微信扫描上方二维码报名,就可以领取这门视频课,并了解课程的详细情况。课程推广期间,不收取任何费用,保证没有套路。

科技动态

1、二氧化碳捕捉工厂

世界目前最大的二氧化碳捕捉工厂,最近在冰岛建成了。它通过风扇吸入空气,过滤出二氧化碳,然后再将二氧化碳液化,注入地下1,000米深处的矿洞存储。

二氧化碳的过度排放,是气候变暖的主要原因,目前没有好的方法吸收二氧化碳。该工厂每年只能吸收4000吨二氧化碳,只相当于800多辆汽车的排放量,而全世界2015年的二氧化碳排放量是360亿吨。(@emac 投稿)

2、搞笑诺贝尔奖

哈佛大学每年评选(似乎)无意义的研究,颁给搞笑诺贝尔奖。今年,日本学者(上图)获得了其中的动力学奖,他通过马路实验(下图)得到结论:

"如果两波人相向而行,其中有人边走边看手机,将引发人群的冲撞。"

这已经是日本学者连续15年获得搞笑诺贝尔奖了。日本网民也在议论,日本的学者是不是太多了,找不到有意义的研究题目了?

3、违规跑鞋

9月12日的维也纳马拉松赛上,埃塞俄比亚选手以2小时09分22秒的成绩获得冠军,但是随后就被取消成绩,理由是他的跑鞋违反了最新规定。

按照规定,跑鞋的鞋底厚度不能超过40毫米,但是该选手的鞋底厚达50毫米,多出整整一厘米。更厚的鞋底可以提供更好的弹力,帮助选手节省体力。

4、游戏厅的衰落

日本的电子游戏厅曾经盛极一时,各种街机玲琅满目,但是,现在已经盛况不再。根据警方的统计,1986年日本全国曾经有 26,573 家游戏厅,到了2019年只剩下 4,022 家,疫情爆发以后,数目更是继续锐减,可能不到最高峰时的十分之一。

互联网和手机游戏的兴起,是游戏厅衰落的主要原因。目前,游戏厅的最大顾客来源是传统游戏爱好者,以及老年人。一位65岁的老人说,他把游乐厅当作社交场所,可以结交很多朋友。

一些人认为,游戏厅最终会消失。另一些人则认为,游戏厅值得保留下来,街机有其独特的乐趣。南梦宫、Square Enix、世嘉等街机公司,呼吁日本政府采取措施,促进街机行业的发展。

5、漂浮式光伏电站

山东德州最近建成了,世界最大的单体水上漂浮式光伏电站。在水库的水面上,漂浮着面积2200亩的太阳能光伏板,设计总容量为320兆瓦,第一期建成了200兆瓦。

漂浮式光伏电站的好处是,对水体没有破坏性,比较环保,节省地基成本,施工周期短,还可以减少水库的水量蒸发、遮挡阳光抑制藻类生长。另一方面,光伏板放在水里,可以有效降温,显著提高发电效率。

6、果蝇独居的后果

科学家已经知道,果蝇是一种社会化动物,所以常常用它做实验,研究人类社会。

《自然》杂志最近报道,科学家将果蝇单独隔离,与群居的果蝇进行对比。结果发现,独居的果蝇有更少的睡眠、更多的进食。

这个实验如果可以引申到人类,就表示长期独居对健康不利,群居、与他人的日常互动对健康更有利。

文章

1、WebOS 死亡十周年纪念(中文)

多年前,Palm 公司曾经发布过一个手机系统 WebOS,有很多令人难忘的特性,非常易用。2011年8月11日,该系统被宣布放弃。本文回顾了这个系统。

2、消灭后视镜(中文)

越来越多的小汽车使用摄像头取代后视镜,有的甚至连突出的门把手都隐藏了,本文讨论这样做的原因。

3、如何免费搭建一个 VS Code 网页版(中文)

VS Code 编辑器有网页版,本文教你怎么把它部署在免费服务器上,可以用来写笔记,或者偶尔远程编辑代码。(@DoctorLai 投稿)

4、你所不知道的 ASML(英文)

全世界最先进的芯片工厂,都依赖一家公司生产的 EUV(极紫外光刻)芯片制造机,这家公司就是荷兰的 ASML(阿斯麦)公司。它一年生产50台 EUV,每台售价1.5亿美元起。

5、12 张视觉错觉图片(英文)

本文收集了 12 张著名的视觉错误图片。比如,上面这两张图片,看上去是不同角度拍摄的同一条街道,但是实际上,它们是同一张图片并排放在一起。

6、绝对值的计算并不简单(英文)

绝对值的算法似乎是一个微不足道的问题:判断一个数是否为负数,如果是的,就去掉它的负号。如果你采用这样的算法,那你就错了。

7、React usePrevious 钩子(英文)

本文介绍如何使用 useEffect 和 useRef 的官方钩子,写一个自己的 usePrevious 钩子,返回组件上一次渲染时的 props 参数。

8、浏览器的"包导入地图"(英文)

Chrome 89 开始支持 ES 模块的"包导入地图"功能(package import maps),可以为导入模块指定别名。

工具

1、Ventoy

一个 U 盘的启动盘制作工具,直接把系统镜像文件复制到 U 盘即可。它支持电脑启动时,从多个系统镜像文件里面选择一个启动。

2、WebDen

专供手机浏览器使用的网页代码编辑环境,提供 HTML、CSS、JS 的开发和调试。

3、View Source

一个 Web 小工具,输入网址,就可以高亮显示该网址的源码,非常好用。

4、fishdraw

一个 JS 脚本,可以自动生成鱼的图形。这里还有一个可以自动画树的库。

5、MDvideo

一个桌面软件,自动将 Markdown 文档转成一段视频。文档里面的视频、音频、图片网址,都会抓取后插入视频,还可以根据文字生成人工语音的旁白朗读。

它非常适合快速生成产品的介绍视频。(@linqian02 投稿)

6、group-by-repo-on-github

油猴脚本,用来处理 GitHub 的网页搜索结果,可以根据仓库分类,并自动获取分页结果,即不用点击"下一页"了。(@foamzou 投稿)

7、iDraw.js

一个简单的 JS 库,用来使用脚本进行网页绘图。它基于 Canvas,可以绘制文字、矩形、圆形、图片、HTML 片段和 SVG 文件。(@chenshenhai 投稿)

8、PikaScript

这个项目用来为单片机提供 Python 支持,让你可以通过 Python 脚本操作单片机。(@pikasTech 投稿)

9、mini-stores

小程序状态管理库,方便管理页面状态,支持各公司的小程序。(@linjc 投稿)

10、Neumorphism

一个网页工具,用来生成照明阴影效果的 Flutter 代码。这里还有 CSS 版。(@xrr2016 投稿)

资源

1、Samantha Ming 个人网站

Samantha Ming 是一名加拿大前端工程师,她在个人网站上分享了很多前端代码的技巧,内容质量很高,制作精美。(@wxyudl 投稿)

2、糖尿病教育网站

加州大学旧金山分校主办的网站,全部使用中文,提供糖尿病、健康、运动、饮食知识,内容很不错。(@Stupid-Human 投稿)

3、程序员的数学导论

一本英文的数学教材,专门写给没有数学基础的程序员,主要介绍微积分和线性代数,并且结合编程实例(比如加密和神经网络)进行讲解。全书可以0元购买。

4、 SQL Bolt

一个交互式的英文在线教程,一共19课,从零开始讲授 SQL 知识,以及如何查询数据库。

5、国际数据管理手册

该网站提供全世界各国的各种数据格式,比如地址、电话、邮政编码、货币、车牌等的格式。

图片

1、手机学习网页开发

一个尼泊尔老师在网上贴出照片,他的学生没有笔记本电脑,只能通过手机架设开发环境,学习网页开发。

经过两个月,这个学生已经初步掌握 HTML、CSS 和 JS。

2、世界上最长的钢琴

钢琴的音质与琴弦的长度有关。琴弦越长,振动越慢,就能发出越低频的声音,所以高级的三角钢琴,体积都很大,长度都不短于2.3米。

一个新西兰青年阿德里安不禁突发奇想,如果制造一个特别长的钢琴,不就可以发出非常低的音频吗?

他决定自己来造一个,从图书馆找到资料,在钢琴师傅的指导下,开始动手建造。

最后,他造出了世界上最长的钢琴,长度接近10米。

他说,本想造得更长,但是他家的车库放不下了。

果然,这架钢琴的音质相当好,吸引了很多专业音乐家专程来演奏,还灌制了唱片。

文摘

1、乔布斯的白板事件

摘自沃尔特·艾萨克森的《乔布斯传》。

1985年,30岁的乔布斯被赶出了苹果公司。

他随即创立了 NeXT 公司,继续开发符合他理想的下一代个人电脑。

1986年,一家叫做 Pixar 的图形硬件公司找到了乔布斯。那家公司濒临破产,急需资金。乔布斯经过思考,同意给钱,成为这家公司的最大投资者。

最初,这只是一笔单纯的风险投资。但是后来几年,Pixar 还是不断亏损,乔布斯不得不追加投资,最后索性成了公司的董事长,直接介入公司管理。

那时,乔布斯每周有一两天去 Pixar 公司办公,剩下的时间就去 NeXT 公司办公。

进入九十年代以后,NeXT 公司和 Pixar 公司的业务都不见起色,看上去两家公司都会失败。这是乔布斯一生中的最低潮,他的压力很大。

有一天,Pixar 公司开会,乔布斯对 Pixar 创始人史密斯和其他高管破口大骂。因为他们一直拖延,没有按时拿出下一代图像电脑的电路设计。

那个时候,NeXT 的新产品也没有按时完成。史密斯就反唇相讥:"嘿,你的 NeXT 电路板做完了吗?不要光盯着我们。"

乔布斯听了勃然大怒。史密斯后来回忆:"他气到失去理智。" 史密斯说话有西南部口音,乔布斯就学他的口音,冷嘲热讽。史密斯说:"他欺人太甚,太过分了。我也气炸了,什么话都骂出口。于是我们几乎贴着对方的面,大约只隔一个巴掌宽,互相对骂。"

乔布斯开会时,一定要把白板据为己有,不轻易让别人使用。史密斯利用自己的大块头,一把推开他,迳自走到白板前,边写边解释。乔布斯吼道:"你给我住手!"

"咦?"史密斯回击:"白板是你的吗?我不能写吗?简直是胡扯。"乔布斯气得当场离席。

这场会议以后不久,史密斯就辞职离开了 Pixar,自己创业开了一件软件公司,专攻电脑绘图和影像编辑。

他很幸运,微软后来买下了他的公司。他可能是历史上唯一一位,创立了两家公司,一家卖给了乔布斯,另一家卖给了比尔·盖茨。

言论

1、

如果你走得足够远,其实就再也没办法回去了。当你回到故乡,原来的一切都已经消失了。

不过,这也不算什么,归根结底,旅程才是你的故事中最重要的部分。

-- 《如果有无尽的推动力,宇宙飞船可以飞多远?》

2、

二战开始后,德国轰炸伦敦。伦敦市区每天晚上都有炸弹落下,郊区的炸弹比较少,可能每周一次。

战后的调查发现,在这段时期,市区居民的胃溃疡发病率显著增加,奇怪的是,郊区居民的胃溃疡发病率,增加得比市区还要大得多。这说明压力的不确定性比压力本身伤害更大。

--《学会减少不确定性》

3、

Dropbox 这个软件给人的感觉是,很早就达到了顶峰,然后随着开发团队尝试添加越来越多的功能,而逐渐变得糟糕。

-- Hacker News 读者

4、

赚到一百万美元以后,再往前走的动力都来自你的热情。

-- Joel Spolsky

5、

幸福的秘诀就是永远抱有低期望值。(the secret to happiness is low expectations.)

-- Hacker News 读者

历史上的本周

2020年(第 125 期):数字人民币要取代谁

2019年(第 73 期):数据统计的威力

2018年(第 22 期):猴子自拍,版权归谁

订阅

这个周刊每周五发布,同步更新在阮一峰的网络日志微信公众号

微信搜索"阮一峰的网络日志"或者扫描二维码,即可订阅。

(完)

文档信息

  • 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证
  • 发表日期: 2021年9月17日

Android系统推送Hook实战

July 18th 2021 at 08:23

背景

随着数据生产功能的逐渐稳定,工作重点开始从保证数据总量转移到了保证数据实效性上来了。传统的定时爬虫能比较轻松的把数据总量做起来,但是对于很多热点数据,却很难做到实时获取。

考虑到大部分产品、尤其是新闻资讯类的产品,都会对热点数据做推送拉活,如果能拦截到这些数据,那么我们就能应当将数据实效性提升一个档次。

这次我们就主要尝试拦截下小米手机的系统通道的推送数据。当然,对于各个 App 自身的通道,也可以用类似的方法做到拦截,不过那就需要对各个 App 再做好保活。

推送流程

以小米官方的文档为例,整体推送流程大致分四步:

  1. 应用客户端在启动时向 MiPush SDK 中注册当前设备,并获得对应的唯一标识 regId。
  2. 应用服务端告诉小米统一推送服务,他需要向某个指定账号、指定类型、或指定设备推送消息。
  3. 小米统一的服务端通过与手机上的 MiPush SDK 的长连接,向手机推送数据,并展示在通知栏中。
  4. 应用客户端实现一个自定义 PushMessageReceiver 做好数据解析的准备,等用户点击通知栏后就可以将数据传过去。

牵涉到的大概有五方的代码:

  1. 手机内部的MIUI框架部分代码。
  2. 应用客户端代码。
  3. 应用客户端引用的小米的 MiPush SDK代码。
  4. 小米 PushServer 的代码。
  5. 应用服务端的代码。

在这里当中,我们其实只需要关心 1、3 两部分的代码即可。

获取思路

简单翻阅了网络上已知的策略,获得通知栏的推送数据一般有如下思路:

  1. 通过 Appium 等自动化测试工具,直接获取通知栏的元素中的消息文本。
  2. 自己写一个 App ,实现 NotificationListenerService 方法,监听所有过来的消息。
  3. 通过 Frida 等 hook 工具,直接拦截 MiPush 推过来的原始数据,自行做解析和解密。

虽然我并没有做尝试,但是前两种方式似乎最多只能获得推送的标题和文本等基本信息,无法获得更详细的原始信息(尤其是跳转 scheme 、参数列表等)。因此我们主要尝试第三种思路。

动手实践

下面就以手头的 Redmi6 设备进行尝试。

定位应用

既然我们希望获取通知栏的数据,那我们首先应当定位到通知栏的相关代码。拉出通知栏,dumpsys一下:

$ adb shell dumpsys activity top|grep ACTIVITY
ACTIVITY com.android.systemui/.recents.RecentsActivity c2efb6 pid=30467
ACTIVITY com.ss.android.ugc.aweme/.splash.SplashActivity f9d8beb pid=7270
ACTIVITY com.miui.home/.launcher.Launcher 119c6 pid=1950

看起来 com.android.systemui 和 com.miui.home 这两个包比较可疑,我们再用 Appium check 一下。

这样就肯定是 com.android.systemui 没跑了。

获取代码

定位到包名,那就去手机上把包搞下来就行:

$ adb shell pm list packages -f |grep com.android.systemui
package:/vendor/overlay/SysuiDarkTheme/SysuiDarkThemeOverlay.apk=com.android.systemui.theme.dark
package:/system/priv-app/MiuiSystemUI/MiuiSystemUI.apk=com.android.systemui
 
$ adb pull /system/priv-app/MiuiSystemUI/MiuiSystemUI.apk
/system/priv-app/MiuiSystemUI/MiuiSystemUI.apk: 1 file pulled, 0 skipped. 10.5 MB/s (4901017 bytes in 0.446s)
 
$ ls -la MiuiSystemUI.apk
-rw-r--r--  1 myths  staff  4901017 Jun 29 15:42 MiuiSystemUI.apk

然后拿 jadx 打开看看:

发现不对劲,怎么一行代码也没有,都是些没用的资源文件。。。在尝试了诸如 jeb、androidkiller、apktool等反编译软件均失败后,我陷入了沉思。

沉思过后,决定换一部更高级的 Redmi9 手机试试,结果发现 Redmi9 手机的 MiuiSystemUI.apk 竟然是有代码的,估计是 Redmi6 用了什么古老的科技把代码隐藏掉或者下沉了啥的,但是代码一定是有的,否则哪来的 com.android.systemui/.recents.RecentsActivity 这个 activity。

于是我暴力 grep 了 /system 目录下的文件,果然给我找到了线索:

$ adb shell su -c "grep RecentsActivity -r /system"
grep: /system/framework/miuisdk.jar: No such file or directory
grep: /system/framework/miuisystem.jar: No such file or directory
Binary file /system/framework/oat/arm/services.vdex matches
Binary file /system/priv-app/MiuiSystemUI/oat/arm/MiuiSystemUI.vdex matches

发现了一个奇怪的 MiuiSystemUI.vdex 文件,简单查了下,应当是为了优化系统 OTA 时 dex2oat 过程的类似预编译的东西,本质还是代码片段。

于是用 vdexExtractor 转成 MiuiSystemUI.dex  :

$ ./vdexExtractor -i  ../../Jadx/MiuiSystemUI.vdex  -o output
[INFO] Processing 1 file(s) from ../../Jadx/MiuiSystemUI.vdex
[INFO] 1 out of 1 Vdex files have been processed
[INFO] 1 Dex files have been extracted in total
[INFO] Extracted Dex files are available in 'output'

再用 jadx 打开这个 MiuiSystemUI.dex :

这下就舒服了。

代码结构

对源码经过简单阅读,再辅以 objection 和 frida 的 Java.choose() 方法动态调试后发现存储通知栏消息的地方主要是在 com.android.systemui.statusbar.phone.NotificationGroupManager 。这个类在系统中似乎还是个单例。

找到这个类之后,四下观察就可以整理出大致如下的类图:

这里每个 NotificationData.Entry 就是每一条推送记录,不断深入看下去就会发现真正的数据还是指向了 StatusBarNotification 这个类。而这个类的源码只能去对应的 Android SDK 里去看了。

于是打开了对应版本(api 27)的SDK,继续整理了一下核心数据涉及到的类:

数据解析

跟踪上面涉及到的相关对象,根据所需要的数据做相关解析和拼接即可:

Java.perform(function () {

    let MurmurHash3 = {
        mul32: function (m, n) {
            let nlo = n & 0xffff;
            let nhi = n - nlo;
            return ((nhi * m | 0) + (nlo * m | 0)) | 0;
        },
        hashString: function (data, len, seed) {
            let c1 = 0xcc9e2d51, c2 = 0x1b873593;

            let h1 = seed;
            let k1 = '';
            let roundedEnd = len & ~0x1;

            for (let i = 0; i < roundedEnd; i += 2) {
                let k1 = data.charCodeAt(i) | (data.charCodeAt(i + 1) << 16);

                k1 = this.mul32(k1, c1);
                k1 = ((k1 & 0x1ffff) << 15) | (k1 >>> 17);
                k1 = this.mul32(k1, c2);

                h1 ^= k1;
                h1 = ((h1 & 0x7ffff) << 13) | (h1 >>> 19);
                h1 = (h1 * 5 + 0xe6546b64) | 0;
            }

            if ((len % 2) === 1) {
                k1 = data.charCodeAt(roundedEnd);
                k1 = this.mul32(k1, c1);
                k1 = ((k1 & 0x1ffff) << 15) | (k1 >>> 17);
                k1 = this.mul32(k1, c2);
                h1 ^= k1;
            }

            h1 ^= (len << 1);

            h1 ^= h1 >>> 16;
            h1 = this.mul32(h1, 0x85ebca6b);
            h1 ^= h1 >>> 13;
            h1 = this.mul32(h1, 0xc2b2ae35);
            h1 ^= h1 >>> 16;

            return h1;
        }
    };

    let NotificationGroup = Java.use('com.android.systemui.statusbar.phone.NotificationGroupManager$NotificationGroup')
    let Entry = Java.use('com.android.systemui.statusbar.NotificationData$Entry')
    let Base64 = Java.use('android.util.Base64');

    function processInfo(statusbar, folded) {
        let mmap = statusbar.notification.value.extras.value.mMap.value;

        if (statusbar.notification.value.contentIntent.value) {
            let intent = statusbar.notification.value.contentIntent.value.getIntent();
            if (intent.mComponent.value) {
                let txt = mmap.get("android.text").toString()
                let id = MurmurHash3.hashString(txt, txt.length, 0).toString().replace("-", "")
                let msg = {
                    'type': 'mi_push',
                    'data': {
                        "folded": folded,
                        "id": id,
                        "intent": intent.getDataString() === null ? "" : intent.getDataString(),
                        "title": mmap.get("android.title").toString(),
                        "text": mmap.get("android.text").toString(),
                        "package": intent.mComponent.value.mPackage.value,
                        "class": intent.mComponent.value.mClass.value,
                        "time": statusbar.postTime.value
                    },
                    'id': id
                };
                let bundle = intent.getExtras()
                if (bundle) {
                    let miPushByte = bundle.get("mipush_payload")
                    if (miPushByte) {
                        let arr = Java.array('byte', miPushByte)
                        msg['data']['mipush'] = Base64.encodeToString(arr, 0).replaceAll("\n", "");
                    }

                }
                send(JSON.stringify(msg));
            } else {
                console.log("contentIntent is null " + mmap)
            }
        } else {
            console.log("intent.mComponent.value not exist " + mmap)
        }
    }


    function processManager(manager) {
        try {
            let entrys = manager.mGroupMap.value.keySet()
            let it = entrys.iterator();
            while (it.hasNext()) {
                let key = it.next();
                let data = manager.mGroupMap.value.get(key)
                let nData = Java.cast(data, NotificationGroup)
                let unfoldIterator = nData.unfoldChildren.value.iterator()
                let foldIterator = nData.foldChildren.value.iterator()

                while (unfoldIterator.hasNext()) {
                    let entry = Java.cast(unfoldIterator.next(), Entry);
                    let row = entry.row.value
                    let statusbar = row.mExpandedNotification.value
                    processInfo(statusbar, false)

                }
                while (foldIterator.hasNext()) {
                    let entry = Java.cast(foldIterator.next(), Entry);
                    let row = entry.row.value
                    let statusbar = row.mExpandedNotification.value
                    processInfo(statusbar, true)
                }
            }
        } catch (e) {
            console.log(e.stack)
        }
    }
    Java.choose("com.android.systemui.statusbar.phone.NotificationGroupManager", {
        onMatch: function (instance) {
            processManager(instance)
        },
        onComplete: function () {
        }
    });
});

需要注意的是,这里的代码和 Android 版本、MIUI 版本都有关,不同种的设备之间大概率是不能兼容的。在我的手机上,这样解析出来数据如下:

{
    "package": "com.smile.gifmaker",
    "mipush": "CAABAAAABQIAAgECAAMACwAEAAAEsOp4pGZjhmJnTz3lGB/Ta7aS5iS5UIKGF23UT2AvpzdmIbLJu0h9JH799FoT7VEdFq9vzPWuiLybhqJ2tcBm5tHXJ5Ff/eSje9jFUm799PQKg7uQEE3ieH/Bu5nglvYwk0+ku7bWfp3MjB8ukRrr5XgIUqLSVEyi4wICeBkHSWwVzIcfk63TH6z8r9wemQq8fZFPqq817rR4zED6MUQu83frrtYY/4i6kSazw2zKkhoR+T0j7/7ed4r/si3RwUBCsO9d73Ehdmrjg7odKxHP9q6yUFam4JihZYN2DzG3KsajgwZg+CmXCOQFanmpyYgKhUc+psE1Kl5bikWY9KqLYo1JD/Boesq4d82Yfub61xGoWaeli/SJwG8JMtsH5FmC7mxDwluhutoDEuRlX3HodtU1GlEaz2SBabRIOcFLYv6SjOrBN1o2xE+lvpHv1o0Reh4GG0mlnmrV7a236XHwMmSOdY4Akr4WR3eQz+NTNaqLwUjvQIb1GwWXauNWMAM4nroK38KAfBORVDLAZSoMLambNM+JsBZKRcmIPreuupVaYlS2hnlEr2qaGsfpPZK8bJs7PgXU6zscMBmVMjy/nL6D5+uUwVEkYbzoPdMCbsHPxxKcFeiR/AYJe7G1QK5KQMTtVY/jnJVRNG8T3H5fC52CO4xOFKRrpdNv30C/NvtBOIokBCqXXXf2Z4gWroq17Tc385/WhxhvcvazXoI8rjaF/ZHh7KGUN24VuN07tnjKvs74h3Z1JJi82WRuzxkSsFDQJNwIX3/Qk6U5mgHGwZJTtdB8IijTUn5GZG6C/c8CNYN36iy7ToIDCiNbIWO40GwDrmIHiaK3R6a0de6jCr917C1AfT1JFlNr9GeztWq/4tLH3F88SGWIhj00XFWcBWVa2EB7Du3yjFD0S18/HoCIi5j5l/HsvvMo0BpdWd0hTRQ89NUK409QiblaVjJ8vCtz3PrNLAiCROJPzA5GcD5x6F7xUi05YsBE+xX59kqf7Wzrs6IXb9frgllwWkS2aKcHNmZYT6TTE6QNII4rYOHlLhIB6bIq6iktbMY63TRAQi8a/1sBPuoMJ3tndbBZmumsV+emgbY5p4xv7YnA9AYK001Q+o2CfyfUjCRlhWAGZ5ZOe0Z/o6jSQegq39ib3KbPyE6ZxjEtNYMt/pLdIKyh86ixiv0rRqI2meLilcekGrFKmI46JK8YbYuVzYr5VIJdDEnvawnxjmw5qYVo6AE/uppaX5p8t+sBAaofqbYmpW6dIFEBR5vmE/ahZZolCgD1dwxNFAkTZf8hoBfG9/cZI3zaqEOfTghxw9FkVyauJwY4Qe5E4VuQxZfHEy4HVKhhIi4zkRLDpPJBUiovGZFaLMR3s2EnHppq2xrnA12FVeBuUjDJQpRER+E8DFErvk8MfjT4CoL05djf5BWPHfCdCvnGFg49Up1LbidPpycnlnTr4f/Nu87Ed0FmL6/81knkj3rT2fPdINpweHWZok9o55lHojNHANHae64FBQrwC/057zoKI3Oo5GBU85qcnCxvGKpzDZbVVF9Y/HQIR4DGVdIC91i6EiYGYdD1VsQVDwRu6P77ixubXjV+eq56KQsABQAAABMyODgyMzAzNzYxNTE3MTMwNTM0CwAGAAAAEmNvbS5zbWlsZS5naWZtYWtlcgwABwoAAQAAAAAAAAAFCwACAAAACzcwMDg0ODc2NDA2CwADAAAACnhpYW9taS5jb20LAAQAAAAIYW5TUVJTRUgIAAYAAAAACwAIAAAAAkM0AAwACAsAAQAAABZzY201OTI1NDYyNDkzMjg4MDk2MmpTCgACAAABelWMEkILAAQAAAAG5b+r5omLCwAFAAAAq+S9oOeci+i/h+eahOWon+WnkO+8iOaJi+acuuaRhOW9se+8ieWPkeW4g+S6huaWsOS9nOWTge+8muWkj+WkqeeahOiaiuWtkOWlveWkmuWViu+9nuWkquiuqOWOjOS6hu+8jOaVtOS4quWkp+iaiummmeaKiuS7luS7rOeGj+aZleWQp++8gSAj5omL5py65pGE5b2xICPliJvkvZzngbXmhJ8gI+WIm+aEjwgABgAAAAAIAAgAAAAACAAJD5/Mww0ACgsLAAAACQAAAA5uX3N0YXRzX2V4cG9zZQAAAIB2SktrUzArUHo2QzVXRXN5MjhtWnlvSE4zZnFRMGorYXhLMVhWWFovK3h0NUFDQTcybmRjSmcxdWh4clhMdStSblI0ODFCc0hwZDNoeWZPVWQ4dHdqbVVJM0lBbDJ3V3J3d3owSEQ0b2xvNXlHQUM5NStZaDdYMU5WT29VbUtTeAAAABFub3RpZnlfZm9yZWdyb3VuZAAAAAExAAAADmNhbGxiYWNrLnBhcmFtAAAAMDE0M0pyQnQ0R2JKVzAwNjM3KjFFTU5QV2pjZHA1dW5zZXQ4MncwTVdoNlJlZG1pNgAAAA1fX3RhcmdldF9uYW1lAAAAQE5UTWpMRk1ZNnRQWkpONFcrdytvZndvTUZOOWxhZDNublFUOWJPUEEyeUNJSDlrSml2bWM5V1Jmb0R0ZWRXOWoAAAAFZmVfdHMAAAANMTYyNDkzMjg4MDk2MgAAAA1jYWxsYmFjay50eXBlAAAAAjE3AAAAF25vdGlmaWNhdGlvbl9zdHlsZV90eXBlAAAAATEAAAAIY2FsbGJhY2sAAAA+aHR0cHM6Ly9wdXNoLmtzYXBpc3J2LmNvbS9yZXN0L2luZnJhL3B1c2gvcHJvdmlkZXIvbXQvY2FsbGJhY2sAAAAGX19tX3RzAAAADTE2MjQ5MzI4ODEzNDQNAAsLCwAAAAEAAAAKc2NvcmVfaW5mbwAABIl7InNlcnZlcl9zY29yZSI6MSwiZ3JvdXBfaW50ZXJ2YWwiOjcyMDAwMDAsImV4dHJhX2luZm8iOiJbe1widlwiOjAsXCJiXCI6LTAuOTI4NjcyMzk3NDQxOTE1NSxcIndcIjpbMTQuMDQyMzAwNjYwODQ4MDQyLC0wLjEwNDgyMTA5MTUxOTE1MzA5LDAsMCwwXSxcImVsXCI6W1szMCwwXSxbMCwwXV0sXCJjbFwiOltbMCwwXV0sXCJjZ1wiOltbMCwwXV0sXCJwa2dcIjp7XCJjb20udGVuY2VudC5tbVwiOjksXCJjb20udGVuY2VudC5tb2JpbGVxcVwiOjksXCJjb20uYW5kcm9pZC5tbXNcIjo5LFwiY29tLmFuZHJvaWQuY29udGFjdHNcIjo5LFwiY29tLmFuZHJvaWQucHJvdmlkZXJzLmNvbnRhY3RzXCI6OSxcImNvbS5hbmRyb2lkLmNhbGVuZGFyXCI6OSxcImNvbS5hbmRyb2lkLnByb3ZpZGVycy5jYWxlbmRhclwiOjksXCJjb20uZ29vZ2xlLmFuZHJvaWQuY2FsZW5kYXJcIjo5LFwiY29tLndoYXRzYXBwXCI6OSxcImNvbS5mYWNlYm9vay5vcmNhXCI6OSxcImNvbS5nb29nbGUuYW5kcm9pZC5nbVwiOjksXCJjb20uYW5kcm9pZC5kZXNrY2xvY2tcIjo5LFwiY29tLmFuZHJvaWQucGhvbmVcIjo5LFwiY29tLmFuZHJvaWQuc3RrXCI6OSxcImNvbS5hbmRyb2lkLmNlbGxicm9hZGNhc3RyZWNlaXZlclwiOjksXCJjb20uYW5kcm9pZC5pbmNhbGx1aVwiOjksXCJjb20uYW5kcm9pZC5zZXJ2ZXIudGVsZWNvbVwiOjksXCJjb20uYW5kcm9pZC5lbWFpbFwiOjksXCJjb20ueGlhb21pLmNoYW5uZWxcIjo5LFwiY29tLmFuZHJvaWQudXBkYXRlclwiOjksXCJjb20uYW5kcm9pZC5zZXR0aW5nc1wiOjksXCJjb20ubWl1aS5wbGF5ZXJcIjo5LFwiY29tLm1pdWkuYnVncmVwb3J0XCI6OSxcImNvbS5lZy5hbmRyb2lkLkFsaXBheUdwaG9uZVwiOjksXCJjb20ueGlhb21pLm1hcmtldFwiOjksXCJjb20uYW5kcm9pZC5wcm92aWRlcnMuZG93bmxvYWRzXCI6OSxcImNvbS5hbGliYWJhLmFuZHJvaWQucmltZXQuYWxpZGluZ3RhbGtcIjo5LFwiY29tLmFsaWJhYmEuYW5kcm9pZC5yaW1ldFwiOjksXCJjb20udGVuY2VudC50aW1cIjo5LFwiY29tLm1pdWkuaG9tZVwiOjl9LFwidGhcIjowLjUsXCJuXCI6M31dIiwidGhyZXNob2xkIjowLjY3LCJyYXdfc2NvcmUiOjAuNjQ3Mzc2NTY0OTk5MDQ1NSwic29ydF9kZWxheSI6MTAwMDAsInNlcnZlcl9zdHJhdGVneSI6ImxyIn0CAAwAAAA=",
    "text": "你看过的娟姐(手机摄影)发布了新作品:夏天的蚊子好多啊~太讨厌了,整个大蚊香把他们熏晕吧! #手机摄影 #创作灵感 #创意",
    "title": "快手",
    "folded": false,
    "id": "scm59254624932880962jS",
    "time": "1624932881487",
    "class": "com.xiaomi.mipush.sdk.PushMessageHandler"
}

可以发现,在 intent 的 bundle 里有一个 key 为 "mipush_payload" 的加密数据(我这里展示成了base64),这里存的就是加密后的推送数据详情,我们还需要继续做处理。

数据解密

入口

数据解密的逻辑应当是放在 MiPush SDK 中,我们就只能去小米开发者中心把源码下下来看看。小米这里的处理比较让人蛋疼,一定要注册小米开发者然后才能下载代码。求爹爹告奶奶终于整到了 SDK,下面就以 MiPush_SDK_Client_4_0_2.jar 这个版本来看。

很容易想到的入手点就是 "mipush_payload" 这个字符串,先简单搜索一番试试:

涉及到的代码不多,挺好。考虑到既然我们想要的是解密,那应当是更关心 getByteArrayExtra 方法。进去看一看:

这里对 action 的值有几个分支判断,我们显然是希望 MESSAGE_ARRIVED 这个事件,那解析代码应当就是这个没跑了。

坑点

入口都找到了,剩下就是无聊的人肉跟踪了。不过小米做了字节码级别的混淆,导致在同一个类中有很多类型不同但是名字相同的变量(不符合java语法、但是符合jvm规范)。这样就使 jadx 无法正常反编译出 java 代码,只能对变量名重命名展示。例如上面的 b.m36a(this.f43a) 这个方法,他的函数声明的地方有 jadx 的注释:

为了方便人眼看,这样的操作还是很有帮助的。但是由于在写代码的时候还是需要用真正的变量名,因此在搞清逻辑之后,我们还是只能用 IDEA 去看真正的代码。上面的代码在 IDEA 中就是这样:

不得不说这种代码混淆是真的恶心,在 IDEA 中我还看到了这种槽点十足、普通方法根本无法调用的代码:

上面的一系列操作导致的结果就是我们不得不一律采用反射来调用这些函数。

密钥

在经过一系列斗争后,发现这些数据其实是采用了 AES 对称加密,加解密用的 key 是从名为 mipush 的 sharedpreferences 中的 regSec 中获得的:

//com.xiaomi.push.i
private static Cipher a(byte[] bArr, int i) {
    SecretKeySpec secretKeySpec = new SecretKeySpec(bArr, "AES");
    IvParameterSpec ivParameterSpec = new IvParameterSpec(a);
    Cipher instance = Cipher.getInstance("AES/CBC/PKCS5Padding");
    instance.init(i, secretKeySpec, ivParameterSpec);
    return instance;
}
 
//com.xiaomi.mipush.sdk.b
public static SharedPreferences a(Context context) {
    return context.getSharedPreferences("mipush", 0);
}
 
//com.xiaomi.mipush.sdk.b
public static a a(Context context, String str) {
    try {
        JSONObject jSONObject = new JSONObject(str);
        a aVar = new a(context);
        aVar.f62a = jSONObject.getString("appId");
        aVar.b = jSONObject.getString("appToken");
        aVar.c = jSONObject.getString("regId");
        aVar.d = jSONObject.getString("regSec");//这个!
        aVar.f = jSONObject.getString("devId");
        aVar.e = jSONObject.getString("vName");
        aVar.f63a = jSONObject.getBoolean("valid");
        aVar.f64b = jSONObject.getBoolean("paused");
        aVar.a = jSONObject.getInt("envType");
        aVar.g = jSONObject.getString("regResource");
        return aVar;
    } catch (Throwable th) {
        com.xiaomi.channel.commonutils.logger.b.a(th);
        return null;
    }
}

密钥的位置在对应 app 内部存储目录的 shared_prefs 文件夹下。以 gifmaker 为例:

$ adb shell su -c 'cat /data/data/com.smile.gifmaker/shared_prefs/mipush.xml'
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <string name="devId">951ECBAB1F2EB405ABF912BD65FC703FF43969CE</string>
    <boolean name="valid" value="true" />
    <string name="regResource">oGZD6g</string>
    <string name="vName">8.2.31.17191</string>
    <string name="pref_msg_ids">,sdm580566248810824873c,sdm58782624881295100Zr</string>
    <int name="envType" value="1" />
    <string name="appToken">5431713053534</string>
    <string name="appId">2882303761517130534</string>
    <string name="regId">NTMjLFMY6tPZJN4W+w+ofwoMFN9lad3nnQT9bOPA2yCIH9kJivmc9WRfoDtedW9j</string>
    <string name="regSec">AMjTZmbZx2LS1X7dp0AAAA==</string>
    <string name="appRegion">China</string>
</map>

这里的密钥就是 AMjTZmbZx2LS1X7dp0AAAA== 。

需要注意的是,这个密钥是在 app 初次启动时生成的,不通设备之间不一样,相同设备的不同次安装也不一样。

实现

最后把相关代码拼接下,在服务端实现一个服务即可。代码参考如下:

public class PushMessage implements Serializable {
    private static final long serialVersionUID = 4745289224411613134L;
 
    private String id;
    private Long channelId;
    private String userId;
    private String server;
    private String resource;
    private String appId;
    private String packageName;
    private String payload;
    private Long createAt;
    private Long ttl;
    private Boolean isOnline;
    private Boolean needsAck;
    
    //some getter and setter
}
 
public PushMessage decrypt(String secKey, String payload) {
    try {
        byte[] data = Base64.getDecoder().decode(payload);
        byte[] key = Base64.getDecoder().decode(secKey);
 
        im im = new im();
 
        ja.a(im, data);
 
        byte[] buffer = null;
        for (Method declaredMethod : im.getClass().getDeclaredMethods()) {
            if (declaredMethod.getReturnType().equals(byte[].class)) {
                try {
                    buffer = (byte[]) declaredMethod.invoke(im);
                } catch (IllegalAccessException | InvocationTargetException e) {
                    throw new RuntimeException(e);
                }
            }
        }
        if (buffer == null) {
            throw new RuntimeException("buffer is null");
        }
 
 
        byte[] decrypted = i.a(key, buffer);
        it a3 = null;
        for (Method declaredMethod : com.xiaomi.mipush.sdk.ai.class.getDeclaredMethods()) {
            if (declaredMethod.getName().equals("a")
                    && declaredMethod.getReturnType().equals(jb.class)
                    && declaredMethod.getModifiers() == (Modifier.PRIVATE | Modifier.STATIC)) {
                declaredMethod.setAccessible(true);
                try {
                    a3 = (it) declaredMethod.invoke(com.xiaomi.mipush.sdk.ai.class, hq.a(5), true);
                } catch (IllegalAccessException | InvocationTargetException e) {
                    throw new RuntimeException("reflect invoke failed", e);
                }
            }
        }
 
        if (a3 == null) {
            throw new RuntimeException("a3 not found");
        }
        new jf(new com.xiaomi.push.js.a(true, true, data.length)).a(a3, decrypted);
 
        PushMessage pushMessage = convertToMessage(a3);
        logger.info("decrypt for {} \ndata: {}\nresult: {}", secKey, payload, JSONObject.toJSONString(pushMessage));
        return pushMessage;
    } catch (DecryptFailedException e) {
        logger.error("decrypt for {} failed\ndata: {}", secKey, payload, e);
        throw e;
    } catch (Throwable e) {
        RuntimeException ex = new RuntimeException("decode failed,", e);
        logger.error("decrypt for {} failed\ndata: {}", secKey, payload, ex);
        throw ex;
    }
}
 
private PushMessage convertToMessage(it result) throws IllegalAccessException, ClassNotFoundException {
    PushMessage pushMessage = new PushMessage();
 
    Field idField = findFieldByNameAndType(it.class, "b", String.class);
    pushMessage.setId((String) idField.get(result));
 
    Field needsAckField = findFieldByNameAndType(it.class, "a", boolean.class);
    pushMessage.setNeedsAck((boolean) needsAckField.get(result));
 
    Field appIdField = findFieldByNameAndType(it.class, "c", String.class);
    pushMessage.setAppId((String) appIdField.get(result));
 
    Field packageNameField = findFieldByNameAndType(it.class, "d", String.class);
    pushMessage.setPackageName((String) packageNameField.get(result));
 
    Class<?> ifClass = Class.forName("com.xiaomi.push.if");
    Field ifField = findFieldByNameAndType(it.class, "a", ifClass);
    Object target = ifClass.cast(ifField.get(result));
 
    Field targetField = findFieldByNameAndType(ifClass, "a", long.class);
    pushMessage.setChannelId((long) targetField.get(target));
 
    Field userIdField = findFieldByNameAndType(ifClass, "a", String.class);
    pushMessage.setUserId((String) userIdField.get(target));
 
    Field serverField = findFieldByNameAndType(ifClass, "b", String.class);
    pushMessage.setServer((String) serverField.get(target));
 
    Field resourceField = findFieldByNameAndType(ifClass, "c", String.class);
    pushMessage.setResource((String) resourceField.get(target));
 
    Field icField = findFieldByNameAndType(it.class, "a", ic.class);
    ic pmsg = (ic) icField.get(result);
 
    Field payloadField = findFieldByNameAndType(ic.class, "c", String.class);
    pushMessage.setPayload((String) payloadField.get(pmsg));
 
    Field createAtField = findFieldByNameAndType(ic.class, "a", long.class);
    pushMessage.setCreateAt((long) createAtField.get(pmsg));
 
    Field ttlField = findFieldByNameAndType(ic.class, "b", long.class);
    pushMessage.setTtl((long) ttlField.get(pmsg));
 
    Field onlineField = findFieldByNameAndType(ic.class, "a", boolean.class);
    pushMessage.setOnline((boolean) onlineField.get(pmsg));
 
    return pushMessage;
}
 
private Field findFieldByNameAndType(Class<?> clazz, String name, Class<?> returnType) {
    for (Field field : clazz.getDeclaredFields()) {
        if (field.getName().equals(name) && field.getType().equals(returnType)) {
            return field;
        }
    }
    throw new FieldNotFoundException(String.format("cannot find name->%s returnType->%s for class->%s", name, returnType, clazz));
}

mipush 的内部实现依赖了 android 相关的类,虽然在解密的过程中并没有用到,但是如果类不存在还是会报错的。因此还需要加两个mock类,空实现即可。

package android.content;
 
public class Context {
}
package android.text;
 
public class TextUtils {
}

由于 java 编译器的关系,在运行时需要加上 -noverify 的参数,否则会报 java.lang.VerifyError 的错。(参考 sf

运行

最后,我们把本次从 gifmaker 拿到的推送解析一把:

{
    "appId": "2882303761517130534",
    "channelId": 5,
    "createAt": 1624932880962,
    "id": "scm59254624932880962jS",
    "needsAck": true,
    "online": true,
    "packageName": "com.smile.gifmaker",
    "payload": "{\"click_payload\":\"true\",\"push_notification\":\"{}\",\"infra_tag\":\"cdp\",\"onlyInBar\":\"false\",\"dndModeIsOn\":\"false\",\"push_msg_id\":\"0000016249328800885970645000158\",\"id\":\"0000016249328800885970645000158\",\"push_back\":\"143JrBt4GbJW00637*1EMNPWjcdp5unset82w0MWh6Redmi6oANDROID_021f5a0bbeeb1eb1\",\"server_key\":\"{\\\"business\\\":\\\"RECO_PUSH\\\",\\\"object_type\\\":\\\"OBJECT_PHOTO\\\",\\\"item_id\\\":\\\"0\\\",\\\"ks_order_id\\\":\\\"empty\\\",\\\"object_id\\\":\\\"5205879746946008323\\\",\\\"sender_id\\\":\\\"0\\\",\\\"push_type\\\":\\\"PUSH_PHOTO\\\",\\\"infra_tag\\\":\\\"cdp\\\",\\\"event_type\\\":\\\"EVENT_CONSUME_AUTHOR_PHOTO_PUSH\\\",\\\"time_ms\\\":\\\"1624932880244\\\",\\\"business_type\\\":\\\"KUAISHOU\\\",\\\"badge\\\":0}\",\"title\":\"快手\",\"body\":\"你看过的娟姐(手机摄影)发布了新作品:夏天的蚊子好多啊~太讨厌了,整个大蚊香把他们熏晕吧! #手机摄影 #创作灵感 #创意\",\"uri\":\"kwai:\\/\\/work\\/5205879746946008323?userId=2331994191&exp_tag=1_a\\/0_ps\"}",
    "resource": "anSQRSEH",
    "server": "xiaomi.com",
    "ttl": 86401,
    "userId": "70084876406"
}

其中的payload 再展开看下:

{
    "click_payload": "true",
    "push_notification": "{}",
    "infra_tag": "cdp",
    "onlyInBar": "false",
    "dndModeIsOn": "false",
    "push_msg_id": "0000016249328800885970645000158",
    "id": "0000016249328800885970645000158",
    "push_back": "143JrBt4GbJW00637*1EMNPWjcdp5unset82w0MWh6Redmi6oANDROID_021f5a0bbeeb1eb1",
    "server_key": "{\"business\":\"RECO_PUSH\",\"object_type\":\"OBJECT_PHOTO\",\"item_id\":\"0\",\"ks_order_id\":\"empty\",\"object_id\":\"5205879746946008323\",\"sender_id\":\"0\",\"push_type\":\"PUSH_PHOTO\",\"infra_tag\":\"cdp\",\"event_type\":\"EVENT_CONSUME_AUTHOR_PHOTO_PUSH\",\"time_ms\":\"1624932880244\",\"business_type\":\"KUAISHOU\",\"badge\":0}",
    "title": "快手",
    "body": "你看过的娟姐(手机摄影)发布了新作品:夏天的蚊子好多啊~太讨厌了,整个大蚊香把他们熏晕吧! #手机摄影 #创作灵感 #创意",
    "uri": "kwai://work/5205879746946008323?userId=2331994191&exp_tag=1_a/0_ps"
}

信息非常全,还带了跳转用的scheme,通过adb shell am start -a android.intent.action.VIEW -d kwai://work/5205879746946008323?userId=2331994191&exp_tag=1_a/0_ps 就能直接跳转到对应页面了。

通知栏清理

上面的脚本仅仅涉及了获取数据,并没有涉及清理通知栏。显然,如果一直不清理,不仅要考虑数据去重,也会影响拉取效率。实际用起来发现,如果一直不清理,也容易 hook 不动。。。

要清理也很简单,在每次拉取消息结束后,调用一下命令即可:

$ adb shell su -c 'service call notification 1'

实时监听

不要忘了,我们需要的是实时监听,因此还需要监听下NotificationGroupManager 的触发器。

let NotificationGroupManager = Java.use('com.android.systemui.statusbar.phone.NotificationGroupManager')
let onEntryAdded = NotificationGroupManager.onEntryAdded;
onEntryAdded.implementation = function (entry) {
    let res = onEntryAdded.call(this, entry);
    processManager(this)
    return res;
}

不过,从实践中看,想通过直接 hook 一次这个 onEntryAdded 事件就能一劳永逸还是想太多了。Frida进程或者是桌面进程总会有各种理由挂掉,因此我们在生产中也并没有依赖这个,而是通过外部定时任务来触发。

定时拉活

由于小米厂家自身的通道(可能)比较贵,因此各大应用几乎都有很大一部分走的是自己的长链接。与小米自身的系统通道不同,这些长链接都是需要 App 在后台运行才能保证的。因此我们也需要定期把那些重要的 App 进行强制拉活,这样我们能收到的 push 才能更快、更多。不过好消息是,应用自身通道的推送数据是不用走 mipush 加密那一套东西,所以搞起来更简单~

结语

最后反手夸一夸腾讯,看起来各大厂家对热点事件的推送中,腾讯爸爸还是最及时的,运营同学们辛苦了。

参考资料

小米推送产品说明

Android 8.0 VDEX机制简介

逆向settings实现监控app通知

Isaac64解密算法JNI的封装

July 5th 2021 at 13:47

前言

众所周知,理论上最安全的加密方式是使用一次一密(OTP)。但是传递与明文长度相等的、完全随机的加密面板这件事情并不具有实践意义,因此就诞生了流密码(Stream Cipher)。流密码将一个密钥作为种子,按照某种伪随机数生成算法生成供OTP使用的加密面板。有了加密面板之后,就可以逐字使用传统的 Vernam 算法 或者 Vigenère 算法进行加密解密。

由于这样进行的加密解密操作没有复杂的计算、并且不需要对数据进行预取分块等复杂操作,因此执行效率很高,非常适合用作流媒体数据的加密。最常见的流加密算法就是大名鼎鼎的RC4。其实 RC4 本质就是一个伪随机数生成器,加密方式其实就是用某个密钥作为种子,通过该生成器生成一个与明文等长的二进制流,再用 Vernam 算法(逐字异或)对明文处理得到密文。

由于是采用 Vernam 算法进行实际的加密,因此判断这类流加密算法的一个很典型的特点,就是对于相同的密钥,将明文和密文进行异或得到的数据是完全相等的(就是那个一次一密的加密板)。

当然,由于 RC4 算法太常见了,业内在使用流密码时常常会选择一些较为小众的伪随机数生成器,比如 Bob Jenkins 提出的 isaac 。而以 isaac 作为伪随机数生成器再结合 Vernam 或者 Vigenère 的加密方法就是 isaac 流加密算法。

由于业务需求,本次我们需要实现 ISAAC64位 的算法。

业界实现

目前较为常见的实现有以下三个。

  1. ISAAC paper 中的伪随机数生成器实现。
  2. Apache Commons Math 中的加密算法实现。
  3. Rosetta Code 提供的加密算法实现。
  4. GNU CoreUtils 中的加密算法实现。

ISAAC paper 中的默认实现只是用C实现了其32位和64位的伪随机数生成器的功能,并没有结合实际的加密功能。

Apache Commons Math 的 org.apache.commons.math3.random.ISAACRandom  提供了 ISAAC 作为伪随机数生成器并结合 Vernam 的加密算法实现。但是只有32位的实现,并没有64位的实现。

Rosetta Code 非常人性化的提供了 C、C++、C#、Dephi、Go等近三十种语言的实现,并且同时支持了 Vernam 算法和 Vigenère 算法,可以说是很有心了。但也是只有32位的实现,没有64位的实现。

GNU CoreUtils 提供了 C 实现的 ISAAC 伪随机数算法,同时适配了32位和64位。不过也并没有结合加密功能。

JavaApi封装

引入

既然我们需要64位的算法,考虑再三后我们选择了 CoreUtils 中的实现。核心文件如下:

https://github.com/coreutils/coreutils/blob/master/gl/lib/rand-isaac.h

https://github.com/coreutils/coreutils/blob/master/gl/lib/rand-isaac.c

由于只依赖了C标准库,因此源文件可以直接拿过来用。

生成JNI

先准备一个有 native 方法的类,可以先把加载动态库的逻辑写进来。(虽然运行时肯定报错,毕竟这个动态连接库目前还没有生成)

package com.mythsman.api.manager;
 
public class IsaacManager {
    static {
        try {
            System.loadLibrary("isaac");
        } catch (Throwable e) {
            e.printStackTrace();
        }
    }
 
    /**
     * @param data      待解密的数据
     * @param decodeKey 解密key
     */
    public native void decrypt(byte[] data, int decodeKey);
 
}
 

然后在适当的位置将这个文件编译一把,再生成一个用于静态加载的头文件。

$ javac com/mythsman/api/manager/IsaacManager.java
$ javah com.mythsman.api.manager.IsaacManager 
$ ls -la com_mythsman_api_manager_IsaacManager.h 
-rw-r--r--  1 myths  staff  547 Jun 18 00:03 com_mythsman_api_manager_IsaacManager.h

生成的头文件大致如下

/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class com_mythsman_api_manager_IsaacManager */
 
#ifndef _Included_com_mythsman_api_manager_IsaacManager
#define _Included_com_mythsman_api_manager_IsaacManager
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     com_myths_api_manager_IsaacManager
 * Method:    decrypt
 * Signature: ([BI)V
 */
JNIEXPORT void JNICALL Java_com_mythsman_api_manager_IsaacManager_decrypt
  (JNIEnv *, jobject, jbyteArray, jint);
 
#ifdef __cplusplus
}
#endif
#endif

根据这个头文件,我们就可以利用 rand-isaac 实现一波加密算法了:com_mythsman_api_manager_IsaacManager.c

/* DO NOT EDIT THIS FILE - it is machine generated */
#include <stdio.h>
#include <string.h>
#include "rand-isaac.h"
#include "com_mythsman_api_manager_IsaacManager.h"
 
/*
 * Class:     com_mythsman_api_manager_IsaacManager
 * Method:    decrypt
 * Signature: ([BI)V
 */
JNIEXPORT void JNICALL Java_com_mythsman_api_manager_IsaacManager_decrypt
        (JNIEnv *jniEnv, jobject obj, jbyteArray data, jint key) {
 
    jbyte *olddata = (*jniEnv)->GetByteArrayElements(jniEnv, data, 0);
 
    struct isaac_state state;
    isaac_word result[ISAAC_WORDS];
    memset((void *) state.m, 0, ISAAC_WORDS * sizeof(isaac_word));
    memset((void *) result, 0, ISAAC_WORDS * sizeof(isaac_word));
    *(uint32_t *) state.m = key;
    isaac_seed(&state);
 
    int encrypted_len = (*jniEnv)->GetArrayLength( jniEnv, data);
 
    for (int i = 0; i < encrypted_len; i++) {
        int mod = i % (ISAAC_WORDS * sizeof(isaac_word));
        if (mod == 0) {
            memset((void *) result, 0, ISAAC_WORDS * sizeof(isaac_word));
            isaac_refill(&state, result);
        }
        int decrypt_tbl_idx = ISAAC_WORDS * sizeof(isaac_word) - 1 - mod;
        *(olddata + i) ^= *((uint8_t *) result + decrypt_tbl_idx);
    }
    (*jniEnv)->ReleaseByteArrayElements(jniEnv, data, olddata, 0);
}

这里的代码最好在 CLion 里写,涉及到 JNI 的相关用法还是要看看源码的,而且用起来也要小心,搞不好就容易内存泄漏或者 core 。。。

编译

都搞完了,整一个 Cmake 文件。主要需要引入一下 jni 所在的头文件,以及生成动态链接库即可。

project(issac)
 
cmake_minimum_required(VERSION 3.5.0)
 
include_directories(
        $ENV{JAVA_HOME}/include
        $ENV{JAVA_HOME}/include/darwin
        $ENV{JAVA_HOME}/include/linux
        ${PROJECT_SOURCE_DIR}
)
 
add_library(isaac SHARED rand-isaac.c com_mythsman_api_manager_IsaacManager.c)

为了编译不污染源文件,先生成一个空的 build 文件夹,现在当前的文件结构如下:

$ tree .
.
├── CMakeLists.txt
├── build
├── com_mythsman_api_manager_IsaacManager.c
├── com_mythsman_api_manager_IsaacManager.h
├── rand-isaac.c
└── rand-isaac.h

现在进入 build 文件夹,执行 cmake .. && make 即可:

$ cmake .. && make
-- The C compiler identification is AppleClang 12.0.5.12050022
-- The CXX compiler identification is AppleClang 12.0.5.12050022
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Check for working C compiler: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/cc - skipped
-- Detecting C compile features
-- Detecting C compile features - done
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/c++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /Users/myths/Downloads/isaac/build
Scanning dependencies of target isaac
[ 33%] Building C object CMakeFiles/isaac.dir/rand-isaac.c.o
[ 66%] Building C object CMakeFiles/isaac.dir/com_mythsman_api_manager_IsaacManager.c.o
[100%] Linking C shared library libisaac.dylib
[100%] Built target isaac
$ ls
CMakeCache.txt      CMakeFiles          Makefile            cmake_install.cmake libisaac.dylib

这样就生成了我们要用的 libisaac.dylib 动态链接库。

使用

启动时,需要带上JVM参数以指定加载动态链接库的路径:-Djava.library.path=/path/to/libisaac.xxx

如果用于单测,则需要在该模块的 gradle 配置文件中加上:

    test {
        systemProperty "java.library.path", "/path/to/libisaac.xxx"
    }

然后就可以直接用啦~

byte[] data = xxxx;
int decodeKe = xxxx;
 
IsaacManager isaacManager = new IsaacManager();
isaacManager.decrypt(data, decodeKey);

这样 data 中的数据就从密文变成了明文。

参考资料

ISAAC Home Page

ISAAC 多语言实现

ISAAC 在GNU中的实现

Canvas指纹隐藏实战

June 30th 2021 at 16:27

前言

前两天和隔壁做风控的同学聊天,据说他们经常使用浏览器指纹来识别和标记爬虫(当然具体的细节是不能透露的),联想到我们最近也经常遇到被风控的情况,于是就花了点时间研究下浏览器指纹相关的知识。

需要明确的是,“隐藏浏览器指纹”和“隐藏Webdriver/Selenium/Puppeteer”、“匿名浏览”都不是同一个问题。“隐藏Webdriver/Selenium/Puppeteer”的目的是告诉服务端自己不是自动化爬虫(这个似乎可以尝试用 stealth.min.js 来做);“匿名浏览”的目的是不让浏览记录在本地留下痕迹(这个可以用浏览器的匿名模式来实现);“隐藏浏览器指纹”是为了不让服务端追踪“现在的我和之前的我是同一个我”(这个是我们这次想解决的问题)。

浏览器指纹一般是通过 Javascript 能提取或计算得到的一些具有一定稳定性独特性的参数。风控的同学将这些参数做一定取舍和组合,就能得到一台浏览器的大致标识。在没有强制登陆的情况下,有了这个标识,风控的同学可以做反爬、广告的同学可以做推荐、数据的同学可以算uv等等,用处还是不小的。

常见的浏览器指纹会提取如下东西:

  1. UserAgent和平台信息
  2. 浏览器加载的字体信息
  3. 声卡指纹
  4. Canvas指纹
  5. 显示器分辨率
  6. 语言、地区时区信息
  7. CPU核数和可用内存信息
  8. 本地存储、Cookie等信息
  9. 浏览器安装的插件信息
  10. 科学计算的指纹信息
  11. IP和代理信息

可以看出来,这里有的指标稳定性比较好,比如UserAgent、分辨率、地区、语言、字体信息等,但是这些指标通常区分度不是很好。因此我们通常更喜欢用一些硬件指纹信息来进行区分。当然,为了衡量指标的这种“独特性”,Peter Eckersley 提出了用一个指标对设备唯一指纹引入的熵来衡量指标的这种特性,下面是我的设备在 coveryyourtracks 中跑出来的一些常见指标的效果:

USER AGENT
Bits of identifying information: 9.04
One in x browsers have this value: 524.71
 
HTTP_ACCEPT HEADERS
Bits of identifying information: 8.96
One in x browsers have this value: 496.75
 
BROWSER PLUGIN DETAILS
Bits of identifying information: 3.25
One in x browsers have this value: 9.54
 
TIME ZONE OFFSET
Bits of identifying information: 5.26
One in x browsers have this value: 38.38
 
TIME ZONE
Bits of identifying information: 6.83
One in x browsers have this value: 114.13
 
SCREEN SIZE AND COLOR DEPTH
Bits of identifying information: 6.28
One in x browsers have this value: 77.57
 
SYSTEM FONTS
Bits of identifying information: 3.92
One in x browsers have this value: 15.12
 
ARE COOKIES ENABLED?
Bits of identifying information: 0.13
One in x browsers have this value: 1.09
 
LIMITED SUPERCOOKIE TEST
Bits of identifying information: 1.41
One in x browsers have this value: 2.67
 
HASH OF CANVAS FINGERPRINT
Bits of identifying information: 9.73
One in x browsers have this value: 847.61
 
HASH OF WEBGL FINGERPRINT
Bits of identifying information: 9.75
One in x browsers have this value: 862.69
 
WEBGL VENDOR & RENDERER
Bits of identifying information: 8.5
One in x browsers have this value: 362.9
 
DNT HEADER ENABLED?
Bits of identifying information: 0.95
One in x browsers have this value: 1.93
 
LANGUAGE
Bits of identifying information: 7.47
One in x browsers have this value: 176.69
 
PLATFORM
Bits of identifying information: 3.11
One in x browsers have this value: 8.62
 
TOUCH SUPPORT
Bits of identifying information: 0.76
One in x browsers have this value: 1.7
 
AD BLOCKER USED
Bits of identifying information: 0.15
One in x browsers have this value: 1.11
 
AUDIOCONTEXT FINGERPRINT
Bits of identifying information: 5.92
One in x browsers have this value: 60.59
 
CPU CLASS
Bits of identifying information: 0.12
One in x browsers have this value: 1.09
 
HARDWARE CONCURRENCY
Bits of identifying information: 1.93
One in x browsers have this value: 3.82
 
DEVICE MEMORY (GB)
Bits of identifying information: 2.31
One in x browsers have this value: 4.96

其中,“Bits of identifying information” 表示这个指标对我的浏览器引入的唯一性的位数,这个位数越大,越表明这个指标更能区分我的浏览器和其他的浏览器;“One in x browsers have this value” 表示平均多少个浏览器的这个指标和我的浏览器的这个指标一样,这个数越大,越表明这个指标的区分度好。

显然,从这里看,区分度最高的指标就是 "UserAgent" , "WebGL指纹" 和 "Canvas 指纹" 。那这些指纹是怎么 work 的呢?例如Canvas指纹,其实就是由于Web 浏览器使用的图像处理引擎、图像导出选项、压缩级别、操作系统的字体,抗锯齿和亚像素渲染算法等的不同,导致画出来的图片在像素级别存在的微小但稳定的差距。这样我们就可以拿这个生成图片的校验和或算一个Hash作为他的指纹。

指纹检测

目前业内较为知名的浏览器指纹检测网站大概有下面这三个:

  1. https://fingerprintjs.github.io/fingerprintjs/
  2. http://f.vision/
  3. https://coveryourtracks.eff.org/

第一个是最常见的浏览器指纹生成项目,之前某手用过一段时间,后来好像不用了,可以作为入门项目来看。

第二个是一个进阶的在线检测指纹的网站,检测点更全,也有一定的反伪造能力。

第三个是 Peter Eckersley 实验用的检测指纹的网站,教育意义更明显,也有一定的反伪造能力。

有矛就有盾,作为(爬虫)普通用户,我们显然不想让服务端跟踪我们的操作路径,但又不想用 Tor 浏览器。因此我们就要想一些对付这些指纹检测的办法。下面就是我做的一些尝试。

Fingerpintjs指纹

指纹生成算法

首先我们先看下 fingerprintjs 检测 Canvas 指纹的核心代码,作为我们首先需要绕过的目标:

function makeTextImage(canvas, context) {
    // Resizing the canvas cleans it
    canvas.width = 240;
    canvas.height = 60;
    context.textBaseline = 'alphabetic';
    context.fillStyle = '#f60';
    context.fillRect(100, 1, 62, 20);
    context.fillStyle = '#069';
    // It's important to use explicit built-in fonts in order to exclude the affect of font preferences
    // (there is a separate entropy source for them).
    context.font = '11pt "Times New Roman"';
    // The choice of emojis has a gigantic impact on rendering performance (especially in FF).
    // Some newer emojis cause it to slow down 50-200 times.
    // There must be no text to the right of the emoji, see https://github.com/fingerprintjs/fingerprintjs/issues/574
    // A bare emoji shouldn't be used because the canvas will change depending on the script encoding:
    // https://github.com/fingerprintjs/fingerprintjs/issues/66
    // Escape sequence shouldn't be used too because Terser will turn it into a bare unicode.
    var printedText = "Cwm fjordbank gly " + String.fromCharCode(55357, 56835) /* 😃 */;
    context.fillText(printedText, 2, 15);
    context.fillStyle = 'rgba(102, 204, 0, 0.2)';
    context.font = '18pt Arial';
    context.fillText(printedText, 4, 45);
    return save(canvas);
}
function makeGeometryImage(canvas, context) {
    // Resizing the canvas cleans it
    canvas.width = 122;
    canvas.height = 110;
    // Canvas blending
    // https://web.archive.org/web/20170826194121/http://blogs.adobe.com/webplatform/2013/01/28/blending-features-in-canvas/
    // http://jsfiddle.net/NDYV8/16/
    context.globalCompositeOperation = 'multiply';
    for (var _i = 0, _a = [
        ['#f2f', 40, 40],
        ['#2ff', 80, 40],
        ['#ff2', 60, 80],
    ]; _i < _a.length; _i++) {
        var _b = _a[_i], color = _b[0], x = _b[1], y = _b[2];
        context.fillStyle = color;
        context.beginPath();
        context.arc(x, y, 40, 0, Math.PI * 2, true);
        context.closePath();
        context.fill();
    }
    // Canvas winding
    // http://blogs.adobe.com/webplatform/2013/01/30/winding-rules-in-canvas/
    // http://jsfiddle.net/NDYV8/19/
    context.fillStyle = '#f9c';
    context.arc(60, 60, 60, 0, Math.PI * 2, true);
    context.arc(60, 60, 20, 0, Math.PI * 2, true);
    context.fill('evenodd');
    return save(canvas);
}

这里主要生成了下面两个东西:

指定形状、字体、文字、颜色等,画了一个图。

指定了颜色、位置,画了一些奇奇怪怪的圆弧。

然后 fingerprintjs 会用这两个图,根据 murmurHash3 算一个指纹。

Canvas Fingerprint Defender

Chrome有一个声称能防止 canvas 指纹泄露的插件 Canvas Fingerprint Defender ,我们姑且拿他来检测绕过 fingerprintjs 看看。

安装后,找到 crx 安装目录,发现他的逻辑主要是在 data/content_script/inject.js 中,核心逻辑如下:

var inject = function () {
  const toBlob = HTMLCanvasElement.prototype.toBlob;
  const toDataURL = HTMLCanvasElement.prototype.toDataURL;
  const getImageData = CanvasRenderingContext2D.prototype.getImageData;
  //
  var noisify = function (canvas, context) {
    if (context) {
      const shift = {
        'r': Math.floor(Math.random() * 10) - 5,
        'g': Math.floor(Math.random() * 10) - 5,
        'b': Math.floor(Math.random() * 10) - 5,
        'a': Math.floor(Math.random() * 10) - 5
      };
      //
      const width = canvas.width;
      const height = canvas.height;
      if (width && height) {
        const imageData = getImageData.apply(context, [0, 0, width, height]);
        for (let i = 0; i < height; i++) {
          for (let j = 0; j < width; j++) {
            const n = ((i * (width * 4)) + (j * 4));
            imageData.data[n + 0] = imageData.data[n + 0] + shift.r;
            imageData.data[n + 1] = imageData.data[n + 1] + shift.g;
            imageData.data[n + 2] = imageData.data[n + 2] + shift.b;
            imageData.data[n + 3] = imageData.data[n + 3] + shift.a;
          }
        }
        //
        window.top.postMessage("canvas-fingerprint-defender-alert", '*');
        context.putImageData(imageData, 0, 0);
      }
    }
  };
  //
  Object.defineProperty(HTMLCanvasElement.prototype, "toBlob", {
    "value": function () {
      noisify(this, this.getContext("2d"));
      return toBlob.apply(this, arguments);
    }
  });
  //
  Object.defineProperty(HTMLCanvasElement.prototype, "toDataURL", {
    "value": function () {
      noisify(this, this.getContext("2d"));
      return toDataURL.apply(this, arguments);
    }
  });
  //
  Object.defineProperty(CanvasRenderingContext2D.prototype, "getImageData", {
    "value": function () {
      noisify(this.canvas, this);
      return getImageData.apply(this, arguments);
    }
  });
  //
  document.documentElement.dataset.cbscriptallow = true;
};
inject();

代码基本不用解释,主要就做了一件事情:重写 Canvas 的 toBlob,toDataURL 方法和 Context 的 getImageData 方法,使他们在生成图片时加一些噪点。

通过下面的 selenium 代码注入上述脚本:

from selenium import webdriver
from selenium.webdriver.chrome.options import Options
 
options = Options()
 
driver = webdriver.Chrome(options=options, executable_path="/path/to/chromedriver")
 
f = open('./inject.js')
js = f.read()
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {"source": js})
 
driver.get('https://fingerprintjs.github.io/fingerprintjs/')

连续跑两次,发现指纹的确并不相同:

f.vision指纹

但是上面的脚本如果应用在 f.vision 上(或者  canvas-fingerprint ),虽然也会生成不同的指纹,但是会发现有下面的结果:

有意思,果然是做了一些检测,让我们摘下面具看看他做了啥。一番搜索后发现了如下检测点。

检测点一

CanvasRenderingContext2D.prototype.getImageData.length !== 4 
|| !CanvasRenderingContext2D.prototype.getImageData.toString().match(/^\s*function getImageData\s*\(\)\s*\{\s*\[native code\]\s*\}\s*$/) 
|| (CanvasRenderingContext2D.prototype.getImageData.name !== "getImageData" && !ie)

原来他check了一下关键对象的 prototype 的属性,我们来看下当前实际的结果:

CanvasRenderingContext2D.prototype.getImageData.length
0
 
CanvasRenderingContext2D.prototype.getImageData.toString()
"function () {\n      noisify(this.canvas, this);\n      return getImageData.apply(this, arguments);\n    }"
 
CanvasRenderingContext2D.prototype.getImageData.name
"value"

这里果然和没有改过的不一样。

检测点二

function cKnownPixels(size) {
    "use strict";
 
    var canvas = document.createElement("canvas");
    canvas.height = size;
    canvas.width = size;
    var context = canvas.getContext("2d");
    if (!context)
        return false;
 
    context.fillStyle = "rgba(0, 127, 255, 1)";
    var pixelValues = [0, 127, 255, 255];
    context.fillRect(0, 0, canvas.width, canvas.height);
    var p = context.getImageData(0, 0, canvas.width, canvas.height).data;
    for (var i = 0; i < p.length; i += 1) {
        if (p[i] !== pixelValues[i % 4]) {
            return false;
        }
    }
    return true;
}

这个是个很容易想到的检测方法,输入一个已知的稳定像素图(由于不存在字体、复杂计算等,因此不同机器的渲染几乎不会有差异),如果输出的结果和已知输入不一样,那肯定是人为加了噪点。

检测点三

function cReadOut() {
 
    "use strict";
 
    var canvas = document.createElement("canvas");
    var context = canvas.getContext("2d");
 
    if (!context)
        return false;
 
    var imageData = context.getImageData(0, 0, canvas.width, canvas.height);
    for (let i = 0; i < imageData.data.length; i += 1) {
        if (i % 4 !== 3) {
            imageData.data[i] = Math.floor(256 * Math.random());
        } else {
            imageData.data[i] = 255;
        }
    }
    context.putImageData(imageData, 0, 0);
 
    var imageData1 = context.getImageData(0, 0, canvas.width, canvas.height);
    var imageData2 = context.getImageData(0, 0, canvas.width, canvas.height);
    for (let i = 0; i < imageData2.data.length; i += 1) {
        if (imageData1.data[i] !== imageData2.data[i]) {
            return false;
        }
    }
    return true;
}

这也是个比较容易想到的检测方法,将同一份像素点连续输出两次,如果这两次的结果不一样,那肯定是加了某些随机的噪点。

检测点四

function cDoubleReadOut() {
 
    "use strict";
 
    var canvas = document.createElement("canvas");
    var context = canvas.getContext("2d");
 
    if (!context)
        return false;
 
    var imageData = context.getImageData(0, 0, canvas.width, canvas.height);
    for (let i = 0; i < imageData.data.length; i += 1) {
        if (i % 4 !== 3) {
            imageData.data[i] = Math.floor(256 * Math.random());
        } else {
            imageData.data[i] = 255;
        }
    }
 
    var imageData1 = context.getImageData(0, 0, canvas.width, canvas.height);
 
    var canvas2 = document.createElement("canvas");
    var context2 = canvas2.getContext("2d");
    context2.putImageData(imageData1, 0, 0);
    var imageData2 = context2.getImageData(0, 0, canvas.width, canvas.height);
 
    for (let i = 0; i < imageData2.data.length; i += 1) {
        if (imageData1.data[i] !== imageData2.data[i]) {
            return false;
        }
    }
    return true;
}

和上面的检测挺像,但是检测出来的结果比较trick,没太搞懂这是检测的啥,不过实践中发现如果 a 通道随机到的噪点偏移值不太好,很容易检测不通过。

各个击破

道高一尺,魔高一丈,知道他是怎么检测的,我们就可以有针对性的处理了。解决思路如下:

  1. 再次 mock 出对应prototype 的正确 length、toString、name 属性。
  2. 对于没有添加文字、没有复杂位图变换的图,不进行随机填充。
  3. 随机参数在启动时就生成且只生成一次,保证相同的图多次处理结果也一样。

具体操作可以参考下面的代码:

function random(list) {
    let min = 0;
    let max = list.length
    return list[Math.floor(Math.random() * (max - min)) + min];
}
 
let rsalt = random([...Array(7).keys()].map(a => a - 3))
let gsalt = random([...Array(7).keys()].map(a => a - 3))
let bsalt = random([...Array(7).keys()].map(a => a - 3))
let asalt = random([...Array(7).keys()].map(a => a - 3))
 
const rawGetImageData = CanvasRenderingContext2D.prototype.getImageData;
 
let noisify = function (canvas, context) {
    let ctxIdx = ctxArr.indexOf(context);
    let info = ctxInf[ctxIdx];
    const width = canvas.width, height = canvas.height;
    const imageData = rawGetImageData.apply(context, [0, 0, width, height]);
    if (info.useArc || info.useFillText) {
        for (let i = 0; i < height; i++) {
            for (let j = 0; j < width; j++) {
                const n = ((i * (width * 4)) + (j * 4));
                imageData.data[n + 0] = imageData.data[n + 0] + rsalt;
                imageData.data[n + 1] = imageData.data[n + 1] + gsalt;
                imageData.data[n + 2] = imageData.data[n + 2] + bsalt;
                imageData.data[n + 3] = imageData.data[n + 3] + asalt;
            }
        }
    }
    context.putImageData(imageData, 0, 0);
};
 
let ctxArr = [];
let ctxInf = [];
 
(function mockGetContext() {
    let rawGetContext = HTMLCanvasElement.prototype.getContext
 
    Object.defineProperty(HTMLCanvasElement.prototype, "getContext", {
        "value": function () {
            let result = rawGetContext.apply(this, arguments);
            if (arguments[0] === '2d') {
                ctxArr.push(result)
                ctxInf.push({})
            }
            return result;
        }
    });
 
    Object.defineProperty(HTMLCanvasElement.prototype.constructor, "length", {
        "value": 1
    });
 
    Object.defineProperty(HTMLCanvasElement.prototype.constructor, "toString", {
        "value": () => "function getContext() { [native code] }"
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.constructor, "name", {
        "value": "getContext"
    });
})();
 
(function mockArc() {
    let rawArc = CanvasRenderingContext2D.prototype.arc
    Object.defineProperty(CanvasRenderingContext2D.prototype, "arc", {
        "value": function () {
            let ctxIdx = ctxArr.indexOf(this);
            ctxInf[ctxIdx].useArc = true;
            return rawArc.apply(this, arguments);
        }
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.arc, "length", {
        "value": 5
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.arc, "toString", {
        "value": () => "function arc() { [native code] }"
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.arc, "name", {
        "value": "arc"
    });
})();
 
(function mockFillText() {
    const rawFillText = CanvasRenderingContext2D.prototype.fillText;
    Object.defineProperty(CanvasRenderingContext2D.prototype, "fillText", {
        "value": function () {
            let ctxIdx = ctxArr.indexOf(this);
            ctxInf[ctxIdx].useFillText = true;
            return rawFillText.apply(this, arguments);
        }
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.fillText, "length", {
        "value": 4
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.fillText, "toString", {
        "value": () => "function fillText() { [native code] }"
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.fillText, "name", {
        "value": "fillText"
    });
})();
 
 
(function mockToBlob() {
    const toBlob = HTMLCanvasElement.prototype.toBlob;
 
    Object.defineProperty(HTMLCanvasElement.prototype, "toBlob", {
        "value": function () {
            noisify(this, this.getContext("2d"));
            return toBlob.apply(this, arguments);
        }
    });
 
    Object.defineProperty(HTMLCanvasElement.prototype.toBlob, "length", {
        "value": 1
    });
 
    Object.defineProperty(HTMLCanvasElement.prototype.toBlob, "toString", {
        "value": () => "function toBlob() { [native code] }"
    });
 
    Object.defineProperty(HTMLCanvasElement.prototype.toBlob, "name", {
        "value": "toBlob"
    });
})();
 
(function mockToDataURL() {
    const toDataURL = HTMLCanvasElement.prototype.toDataURL;
    Object.defineProperty(HTMLCanvasElement.prototype, "toDataURL", {
        "value": function () {
            noisify(this, this.getContext("2d"));
            return toDataURL.apply(this, arguments);
        }
    });
 
    Object.defineProperty(HTMLCanvasElement.prototype.toDataURL, "length", {
        "value": 0
    });
 
    Object.defineProperty(HTMLCanvasElement.prototype.toDataURL, "toString", {
        "value": () => "function toDataURL() { [native code] }"
    });
 
    Object.defineProperty(HTMLCanvasElement.prototype.toDataURL, "name", {
        "value": "toDataURL"
    });
})();
 
 
(function mockGetImageData() {
    Object.defineProperty(CanvasRenderingContext2D.prototype, "getImageData", {
        "value": function () {
            noisify(this.canvas, this);
            return rawGetImageData.apply(this, arguments);
        }
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.getImageData, "length", {
        "value": 4
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.getImageData, "toString", {
        "value": () => "function getImageData() { [native code] }"
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.getImageData, "name", {
        "value": "getImageData"
    });
})();
 

再用 selenium 跑两下 f.vision,结果如下:

这下老实了。

实战-某音

有了上面的能力,理论上我们就可以找个小白鼠来实战试验一下。

下图是某音网页版的推荐页 https://douyin.com/recommend , 指纹计算的逻辑在他的 webmssdk.js 中,具体逻辑没仔细看,姑且当成黑盒测试一下。可以看到他给我计算了一个Canvas指纹(即 canvasHash 变量)。多次打开、清理cookie、使用不同显示器后指纹均能够保持稳定。

然后上一下我们的代码,可以发现指纹的确发生了变化,且每次打开均不同:

coveryourtracks指纹

本来以为上面的代码足够应对绝大多数场景了,但是回头跑了一下 https://coveryourtracks.eff.org/ 这个(检测时可能要翻墙),忽然发现他竟然还是能检测出来我做了伪造:

回想到他在检测时似乎有刷新页面的操作,因此猜测他也是通过刷新页面的方式,比较两次生成的指纹是否相同来检测伪造。于是我尝试将随机性从 js 脚本中提取到 python 代码里,保证相同会话无论刷新多少次都是用的同一套随机数。结果果然印证了我的猜想。

小结

虽然看起来上述操作能解决已知的 Canvas 检测问题,但像所有的攻守对抗一样,只要检测方愿意,他们可以十分轻松的想出很多种办法检测出我们检测出了他们的检测代码😅,问题只在于他们是否有闲情做这件事、以及这件事情是否必要。据我所知目前被搞的比较多的网站大都也都没有做这些检测,毕竟除了Canvas指纹之外,他们也有太多的工具可以使用。

参考资料

BrowserLeaks

Coveryourtracks.eff.org

How Unique Is Your Web Browser?

Device_fingerprint(Wikipeida)

What is Fingerprinting?

IT IS *NOT* POSSIBLE TO DETECT AND BLOCK CHROME HEADLESS

Do I have a canvas fingerprint spoofer installed in my browser

迁移到了Hexo

December 1st 2019 at 17:24

2020-08-29:
更换主题为

检测浏览器 DevTools 是否打开的几种方法

February 13th 2019 at 01:37

前言

今天关于看板娘的文章收到了一个

❌