有些技术的探索,往往始于一个完全不相干的困惑。

欸?账号已注销……

整理B站关注列表的时候,我注意到有一个特别关注的账号显示"已注销",头像灰蒙蒙的,点进去什么也看不到。

这……是谁呢?

我特别关注的人不多,每一个都是认真筛选过的。突然少了一个,心里不免有些空落落的。

一番搜索之下,我发现了一个网站中的一个工具 tool.bili.fan/user-info。这个工具可以通过用户ID查询B站用户的历史信息。我满怀期待地试了试……嗯,最后还是没能查到那几个注销账号到底是谁。

但故事并没有在这里结束。

一个意外的发现!

在探索这个网站的过程中,我注意到角落里还有一个小工具,叫"文字隐写"。出于好奇点进去——"零宽字符隐写"。

欸?零宽字符?那是什么? 简单地说,就是一些肉眼完全看不见的Unicode字符,比如零宽空格(U+200B)零宽断字符(U+200C) 之类的。它们在文本编辑器里不占任何显示空间(一般的),但是是实际存在的。

这个工具做的事情很有趣:把一段秘密信息编码成这些看不见的字符,然后可以把它塞进一段普通的掩护文字里。我立刻想到了它的应用场景——表面上你看到的文本,实际上里面可能藏一大段话(或者版权信息)。

看起来很厉害的样子!

出于好奇心,我想试试能不能自己做一个

最初版本

说干就干!我的思路还是很直接的:

  1. 用两个零宽字符分别代表二进制 01——U+200B 表示 0U+200C 表示 1
  2. 把秘密文本用 TextEncoder 转成UTF-8字节数组
  3. 把每个字节拆成8个二进制位
  4. 每一位映射成一个零宽字符
  5. 拼接到掩护文本后面

代码写出来,测试通过。把 "Hello" 藏进一段中文里,再提取出来……成功了!

不过玩了一会儿我就发现了不足:当我试着隐藏一段稍微长一点的文字——也就一两百字——生成的零宽字符数量就爆炸式的增长。

问题出在哪里呢?现在每个零宽字符只携带 1bit 的信息。 一个英文字母在UTF-8里占1个字节(8bit),需要8个零宽字符;一个中文字符占3个字节(24bit),需要24个零宽字符。如果秘密信息有100个中文字符,就需要2400个零宽字符。隐写后的文本膨胀到原来的好几倍,这在实用中完全不可接受!

于是便开始琢磨怎么让每个零宽字符带更多信息?

6n+2 的上限

后来我再次回到那个网站,研究了一下它的编码效率。反复测试之后,我发现一个规律:隐写后的零宽字符数量等于原始秘密字符数的6倍再加2——也就是 6n+2(n为秘密信息的字符数)。

虽然没法通过源代码看到具体是怎么实现的,但这个数字给了我一个目标。如果我的工具能达到这个效率,至少还有些成就感。

从 1bit 到 2bit

我做的第一件事是增加零宽字符的种类。从2种变成4种——U+200BU+200CU+200DU+FEFF——每个字符可以表示 00011011 四种状态,也就是每个零宽字符携带2 bit信息

这样一来,同样的秘密信息,零宽字符数量直接减半。

我还引入了一个关键优化——Deflate压缩。在编码之前,先用pako库对数据进行压缩。对于长文本,压缩效果非常明显,有时能把数据体积压缩到原来的30%以下。但压缩也有代价:压缩后的数据需要额外的头部信息来标记"是否压缩"以及"原始长度",对于只有几个字符的短文本,压缩反而会让数据变长。所以策略是:压缩后如果比原来小,就用压缩版本,否则用原始版本。

我在编码结果的头部加了5个字节:1个字节表示是否压缩(0x00或0x01),4个字节记录原始数据的长度(大端序)。解码时先读取头部,根据标志决定是否解压。

从 2bit 到 3bit

"既然4种字符可以带 2bit,那8种字符是不是就能带 3bit?"

我把零宽字符扩展到8种——在原来4种的基础上增加了 U+200EU+200FU+2060U+2063——每个字符携带 3bit 信息。这样一来,编码效率又提升了50%。同样是存储一个字节(8bit),原来需要4个零宽字符(2bit×4),现在只需要3个(3bit×3,还多 1bit 容量)。

还差一点

但这还不够。3 bit字节模式虽然效率不错,但离6n+2还有距离。我开始思考:能不能换一种思路,直接从字符的Unicode码点出发,而不是从UTF-8字节出发

UTF-8是面向字节流的编码,每个字符占的字节数不同——英文1字节,中文3字节,Emoji4字节。如果我直接对Unicode码点编码,就可以根据不同字符类型选择不同的编码长度,做到"按需分配"。

于是乎,我设计了一套字符级编码方案,每个字符的编码结构是这样的:

ASCII字符(码点 ≤ 0x7F):

  • 标记位:00(2 bit)
  • 数据位:7bit(直接存放码点)
  • 总计:9bit → 3个零宽字符(3bit × 3)

BMP字符(码点 ≤ 0xFFFF,如中文):

  • 标记位:01(2bit)
  • 数据位:16bit(直接存放码点)
  • 总计:18bit → 6个零宽字符(3bit × 6)

非BMP字符(码点 > 0xFFFF,如Emoji):

  • 标记位:10(2bit)
  • 数据位:20bit(码点减去0x10000后的值,范围0~0xFFFFF)
  • 补位:2个0(凑成24bit)
  • 总计:24bit → 8个零宽字符(3bit × 8)

这套方案最大的好处是不需要额外的头部信息——解码器只需逐段读取,先读 2bit 判断标记,再根据标记类型读入相应长度的数据位,就能还原出完整的码点。每个字符的边界是靠标记位自描述的,不需要额外的长度字段。

而且每个秘密字符最多使用6个零宽字符(ASCII用3个,BMP用6个,非BMP用8个但通常很少见),整体零宽字符数量一定不会超过6n+2

我终于达到了6n+2的目标!

还能更好吗?

达成目标并不意味着探索的结束。我继续想:能不能比6n+2更优?

混合策略

我想到可以把字节级压缩模式和字符级模式结合起来:

  1. 先用字节级压缩方案处理秘密信息,计算出需要的零宽字符数量
  2. 如果这个数量 ≤ 6n+2,就采用压缩方案
  3. 否则回退到字符级方案

这样做的好处是:对于可压缩性强的文本(比如纯英文、重复度高的内容),压缩方案可以大幅低于6n+2;对于不可压缩的短文本,字符级方案也能保证不超过6n+2。无论什么内容,最坏情况就是6n+2,但很多时候会比这个好。

多编码择优

我又想到一个点:UTF-8不是唯一的编码方式。对于纯中文文本,UTF-16BE的字节数可能比UTF-8更少——中文在UTF-16里固定2字节,而在UTF-8里需要3字节。

于是我在压缩模块里同时尝试两种编码:

  • UTF-8 → Deflate压缩
  • UTF-16BE → Deflate压缩

然后选取压缩后字节数更小的那个。在头部增加1个字节记录编码类型(0=UTF-8,1=UTF-16BE)。这个改动对中文内容非常友好,压缩后UTF-16常常胜出。

五策略全局择优

后来我把思路彻底放开:为什么不把所有可能的方案都列出来,然后选最优的那个?

于是我的编码器变成了这样的"策略竞标会":

策略编号 编码方式 是否压缩 每字符bit数
1 UTF-8 是(Deflate) 3(字节模式)
2 UTF-16BE 是(Deflate) 3(字节模式)
3 UTF-8 3(字节模式)
4 UTF-16BE 3(字节模式)
5 字符级编码 3(字符模式)

对每个方案,我都计算出最终零宽字符的总数量,然后选择数量最少的那个。这就像是一个智能的"最优路径规划"——不管输入什么内容,系统都会自动找到最省空间的隐写方式。

从3 bit到4 bit

"既然3 bit可以做到8种字符,那 4bit 能不能用16种字符?"

答案是肯定的。我扩展了零宽字符表到16种:

U+200B, U+200C, U+200D, U+FEFF, U+200E, U+200F, U+2060, U+2061,
U+2062, U+2063, U+2064, U+202A, U+202B, U+202C, U+202D, U+202E

每个零宽字符可以表示0~15共16种状态,也就是** 4bit 信息**。

字节模式下的编码方式变成了:一个字节(8bit)拆成高4位和低4位,分别映射成两个零宽字符。相比 3bit 模式,同样数量的零宽字符可以多承载33%的信息

不过在升级到 4bit 的过程中,我遇到了一个小问题——U+202C(POP DIRECTIONAL FORMATTING)是一个方向控制字符,在某些文本环境中会影响后面文字的显示方向。为了解决这个问题,我在每个 4bit 编码的末尾自动追加一个U+202C作为"隔离符",确保方向控制不会泄漏到掩护文本上。

我也留了一个心眼:不是所有秘密信息都适合 4bit 字节模式,尤其是非常短的文本(几个字符),字符级编码可能更高效。所以我把 3bit 的字符级编码保留下来,作为最后的回退方案。

最终方案

版本 零宽字符数 bit/字符 压缩 编码方式 头部开销
初版 2种 1bit 逐字节二进制
V4 4种 2bit 自动 字节模式 5字节
V5 8种 3bit 自动 字节模式 5字节
V7 8种 3bit 字符级(按码点) 无(自描述)
V8 8种 3bit 混合 字节压缩 + 字符级 5字节/无
V10 8种 3bit 五策略择优 压缩/原始 × UTF-8/16 + 字符级 5字节/无
V11 16种 4bit 五策略择优 同上(字节模式升级) 6字节 + 隔离符
V13 16种 4bit 五策略择优 完整方案(含字符级回退) 6字节 + 隔离符

最后我想说…

经过这么多轮迭代,我总结了几点实用心得:

关于压缩阈值:Deflate压缩对纯英文长文本效果极佳,压缩率常在40%~60%之间。但对已经比较紧凑的二进制数据(比如加密后的密文),压缩效果有限,甚至可能变大。所以"压缩后比较再决定"这个策略是必要的。

关于编码选择:UTF-8对英文友好(1字节/字符),UTF-16对中文友好(2字节/字符)。对于混合内容,让程序自动尝试两种编码再择优,比手动指定要可靠得多。

关于4 bit字符池:可用的零宽字符其实比16种多,但有些字符(如U+202C)具有特殊控制功能,使用时需要额外注意。我选择把U+202C用作隔离符而不是数据字符,就是为了避免方向控制干扰。

关于解码健壮性:实际使用中,编码后的文本可能会经过复制、粘贴、格式转换等操作,部分零宽字符可能丢失或被过滤。所以在解码时,我做了容错处理:忽略无法识别的字符,只处理能匹配上的零宽字符,尽可能还原出原始信息。

升华?

所以呀——

好奇心是最好的驱动力。 如果不是因为好奇B站注销账号是谁,我可能永远不会发现那个零宽字符工具,也就不会有后面这一系列的学习和探索。

一个目标很重要。 比如"6n+2"这个上限,让我知道自己该往哪个方向努力。

没有目标的时候很容易满足于"能用就行",有了目标才会不断追问"还能更好吗?"

迭代 > 一次完美。 第一版代码很粗糙,但它能跑。如果一开始就想做到最好,我可能永远都不会开始。先做出来,再慢慢优化——这是我觉得最踏实的一条路。


现在的工具已经可以做到:对大部分常见文本,零宽字符的膨胀率控制在4倍以内,对纯英文长文本甚至能逼近2.5倍。而且这一切都是自动完成的,用户只需要输入掩护文本和秘密信息,点一下按钮就行。

也许下次当你在某个角落看到一段看似普通的文字时,说不定藏这某个人给你的悄悄话呢。

哦对了,最后虽说我还是没找到那个注销账号是谁。但,这段意外开启的编码之旅,比那个答案有趣多了,不是吗?