写在前面 在过去的两年多里,我一直在用之前获取的DMZJ轻小说API下载和更新汉化轻小说。它在几乎没有修改的情况下一直工作到了现在,令我十分惊讶。这个项目的初衷原本只是为了提取轻小说供阅读器使用,因为我尚且还能忍受动漫之家App的漫画阅读体验,同时随着叔叔版权化的作品越来越多,在这个平台上看漫画的比例也一直在下降(叔叔购入版权后DMZJ的对应漫画就会莫名消失或无法观看,尽管历史数据甚至汉化组提交更新都还是正常的,用户一般称为“神隐”)。但叔叔毕竟是国内正规的平台,一些带有NSFW要素(尽管不是不是R18)的作品是无法上架的,也只会购买有着一定受众群体的漫画,DMZJ这种游走在灰色地带的汉化漫画提供者始终有一定价值。虽说如此,DMZJ也仍有其局限,一些有名的,有H要素的作品还总是会被“神隐”掉,即使没有版权平台上架。这种例子我接触得很少,直到今年《憧魔》大火之后DMZJ也顺势下架了这部高人气NSFW向作品,此时终于意识到还是有必要做些什么的。
安卓客户端逆向 DMZJ拦截隐藏漫画的手段很简单,服务端下发的漫画列表已经是过滤好的,不会出现隐藏漫画;但观看历史记录列表却仍会留存这部漫画,可能是以备将来这些神隐漫画重新上架吧。点击已下架漫画的历史记录,App详情页会做一次拦截,给出空的页面;同时直接点击阅读按钮也不能直接跳转进阅读页。简单逆向了一下漫画详情页com.dmzjsq.manhua_kt.ui.details.cartoon.CartoonDetailsActivityV3
和漫画阅读页com.dmzjsq.manhua.ui.abc.viewpager2.BrowseActivityAncestors4
后发现后者确实没有拦截逻辑,但唤起这个Activity带的参数里有Comic和Chapter两个Parcelable,因此不方便在App外通过adb shell am
直接启动,需要对App本身进行一定修改。
1 2 3 4 5 6 7 8 9 public static void q (Activity activity, BookInfo bookInfo, ChapterInfo chapterInfo, int i10, boolean z10) { Intent intent = new Intent(activity, BrowseActivityAncestors4.class); intent.putExtra("intent_extra_bookinfo" , (Parcelable) bookInfo); intent.putExtra("intent_extra_chapterinfo" , (Parcelable) chapterInfo); intent.putExtra("intent_extra_readpage" , i10); intent.putExtra("intent_extra_share" , z10); intent.setFlags(DownloadExpSwitchCode.BUGFIX_GETPACKAGEINFO_BY_UNZIP); activity.startActivityForResult(intent, 35 ); }
在练习两年半后,DMZJ的安卓开发终于在用Kotlin和MVVM架构重构他们的App了,也学会了打开混淆,泪目。
API 还是熟悉的味道,没有用Retrofit
, 就是裸调OkHttp
, 在一个大的Util类里自己写接口方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public void a (String str, String str2, String str3, Map<String, File> map, com.dmzjsq.manhua.net.b bVar) { this .httpClient.newCall(new q2.a(SqHttpUrl.f18278a.a(SqHttpUrl.ApiType.API_USER_BASE) + "register/m_submit_v2" ).b("nickname" , str).b(NotificationCompat.CATEGORY_EMAIL, str2).b("passwd" , str3).h(map, "用户注册接口Emial" )).enqueue(bVar); } public void c (String str, String str2, com.dmzjsq.manhua.net.a aVar) { this .httpClient.newCall(new q2.a(SqHttpUrl.f18278a.a(SqHttpUrl.ApiType.API_RELASE_BASE) + "game_c/game/appoint/" + str + "/" + str2 + ".json" ).d("游戏预约" )).enqueue(aVar); } public void d (String str) { this .httpClient.newCall(new q2.a(SqHttpUrl.f18278a.a(SqHttpUrl.ApiType.API_RELASE_BASE) + "game_c/game/history/add?game_id=" + str).d("添加玩过的游戏" )).enqueue(new h(this )); } public void e (String str, com.dmzjsq.manhua.net.b bVar) { this .httpClient.newCall(new q2.a(SqHttpUrl.f18278a.a(SqHttpUrl.ApiType.API_V4) + "comic/detail/" + str + "?coreToken=" + DmzjCore.getToken(CApplication.getInstance())).d("漫画详情proto" )).enqueue(bVar); } public void f (String str, String str2, com.dmzjsq.manhua.net.b bVar) { this .httpClient.newCall(new q2.a(SqHttpUrl.f18278a.a(SqHttpUrl.ApiType.API_V4) + "comic/chapter/" + str + "/" + str2 + "?coreToken=" + DmzjCore.getToken(CApplication.getInstance())).d("漫画章节proto" )).enqueue(bVar); } public void h (String str, String str2, com.dmzjsq.manhua.net.b bVar) { this .httpClient.newCall(new q2.a(SqHttpUrl.f18278a.a(SqHttpUrl.ApiType.API_V4) + "comic/update/list/" + str + "/" + str2).d(" 漫画更新列表proto" )).enqueue(bVar); }
注意最后的Request.d/e/f
方法是用来加参数的,中文String做参数,用来走分支判断加一些特定的参数。
1 2 3 4 5 6 7 8 9 10 11 12 13 public Request d (String str) { if (str.equals("漫画吐糟Index" )) { this .f54212a.put(OapsKey.KEY_SIZE, "3" ); } if (str.equals("漫画章节proto" ) && CartoonDetailsActivityV3.f18538v.getDisable_level().equals("1" )) { this .f54212a.put("disable_level" , "1" ); } this .f54212a.put("channel" , "Android" ); this .f54212a.put(HiAnalyticsConstant.HaKey.BI_KEY_PHONETYPE, RomUtils.getRomTypeForDMZJ()); ... Request.Builder url = new Request.Builder().url(this .f54213b); return url.addHeader("User-Agent" , "Android,DMZJ1," + com.dmzjsq.manhua.utils.c.getSystemCode()).build(); }
总之主打一个传统派程序员的风格自由。
加密 上面的代码里有个新东西:DmzjCore.getToken(CApplication.getInstance())
, 用来拼一个coreToken
参数上去,这个方法是一个JNI方法,看起来是他们自己写的:
1 2 3 4 5 6 7 8 9 10 11 public class DmzjCore { static { System.loadLibrary("dmzj" ); } public static native String checkSignature (Context context) ; public static native String getSignature (Context context) ; public static native String getToken (Context context) ; }
但实际上无论加不加这个token都是能正常拉数据的,且这个JNI方法实际上是没有调用的:
当然不排除他们把之前的Java版RSAUtil搬到so里或用so取密钥来给逆向者上(也许只有一点的)强度。
除此之外还是熟悉的RSAUtils和明文密钥,如下图所示:
IDL 与两年前不同的是,现在的AI工具能够轻松地将protoc
生成的一大堆Java代码转换为Protobuf IDL,极大地节省了工作量。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 syntax = "proto3" ; package comic;message ComicTag { int32 tag_id = 1 ; string tag_name = 2 ; }message Urls { int64 id = 1 ; string title = 2 ; string url = 3 ; string icon = 4 ; string package_name = 5 ; string d_url = 6 ; int32 btype = 7 ; }message UrlLinks { string title = 1 ; repeated Urls list = 2 ; }message ChapterInfo { int64 chapter_id = 1 ; string chapter_title = 2 ; int64 updatetime = 3 ; int32 filesize = 4 ; int32 chapter_order = 5 ; bool is_fee = 6 ; }message ComicChapters { string title = 1 ; repeated ChapterInfo data = 2 ; }message ComicInfo { int64 id = 1 ; string title = 2 ; int32 direction = 3 ; int32 islong = 4 ; int32 is_dmzj = 5 ; string cover = 6 ; string description = 7 ; int64 lastUpdatetime = 8 ; string lastUpdateChapterName = 9 ; int32 copyright = 10 ; string firstLetter = 11 ; string comicPy = 12 ; int32 hidden = 13 ; int64 hotNum = 14 ; int64 hitNum = 15 ; int64 uid = 16 ; int32 isLock = 17 ; int32 lastUpdateChapterId = 18 ; repeated ComicTag types = 19 ; repeated ComicTag status = 20 ; repeated ComicTag authors = 21 ; int64 subscribeNum = 22 ; repeated ComicChapters chapters = 23 ; int32 isNeedLogin = 24 ; repeated UrlLinks urlLinks = 25 ; int32 isHideChapter = 26 ; repeated UrlLinks dhUrlLinks = 27 ; string cornerMark = 28 ; int32 isFee = 29 ; }message ComicResponse { int32 errno = 1 ; string errmsg = 2 ; ComicInfo data = 3 ; }message ChapterDetail { int64 chapter_id = 1 ; int64 comic_id = 2 ; string title = 3 ; int32 chapter_order = 4 ; int32 direction = 5 ; repeated string page_url = 6 ; int32 picnum = 7 ; repeated string page_url_hd = 8 ; int32 comment_count = 9 ; }message ChapterResponse { int32 errno = 1 ; string errmsg = 2 ; ChapterDetail data = 3 ; }
Archive 到ChapterDetail
层就可以拿到两种分辨率的漫画图片列表了,这些图片只要带着正确的User Agent,类似DMZJ1,x.xx
就能直接下载下来,不需要额外的Token以及response解密,反而比之前抓的轻小说API更容易。注意Chapters列表可能有多个,比如“连载”和”单行本“就可能同时存在,将这个列表下载下来即可。
不确定它是否有反爬措施,毕竟这次用的API就是客户端API,而客户端是支持多线程下载的,至少我一个IP下载几千张图没出什么问题。实在不放心可以挂个代理,只是梯子可能会不时导致少数图片下载失败。
最后用更新的逻辑顺便兜底了这个场景,下载失败的图片和更新的图片会在下次跑的时候增量更新上去,作为脚本其功能也算是完善了。
写在后面 这组三脚猫功夫的API加密其实根本拦不住第三方开发者,虽然不像几年前API传得满GitHub都是,第三方客户端遍地开花,但现在也还是有很好的客户端 可以正常使用。写到这里我不时会想起七八年前在Windows Phone上用第三方DMZJ客户端的情景;第三方甚至第一方的App都不是永恒的,甚至连运行他们的平台都会消失,这也正是自己拥有数据的意义。备份就像保险,怎么算期望都是亏本的,也许很久都不会用到,但条件允许的前提下还是得有所投入的嘛。