// Linux · 2026-08-30

fortunes / fortunes-zh:从一条命令行到词库底层

1. 这是两个什么东西

  • fortune(包名 fortune-mod):一条随机打印“格言 / 签语”的命令。名字来自西方谚语 Fortune cookie(幸运饼干),掰开就是一句签文。它是个上古 Unix 工具,Ken Arnold 在 1979 年写的,至今还活在几乎每个发行版里。
  • fortunes-zh:中文词库包,附带一个 fortune-zh 包装脚本。内容包含唐诗三百首、宋词一百首、成语、谚语、警句,甚至 Debian 社区礼仪、Docker 手册摘录等,默认配色输出。

一句话:fortune 是“抽签机器”,fortunes-zh 是“中文签文库”,两者一拼,终端就变成了一个会念诗的文化人。

2. 安装

Debian 系(Kali / Ubuntu)一行装完:

$ sudo apt install fortune-mod fortunes-zh

装完先别急着用 —— 会踩一个经典坑:二进制不在默认 PATH 里。

$ which fortune
fortune not found

Debian 把游戏类程序统一放在 /usr/games/,而这个目录往往不在普通用户的 PATH 里。看一下包归属:

$ dpkg -S /usr/games/fortune /usr/games/fortune-zh /usr/bin/strfile
fortune-mod: /usr/games/fortune
fortunes-zh: /usr/games/fortune-zh
fortune-mod: /usr/bin/strfile

所以要么写全路径,要么把目录加进 PATH(写入 ~/.zshrc / ~/.bashrc):

$ export PATH="$PATH:/usr/games"

顺带一提,strfile 是生成索引的工具,它乖乖待在 /usr/bin,后面第 6 节会用到。

3. 第一炮

验证版本(注意老工具不认 --version 这种 GNU 风格长选项,但会顺口把版本号吐出来):

$ fortune -h 2>&1 | head -1
fortune-mod version 9708
fortune [-afilosw] [-m pattern] [-n number] [ [#%] file/directory/all]

直接抽签:

$ /usr/games/fortune
佳节清明桃李笑,野田荒冢只生愁。
    -- 黄庭坚《清明》

这是英文版工具的默认输出吗?不是。fortune 没带参数时扫的是默认数据目录 /usr/share/games/fortunes/,而里面装的全是中文词库,所以抽出来自然全是中文。

用官方包装脚本 fortune-zh,输出更讲究(作者一行用黄色、书名用绿色):

$ /usr/games/fortune-zh
花褪残红青杏小。燕子飞时,绿水人家绕。枝上柳绵吹又少,天涯何处无芳草!
墙里秋千墙外道。墙外行人,墙里佳人笑。笑渐不闻声渐悄,多情却被无情恼。
      -- 苏轼《蝶恋花》

4. 常用选项

fortune 的选项不多,个个有用:

选项 含义
-s / -l 只要短句 / 只要长句
-n N 定义“短句”的字符数阈值,默认 160
-e 所有词库等概率
-c 顺便打印词条来自哪个文件
-f 只列出会扫描的词库清单,不输出签文
-m 正则 在词库里搜索所有匹配的正则,输出到 stdout
-i 配合 -m 忽略大小写
-w 读完按字数等一会儿再退出(用于登出提示)

实测几个:

$ fortune -s          # 只要短句(阈值 160 字符)
$ fortune -l          # 只要长句
$ fortune -n 30 -s    # 阈值收到 30,短句更短
Orz

-e 等概率模式下,抽到的可能是任何词库,包括唐诗:

$ fortune -e
《奉和圣制从蓬莱向兴庆阁道中留春雨中春望之作应制》
作者:王维
渭水自萦秦塞曲,黄山旧绕汉宫斜。
銮舆迥出千门柳,阁道回看上苑花。
云里帝城双凤阙,雨中春树万人家。

-c 显示来源:

$ fortune -c
(tang300)
%
《秋日赴阙题潼关驿楼》
作者:许浑
红叶晚萧萧,长亭酒一瓢。

-f 列出词库清单(先记住这张表,第 8 节要回来对账):

$ fortune -f
100.00% /usr/share/games/fortunes
    92.79% chinese
     1.69% song100
     5.52% tang300

-m 是全库正则搜索,输出到 stdout,词条带 % 分隔符 —— 也就是说重定向出去就是一份合法的 fortune 词库文件:

$ fortune -m 明月 tang300
(/usr/share/games/fortunes/tang300)
%
《月下独酌》
作者:李白
花间一壶酒,独酌无相亲。
举杯邀明月,对影成三人。
%
《关山月》
作者:李白
明月出天山,苍茫云海间。

小贴士:-m 把文件名打到 stderr,签文打到 stdout。想要纯净版词库,只重定向 stdout 即可。

5. 词库长什么样(底层 · 一)

数据全部躺在 /usr/share/games/fortunes/:

$ ls -la /usr/share/games/fortunes/
.rw-r--r-- 2.1M root  6 Nov  2024 chinese
.rw-r--r--  21k root  6 Nov  2024 chinese.dat
lrwxrwxrwx    - root  6 Nov  2024 chinese.u8 -> chinese
.rw-r--r--  29k root  6 Nov  2024 song100
.rw-r--r-- 412  root  6 Nov  2024 song100.dat
lrwxrwxrwx    - root  6 Nov  2024 song100.u8 -> song100
.rw-r--r--  89k root  6 Nov  2024 tang300
.rw-r--r-- 1.3k root  6 Nov  2024 tang300.dat
lrwxrwxrwx    - root  6 Nov  2024 tang300.u8 -> tang300

每个词库三个文件:

  • 数据文件(chinese、tang300、song100):纯文本,就是签文本体。
  • 索引文件(.dat):strfile 生成的偏移表,让 fortune 能“随机直达”任意一条,下一节细讲。
  • .u8 符号链接:Debian 惯例,指向同名数据文件,标记“这份文件已经是 UTF-8”。对应 fortune -u 选项(“不要把 UTF-8 词条转换成 locale”),这样程序就知道不需要再对内容做编码转换。

打开数据文件看格式。每条签文用独占一行的 % 分隔:

$ xxd /usr/share/games/fortunes/chinese | head -6
00000000: e8a6 81e6 9c89 e7a4 bce8 b28c 0a0a e59c  要有礼貌\n\n在
00000010: a820 4465 6269 616e 20e8 bf99 e7a7 8de8  在 Debian 这...
00000020: a784 e6a8 a1e7 9a84 e9a1 b9e7 9bae e4b8  样的项目...
...
00000150: 6d0a 250a e596 84e6 848f e68e a8e5 ae9a  m\n%\n善义推...

注意 250a 就是 %\n,它是条与条之间的“封口”。另外还能看到签文里嵌着 ANSI 转义序列——颜色不是程序在输出时加的,而是直接写在数据文件文本里的:

$ xxd /usr/share/games/fortunes/chinese
00000110: e59b b4e3 8082 0a0a 1b5b 3333 6d20 2020  围。\n\n\x1b[33m
00000120: 202d 2d20 4465 6269 616e 201b 5b33 326d    -- Debian .[32m

\x1b[33m(黄色)打在作者名前、\x1b[32m(绿色)打在书名前、\x1b[m 复位——所以那份黄绿配色是“印”在词库文本里的,不是 fortune 程序渲染的。

三个词库的规模(用 Python 解析 .dat 头部得到,见下节):

词库 条目数 最长条目(字节)
chinese 5262 26553
tang300 313 2991
song100 96 910

6. .dat 索引:随机访问的关键(底层 · 二)

大文件有 2 万多条签文,fortune 不能每次从头读到尾——那样“随机”太慢。办法是维护一张偏移表:每条签文的起始字节位置。这就是 .dat 文件。

.dat 的二进制格式很古老:24 字节大端头 + 一串 4 字节偏移量。用 Python 精确解析:

$ python3 -c "
import struct
d = open('/usr/share/games/fortunes/chinese.dat','rb').read()
ver, nums, longest, shortest, flags = struct.unpack_from('>IIIII', d, 0)
offs = struct.unpack_from('>%dI' % ((len(d)-24)//4), d, 24)
print('version =', ver)
print('numstr  =', nums, '(条目数)')
print('longest =', longest, ' shortest =', shortest)
print('delim   =', repr(chr(d[20])))
print('offset 数量 =', len(offs), '= numstr + 1')
print('前 8 个偏移 =', offs[:8])
print('最后一个偏移 =', offs[-1])
"
version = 2
numstr  = 5262 (条目数)
longest = 26553  shortest = 4
delim   = '%'
offset 数量 = 5263 = numstr + 1
前 8 个偏移 = (0, 356, 736, 1236, 1808, 2339, 3365, 4636)
最后一个偏移 = 2115534

结构一目了然:

┌─────────────────────────────────────────┐
│  24 字节头部(大端序)                    │
│  version    : uint32 = 2                 │
│  numstr     : uint32 = 5262  条目数      │
│  longest    : uint32 = 26553 最长条目    │
│  shortest   : uint32 = 4     最短条目    │
│  flags      : uint32 = 0                 │
│  delim      : 1 字节 = '%'               │
│  delimlen   : uint16 = 0                 │
│  1 字节填充                              │
├─────────────────────────────────────────┤
│  偏移数组  off[0]..off[numstr]           │
│  每条 uint32(大端),共 numstr+1 个      │
│  off[i] = 第 i 条签文的起始字节位置       │
│  off[numstr] = 文件总大小(哨兵)          │
└─────────────────────────────────────────┘

所以 fortune 抽一条的逻辑就三步:

  1. 随机取 i ∈ [0, numstr);
  2. 打开数据文件,lseek 到 off[i],读 off[i+1] - off[i] 个字节;
  3. 去掉末尾的 %\n,打印。

看一眼最直观的证据——用一个小到能一眼看穿的词库:

$ cat /tmp/fortune_demo/myfortunes
第一条我的小纸条。
   --- 自己
%
君不见黄河之水天上来,奔流到海不复回。
   --- 李白
%
第三条,最短验证。

strfile 生成索引(注意它报的 3 条 / 最长 72 字节 / 最短 28 字节,跟字节流完全对得上):

$ /usr/bin/strfile /tmp/fortune_demo/myfortunes
"/tmp/fortune_demo/myfortunes.dat" created
There were 3 strings
Longest string: 72 bytes
Shortest string: 28 bytes

3 条签文,40 字节的 .dat(24 头 + 4×4 偏移):

$ xxd /tmp/fortune_demo/myfortunes.dat
00000000: 0000 0002 0000 0003 0000 0048 0000 001c   ver=2  n=3  longest=72 shortest=28
00000010: 0000 0000 2500 0000 0000 0000 0000 002c   flags=0 delim='%'  off[0]=0  off[1]=44
00000020: 0000 0076 0000 0092                       off[2]=118  off[3]=146(=文件总大小)

对照原文字节数:第一条 0–43 字节、第二条 44–117、第三条 118–145,文件总共 146 字节。偏移数组精确地把每条签文“框”住了。fortune 读起来:

$ fortune /tmp/fortune_demo/myfortunes
君不见黄河之水天上来,奔流到海不复回。
   --- 李白

7. fortune-zh 脚本解剖(底层 · 三)

/usr/games/fortune-zh 只有 33 行,是个 POSIX shell 脚本,核心就一句:

$ cat /usr/games/fortune-zh
#!/bin/sh
# fortune-zh
set -e

FORTUNE="/usr/games/fortune"
[ -x $FORTUNE ] || ( echo "E: Please install package 'fortune-mod'."; false )

# The old version (1.*) of fortune-zh contains only tang300 and song100.
# Note, $\sum_{i} P_i = 1$, i.e. all the possibilities must sum to 1.
DICT="7% tang300 2% song100 91% chinese"

# check LANG
if [ ! -z $LC_ALL ]; then
	LANG="$LC_ALL"
fi

# output according to LANG
case "$LANG" in
"zh_CN.GB2312")
	LANG=zh_CN.UTF8 $FORTUNE $DICT | iconv -c -f utf8 -t gbk
	;;
"zh_TW.Big5")
	LANG=zh_TW.UTF8 $FORTUNE $DICT | iconv -c -f utf8 -t big5
	;;
*)
	if [ ! -z "$FORTUNEZH_NOCOLOR" ]; then
		$FORTUNE $DICT | sed -r "s/\x1b\[([0-9]{1,2}(;[0-9]{1,2})?)?m//g"
	else
		$FORTUNE $DICT
	fi
	;;
esac

逐段拆解:

  • 加权词库:DICT="7% tang300 2% song100 91% chinese" —— 三个词库按 7% / 2% / 91% 的概率被抽到。这解释了为什么 fortune-zh 抽到的大多是 chinese 里那些杂学内容,唐诗宋词反而少见。
  • 编码适配:老系统 zh_CN.GB2312 / zh_TW.Big5 环境,用 iconv 把 UTF-8 数据实时转成目标编码再输出;现代 UTF-8 环境直接原样输出。
  • 去色开关:数据文件里嵌着 ANSI 颜色码,嫌花哨就设 FORTUNEZH_NOCOLOR=1,脚本用 sed 把 \x1b[NNm 全部剥掉:
$ FORTUNEZH_NOCOLOR=1 fortune-zh
世间无限丹青手,一片伤心画不成。
    -- 高蟾《金陵晚望》

对比带色的原始输出,能明显看到剥掉的是 \x1b[33m、\x1b[32m 这类序列。

8. 加权算法:实测而非臆测(底层 · 四)

-f 默认输出(第 4 节)显示 chinese 占 92.79%、song100 占 1.69%、tang300 占 5.52%。这三个数哪来的?是按各词库条目数算的:

chinese 5262 + song100 96 + tang300 313 = 5671 条
chinese: 5262 / 5671 = 92.79%
song100:  96 / 5671 =  1.69%
tang300: 313 / 5671 =  5.52%

也就是说,没给百分比时,fortune 把目录里所有文件当成“平摊剩余额度”,再按条目数(不是文件字节数)分配。man page 里原话就是 “twice as big (in number of fortunes, not raw file size)”。

那给了显式百分比呢?man 里只含混地说“n% 概率”。别猜,直接实验。

实验 A:50% short 50% long,两个文件各 10 条,但最长条目差 175 倍(17 字节 vs 2977 字节)。 跑 1000 次数分布:

$ fortune 50% short 50% long | …   # 统计 1000 次
    510 长句
    490 短句

510/490 ≈ 五五开。结论:显式百分比下,“最长条目长度”完全不参与加权——即使一个文件条目长得离谱,50% 就是 50%。

实验 B:90% short many long,其中 short 10 条、many 30 条、long 10 条,只有 short 标了百分比。

$ fortune -f 90% short many long
90.00% /tmp/fortune_demo/w/short
 7.50% /tmp/fortune_demo/w/many
 2.50% /tmp/fortune_demo/w/long

剩下 10% 分给了两个没标百分比的文件,且按条目数 30:10 拆成 7.5% : 2.5%。实测 500 次分布 88 / 8.6 / 3.2,符合预期。

实验 C:fortune -f 7% tang300 2% song100 91% chinese(正是 fortune-zh 那句):

$ fortune -f 7% tang300 2% song100 91% chinese
91.00% /usr/share/games/fortunes/chinese
 2.00% /usr/share/games/fortunes/song100
 7.00% /usr/share/games/fortunes/tang300

显式百分比原样生效。

于是加权规则收敛成一个简单模型:

对每个词库:显式给了 N%  → 权重就是 N
            没给百分比     → 平分剩余百分比,再按条目数比例细分
取随机数 r ∈ [0, 总和),按权重顺序累加,落在哪个区间就抽哪个文件

9. 用 30 行 Python 重写 fortune 核心

把第 6 节的逻辑落地成脚本,跑出来的效果和原生 fortune 一模一样:

#!/usr/bin/env python3
"""minifortune: 用 30 行重现 fortune 的底层取词逻辑"""
import os, random, struct

def pick(datafile):
    datfile = datafile + ".dat"
    with open(datfile, "rb") as f:
        d = f.read()
    # 24 字节大端头: version, numstr, longest, shortest, flags, delim, delimlen
    ver, numstr, longest, shortest, flags = struct.unpack_from(">IIIII", d, 0)
    delim = chr(d[20])
    offsets = struct.unpack_from(">%dI" % (numstr + 1), d, 24)
    # 随机选一条,读 [off[i], off[i+1]),去掉末尾的分隔符 "%\n"
    i = random.randrange(numstr)
    with open(datafile, "rb") as f:
        f.seek(offsets[i])
        entry = f.read(offsets[i + 1] - offsets[i])
    entry = entry.rstrip(b"%\n")
    return entry, numstr, longest, shortest, delim

entry, numstr, longest, shortest, delim = pick("/usr/share/games/fortunes/chinese")
print(">> minifortune 解析: numstr=%d longest=%d shortest=%d delim=%r"
      % (numstr, longest, shortest, delim))
print(entry.decode("utf-8"))

跑一次:

$ python3 mini_fortune.py
>> minifortune 解析: numstr=5262 longest=26553 shortest=4 delim='%'
口封得住,人口封不住。
    -- 《谚语》

底层真相就藏在这 30 行里:随机 = 先随机一个下标,再用偏移表直读那一段字节。

10. 自己造一个词库

三步走,5 秒钟搞定:

① 写数据文件,条目间用独占一行的 % 分隔:

$ cat > /tmp/mine <<'EOF'
早起的鸟儿有虫吃,早起的虫子被鸟吃。
   --- 我自己
%
代码写得好,Bug 逃得早。
%
最后的签文:人生苦短,我用 Python。
EOF

② strfile 生成索引:

$ /usr/bin/strfile /tmp/mine
"/tmp/mine.dat" created
There were 3 strings

③ 直接当参数用,或放进 FORTUNE_PATH 指向的目录当默认库:

$ fortune /tmp/mine
代码写得好,Bug 逃得早。

$ mkdir -p ~/.myfortunes && cp /tmp/mine /tmp/mine.dat ~/.myfortunes/
$ FORTUNE_PATH=~/.myfortunes fortune
早起的鸟儿有虫吃,早起的虫子被鸟吃。
   --- 我自己

以后每次新开终端想被灌一句鸡汤,把它加进 ~/.zshrc:

echo "$(/usr/games/fortune-zh)"

11. 一张图看懂全链路

你敲: fortune-zh
        │
        ▼
fortune-zh 脚本 (shell)         ←── 3 层外壳
   ├─ 拼参数: 7% tang300 2% song100 91% chinese
   ├─ 按 LANG 决定是否 iconv 转码
   └─ 按 FORTUNEZH_NOCOLOR 决定是否剥色
        │
        ▼
fortune 程序 (C)                ←── 2 层索引
   ├─ 读各 .dat 24 字节头 → 拿到条目数 numstr
   ├─ 加权随机选一个文件、一个下标 i
   └─ lseek 到 off[i],读 off[i+1]-off[i] 字节
        │
        ▼
数据文件 chinese / tang300 / song100   ←── 1 层文本
   └─ 一段以 "%\n" 结尾的 UTF-8 文本(内含 ANSI 配色)
        │
        ▼
stdout: 一句古诗/名言(带色)

结语

一个 40 年前的命令行小玩具,剥开看其实是三件套:一份文本、一张偏移表、一个加权随机。数据文件决定“有什么”,.dat 决定“怎么快”,百分比语法决定“怎么偏”。理解了这三层,fortune 在你眼里就不再是黑盒——它甚至不如你自己写的 Python 复杂。

下次想在终端里“撞见”一句诗,记得先 export PATH="$PATH:/usr/games"。

原文 https://blog.csdn.net/2301_79518550/article/details/164194705