孩子们,我回来了

该页面有相关的 个人项目 , 您在文章中看到的某些代码片段可能会在其中。

写在前面

在过去的两年多里,我一直在用之前获取的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 com.dmzjsq.manhua.proto;
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; // Assuming this is an icon or similar field
string package_name = 5;
string d_url = 6; // Assuming this is a direct or download URL
int32 btype = 7; // Assuming this is a type indicator, represented as an integer
}

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都不是永恒的,甚至连运行他们的平台都会消失,这也正是自己拥有数据的意义。备份就像保险,怎么算期望都是亏本的,也许很久都不会用到,但条件允许的前提下还是得有所投入的嘛。