2026-01-06 20:44:07
最近因为多邻国 App 的 Bug,我正在考虑切换到 Web 版本。但没想到移动端 Web 版本体验那么差,每次打开首页都会弹出 App 下载推广,这真的太烦人了。我第一时间就想去写个 UserScript 彻底删除它,但没想到还碰到一些有趣的问题。

从 DevTools > Elements 中很容易就找到了弹窗元素的稳定选择器。
1 |
|
页面遮罩也很容易定位。
1 |
|
然后我就可以正常点击了,但我仍然无法自由滚动,而滚动条锁定又不在 HTML/body 上,所以这种情况下应该如何找到是哪个 HTML 元素导致的滚动条锁定呢?

想要解决这个问题需要一些简单的启发式的方法。首先,一般如何实现滚动条锁定?
通常只需要给 body 元素增加一些样式即可,例如
1 |
|
或者使用 position: fixed
1 |
|
按照这种思路,我们可以找到一个在无法滚动区域内的元素,然后递归向上查找,直到找到 overflow: hidden; 或者 position: fixed 的元素即可认为它就是阻止滚动的罪魁祸首,实现起来相当简单。
1 |
|
视频演示
现在,我们可以通过 CSS 恢复正常的滚动条了。
tip:
$0在 DevTools > Console 中代表在 DevTools > Elements 面板中选中的元素。
如果你也想尝试开发 Extension 或者 UserScript 来改善你的 Web 体验,不妨一试,JavaScript 提供了相当多 trick 的技巧,如果只是编写普通的 Web App,可能永远不会有机会尝试。
2026-01-02 19:11:27
你是否曾经对某个网页的功能感到不满?比如 Google 搜索页面上那个显眼的 AI Mode 按钮:

通过浏览器扩展,你可以让它彻底消失:

一般而言,浏览器扩展是一种修改用户浏览网页的方式,它赋予了网站使用者而不是开发者更多的权限。让有能力的用户可以自定义它们的网页浏览体验,现代浏览器提供了极其丰富的扩展 API,甚至能实现一些看起来需要 app 才能做到的事情,但它的基本出发点是让用户可以控制正在浏览的网页。
一些例子
1 |
|
现在,我们来创建一个基本的扩展,不使用任何包管理器、TypeScript、或者 Web 框架,只使用基本的 JavaScript 完成。在下一章节中,将会使用现代开发工具链(WXT)重写它,这里只是让我们对扩展的实际结构有个了解。作为例子,我们将会创建一个扩展来隐藏 google.com 中那个 AI Mode 按钮。

首先,创建一个 manifest.json,里面是一些基本信息,manifest_version 是一个固定值,代表使用扩展 API 的第三个主要版本。
1 |
|
然后我们需要创建一个 content-scripts/content.js 脚本(这个路径不是固定的,只是一般做法),里面实现在网页加载时自动隐藏 AI Mode 按钮。
基本思路也很简单
打开 google.com,然后打开 DevTools > Elements 可以看到 AI Mode 是一个 button 元素,但它看起来并没有可以作为稳定 CSS selector 的东西。

对于现代网站而言,class 通常被构建工具压缩的连亲妈都不认识了,所以需要一些跳脱的方法,例如 button 中包含的 SVG 似乎很适合作为一个选择器(网站上的 icon 改动频率并不高)。这样,借助 :has 子选择器和属性选择器,我们就可以组合出一个稳定的 selector 了。

1 |
|
:has指的是如果元素包含指定 selector 的子元素,就匹配它。对于这个 selector 而言,这意味着只有包含特定 AI Mode svg 图标的 button 按钮才会被匹配到。
接下来创建 content.js 脚本,由于我们只想隐藏这个按钮,最简单的方法就是注入一个 css 样式,例如
1 |
|
接下来,我们还需要在 manifest.json 中声明这个内容脚本,告诉浏览器要在哪些网站注入,这里我们只需要在 google.com 注入,所以需要修改为
1 |
|
接下来,我们需要在 Chrome 中加载这个扩展。
首先,打开 chrome://extensions/,并启用 Developer Mode

然后,使用 Load unpacked 按钮选择扩展目录,就能加载扩展到浏览器了。
访问 google.com,可以看到 AI Mode 按钮确实不见了,但会闪一下出现然后才消失,这意味着注入的脚本时机不够早,在注入的脚本执行之前,AI Mode 按钮就已经显示了,然后脚本注入才隐藏了按钮,这是一个基本的时序问题。幸运的是,可以简单调整 manifest 配置解决这个问题。
1 |
|
然后在 chrome://extensions/ 重新加载扩展让修改生效。

接着就看到了一个错误 Uncaught TypeError: Cannot read properties of null (reading 'appendChild')。

这是因为注入脚本的时机非常早,甚至连 document.head 标签都还没有渲染,可以修改为在 document.documentElement 注入 style 标签。
1 |
|
再次刷新扩展然后访问 google.com 可以看到 AI Mode 按钮不见了,而且也不再出现闪烁的情况。

最后,这个扩展还缺少图标,我们将一张 128x128 的 png 图像放在 icon/128.png。
然后修改 manifest 添加 icons 设置即可。
1 |
|
再次刷新扩展,就可以看到扩展图标已经被正确加载了。

这就是第一个基本扩展。通过这个例子,我们了解了扩展开发的几个核心概念:
document_start、document_end 或 document_idle
你可能注意到,直接编写原生 JavaScript 并手动管理文件有些繁琐。在下一章中,我们将使用 WXT 这个现代开发工具重构这个扩展,体验 TypeScript、热重载等特性。
如果有任何问题,欢迎加入 Discord 群组讨论。
https://discord.gg/VxbAqE7gj2
完整代码:https://github.com/rxliuli/browser-extension-dev-examples/tree/main/packages/01-basic
Google Chrome 扩展开发文档 https://developer.chrome.com/docs/extensions
2025-09-18 21:12:09
在为 Chrome 开发了一个扩展程序之后,接下来就是移植到其他浏览器中了,而 Firefox 一般认为是首要选择,它们都使用类似的 Browser Extension API 接口,所以这应该非常简单,对吧?
不,Firefox 有很多微妙的长期问题,如果你遇到了,可能会变得非常棘手,下面是一些吾辈在移植扩展到 Firefox 中已经发现的问题。
CSP 在 Firefox 扩展中很奇怪,与 Chrome 甚至 Safari 都非常不同,例如
Firefox Extension 不支持访问 localhost 导致 wxt 这种扩展开发框架无法支持 dev mode 热更新 [1],在 bugzilla 中提出的 issue 也已经有 2 年了 [2]。目前唯一的解决方法就是在 Chrome 中进行开发,然后构建在 Firefox 进行验证和测试。
Firefox 会根据网站本身的 CSP 来限制扩展注入的 Content Script(使用前朝的剑来斩本朝的官)[3]。这也涉及到一些已经存在 9 年的 bug [4],预计短期内不可能得到解决,幸运的是,可以使用 declarativeNetRequest 来禁用网站的 CSP 来绕过这个问题。
下面是一个基本的 rules.json 规则配置文件来删除特定网站的 Content-Security-Policy,当然,这会导致一些安全问题,但这也是对于业务层侵入最小的方法。
1 |
|
Firefox Extension Page 中使用 webworker 会出现错误,例如使用 esbuild-wasm 会出现以下 CSP 错误
1 |
|
即便已经在 manifest 中设置了 CSP
1 |
|
这同样与一个存在 9 年的 bug 有关,即便它已经被开发者关闭,但通过 blob URI 使用 webworker 仍然无法工作 [5]。
对于 esbuild-wasm 而言,它提供一个选项可以禁用 webworker,这可以解决该问题。
1 |
|
基本上 Firefox 就像 Apple 一样,要求所有扩展都必须进行公证和签名 [6],即使不打算发布给其他人使用,也必须提交到 AMO [7] 进行审核。如果你直接拖拽一个构建好的 zip 到 Firefox,就会出现 This add-on could not be installed because it is not been verified 的错误。

当你的扩展使用人数达到一定数量,AMO 每次审核都会变得异常缓慢,因为总是需要人工审核。并且审核人员并不总是优秀的浏览器扩展开发者,他们甚至可能不是 Web 开发者,糟糕的审核流程惹恼了一些非常知名的扩展开发者,让他们放弃了在 Firefox 发布扩展,例如

例如在使用 monaco-editor 时,ts 的 LSP 代码很大,直接导致了扩展被自动拒绝,而在 Chrome 中根本不会发生。

Firefox 曾经是一个优秀的浏览器,但这几年除了“碰瓷” Chrome 的名声与 Vue 类似之外,似乎没什么值得大惊小怪的。而且最近开始往浏览器中塞 AI 相关的功能,似乎总是在追逐闪闪发光的东西而不是真的去正视现有的问题。
2025-08-08 07:28:40
今年以来,吾辈开始发布一些 Safari 扩展程序到 AppStore 中,由于吾辈并不使用 iPhone,所以仅发布了 Mac 版本。而这个月吾辈开始实践全平台浏览器扩展的开发,即为所有主流的桌面浏览器(Chrome/Safari/Edge/Firefox)和所有支持扩展的移动端浏览器(Kiwi/Edge/Safari/Firefox)发布相同的插件,这让吾辈将发布 iOS Safari 扩展重新提上日程。
关于如何转换 Chrome 扩展为 Safari 扩展,请参考 转换 Chrome Extension 为 Safari 版本
在已经有一个 Mac Safari 扩展的情况下,为它发布到 iOS 版本理论上很简单。但发布之前,必须通过模拟器调试确保没有漏洞。
首先,在 Target 中选择 iOS 平台,然后选择一个模拟器,建议 iPhone 16 Pro,最后点击 Build 按钮。
其次,模拟器中的 iOS 扩展的封装 App 就会自动打开。
再其次,点击模拟器顶部工具栏的 Home 图标,返回桌面,打开 Settings > Apps > Safari > Extensions 中,即可看到刚刚 Build 的扩展。默认情况下它应该是 Disable 的,进入然后 Enable 即可。如果你无法找到刚刚 Build 的扩展,请参考下面的问题,就我而言,在排查问题的过程中 Claude 4.1 Opus 确实给了不错的提示,让吾辈意识到排查错误的方向和关键词是什么。

最后,点击 Home 回到桌面,找到 Safari 打开,你应该能在浏览器工具栏看到刚刚 Build 的扩展。
参考: Apple 官方视频 2022
在测试完成确认没有漏洞之后,就可以发布到 AppStore 了。
首先,在 Xcode 中选择 Product > Archive 进行打包。
其次,在弹窗中点击 Distribute App 按钮,接着选择 App Store Connect 作为分发渠道,最后点击 Distribute 按钮,你的 App 就会开始上传到 AppStore 了。

但请注意,上传完成之后并未发布,只是上传了一个构建包,还需要到 App Store Connect 添加版本信息、App 描述、截图等一系列常规信息,并提交审核才能最终发布。
如果没有报错但同时也不生效,那可能不是你的错,只能怪 Apple/Safari 的开发体验太糟。
如图

根据这个 社区 issue,可以知道是 18.2 的 bug,升级到 18.4 解决。

验证

如图

升级至最新版的 XCode 及虚拟机解决,就吾辈而言,是 XCode 16.4 及 iOS 18.4 的虚拟机。

验证方法是通过 XCode 创建一个全新的 Safari Extension 项目,然后 Build 并检查 Settings > Safari > Extensions 中是否能看到。
Apple 的官方文档几乎没什么用 https://developer.apple.com/documentation/safariservices/troubleshooting-your-safari-web-extension
但吾辈看到一个今年刚出的视频感觉很有帮助 https://www.youtube.com/watch?v=DZe7L70CDPc
一个很小的问题,CJK 输入法在 Mac 上输入的字符是 \u0020,即便输入的是中文的空格,但 keydown 事件中仍然识别为标准的 \u0020。
例如
1 |
|
而在 iOS Safari 上,输入中文空格后,在 beforeinput 事件中,e.data 是 \u3000。
1 |
|
所以针对 iOS Safari 必须小心处理输入相关的事件。
例如,在插件中使用了 OpenAI 的 API 实现部分功能,发布到国区就无法过审。Apple 声称根据 MIIT(工信部)的要求,所有 AI 相关的 App 都必须报备取得资质。如果是个人开发者,建议直接放弃国区。Fuck of MIIT。

截止目前为止,吾辈已经成功发布了两个全平台浏览器扩展,分别是
发布 Safari 扩展虽然有趣,却也让人意识到 Safari 扩展的开发体验有多么糟糕,吾辈在开发过程中踩了不少坑,也浪费了不少时间。
2025-06-28 17:35:11
六月初有个去新疆自驾的机会,于是便和新一开始了二刷新疆之旅。大致路线定的是南疆环线,由于距离霍尔果斯口岸很近,所以也顺便出国去哈萨克斯坦看了看。这次的旅行体验比上次报团要好得多,主要是单个地点好玩的话可以多玩会而不再卡时间了。
首先第一站前往了赛里木湖,中间由于路途太远,所以在精河县暂住一晚,第二天继续前往赛里木湖。
首先去游客中心买票,确认了自驾是每个人 140,人车合一而非车单独计算。
将这里称作天空之镜似乎很合适。
让吾辈大吃一惊的雪堆,在 6 月中旬这个季节。
远处的湖边有人正在拍“场照”?
从赛里木湖西侧的山坡木栈道上向下俯拍,可以看到赏心悦目的风景。
雨过天晴,很幸运的看到了双彩虹。不幸的是,吾辈没有拍好它。
第二天早上,早起前往点将台等待日出。
上午的湖水无比湛蓝。
还在湖边用石头堆起了一个玛尼堆。
接下来,由于赛里木湖附近就有一个陆上口岸,加之哈萨克斯坦又是免签国家,所以之后前往了口岸城市霍尔果斯,休息一晚后第二天出发前往扎尔肯特。
霍尔果斯当地并没有什么东西,旧口岸附近有一个观光塔。
闹市之中有一处欧式建筑。
暂住一晚后第二天前往哈萨克斯坦的扎尔肯特小镇,从新国门出去。
再经过两三个小时漫长的安检审核流程之后,终于到了扎尔肯特,由于有 3 个小时的时差,所以还能吃上午饭。第一次吃如此巨大的鸡肉卷。
在路上看到的一个广告牌,有点好奇这是否就是 Local Superman?
之后前往了旅馆,但由于哈萨克斯坦是旧苏联加盟国,所以西方软件在此地不好使,打车、住宿和支付都使用俄罗斯 Yandex 系列的软件,而不是常用的 Uber/Booking/GooglePay。由于没有自驾而且也无法打车,所以步行前往了最近的清真寺。这个清真寺似乎不太清真,杂糅了中国风?
这是小镇上最大的超市,大部分本地人都来这里采购东西,甚至被作为一日游的景点了。
在路上随处可见的管道中不知道有什么,自来水?或者是天然气?
回国之后到了伊宁,六星街没什么好玩的,喀赞其民俗旅游区也因为天气原因没有去看,所以直接略过,开始翻越天山的伊昭公路之旅。
山脉之间。
尽管已经快没有信号了,但海拔三千米仍然有人卖现杀羊肉烧烤。
这是沿山而筑的一条小路,虽然危险,但风景却也是极好的。
下山后快到昭苏时,开始看到牛和羊。
这个弧线看着真的太舒服了,如果没有人就更好了。
山上还牧民的马。
吃完午饭,开始前往了昭苏附近的一个小景点:玉湖。但它却着实带来了不少惊喜,首先,它的门票人和车分开计算,一辆车只会计算一次,所以人均只有 55,与赛里木湖(140)、那拉提(200)相比,实在太划算了。
只看湖水颜色有点像西藏的羊卓雍湖。
但真正带来惊喜的不是湖水,而是景区内部公路的长度,单程至少超过 45km(没能走到终点),而景区区间车只走 25km。在后面能看到雪山、草地和成群的牛羊,风景着实不错。
往回走时看到远方层层叠叠的山有一些透明的感觉。
在中途经过特克斯八卦城住下之后,前往了非常有名的天山花海,但却大失所望,里面的花海挺壮观,但从地面上非常难拍。
成片的薰衣草花海。
蜜蜂正在采蜜。
似乎已经到末期的绳子草。
在纪念品购买的地方看到介绍天山花海似乎是个农业庄园,只是兼具旅游的功能。
与赛里木湖一样,那拉提也是二刷。上次抱团来的时候体验极其糟糕,只是乘坐区间车快速走马观花看了空中草原(盛名之下,其实难副)。这次自驾进来,在 48 小时内一共进来了 3 次,空中草原的体验仍然一般般,但河谷草原末端登上天鹰台才真正看到很棒的风景。
不知道什么时候立的一座雕像。
空中草原,也就是说,在海拔很高的山上的一片草原。
来的时候还下着淅淅沥沥的小雨,天气比较糟糕。
曾经的牧民就住在这种小房子中,牛羊在外面吃草。
一条小溪从山间流下,也许正是来自雪莲谷。
终于,在经过上午去空中草原的失望之后。在河谷草原末端,登上天鹰台,便可以远眺整个那拉提。
山顶的小路两侧正卧着几只牛。
山上还放牧着一群马。
还看到一朵不知名的花。
之后就是独库公路,由于之前已经走过伊昭公路,也同样是翻山越岭,加之在那拉提镇不小心磕伤需要提前回去,所以并没能带来预期的体验。
路边随手一拍。
雪山草甸。
雪山之顶。
奇形怪石。
高山深壑。
途径山泉。
丹霞地貌。
天山峡谷。
这次旅行起于偶然,终于偶然。几乎盲目的进入新疆,完全没有计划。所有的住宿都是临时决定,所有的门票都是现场购买。这种旅行体验之前从未尝试过,但自驾旅行的话,似乎确实不需要完整的旅行计划。实在不行,睡在车上凑活一晚也不是不行。