解码AES-128的一些小坑

短文,个人笔记用。

昨天因为某个需求需要手动解码下下来的加密的TS块。理论上很简单,我随便就搜到了Python:

from binascii import unhexlify

from Crypto.Cipher import AES

key = "614c9b1fa9ea1b1be878929c592d20e0"
key = unhexlify(key)
iv = 630
iv = iv.to_bytes(16, 'big')

decipher = AES.new(key, AES.MODE_CBC, iv)
encrypted = open('enc', 'rb').read()

decrypted = decipher.decrypt(encrypted)
open('dec_python', 'wb').write(decrypted)

Node.js(抄袭自Minyami):

const fs = require('fs');
const crypto = require('crypto');

let key = '614c9b1fa9ea1b1be878929c592d20e0';
let iv = (630).toString(16);
const algorithm = "aes-128-cbc";

if (iv.length % 2 == 1) {
    iv = "0" + iv;
}

const keyBuffer = Buffer.alloc(16);
const ivBuffer = Buffer.alloc(16);
keyBuffer.write(key, "hex");
ivBuffer.write(iv, 16 - iv.length / 2, "hex");

const input = 'enc'
const output = 'dec_node'

const decipher = crypto.createDecipheriv(algorithm, keyBuffer, ivBuffer);
const i = fs.createReadStream(input);
const o = fs.createWriteStream(output);
i.pipe(decipher).pipe(o);

OpenSSL:

openssl enc -aes-128-cbc -d -K 614c9b1fa9ea1b1be878929c592d20e0 -iv 276 -in enc -out dec_openssl.ts

多种方法。本来这事儿就完了,解码出来的文件也都可以正常播放;但是我闲着没事对比了下几种方式出来的文件,发现居然hash都不一致?!

Padding

以结果不match搜索,很快就知道了Python和Node的异同:Node这个decipher比较高级,默认就有auto Padding(对于解码,就是auto unpadding)。这个可以通过decipher.setAutoPadding(false);来取消掉。相对,Python我这个就比较底层,所以要手动unpad:

from Crypto.Util.Padding import unpad

decrypted_unpad = unpad(decrypted, AES.block_size)
open('dec_python_unpad', 'wb').write(decrypted_unpad)

我们这样生成了unpad(JS默认,Pythonunpad)和没unpad(Python默认,JS设为false)的两组文件,两两hash相等。

不过,对于我们的应用(解码HLS的视频块),是否真的需要unpad呢?rfc8216#section-4.3.2.4提到了要用标准的PKCS7 padding,那么我们就理解为需要unpad吧。

OpenSSL的IV补全问题

OK这俩一致了,那么OpenSSL是怎么回事?OpenSSL也是默认pad/unpad的,可以通过-nopad参数来取消。但是无论加还是不加都和上面两个产生的结果不一致。另外还有-nosalt,但是似乎对AES-128-CBC算法并没有区别。

我在网上搜了半天也没什么头绪,不过发现运行时OpenSSL有个提示:hex string is too short, padding with zero bytes to length。会不会有关系?我于是测试了下把IV改成0276、000276等等,发现结果居然都不一样!不是自动在前面填充0吗,为啥会有区别?百思不得其解下,我用这个warning作为关键词搜了半天,终于搜到了一个相关的文章——虽然这个文章没有直接说只是给你演示了一波,但是可以看出,原来天杀的他是在后面填充〇而不是前面!虽然现在看来,这也很正常,但是由于HLS这里的IV用的是默认的sequence number(一个int),所以我直觉地天真以为他肯定是在前面填充〇了。这个文章里引用了SO某答案里的一句:

It is always best to provide the exact size inputs to encryption functions, specify 32 hex digits for an AES 128-bit key.

zaph from https://stackoverflow.com/a/39908983/3939155

深表赞同。我要是一开始这么做也不会踩坑了。

其实这个文章也不需要那么大费周章对比,因为OpenSSL enc有个argument是-p,可以直接print相关的key/IV等:

G:\2>openssl enc -aes-128-cbc -d -K 614c9b1fa9ea1b1be878929c592d20e0 -iv 276 -in enc -out dec_openssl -p
hex string is too short, padding with zero bytes to length
salt=2831EC7600000000
key=614C9B1FA9EA1B1BE878929C592D20E0
iv =27600000000000000000000000000000

就直接可以看到问题所在了。

用FFMPEG解密

顺便一提,如果你直接搜索解密HLS加密的ts文件,搜到的绝大部分都是教你用ffmpeg;但是ffmpeg解码一个m3u8文件倒是很方便,但是直接解码单独ts文件怎么做却几乎没人提及。

我搜了半天终于搜到一个superuser的问题的评论里提到方法:

ffmpeg -key <key> -iv <iv> -i crypto:dec.ts

这里key和iv也都是hexstring(同上,自己手动填充到足bytes吧!),注意后面的-i要加crypto协议

如果要输出后面就-c copy out.ts就行了。但是注意,因为ffmpeg的操作,即使是copy,也会在容器级别有很多操作,所以和上面三种方法纯解码的是无法直接对比的。我只能说肉眼收货解码是成功的就是了。

IV对最后结果的影响

在最开始我用OpenSSL输入IV长度不够导致被错误 append zeros的时候,解码出来的文件也是可以完全正常播放的。因为AES-128-CBC的原理就是用第一个block明文+IV来加密成密文,然后再用第一个block的密文当做第二个block的IV来继续加密。所以,即使IV错误,也只会导致第一个block解码错误。由于mepg-ts是一个容错性非常强的格式,几乎完全不会导致解码结果有任何问题。但是既然我们都有IV了,那还是正确处理吧。

Python在code page 65001命令行下print 非ASCII字符的问题

之前不是写过一个自动化破解Kindle提取图片的脚本嘛,里面有一部是调用别人的一个py2脚本来抽取res里的HD图片。

那个脚本我琢磨着不是很复杂,于是想想能不能改成py3,这样就可以直接当模块调用了,elegant很多。我先用2to3转了一波,把里面的printdict.keys()list(dict)之类的比较简单的转换了。跑了下果然还不行,然后就照着报错信息一点点改。

其实,绝大部分的问题都源自字符串的处理。Py2里字符串默认是byte,py3是Unicode字符串。这个其实再做一些低级操作(比如这里的抽取文件头啥的)反而是py2比较方便,抽出来就是byte直接就能操作。不过既然我们要转py3了,那就逐一处理吧。里面如果本来就是当字符串用的不用处理,那些从byte读出来的则实质是byte类型,所以如果要print或者和其他真·字符串进行合并之类的,要先decode()一下;大部分其实都是一些ASCII的控制字符,所以decode()默认的utf-8就行。原本的从Unicode encode到byte的直接去掉encode()unicode()要变str().encode('hex')(假设前面的变量是byte)直接换.hex()。其实,直接print byte也无所谓,就是会显示成b'abc'而已,但是强迫症表示受不了。

然后我着手处理之前懒得处理的print(self.title)崩掉的问题。其实转换成py3之后,这个问题就很少出现了,但是我发现当你把输出redirect到文档(比如x.py > 1.txt)还会有问题。研究了下,发现在输出到文档的时候,sys.stdout.encoding会变成非U的locale,而不是utf-8。虽然我平时也不这么干,但是保险起见搞了个

try: 
    print(self.title)
except: # It will have problem otherwise in certain env, such as when redirect output to '> 1.txt' 
    print(self.title.encode(sys.stdout.encoding, "ignore").decode(sys.stdout.encoding))

应付。这么搞了一波之后学校测试了一番没问题,很开心。结果回家之后一跑脚本报错了。

由于报错之后直接就关掉了,我于是先启动cmd然后里面再运行我的命令,结果这次报错的地方都变了。我不断地注释语句,最后得出的结论是,凡是print的地方都有可能出错——但是用VS Code的控制台(CMD)运行同样的命令就不会错。

折腾了很久之后,我想起之前的一个现象,用ebook-extract的时候,他会很自作多情地先把CMD的code page切成65001(理论上应该是UTF-8,但是微软的实现有很多缺陷经常被社区诟病)——我能发现是因为字体点阵高度会变,导致窗口变得很矮。我手动切CP(CMD命令:chcp 65001)试了下:

Active code page: 65001

C:\Users\Administrator>py -3 -c print('\u0142')
Traceback (most recent call last):
 File "<string>", line 1, in <module>

C:\Users\Administrator>

果然Python在65001的CMD下,输出任何非ASCII的字符都会直接报错(return?)。搜了下Python的bug tracker,开发者说这是Windows的bug,具体来说是在CP65001下,Win对Unicode字符错误地按ANSI来准备buffer,导致buffer大小不足导致。

其实calibre的所有命令行工具都有这个毛病。暂时不是很清楚为什么一定要切换到CP65001操作,而且最可恶的是用完还不切回来。顺便一提,还有个bug是运行完calibre的CLI工具后,在同一个CMD窗口进行复制操作会直接崩掉CMD,也是酷炫。

不过很奇怪地是,在VS Code里的CMD就不会有这个问题,不过VS Code的console一直都比较robust就是了。

既然知道了原因,解决方法也简单粗暴:直接在ebook-convert跑完后自行把CP切回来。我的函数是

import os, locale
def changeCodePageBack(): 
    cp = locale.getpreferredencoding().replace('cp','')    
    os.system('chcp '+cp)

这里用locale.getpreferredencoding()可以读取到当前电脑的默认非Unicode环境。

哦对了,我修改完的Py3版抽取res的脚本在这里。Rev.1是原py 2版,可以看diff。由于属于盗版原版,不要到处传播哈。

自动化破解Kindle图书到图片

21年3月3日更新:

最近老有人问,正好DeDRM Tool也升级到py3了,公布下自己写的一键提取脚本:

https://github.com/fireattack/extract_kindle

有问题去那里发issue吧。


今天购入了日亚的Kindle Unlimited,瞬间有一大堆电子版的杂志要转换格式。

数量多了再用上文提到的手动方法就不行了,会烦死。于是琢磨着怎么能自动化。首先我想到一个简单的办法:既然高清图片都在.res文件里,那就直接把.res文件关联到那个python2脚本,然后直接extract图片就完了。

方法么自然就是修改注册表,也很简单,就加这么一句:

Windows Registry Editor Version 5.00

[HKEY_CLASSES_ROOT\.res\shell\extract\command]
@="py -2 \"E:\\sync\\code\\python\\_example\\DumpAZW6_v01.py\" \"%1\""

(根据自己具体目录修改)也好使,使用之后会自动把图片提取到和输入文件相同的目录里。

然而好景不长,我刚搞了两本,就发现有的是只有部分图片有高清图——所以你得从.azw里提取所有的标清图片,然后把有高清版那些页数逐个替换进去。如此看来,不写个脚本是不可能简单实现了。

要写脚本,我们用到的工具都得有命令行版本才行。从azw提取图片可以用calibre的命令行工具ebook-convert.exe,或者用上述提取res文件的脚本作者开发的KindleUnpack(这个其实有图形界面,但是可以直接调用kindleunpack.py)。

但是有个重大问题:这两个工具都不支持有DRM的书(见追记)。所以,我们得先破解。calibre的命令行工具都是无法支持插件的见追记),所以我们得找到那个DeDRM插件作者单独提供的stand-alone版本

好不容易调试好python 2的环境,结果这工具一用就报错(已经装了pyCrypto)。自己debug了半天,发现是他藏得很深的用了一个lzma库,我没安装。按照代码去装backports.lzma,结果……装不上。在issues里看了下,果然有人报过,说白了就是作者的setup.py写的太烂对Windows不兼容。更囧的是作者研究了下,发现他自己搞不定所以这个issue就这么在这摊着。

说到这里不得不吐槽下python开发的库管理简直烦,所谓的platform independent某种意义上是个笑话,有许多库的开发者可能只用Linux/MacOS,这已经不是第一个我遇到的在Win下连安装都有问题的库了,更不要说使用了。

还好有人在另外一个issue里提供了一个wheel(编译好的版本),虽然非常老(0.0.3,现在已经是0.1.0),但是能用(见追记)。

这里其实有个插曲,我一开始装了这wheel之后还是不能用(连直接from backports import lzma都能报错),于是我一怒之下把deDRM工具里ion.py调用lzma的部分给删掉了,结果反而能用了,因为lzma其实只有对付上文提过的Kindle最新KFX格式才用得到。至于lzma不能用的问题?我干掉几个后台卡住的python.exe进程之后就好了。

OK,工具准备齐全,我们接下来要做的就是几步走:

  1. 用上文所述的azw6抽取脚本提取res里的高清图片
  2. 用deDRM tool的Windows application破解azw文件
  3. 用Calibre的命令行工具提取破解后的no_drm azw3里的图片
  4. 对比两组图片,把3里的部分用1里的高清图片替换。
  5. (可选)利用calibre命令行工具获取meta tag来给文件夹改名
  6. (可选)自动打包

其中1、2两步都有source code,理论上可以import进我的脚本里,但是因为这是python 2写的我决定还是直接process.call

虽然调试耗了不少功夫,但是最后结果还是相当满意的。代码比较丑陋这里就不放了,中间遇到的两个坑:

  1. 那个抽取AZW6的脚本里有句话是平淡无奇的print self.title(这是py2,print书籍的title而已)但是如果是飞ASCII的标题经常会在控制台卡住,报错“no such file or directory”(??)(经常是我手动运行不卡,用VS调用就卡),烦得很直接注释掉了。
  2. Python的re.match原来和re.search不一样……不一样。

追记

5月20日追记三点。

1是backports.lzma的第三方wheel版可以在这里下载。另外,DeDRM工具那边也已经修改了代码,让缺少LZMA有正确的提示(也在说明里提到了要有LZMA),并且也增加了对pylzma(另外一个py2有的lzma库,上面那个链接里也有wheel可以下,PyPI好像也有)的支持。

2是解压电子书。如上文所述,我现在用的是Calibre带的ebook-convert。这个的本质是把电子书转成网页/压缩包的形式,然后再解压(命令行就是ebook-extract 你的azw文件.azw temp.zip --extract-to 解压到的dir)。有个小问题就是转换成网页的时间有时候会很长。所以我又去试了下那个KindleUnpack——快是快很多没错,但是这个有个问题就是出来的图片名并不是从1开始,感觉上似乎是直接按照原始包的section来命名,比如第一页经常就是40多编号了。这个在后续处理,尤其是替换HD图片的时候非常不方便,所以还是放弃了。另外提示一下,这个的GUI还是CLI都要用py -2运行:否则虽然GUI能跑起来但是实际转换是要报错的。

3是ebook-convert无法处理deDRM的问题。我上面提过这个工具不支持deDRM——其实我是错的。理论上,Calibre的命令行工具都支持各种插件的。事实上,如果你用命令行工具calibredb:

calibredb.exe add book.azw

来加入一本encrypted的书,会自动调用插件deDRM。

但是问题在于,那个deDRM的插件在设置里把自己的类型设成了“仅添加时调用”。所以,在单纯convert的时候,并不会参与。这个是我去mobileread的论坛问了下才明白(顺便一提,那边的人回复好快!我发帖不到半小时就N个回帖了…)。

我自行修改了那个插件的init.py,添加了on_preprocess=True,立刻就可以在ebook-convert里调用了。而且,似乎没什么副作用,不会影响正常转换noDRM的图书。我在犹豫要不要建议插件作者自行加上。

这么搞了之后,上面的流程的第一步和第二步就可以合并了。但是我暂时还没这么做,因为这样得保证使用环境的calibre必须装了修改版的DeDRM插件才能用,不是很便于部署。

追记2

上面提到的转换速度太慢的问题一直困扰我,后来我又研究了下,发现calibre其实是提供另外一个binary,calibre-debug.exe具有只解包不转换的功能,switch是-x

批量处理方面,唯一需要注意的是爆出来之后最后一张图片会是一张重复的封面的小图,没有必要,我目前观察所有拆的包都有,可以无脑删除。

至于上述3提到的修改calibre来直接支持DeDRM插件,考虑到可移动性的问题依然没有用,还是调用deDRM的py版本。

提取高清图片的脚本部分已经改写成py3并且直接调用了,参见后文

魔力女管家音乐歌词数据库搭建完成,顺便说说Sphinx

就像上一篇文章许诺的,我费了三天时间,把魔力女管家歌词库给搭出来了!

先上地址:http://fireattack.github.io/mahoromaticdb/ 在Github因为侵权把我的网站日掉之前,暂时就挂在这里了。

包括了所有的CD的简要信息,以及所有歌曲信息歌词(大部分含翻译)。所有的CD我都用当年收藏的Booklet精心制作了1000 px的封面(除了少数几个找不到BK的),欢迎使用。哦里面在最后还随意地包含了两张同人CD(C60、C61发售)的信息,其中第一张网上应该是能找到的,意外地非常好听,强烈推荐。

搭建过程中才发现我之前的文档写的有多烂。有好多后来才找到歌词的歌没包括就算了,格式也是一团糟,错字、标点符号不统一的问题比比皆是。看来我当年的强迫症要轻得多啊!我尽量把标点符号统一为:跟日文用全角,跟英文用半角,但是连用符号(例如:!?)用半角,波浪号的副标题前空一格(但是魔力女管家第二季的标题则不空),括号统一用全角。艺术家名义统一用角色(声优)的格式,但是少数早期CD直接单用声优名字。

呃好吧我承认这并不是特别统一…因为我有个更严重的强迫症,叫做“名从主人”…我一般会尽量遵照BK上的写法,因此牺牲一些统一度。

接下来的地方讲讲搭建网站过程中的一些值得记录的东西吧。

这次用的技术是Sphinx——一个用Python写成的Doc建站软件。之所以没有用Github Pages支持的Jekyll,主要是那个是用Ruby写的,不想去接触。不过后来才发现使用过程中99%都只是在和reStructuredText这玩意打交道,和后端的语言一点关系都没有,早知道就用Jekyll了——毕竟那个是用更简单的Markdown的来生成HTML的。

果然还是先说reStructuredText这种标记语言吧,毕竟大部分时间都耗在和他打交道了。一言以蔽之,这玩意的语法非常的反直觉和不灵活。不过用了三天之后,也算是慢慢熟悉了。reStructuredText的设计思想就是非常注重可读性:基本上而言,源代码就已经在ascii的层面上“格式化”了。例如其最反人类的设定——表格,正常来讲你需要手动用各种线把框框画出来(见下)!

+------------------------+------------+----------+----------+
| Header row, column 1   | Header 2   | Header 3 | Header 4 |
| (header rows optional) |            |          |          |
+========================+============+==========+==========+
| body row 1, column 1   | column 2   | column 3 | column 4 |
+------------------------+------------+----------+----------+
| body row 2             | Cells may span columns.          |
+------------------------+------------+---------------------+
| body row 3             | Cells may  | - Table cells       |
+------------------------+ span rows. | - contain           |
| body row 4             |            | - body elements.    |
+------------------------+------------+---------------------+

还好,还有csv table可以用,否则这真的要死人的(不过这里有个table ganerator可以用)。

除了最基本的一些标记(例如,粗体、斜体啥的,不过注意和Markdown不同),reStructuredText核心元素是directive和role这俩东西。前者是一种特定格式/结构的元素,一般“成块”出现;一般格式是:

.. directivename:: argument ...
   :option: value

   Content of the directive.

在第一行调用directive的名字,然后在第二行起带缩进写设置,然后空一行带缩进(缩进必须和前面保持一致)写被格式化的内容(有些时候则没有内容,比如图片啥的)。这个东西可以用来实现插入目录、图片、目标(锚点)、给内容加class等功能。role和directive类似,但是一般是用于行内(inline)mark一些内容,例如上面提到的粗体、斜体啥的,本质上也是一种预定义好的role。你也可以自定义role。

那么就大致按照我写站的时间顺序来讲吧,没啥逻辑关系。

在建立了你的Sphinx网站之后(推荐使用官方带的Sphinx-quickstart),第一件事在index(链接是到rst源代码,下同)页面中把站点的目录放在上面。这个倒是蛮简单,用Sphinx自带的toctree这个directive就行。在目录里你可以输入想包括的页面名称,以及目录深度。我设想的结构是所有CD信息在一个页面(cdlist.rst),而歌曲因为歌词较长,则每首歌分割为一个单独的页面放到里子目录songs/里。

这里就遇到了第一个问题:如果你用songs/kaerimichi的方式添加页面到toctree中,他会和cdlist都是平级——而不是处于一个Songs的一级目录之下。稍微研究了一下发现要这么做:在songs/子目录下先建立一个index.rst,然后在该rst中再建立一个toctree(下称toctree2),包含所有songs/下的页面。因为toctree总是从当前目录开始查询的,所以对于toctree2,你只需要罗列所有歌曲页面的名字就行,无需加songs/前缀。当然因为歌曲太多了,我们利用:glob:这个参数,就可以用通配符*来匹配所有页面了。回到根目录index的toctree,我们也只需要包含songs就可以了,会自动把toctree2里面的项显示为二级项(结果)。

正式写页面,对于CD list,我需要在最上面附一个(本页面内的)目录。这个用带的contents directive就能轻松做到。但是默认会加一个很多余的根目录节点,用:local:参数去掉它。搞定后,这个目录就会列出所有的section了。加一个section很简单,只要在一行文字下面加一堆“-”、“=”之类的就行。如果要多级section则需要分别套用不同的符号,不过我这里只有一层所以无所谓。一般而言,每个section会自动生成锚点,上面加的目录就能跳转。

因为页面上某些文字需要是小字,而且这些文字混在正文中(inline),我们需要一个role。当你给内容指定了role之后,生成html会自动指定对应的class名,因此配合CSS就可以实现想要的样式。要定义一个role,要先在文档某处(一般是最开头,我不确定在别的地方可不可以)写:

.. role:: smallfont

之后(注意:每个用到:smallfont:的rst都必须重新写一遍这个…),你就能用:smallfont:`your content`来标记你的内容了。不过这里有个限制:这一段代码的前后必须是非“word”的东西,也就是说中间一般得有一个空格。如果你不想要你的普通内容和小字内容之间有空格?需要加反斜杠来消掉空格。即:

your normal content\ :smallfont:`your small font content without space inbetween`

OK,那现在在CSS里写:

.smallfont{
 font-size: 80%;
 color: grey;
}

就行了。不过,怎么把自定义的CSS包含在你生成的网站呢?这里有好几个办法

  1. conf.py中加上
    def setup(app):
        app.add_stylesheet('custom.css')  # may also be an URL

    (你的custom.css应该在_static/目录下)

  2. conf.py中加上(注意这里又变成从根目录起了…下同)
    html_context = {
     'css_files': ['_static/custom.css'],
    }
  3. 先把你模板里的layout.html拷贝到目录下的_templates里(当然,保证你没删掉conf.py里的templates_path = ['_templates']),然后找地方加一行
    {% set css_files = ['_static/custom.css'] %}

    (在SO看到的是

    {% set css_files = css_files + ['_static/custom.css'] %}

    但似乎使用上并没有区别。)

  4. 还是上面的说的layout.html,直接强行加
    {%- block extrahead %}
      		&lt;link rel="stylesheet" href="{{ pathto('_static/custom.css', 1) }}" type="text/css" /&gt;
    {% endblock %}
    
  5. 最暴力的方法,找到你模板的CSS文件,然后修改之;或者修改之后放在_static/里(原因见下面)。

对于我用的alabaster模板,默认的layout.html已经包含了上面的选择4,所以我只需要把custom.css放在_static/下即可。哦这里顺便说句,你放在_static/下的文件默认全部都会复制到你build出来的html里(不管用不用得到),你可以利用这个来覆盖模板里的东西(如上面的5所述)——不过这里注意是覆盖,不是添加。另外,图片之类的resource就别往里放了,否则会复制两遍(因为所有引用过的图片会自动被Sphinx复制在build目录的_images/目录里),占地方。

搞定了“小字体”这个样式之后,在每个歌曲的页面,我还需要引入两种新的样式:日文和中文,分别用来标记不同语种的歌词,从而实现更好的字体显示效果。

这里因为是成块的内容,我们就不用role了,用一个directive:class。如名字所示,其功能和role类似,也是给一块内容标记class。原始的reStructuredText直接用class就行,但是Sphinx是为Python文档开发的,默认把class给定义成一个role了,所以需要改用rst-class

.. rst-class:: ja

	| まなざし そっと ひとつ
	| 誰にもみつからぬように
	| ふんわり時間だけが
	| 流れては消えてく

这里可以看到,argument(双冒号+空格后面的)自然是想要的class名称。因为没有选项(options),所以内容就从第三行(空一行)开始。至于pipe符号(|)这里的目的是强制换行。另外注意,所有的内容必须有一致缩进(具体多少无所谓,反正不会显示成缩进)——缩进结束就退出了这个rst-class了。而相对地,在普通正文中的缩进就是正常的缩进,而且你加几个空格都会如实反映。当然,你还得去你的CSS文件里定义.ja,这里就不赘述。

在写歌曲页面的时候,我遇到一个非常蛋疼的锚点问题。一般而言,锚点在加section/heading时是自动生成的,但是如果你不想开新的section呢?方法是在文档中加入这么一行:

.. _targetname:

至于引用(指向)目标时,Sphinx推荐的用法是用:ref:这个role:即形如:ref:`targetname`这样来引用。但是注意!一般而言target是配合section来用的,所以会自动成section的名字。但是现在我们是在正文中随便添加的,我们必须显式指定他的名字::ref:`Display Name <targetname>`才行。

但是这里有个问题——在Sphinx里,所有的target和ref都是全局的,跨文件的。所以,我每个文件里的target还不能一样,比如如果我每个文件里都有个.. _ja:,用:ref:`Display Name <targetname>`会不知道飞到哪个文件的_ja锚点里去。

研究了半天,发现只能用reStructuredText自带的引用方式——`targetname`_因为这个只适用于本文件,这样即使我每个文件都有个重名的锚点,也只会正确跳转到本文件内的。不过这个有个缺点,不支持Display name和targetname不一样(就是上面带尖括号的用法),所以我的targetname必须就是我想显示的文字。还好,似乎支持中文字符和符号,我用了“[中文]”当锚点名称也没事儿。最终效果

那么最后需要的功能就是给CD曲目列表里的对应歌曲添加链接了。因为是跨文档引用,所以推荐的方法是给每个文档的标题加上个target,然后用:ref:;但是我嫌麻烦,直接用另外一个role,:doc:做。方法基本一样,直接输入:doc:`filename`就行了——显示的文字自动从对应文档的标题提取。不过这里我也并没有直接这么做,因为Sphinx带了个非常好用的role,叫:any::用它可以智能地自动寻找最接近的reference,可以是:ref:,可以是:doc:。在用:any:之前,我们更可以把它指定为“default role”——在conf.py里添加:

default_role = 'any'

这样,当你使用单个撇括起来时(例如:`songs/kaerimichi`),会自动调用:any:这个role。这里,因为这是文档名,又会进而自动调用:doc:

到这里,基本在reStructuredText里遇到的困难都说完了。在Sphinx这边,我对默认的alabaster模板也没怎么改,但是有一点要注意:如果要用这个模板的完整功能,要修改sidebar为

html_sidebars = {
 '**': [
 'about.html',
 'navigation.html',
 'relations.html',
 'searchbox.html',
 'donate.html',
 ]
}

才行,因为模板自带了一些sidebar并没有包括在默认的设置中。模板带的选择在conf.py里修改html_theme_options,基本很好理解,我就改了logo和字体。

Sphinx有一个自带的“basic”模板,还有一些JS和CSS,基本所有的模板都有inherit。但是那个JS(doctools.js)有个问题(Chrome only,我已经汇报到Sphinx dev team),重现方法如下:

  1. 点一个带锚点的地址
  2. 滚动一些(即,你已经不再在锚点的原始位置)
  3. 点一个(非本页内的)链接,跳转到了其他页面
  4. 点“后退键”

正常来讲,点了后退之后,会后退到之前页面的之前位置。但是bug就是,后退之后会强制再读取一次锚点位置,然后跳转过去,而非你之前的阅读位置。你可以在Python 3的官方文档页面重现此bug,因为他用的就是Sphinx。相反Sphinx自己的文档页面则不会,因为他用的是旧版的doctools.js文件。Firefox下无此bug。

我研究了半天,发现是该JS文件中的以下函数:

/**
* workaround a firefox stupidity
* see: https://bugzilla.mozilla.org/show_bug.cgi?id=645075
*/
fixFirefoxAnchorBug : function() {
if (document.location.hash)
window.setTimeout(function() {
document.location.href += '';
}, 10);
},

导致的。讽刺地是,该函数的目的是为了workaround Firefox的一个bug——Firefox对于HTML5新增的<section>锚点tag支持不好——才加的。本来函数有一个判断是只对Firefox有效,但是由于JQuery移除了$.browser,这里被改成了对所有浏览器有效,从而导致了上面所述的副作用。因为那个Firefox的bug对一般应用并没有什么影响(一般应用的锚点都是靠id="xxx"来搞的,并不会用到<section>),所以我们这里直接删掉就好。删除的方式则是复制一份这个JS文件修改,然后放到_static/文件夹内。这样,每次build,会自动覆盖。注意复制的时候,别复制成了原始模板文件夹里那个doctools.js_t了——那个是个JS“模板”,中间有些参数还没生成的。正确的方法是先build一遍,然后从html的目录里复制个原始版的JS。

还有一点要注意,用sphinx-build来build HTML文件的时候,有的时候并不会刷新改刷新的文件(尤其是sidebar之类的),所以隔一段时间最好把_build/目录全删了然后重新build。当然你也可以用-E选项,不过一般还是别强制了,因为要慢许多。

往Github Host的时候,需要加个.nojekyll文件来禁用GitHub Pages自带的翻译引擎,否则会有问题。但是这个文件只有加在根目录才有效,而我是把整个网站放到mahoromaticdb/子目录下的(因为GitHub一个用户好像就支持一个站),所以一开始迷惑了一阵为什么不好使。

Python vs. Unicode:两个Python下的输出Unicode字符问题的解决方案

前几天开始自学Python,这语言确实看上去很简练外加高度抽象,但是对Unicode字符串的处理简直要让人发疯。

长篇大论讲这个问题的在网上随便一搜“Python 中文”就有,我这里只想特别讲讲今天遇到的两个问题和解决方案。


2.X Python官方IDLE的BUG

2.X官方的IDLE有个很严重的BUG:即使你显式定义一个Unicode字符(准确地说是对象),他居然也会用系统ANSI编码来存储,而不是Unicode。

>>> import sys
>>> import locale
>>> sys.getdefaultencoding()
'ascii'
>>> locale.getpreferredencoding()
'cp936'
>>> s='中文'
>>> s
'\xd6\xd0\xce\xc4'
>>> u=u'中文'
>>> u
u'\xd6\xd0\xce\xc4'

可以看到,我们的Unicode对象u,实际上却是用了GBK编码,而不是Unicode。len(u)也会因此变成4而不是2。更严重的后果是,你似乎无法还原输出这个字符串的字符本身:

>>> print s
中文
>>> print s.decode('gbk')
中文
>>> print u
ÖÐÎÄ
>>> print u.encode('utf8')
脰脨脦脛
>>> print u.encode('gbk')

Traceback (most recent call last):
  File "&lt;pyshell#14>", line 1, in &lt;module>
    print u.encode('gbk')
UnicodeEncodeError: 'gbk' codec can't encode character u'\xd6' in position 0: illegal multibyte sequence

可以看到,对于str类型、GBK编码的s可以直接输出,或者显式用GBK解码成Unicode对象后再输出。但是对于我们的u,理论上一个Unicode对象正确的做法是编码成本地locale(GBK)或者utf-8输出,但是很显然都不好使。

那么,既然我们前面说了u被错误地用GBK编码了,那么我们就把他当成str然后用GBK解码行不行呢?

>>> print u.decode('gbk')

Traceback (most recent call last):
  File "&lt;pyshell#17>", line 1, in &lt;module>
    print u.decode('gbk')
UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-3: ordinal not in range(128)

答案是否定的。

值得注意的是,这个错误使用Python命令行并不会出现。输入的Unicode中文会逐字符(而不是逐字节)地正确存为Unicode字符串(所以结果是2个字符/对象),输出时既可直接输出(本质上还会被Python先编码成GBK,因为CMD是GBK的),或者自己手动编码成GBK再输出:

QQ截图20150816202253

虽然这是IDLE独有的BUG。但是由于初学者会大量使用IDLE来进行测试,相信会对很多人造成困扰。事实上中文圈有很多文章都提到了IDLE这一BUG:文章1文章2

经过一番搜索,我发现这个BUG对应的报告应该是官方tracker上的issue15809。可怕的是,早在2012年就已经提出,居然过了3年都没有修复。不过幸运的是,已经有人做出patch,相信在不久的将来有修复的可能。

在这篇文章中还无意得知了在当下BUG的情况下的临时解决方案:

>>> u.encode('latin1')
'\xd6\xd0\xce\xc4'
>>> u
u'\xd6\xd0\xce\xc4'
>>> print u.encode('latin1')
中文

没错……就是先用Latin1编码把原代码完全一样地转换成完全对应的str类型,然后再输出(默认GBK解码)。为什么是Latin1?天知道。


用Sublime Text Build Python的编码问题

先说Python 2.x的情况。

其实Python 2.x下如果用控制台,输出个Unicode字符串是蛮简单的。

直接u=u’中文’然后print u就可以了。其实这种做法等效于print u.encode(‘gbk’)——因为Unicode对象存的是字符本身(这只是便于理解的说法,准确地说也是用UTF-16编码),得先编码成byte。而你用简体中文系统的CMD直接隐含了默认编码成gbk了。

但是在Sublime Text里一切就变得很复杂。

还是上面的代码原封不动:

u=u'中文'
print u

输出:
SyntaxError: Non-ASCII character '\xe4' in file C:\Users\Administrator\Desktop\test2.py on line 1, but no encoding declared; see http://python.org/dev/peps/pep-0263/ for details

什么,你居然敢不声明文件的编码就让老子跑还夹杂非ACSII代码!是在下错了,毕竟不是console不能这么凑乎……老老实实最前面加上# -*- coding: utf-8 -*-

结果:
UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-1: ordinal not in range(128)

这又是为什么?看报错信息可以看出,是python试图用ascii来编码我输入的“中文”,二字,很显然地失败了。但是为什么会用ascii去编码?经过一番搜索,在这篇文章里提到,这里编码的选择和sys.stdout.encoding这一环境变量有关。在控制台下,该值是cp936(GBK);但是在Sublime Text下,该值居然是None。

解决方法是上面提过的,把变量u显式编码成utf-8再输出:

# -*- coding: utf-8 -*-

u=u'中文'

print u.encode('utf-8')

这次终于成功输出“中文”二字了。不过为啥在控制台用gbk这里用utf-8?事实上是,你可以用gbk,但是结果就是编译不会出错但是输出结果是空白。应该是Sublime Text的result输出窗口只支持utf-8码所致。同理,你也可以在控制台里编码成utf-8输出,只是显示出来是乱码而已(因为控制台的是GBK)。

说完2.X+Submine Text的解决方案,再来说说3.X。由于Python 2.X的Unicode支持就是一笔糊涂账,我想了想干脆换用3.X算了反正我也没啥包袱。结果上来就出问题了:

由于3.X默认的字符串就是Unicode的,也没必要再加u了。于是我在Sublime Text 3下随便试了个字符串输出

u='你好'
print (u)

可以编译无问题,但是输出是空的?拿控制台和CMD都试了下,无法重现。看来又是Sublime Text的问题。按照上面的尿性先检查下sys.stdout.encoding:这次不再是None了,是cp936。但是还是不行啊我们上面说了Sublime Text只接受utf-8输出。那再用上面的老方法,把字符串手动编码成utf-8试试?

u='你好'

print(u.encode('utf-8'))

输出:
b'\xe4\xbd\xa0\xe5\xa5\xbd'
[Finished in 0.1s]

不妙,结果直接变成bytes了……这里需要厘清一个概念。Py2和3的print默认期望接受的类型是不一样的。在py2里由于str默认就是bytes,所以如果你输出的是一个Unicode类型的字符串,则需要自动(控制台下)或手动(sublime Text里)先编码成bytes。而这个byte最后又会被你的控制台或者别的什么东西再解码回字符输出(好绕)。py3里反过来了,默认str就是Unicode,所以期望接受一个没编码过的字符本体,如果你编码成byte他反而不理解了,直接把byte原封不动给你输出出来。那么既然我们无法再显式控制这编码成byte的过程,如何让python给我编码成utf-8呢?

答案是,手动修改Sublime Text的build system,修改相应的参数。默认的python build我们是不能用了,因为参数改不了。那么手动去Tools->Build System->New Build System.. 新建一个.sublime-build文件,内容写

{
    "cmd": ["python", "-u", "$file"],
    "file_regex": "^[ ]*File \"(...*?)\", line ([0-9]*)",
    "selector": "source.python",
    "env": {"PYTHONIOENCODING": "UTF-8"}
}

前面几行是默认的。重点就是env这个参数,他让py把所有的标准输入输出接口的编码方式都改成utf-8。将这个build system保存之后(默认那个users文件夹就好),我们再看看sys.stdout.encoding,是不是就变成utf-8了?

现在,我们可以完美地直接输出字符串’中文’了。

除此之外,还有另外一个修改build system的办法,就是修改encoding参数:

{
    "cmd": ["python", "-u", "$file"],
    "file_regex": "^[ ]*File \"(...*?)\", line ([0-9]*)",
    "selector": "source.python",
    "encoding": "cp936"
}

和PYTHONIOENCODING不同,这里的encoding控制的是Sublime Text这边接口的编码,粗略可以理解成下方输出栏的解码方式。自然,只要这个和py那边输出的output的编码一致,自然也可以正确地显示出结果。

我个人还是推荐第一种方法,因为毕竟全Unicode的workflow的兼容性更好。另外提示一点,两条参数不能共用,否则结果又会变成乱码(想想为什么)。

顺便一提,在某些网站查到了一种修改env参数中的”LANG”为utf-8或者en_US.UTF-8,我这边并没有作用。不过可能对解决一些别的编码问题有帮助,可以参见此文的附带部分。


总而言之,Python的输出就是这么恶心,各种编码玩死你。一个字符串被翻来覆去编码解码好多回,每个流程都有可能出错。在这个Stackoverflow的答案中建议直接使用sys.stdout.buffer.write(data)os.write(sys.stdout.fileno(), data)来输出数据(要先自行编码成bytes),绕开问题多多的print,也不失为一个好选择。

唉,这种时候就怀念全盘Unicode化的C#的好了。