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一行装完$sudoaptinstallfortune-mod fortunes-zh装完先别急着用 —— 会踩一个经典坑二进制不在默认 PATH 里。$whichfortune fortune not foundDebian 把游戏类程序统一放在/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$exportPATH$PATH:/usr/games顺带一提strfile是生成索引的工具它乖乖待在/usr/bin后面第 6 节会用到。3. 第一炮验证版本注意老工具不认--version这种 GNU 风格长选项但会顺口把版本号吐出来$ fortune-h21|head-1fortune-mod version9708fortune[-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-n30-s# 阈值收到 30短句更短Orz-e等概率模式下抽到的可能是任何词库包括唐诗$ fortune-e《奉和圣制从蓬莱向兴庆阁道中留春雨中春望之作应制》 作者王维 渭水自萦秦塞曲黄山旧绕汉宫斜。 銮舆迥出千门柳阁道回看上苑花。 云里帝城双凤阙雨中春树万人家。-c显示来源$ fortune-c(tang300)% 《秋日赴阙题潼关驿楼》 作者许浑 红叶晚萧萧长亭酒一瓢。-f列出词库清单先记住这张表第 8 节要回来对账$ fortune-f100.00% /usr/share/games/fortunes92.79% chinese1.69% song1005.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 root6Nov2024chinese .rw-r--r-- 21k root6Nov2024chinese.dat lrwxrwxrwx - root6Nov2024chinese.u8 -chinese .rw-r--r-- 29k root6Nov2024song100 .rw-r--r--412root6Nov2024song100.dat lrwxrwxrwx - root6Nov2024song100.u8 -song100 .rw-r--r-- 89k root6Nov2024tang300 .rw-r--r--1.3k root6Nov2024tang300.dat lrwxrwxrwx - root6Nov2024tang300.u8 -tang300每个词库三个文件数据文件chinese、tang300、song100纯文本就是签文本体。索引文件.datstrfile生成的偏移表让fortune能随机直达任意一条下一节细讲。.u8符号链接Debian 惯例指向同名数据文件标记这份文件已经是 UTF-8。对应fortune -u选项“不要把 UTF-8 词条转换成 locale”这样程序就知道不需要再对内容做编码转换。打开数据文件看格式。每条签文用独占一行的%分隔$ xxd /usr/share/games/fortunes/chinese|head-600000000: e8a6 81e6 9c89 e7a4 bce8 b28c 0a0a e59c 要有礼貌\n\n在 00000010: a82044656269616e 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 b4e380820a0a 1b5b33336d202020围。\n\n\x1b[33m 00000120: 202d 2d2044656269616e 201b 5b33 326d -- Debian .[32m\x1b[33m黄色打在作者名前、\x1b[32m绿色打在书名前、\x1b[m复位——所以那份黄绿配色是印在词库文本里的不是fortune程序渲染的。三个词库的规模用 Python 解析.dat头部得到见下节词库条目数最长条目(字节)chinese526226553tang3003132991song100969106..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]) version2numstr5262条目数 longest26553shortest4delim%offset 数量5263numstr 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大端共 numstr1 个 │ │ off[i] 第 i 条签文的起始字节位置 │ │ off[numstr] 文件总大小哨兵 │ └─────────────────────────────────────────┘所以fortune抽一条的逻辑就三步随机取i ∈ [0, numstr)打开数据文件lseek到off[i]读off[i1] - off[i]个字节去掉末尾的%\n打印。看一眼最直观的证据——用一个小到能一眼看穿的词库$cat/tmp/fortune_demo/myfortunes 第一条我的小纸条。 --- 自己 % 君不见黄河之水天上来奔流到海不复回。 --- 李白 % 第三条最短验证。strfile生成索引注意它报的 3 条 / 最长 72 字节 / 最短 28 字节跟字节流完全对得上$ /usr/bin/strfile /tmp/fortune_demo/myfortunes/tmp/fortune_demo/myfortunes.datcreated There were3strings Longest string:72bytes Shortest string:28bytes3 条签文40 字节的.dat24 头 4×4 偏移$ xxd /tmp/fortune_demo/myfortunes.dat 00000000: 0000 0002 0000 0003 0000 0048 0000 001cver2n3longest72shortest2800000010: 0000 000025000000 0000 0000 0000 002cflags0delim%off[0]0off[1]4400000020: 0000 0076 0000 0092 off[2]118off[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-zhset-eFORTUNE/usr/games/fortune[-x$FORTUNE]||(echoE: 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.DICT7% tang300 2% song100 91% chinese# check LANGif[!-z$LC_ALL];thenLANG$LC_ALLfi# output according to LANGcase$LANGinzh_CN.GB2312)LANGzh_CN.UTF8$FORTUNE$DICT|iconv-c-futf8-tgbk;;zh_TW.Big5)LANGzh_TW.UTF8$FORTUNE$DICT|iconv-c-futf8-tbig5;;*)if[!-z$FORTUNEZH_NOCOLOR];then$FORTUNE$DICT|sed-rs/\x1b\[([0-9]{1,2}(;[0-9]{1,2})?)?m//gelse$FORTUNE$DICTfi;;esac逐段拆解加权词库DICT7% tang300 2% song100 91% chinese—— 三个词库按7% / 2% / 91%的概率被抽到。这解释了为什么fortune-zh抽到的大多是 chinese 里那些杂学内容唐诗宋词反而少见。编码适配老系统zh_CN.GB2312/zh_TW.Big5环境用iconv把 UTF-8 数据实时转成目标编码再输出现代 UTF-8 环境直接原样输出。去色开关数据文件里嵌着 ANSI 颜色码嫌花哨就设FORTUNEZH_NOCOLOR1脚本用sed把\x1b[NNm全部剥掉$FORTUNEZH_NOCOLOR1fortune-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% 概率。别猜直接实验。实验 A50% short 50% long两个文件各 10 条但最长条目差 175 倍17 字节 vs 2977 字节。跑 1000 次数分布$ fortune50% short50% long|…# 统计 1000 次510长句490短句510/490 ≈ 五五开。结论显式百分比下最长条目长度完全不参与加权——即使一个文件条目长得离谱50% 就是 50%。实验 B90% short many long其中 short 10 条、many 30 条、long 10 条只有 short 标了百分比。$ fortune-f90% short many long90.00% /tmp/fortune_demo/w/short7.50% /tmp/fortune_demo/w/many2.50% /tmp/fortune_demo/w/long剩下 10% 分给了两个没标百分比的文件且按条目数 30:10 拆成 7.5% : 2.5%。实测 500 次分布 88 / 8.6 / 3.2符合预期。实验 Cfortune -f 7% tang300 2% song100 91% chinese正是 fortune-zh 那句$ fortune-f7% tang3002% song10091% chinese91.00% /usr/share/games/fortunes/chinese2.00% /usr/share/games/fortunes/song1007.00% /usr/share/games/fortunes/tang300显式百分比原样生效。于是加权规则收敛成一个简单模型对每个词库显式给了 N% → 权重就是 N 没给百分比 → 平分剩余百分比再按条目数比例细分 取随机数 r ∈ [0, 总和)按权重顺序累加落在哪个区间就抽哪个文件9. 用 30 行 Python 重写 fortune 核心把第 6 节的逻辑落地成脚本跑出来的效果和原生fortune一模一样#!/usr/bin/env python3minifortune: 用 30 行重现 fortune 的底层取词逻辑importos,random,structdefpick(datafile):datfiledatafile.datwithopen(datfile,rb)asf:df.read()# 24 字节大端头: version, numstr, longest, shortest, flags, delim, delimlenver,numstr,longest,shortest,flagsstruct.unpack_from(IIIII,d,0)delimchr(d[20])offsetsstruct.unpack_from(%dI%(numstr1),d,24)# 随机选一条读 [off[i], off[i1])去掉末尾的分隔符 %\nirandom.randrange(numstr)withopen(datafile,rb)asf:f.seek(offsets[i])entryf.read(offsets[i1]-offsets[i])entryentry.rstrip(b%\n)returnentry,numstr,longest,shortest,delim entry,numstr,longest,shortest,delimpick(/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.pyminifortune 解析:numstr5262longest26553shortest4delim%口封得住人口封不住。 -- 《谚语》底层真相就藏在这 30 行里随机 先随机一个下标再用偏移表直读那一段字节。10. 自己造一个词库三步走5 秒钟搞定① 写数据文件条目间用独占一行的%分隔$cat/tmp/mineEOF 早起的鸟儿有虫吃早起的虫子被鸟吃。 --- 我自己 % 代码写得好Bug 逃得早。 % 最后的签文人生苦短我用 Python。 EOF②strfile生成索引$ /usr/bin/strfile /tmp/mine/tmp/mine.datcreated There were3strings③ 直接当参数用或放进FORTUNE_PATH指向的目录当默认库$ fortune /tmp/mine 代码写得好Bug 逃得早。 $mkdir-p~/.myfortunescp/tmp/mine /tmp/mine.dat ~/.myfortunes/ $FORTUNE_PATH~/.myfortunes fortune 早起的鸟儿有虫吃早起的虫子被鸟吃。 --- 我自己以后每次新开终端想被灌一句鸡汤把它加进~/.zshrcecho$(/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[i1]-off[i] 字节 │ ▼ 数据文件 chinese / tang300 / song100 ←── 1 层文本 └─ 一段以 %\n 结尾的 UTF-8 文本内含 ANSI 配色 │ ▼ stdout: 一句古诗/名言带色结语一个 40 年前的命令行小玩具剥开看其实是三件套一份文本、一张偏移表、一个加权随机。数据文件决定有什么.dat决定怎么快百分比语法决定怎么偏。理解了这三层fortune在你眼里就不再是黑盒——它甚至不如你自己写的 Python 复杂。下次想在终端里撞见一句诗记得先export PATH$PATH:/usr/games。
