小心BW的数字水印

前文的一个小小的补充。

在评论区有人提出用上文所述的脚本提取出来的封面和试阅版的封面hash不一致。我一开始没当回事,说不定两张图根本只是单纯在服务器压缩了2次而已,hash不一样的可能性太多了。不过为了谨慎起见,还是测试了一下。没想到一测,就发现还真的有点东西。

留言的网友询问的是 BW 台湾站,那就拿台湾站来测试。现在角川所有的服务器都已经升级到了最新版的JS,所以直接改下US的@match就行。

检查无混淆的图

我随便找了本漫画,先是下载了试读版——试读版和正式版不同,是所有页面都没有混淆的,所以其实你控制台直接下载也行,和用我的脚本是一样的。因为试读版不登录就可以查看,所以应该是没有什么账号信息的。

然后使用网友提供的账号下载同一本作品的完整版——但是我偷懒没有下所有页数啦,得益于脚本的更新,现在可以选择页数了,我就下了前10页。

和BW日本站一样,封面是无混淆的图,后面的页面混淆。我们的重点是这个没有混淆的cover页。理论上,这个图应该和试读版一样,然而两者的文件大小错了有整整20KB。

那么就是一系列的对比啦,我用了下面这些步骤。

第一步最简单的,对比图像数据。我手头有一个我自己写的Py小脚本来对比两张图的像素,也可以用拖进PS->两个图层叠加->计算差值->合并->查看对比度的笨方法。嗯,两者确实是逐像素相等的。

那么第二部就是打开XnView MP查看元数据:

Image

(我一般properties和ExifTool都看一遍)也没有任何区别。

于是我打开Beyond Compare想直接二进制对比,结果发现两者有巨大差别,打了个我个措手不及。明明逐像素相等,为什么图像数据的部分字节也对不上?没什么头绪的时候,突然想起之前研究JPEG spec的神器,JPEGsnoop,赶紧掏出来。

一比就发现了为什么二进制错那么多:原来两张图的霍夫曼表完全不一样——一个“优化”了(正式版),一个没有优化(试读版),具体区别可以查看旧文。难道这就是两者的唯一区别了吗?在我即将关闭JPEGSnoop之时,看到一个非常重要的信息:

Image

好家伙,在EOF后面藏东西,那基本可以猜到是啥了:

Image

基本不用猜,这32bytes 的数据肯定是用户的ID了。我赶紧测试了下其他的图,结果很意外地发现那些混淆后的图反而没有这串水印——不过确实也没啥意义吧?毕竟你加什么水印只要不是加到图像内容里,被我们重新拼图之后都消失了。

我又进行了以下测试:

  • 用同账号下载别的完整版书籍——封面图依然有水印而且ID一致;
  • 用我自己申请的新账号购买了同一本书——果然水印ID不一样。

那么基本可以99%肯定这就是角川加进来用来反查用户的数字水印了。而且这种加法很容易,服务器给文件的时候直接在文件后面append就行。他服务器也不需要存多份文件。

移除水印

移除这水印很简单,可以直接二进制删掉最后32 bytes。如果不熟悉二进制操作,我测试了下XnView MP自带的清理元数据功能:

Image
Image

其实这里勾啥都行,因为任何操作都会导致他重新生成一次JPEG文件架构,从而删掉EOF后面的多余字节。这里如果勾选第一个优化霍夫曼列表,最后出来的文件大小就是类似于完整版,否则就是类似于试读版。我们正好可以验证一下,圈中所有三个文件(试读版,账号1的完整版,账号2的完整版)clean一波,出来的三个文件 md5 完全一致。

当然,你也可以直接再存成PNG,那肯定啥水印都没了。虽然没必要,徒增体积。

混淆过的图的检查

上面已经说过混淆过的图并没有这个二进制的ID水印。不过内容我们还是检查下吧。检查很简单——先把账号1和账号2的图都解码,然后互相对比:结果是逐像素相等。那就说明没有任何可以识别出下载者账号的内容水印

我然后和预览版的对比——预想来说是不可能逐像素相等的,因为毕竟服务器给的资源是把原图(JPEG)拆成32px的块之后混淆后再存了一次JPEG,那自然有新的JPEG artifact引入。虽然我们还原时是用了bmp/png没有再引入新的JPEG artifact,但是也无法去掉第二次的。

结果一对比还挺出乎我意料的:居然除了最右边一排别的都是逐像素相等?怎么做到再存了一次JPEG没引入新的JPEG artifact的?如果认真学习过(误)我之前写的JPEG spec文章的应该就懂,JPEG的最小编码单元(MCU)是8×8的,所以是可以以最小8×8的尺度对原图进行无损重新组合的(或者旋转)。而角川的混淆的块儿是32×32,所以完全可以。没想到角川居然真的用到了JPEG的这个特性,混淆图片时直接是对原始JPEG流的MCU进行的swap操作!太低估他们了。

这还意味着什么呢?我们目前在 Python 里进行的还原混淆的canvas操作,其实也完全可以采用直接交换JPEG流的MCU的方式,来实现完美还原原始JPEG——好吧应该说90%完美,因为边缘非8的倍数的部分还是没法100%还原,这部分在角川生成混淆JPEG的时候已经给padding到了8的倍数了(也因此引入了二次压缩,所以上面也观察到最右边一排还是没能逐像素相等)。

Hmm,想了想意义很小而且好像很麻烦,就懒得折腾了。留作以后的课题。

结语

虽然上面都是说台湾站,但是日本站也是一样的,至少我随便看了下,无混淆图也是有ID的。

虽然我自己是无所谓,因为我dump BW全都只是自己收藏而已,但是如果要拿出去分享尤其是大范围分享,那确实得小心一点了。我更新了下之前的bw.py,现在可以自动移除数字水印了。

一个更好的Book Walker的网页版的dump方式

2021-10-27更新:

BW又更新了JS,这次和上次间隔如此短,让我有点担忧。下面的脚本已经移除。如果需要,请留言 GitHub 邮箱。

2021-10-19更新:

BW更新了他们的JS到2021-09-30,许多变量名都变了于是修改了一波。同时也完全重写了,现在不再hook,而是完全调用自带的那些函数来运行,速度会快很多(也支持Chrome了!)。旧版的直接删掉了,如果需要请留言。

注意bw.py也小更新了一下,必须同时更新。

2021-08-30更新:

感谢评论区的HuHu菊苣指点,已经更新(见文末的 gist.github.com)了另外一个仅hook渲染函数而非drawImage()的userscript。虽然使用上体验应该没有区别。bw.py也更新,同时兼容新旧两种方式。


之前已经大致讲过如何通过canvas来提取BW的图片。

之前脚本的问题

脚本的原理很简单,就是覆盖BW的viewer JS的某个渲染函数来让其把图片解码绘制到我们提供的一个canvas中,然后dump那个canvas的内容。

这个方法一切都好,但是有个小毛病:就是图像最下面会有2px的白边。

注意,这里的白边指的不是dummy width/height:BW的资源文件(JPEG)有时候会有一定程度的无效内容”出血“,但是在元数据里已经标记出来,在浏览器显示的时候,自动就把这部分切掉了。我之前的脚本也会正常将这个部分切掉。

这里说的白边的毛病是,即使对于完全没有dummy height/width的图,渲染/解码到尺寸和图片完全一致的canvas里,也会在下方出现2px的白边。如果只是单纯添加白边就罢了,他还导致所有上面的内容resize一次(例如1448×2048->1448×2046),整个图片变糊,在文字部分尤其明显:

Image

能否通过给canvas多加2px高度来解决呢?答案是否定的,只会变成这样(笑):

Image

其实你在浏览器里浏览的时候,就会发现这个白边(严格来讲是杂色边)本身就存在的(下图)。所以不是我们的破解搞出的问题。

Image

理论上来讲,如果我们hack更底层一些,应该是可以在浏览器端解决这个问题的,但是我实在是懒得搞了,就这么一直忍了下来。

2021-08-30更新:根据评论区HuHu的信息,这个2px高的杂色条其实是用来track用户账号的条码!那就更应该一定要把它去掉了。

更好的dump方式

直到今天有网友推荐了这篇aloxaf的文章,开启了新的大门。

此文中,作者通过另外一种方法:直接hook了drawImage(),然后记录下来所有的swap块的操作dump下来,这样本地就可以还原了。

(顺便一提,作者提到了在Firefox里直接dump canvas会提示“The operation is insecure”的问题——也就是上文里提到的Chrome里的tainted canvas的问题。但是很吊诡的是,在成文之后更新了数个Chrome版本之后可以完全直接dump不用加--disable-web-security了,但是试了下Firefox还是不行。)

这个方法有2个优点:1是完全规避了上面提到的BW本身viewer的2px白边劣化;2是dump下来的原始JPEG文件没有经过2次保存,存档节省空间。观看的时候动态重新解码即可。

不过也有2个问题。

首先,这个脚本不支持Chrome。至于为什么不支持,是因为在drawImage()操作时,Firefox里收到的第一个参数是Image,但是在Chrome里却是ImageBitmap。浏览器本身的原版函数这两种对象都是支持的,暂不清楚为什么会有区别,可能是BW的JS对不同浏览器进行了(没有意义的)适配(根据这篇文章,如果输入ImageBitmap,性能要好一些,但是似乎没有考虑到转换ImageImageBitmap消耗的时间?)。Image对象转换成ImageBitmap就丢失了src,所以导致匹配页码什么的变得很麻烦。虽然应该改改也很容易,但是因为hook这种底层函数老导致浏览器卡死,也没什么心情继续折腾了,就用Firefox呗。

另外一个问题则比较关键,就是上面提到的dummyHeight、dummyWidth的问题。如果丢失这两个参数,会导致下载下来的图片解码后,无法得知应该切掉多少无效内容。而且,每页的出血也不一定是一致的,所以也不能无脑裁剪。

还好,稍微研究了下全局对象,把元数据的部分下载的时候直接一并dump下来,再在本地处理即可。

这里多说几句,根据我的观察,一般而言BW的书大概都是这么个结构:

第一页一般是无混淆、无出血的。估计是因为封面图会在别的地方,比如目录,展示用。这有个副作用会导致我们记录 drawImage()记录出一些无效的信息,暂时直接在Python那边忽略了。

第二页起有混淆和一致的出血。出血都是在右边、下边(我有确认过是真·无效内容,基本都是乱色)。目前见过:

  • 无出血 (VOICE BRODY)
  • 5, 5 (宽,高,下同) 出血 (声A)
  • 6, 0 出血 (My Girl)

我的修改版本

我的修改版本文章最下方,主要改动如下。

JS部分改动

原始脚本里,有个比较原始的自动翻页的功能,是调用了viewer本身的function(具体字段名称升级经常变动。我这里修改成了最新版本)。不过,并不太好用——偶尔会漏页,而且翻太快会出现403之类的。

我改写了个更robust的。虽然非常啰嗦,但是一时想不出更好的写法,就这样吧。

我之前的脚本是直接复制到控制台运行(或者做成bookmarklet)的,这个脚本作者写成了userscript的形式。这点是很必需的:如果在页面渲染完才输入,肯定会有很多次drawImage()事件就错过了。

不过用userscript会无法访问BW viewer生成的那一堆全局对象(因为注入时还没生成),在添加一些功能时有一定局限性(比如作者最开始的设计要手动在命令行输入SaveToZip())。我懒得折腾MutationObserver之类的,简单粗暴加了个延时改写一下。

2021-08-30更新:更了V2版,直接调用他原本的函数取得交换参数。V1版保留(毕竟那个应该更耐艹)。

Python部分改动

py合成的部分基本重写了,加上了切出血的步骤。另外额外增加了自动切声A和Animedia的功能(这个社的杂志源文件就自带白边)。使用方法就是bw.py {zip文件名或者解压后的目录名}

乐天的杂志放题

本月起Amazon的Kindle Unlimited不知道为什么突然不再放声A、声G和Animedia了。本来怀疑是因为本月发行时间较早导致的未能同步更新,结果过了一周之后也没有。再加上Animage有正常放出,那就不得不怀疑是这些杂志退出了Unlimited了。虽然目前还抱有下个月观察一下的希望,不过也要先研究下别的路子。

上文有提过BW的放题服务没有声G,所以我随便浏览了下别的几家——乐天的很吸引眼球。价格低廉(月供380日元),可以PC、App阅览,而且网站做的还不错,最重要的是上述那些杂志都有。可以试用一个月,所以就先来白嫖试试。

浏览器看需要日本IP,app则不需要(仅测试安卓;app本体需要日区市场才能下载)。

其网页版默认是显示1024px的小图,直接读取(虽然我没搞明白为啥thumb的资源文件直接打不开,但是不重要没细究);双击或者滚轮放大后自动后台读取pdf格式的原始文件然后用pdf.js加载到canvas里。阅览器是angular写的。

本来以为pdf肯定有加密毕竟下下来直接打不开。第一时间肯定是先试试改后缀名,但是改了.jpg之后我用的XnView MP还是打不开。所以我研究了N久的如何hook JS来批量下载blob(手动下载倒是直接就能下),但是也没搞明白,js框架太烦了(顺便一提,不要直接保存canvas,二压姑且不提,canvas是又根据你窗口大小缩放过一次的图。)

回家后,想先用FlexHex看下那pdf文件的header,结果发现家里用的ACDSee直接tm就把我改过后缀的图打开了……对比了下header,那个所谓的pdf好像就是在一个JPEG文件前面加了几百个Byte的伪PDF文件头,ACDSee和PS的鲁棒性都比较强直接就能读,XnView MP比较严格,没有看到JPEG的文件头就不行。简单地搜索FF D8 FF然后把前面的bytes删掉之后,就和正常的JPEG一样了。

之后又费了些功夫写了个脚本,可以直接给个网址自动下载所有图片外加后处理。中间碰到的坑是Python的requests读取到MozillaCookieJar中expiration为0的session cookie会自动无视,要手动强制改下时间。

顺便一提,其资源文件名顺序都是按左开来排序的,对于这些右开的杂志,下下来就会变成19、18、21、20这样子,还得每两页swap一下(封面封底不动)。

之前说过,之所以会选择Kindle Unlimited而不是其他一些便宜多的同类服务,纯粹是为了提取方便便于收藏。既然这个也能这么简单地下载,那确实挺让人心动了。

不过,下载方便只是一方面,重头戏自然是图像质量。由于这个可以直接下载到网站提供的“原图”,所以首先就杜绝了BW那种原始资源就是混淆过的,你重新拼起来后所以为了防止二压只能被迫存成PNG的体积注水问题。而且更可怕的是,其原图是3072px高,比Amazon的1920px和BW的20xx px都要高得多。而且分辨率是真实的,并非放大,看文字部分的锐度就很明显(上文说过,BW网页版虽然看似有2000,但是原始资源即使1:1也会发虚,有水分)。

但是!一张3000px的图只有几百K,这JPEG质量相信不用说也能猜到有多惨了——70%,4:2:0。如果仅仅是这个我觉得还能忍,因为缩到1920px之后的信息熵估计也没差到哪里去嘛,但是还有个无比纠结的颜色问题。

之前提过BW的声A和Kindle完全一样,但是声G略有偏色;结果到了这边,好像所有的杂志颜色都和Kindle不一样——颜色比起Kindle版,要深非常多(注意,和BW的偏法还不一样)。虽然单独看几乎不会觉得有啥问题,但是和Kindle比了比还是觉得Kindle好些。而且Kindle的声A的画质真的高(95%,4:4:4),这70%的JPEG缩2/3估计也打不过。声G倒是另说,Kindle版的图像虽然名义上是95%,但是有色度抽样(4:2:0)外加是用极差缩放算法缩出来的,有很明显的狗牙和JPEG artifact。

说了这么多来看几个例子。先看看声G。

先对比颜色:

(其实单独放一张图不太客观,毕竟不知道照片本身调色是什么风格。整本一起看会更准确点。)

再来看看细节:

(推荐查看原图)多了那么多分辨率,确实文字还是清晰多了。不过也能看到JPEG artifact之多。

声A我们换个比法,我把这个3000px的图缩到1920然后和Kindle版side by side对比下。

ImageImage
左Kindle,右Rakuten

Hmmm..好像也不是很差,而且有人估计更喜欢这颜色而嫌左边太亮?

对比完,于是我更纠结了。虽然3k的分辨率是很有吸引力,但是颜色问题果然太重要了。如果Kindle能够恢复,我还是主力收集Kindle的版本好了,这个和BW的可以当备胎。

后记:几个月后(21/01/31)终于反应过来,乐天的版本原来又是错了一个pc/mac的gamma!ps加个1.22(2.2/1.8)后立刻和其他版本一致了!

再后记:似乎还是不对……

BOOK WALKER的新订阅(放题)服务

Book Walker(角川旗下电子书服务,下称BW)日前关闭了原有的杂志放题服务“マガジン WALKER”(下称MW),新开了“マンガ・雑誌 読み放題”服务,直接归属于BW下。

价格方面,从原来的550日元/月涨价到760日元/月(前几个月优惠价,外加有促销送一些BW的积分等),但是增加了漫画放题。对我来说漫画放题基本没有用,不过涨价2美元左右也算可以接受。

其实,我一直有在订阅日本亚马逊的放题服务,那个比较贵,大概980日元/月。优点在于Kindle的破解很成熟,所以杂志都可以下载提取备份。但是亚马逊从大概大半年前开始,放题都不再是0点更新,而是要等到日本时间快中午才更新,就很不爽,于是多定了个MW当备份。而且,MW也有一些亚马逊不包含的杂志,比如《電撃萌王》、连载ML BC的《電撃マオウ》(都是角川系)等等,也不算亏。(顺便吐个槽,作为动画情报志御三家之一的NewType,角川一直故意不出电子版,很猥琐。Animage和Animedia都有电子版而且都是放题对象。)

在今天4月1日放题活动正式启动后,我就购买了看了下。和老的MW对比,首先,新版有日本IP才能点开放题的书/杂志(和BW买电子书其实一样),MW只有登录需要日本IP,有cookie后随便看。进入阅读器后,倒是都不需要日本代理。

基本而言我看的杂志都还有,但是唯独少了看的最多之一的《声優グランプリ》。其实之前预告期间,我就发现下面的名字里没列出声G,以为只是省略了,没想到真的没有。之前《声優アニメディア》被母公司学研プラス卖给イード后,我有担心会不会影响放题阅读;结果那边没问题,反而是声G没谈妥。

这里插播一段。声G的电子版的画质一直都很差,图片压缩率很高,满脸的JPEG artifacts,而且还经常有锐化过度、缩图出锯齿的问题。相比之下,声A的可以说是超高清了。另外,我最近还发现,声G的MW版和Kindle版的颜色是不太一样的。MW版的对比度偏低,而且稍微有点色偏:

很难说哪个更好,但是至少声G的官推的sample图都是和Kindle版颜色一致,姑且认为那个应该是正确的版本吧。另外Kindle是1920px高,MW/BW是2048px,区别不大。

提取

当然,比起Kindle,MW或者说BW系的破解一直是个大难题,尤其是客户端的破解异常复杂。虽然网上有零星关于破解的消息,但是角川经常升级,所以少数掌握破解方式的人一般都不外传。(写这文的时候去看了下,发现BW居然已经直接停止支持客户端了,PC只能用web看现在。我之前装的客户端倒是还能用。)

与之相对,网页版的破解就容易许多。虽然BW/MW的js依然是加密的而且又臭又长,所以破解也很难(远超出我的能力),但是不难找到部分函数比如渲染canvas的函数,然后强行hook把解码后的图片保存。这样虽然无法做到无二次压缩保存原始图片(其实也不可能,因为网页版服务器提供的就是混淆后的文件压成JPEG),但是至少1:1比例的图源可以拿到。具体方法这里不赘述,可以参考Jixun大的日志。(要自动保存的话,Chrome需要加参数--disable-web-security --user-data-dir=XXX来破掉tainted canvas的问题(第二个参数不能省略,否则第一个参数无效)。)

之前BW用的js是viewer_image_2.0.10_2019-09-18.js,而MW用的是更老的viewer_image_v0.1.10_2018-11-15.js。两者功能基本没区别,但是渲染canvas的函数一个是window.NFBR.a6G.a5x.prototype['b9b'],一个是['B0F']。现在,两者(BW本体的web阅读器网址是的viewer.bookwalker.jp,放题的是viewer-subscription.bookwalker.jp)统一成了viewer_loader_2.0.15_2020-03-05.js,但是函数位置没变还是['b9b']

新版放题某些杂志图片的白边问题

在更新之后,我打开一本声A,首先注意到的是图片渲染好像有点小问题——有白边。

这里要先说一下BW的viewer是怎么渲染的。其渲染函数有5个arguments,分别是:

targetCanvas, page, image, drawRect, flag

第一个就是目标<canvas>元素,第二个是关于page的一些参数(见下图):

page

第三个是image的参数(其实基本就只有width和height),drawRect是你要draw的大小(取决于屏幕和zoom比例),flag是说是左页还是右页。

在正常观看的时候,由于屏幕显然没有2000+px那么高,所以实际的drawRect是一个很小的数,比如几百px。但是如果你要下载原图的话,就可以把原始的size填进来(保证canvas的高宽一致)再保存,就可以把原始大小的图片渲染进canvas然后保存了。

但是需要注意的是,page 的size(width, height)和image的size一般是不同的:BW和MW里,两者的高度基本都是2048px,但是宽度不同。以我买过的某一期My Girl为例,page size是1586px宽,image size则是1592px宽。到底哪个才是“原始大小”?

你可能会想,那我们肯定要取image size,这样才是原图嘛。其实不然:根据我的观察,page size才是图像的原始大小,而image size之所以会大一些,可能是因为混淆加密的需求,而在原始图像上加了白边凑到mod 8导致。事实上,可以分别下载两者比较,结果就显而易见。在用page size作为你的drawRect以及canvas大小后,下下来的图直接就是没有左右白边的(对于跨页图用了page size后,你下载的图能和网页viewer一样,能直接完美拼上中缝)。

但是在新版放题的viewer-subscription.bookwalker.jp里,就不是这样了:以这本声A为例,我发现page size变成了1851×2339,而image size变成了1856×2344。

而且,在viewer里都有肉眼可见的白边了,跨页图根本没法完美地拼在一起:

newbw

那么问题来了:现在我们应该渲染成page size还是image size下载呢?之前两者的高度是相同的,选择page size仅仅是等于自动切了白边,不影响有效内容;但是现在连高度都不同,会怎样渲染呢?

经过测试,可以得知:还是应该选择page size。如果使用image size导出,会得出一个看似大了一点、但是仔细看会发现图片变糊(这个在有字体的地方比较明显,看照片部分一般看不太出来)——所以其实是用原图放大搞出来的。所以,只有用page size,才能正确输出1:1比例的原图。

其实回过头去看上面我贴的那个page参数,其实已经可以看到这两者的差值(宽高各5px)正是那page对象info里的DummyWidthDummyHeight。上面举例的2048px的书,则是DummyWidth: 6DummyHeight: 0。所以答案很明显了。唯一的不同之处就是2048px的那个书即使用image size下载也不会拉伸,只是加白边而已,这里会罢了(因为渲染函数会保证宽高比不变)。

至于切割白边,由于这里1856*2339/2344~=1852,和比page size的1851只错了1,基本等于没有自带切白边,而上面说到的四周多的白边其实是原图的一部分了(否则在viewer里的渲染效果也不会中间有白边),所以只能自己下载后手动切除。

手动切除白边后,尺寸变成1835×2317,看似比之前的1621×2048要大,但是仔细对比后感觉字体还是稍微糊了些,可能还是upscale的。不过,我也发现其颜色比旧版要好(比如红色没有明显劣化),所以有可能使用了比较高的quality或者没有用sub-sampling(4:2:0或者4:2:2)。

这里对比三种:

compare

如果要问我的话,我还是觉得2048px才是没有放大过的尺寸。其他都是从比较小的尺寸放大过的(倒不是说“原始尺寸”就是2048px,因为出版社提供给BW的应该是更大的分辨率。只是BW放题这个感觉是用先缩了一次的图又拉伸了一次的感觉。)

不过,并不是所有杂志都有这个问题,比如萌王就没有。可能只是BW内部转换出了错误,比如默认所有图都是一个高宽比,不够高的加白边之类的。

目测BW放题无论是服务本身还是技术上还会有各种微调(虽然对日本程序员不抱什么希望),我们拭目以待。