我做了个 Telegram 视频下载 Chrome 扩展 TGDown。
商店里 4.9 分、800 多用户、57 条评论,看起来一切正常。但后台有一个数字一直让我难受:
卸载率长期在 47% 左右。
我改过引导页、按钮文案、下载速度,数据都没什么起色。直到一次夜里调试,我才发现:问题不在用户不想用,而在不少用户装完后,扩展其实根本没工作。
这篇记录几个我踩过的坑。它们不复杂,但足够让一个"自己电脑上一直正常"的扩展,在真实用户那里直接失效。
文中讨论的下载场景仅限用户拥有版权或已获得保存授权的媒体,不涉及绕过付费、访问控制或他人版权限制。
暗色模式根本没测过
说出来有点丢人:我开发时一直用亮色主题。
在我的环境里,打开 Telegram Web,视频旁边有下载按钮,点一下能下载,一切正常。我就上线了。
但 Telegram Web 在亮色和暗色下会渲染不同的 DOM 结构与 className。我的第一版探测逻辑依赖了一个具体的 class:
dart
document.querySelectorAll('.video-player-wrapper video')
亮色下能匹配,暗色下这个 className 变了,结果就是:
- 页面不报错;
- 扩展没有崩;
- 下载按钮也不出现;
- 用户只会觉得"这扩展没用",然后卸载。
后来我没有继续追 className,而是把策略改成"先找媒体元素,再验证媒体地址",例如读取:
video.currentSrcvideo.src- 子元素
<source>的地址
核心思路从"相信某个 Telegram 私有 className"变成"识别浏览器原生媒体元素"。
javascript
function getVideoUrl(video: HTMLVideoElement) {
const sourceUrls = Array.from(video.querySelectorAll('source'))
.map((source) => source.src)
.filter(Boolean)
return [video.currentSrc, video.src, ...sourceUrls]
.find(Boolean) ?? ''
}
上线这一版后,我观察到卸载率从约 47% 降到约 31%。
这里不能严谨地说"下降全部由暗色模式修复造成"------用户来源、版本、季节性都会影响指标。但它至少说明了一件事:
之前有相当一批用户,装完后连扩展入口都没看见。
这类用户通常不会给反馈,也不会留差评。他们只是沉默地卸载。
Telegram Web 的 A/K 不是同一个产品
Telegram Web 至少有两个常见客户端入口:
web.telegram.org/a/web.telegram.org/k/
它们不是"同一套页面换了个皮肤"。
A 版是 Telegram 的 Web Z 客户端,采用自研的 Teact(React 风格)架构;K 版使用 Solid.js。两边的 DOM 结构、媒体加载时机和虚拟列表行为都不同。
我一开始只测了 A 版。
于是出现了第二个问题:A 版用户能看到按钮,K 版用户可能什么也看不到。
A 版相对直接,视频元素通常能较早暴露可用的媒体地址。K 版则更依赖交互和按需渲染:媒体可能直到预览层打开后才真正挂载,页面列表还会因为虚拟滚动不断复用节点。
所以后来我的逻辑不再假设"页面存在一个静态 video 标签,读到 src 就结束",而是改成按客户端分别处理:
- 识别当前位于 A 还是 K;
- 在用户主动触发下载后,定位对应的消息和预览区域;
- 等待真实媒体元素挂载;
- 优先读取
currentSrc,同时兼容src和<source>; - 对节点复用、预览关闭和重复绑定做清理。
这也是我后来最重要的一条经验:
不要把 URL 路径不同理解成"小兼容性差异";对扩展来说,它往往意味着两个独立的适配目标。
blob URL 不是可以交给后台的普通下载链接
第三个坑来自受访问控制媒体的下载。
有些媒体在页面侧表现为 blob: URL。它不是稳定的远程文件地址,而是浏览器在当前页面执行上下文中创建的对象 URL。
我最初的想法很自然:
content script 拿到 blob URL,发给 Service Worker;Service Worker 再调用下载 API。
结果失败了。
原因不在 chrome.downloads 本身,而在于:页面创建的 blob URL 不能被当成普通字符串跨扩展上下文使用。
扩展的 Service Worker 运行在独立的 extension origin 中;它不能可靠地解引用 Telegram 页面上下文创建的 blob URL。更何况 MV3 的 Service Worker 会按需休眠,也不适合把长下载任务的全部状态只放在内存里。
最后我把职责拆开:
- 页面侧:在能访问媒体对象 URL 的页面上下文中读取数据;
- 下载触发:页面侧将数据转成 Blob,通过用户已触发的下载流程保存;
- Service Worker:负责消息协调、配额、状态持久化、徽标和普通 URL 下载等任务。
简化后的页面侧触发逻辑类似这样:
ini
function saveBlob(blob: Blob, filename: string) {
const objectUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = objectUrl
link.download = filename
link.click()
setTimeout(() => URL.revokeObjectURL(objectUrl), 4000)
}
这里真正重要的不是代码,而是边界:
blob URL 不是"下载地址",而是某个页面上下文里暂时可访问的对象引用。
一旦把它当普通 URL 在不同上下文之间传递,就很容易遇到 Failed - Error 这种看起来毫无信息量的问题。
我也踩过 Chrome 商店审核的坑
功能能跑通,不代表能顺利上架。
我前后收到过三类审核反馈:
- 文案中的 "free unlimited download" 被认为是过度承诺,改成了更克制的描述;
host_permissions最初写得过宽,后来收窄到https://web.telegram.org/*;- 商店截图中出现了 Telegram 标识,审核反馈要求移除。
这些不一定是所有扩展都会遇到的固定规则,但至少对我来说,它们说明了一件事:
审核员会按最严格、最字面的方式理解你的文案、权限和素材。
现在我会在提交前额外检查:
- 有没有 "unlimited""best""guaranteed" 这类难以证明的绝对化措辞;
- 权限是否只覆盖当前已实现的功能;
- 截图、图标、文案里是否使用了第三方商标或容易引起误解的素材;
- 隐私说明、商店描述与实际行为是否一致。
Chrome 商店对最小权限有明确要求;宽泛的 host 权限也会增加审核和用户信任成本。
最后复盘:卸载率不是一个"运营指标"
我以前把卸载率当成一个结果数据:高了就改引导、改 UI、加功能。
后来才意识到,它更像一个报警器。
用户卸载不一定是因为"不喜欢",也可能是因为:
- 根本没看到入口;
- 当前主题下按钮没有渲染;
- 使用的是另一套 Web 客户端;
- 点击后没有任何可理解的反馈;
- 下载失败,但扩展没有告诉他为什么。
所以现在我更关心一条漏斗,而不是只盯卸载率:
扩展安装
→ Telegram 页面打开
→ 下载按钮成功展示
→ 用户点击按钮
→ 媒体地址解析成功
→ 下载任务成功完成
→ 用户保留扩展
只看最后一步,很难知道问题发生在哪里。
这次最让我后怕的不是某个选择器写错了,而是我在自己的环境里验证成功后,就默认它对所有用户都成立。
暗色模式要测。
A/K 两个客户端都要测。
blob URL 要按执行上下文设计,不能把它当普通链接。
商店文案、权限和截图,要按审核员会挑刺的标准准备。
用户不会替你做兼容性测试;他们只会在第一次用不了时离开。
参考链接: