这篇博文,我要讨论一下如何将腾讯动漫中的漫画内容下载到电脑中,实现下载后离线阅读。这里特别涉及到其中的付费漫画章节。
腾讯动漫中漫画的付费模式——iOS客户端VIP会员的特权
腾讯动漫的付费方式有单章节付费和VIP会员付费。其中,除了不需付费全员免费的漫画之外,如果付费成为包月或包年的VIP会员,还可以在全客户端免费看一部分付费漫画不必额外付费。另外,仍有一部分漫画章节,即使是VIP会员也需要额外单独付费才能阅读,只是VIP会员再对单章节付费可打8折。
但是这第三种情况有一个例外,iOS客户端由于受制于苹果的内购策略,只能实行包月VIP会员策略,难以实行单章节付费机制,因此腾讯在iOS客户端采取了和其他终端不同的策略:经由iOS客户端内购的VIP会员,在iOS客户端上可以阅读所有付费漫画,不再需要单独购买章节,即这些特殊的VIP会员实际上在iOS客户端上拥有阅读全站漫画的权限。但是,这些iOS端的VIP会员,如果在其他终端(网页端或Android客户端等)登录,则拥有和其他渠道的VIP会员一样的权限,那部分付费漫画依然需要付费。也就是说,只有“经由iOS客户端的内购渠道成为VIP会员”和“使用iOS客户端阅读”两者都成立,才能实现阅读全站漫画。
因此,腾讯动漫全站的漫画章节,从VIP会员阅读权限的角度,可以分为3类:
- 全员付费的漫画章节,即使不登录也可以阅读
- VIP免费的漫画章节,只要以VIP会员身份登录,就可以在全平台阅读不需再单独付费
- 付费章节,即使VIP会员也需单独付费(有打折),但经由iOS客户端的内购渠道成为VIP会员并使用iOS客户端可以直接阅读
下面就分为全员免费漫画或VIP免费漫画,和完全付费漫画两种情况,分别说明如何下载。下载程序都是在macOS下使用python 3编写。
全员免费漫画或VIP免费漫画
对于这两类漫画,通过基于Selenium.webdriver的爬虫程序,访问桌面端及移动端的腾讯动漫网页,可以很容易地使用find_element_by_xpath函数找到章节链接,并进一步进入章节链接,再度使用find_element_by_xpath定位到每一个图片链接,从而下载到漫画的内容。这里我使用ChromeDriver的Headless模式:1
2
3
4
5from selenium import webdriver
from selenium.webdriver.chrome.options import Options
options = Options()
options.headless = True
driver = webdriver.Chrome(chrome_options=options)
登录账号(VIP免费漫画需要)
全员免费漫画和VIP免费漫画下载流程的差异,只在于后者需要你有一个VIP会员账号,而且在下载前需要登录。
这里建议访问移动版网页端登录页面http://ui.ptlogin2.qq.com/cgi-bin/login?style=9&low_login=1&pt_no_onekey=0&s_url=https%3A%2F%2Fm.ac.qq.com%2Fhome%2Findex,登录页面如下图:
为了保持Headless状态,直接写个自动填充表单功能,自动往这个页面输入QQ号和QQ密码并点击登录。大致代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16from time import sleep
try:
driver.get("http://ui.ptlogin2.qq.com/cgi-bin/login?style=9&low_login=1&pt_no_onekey=0&s_url=https%3A%2F%2Fm.ac.qq.com%2Fhome%2Findex")
sleep(3)
uinput = driver.find_element_by_id("u")
uinput.send_keys(username)
pinput = driver.find_element_by_id("p")
pinput.send_keys(userpassword)
driver.find_element_by_id("go").click()
sleep(3)
usernamedisplay = driver.find_element_by_xpath("//div[@class='top-user-name VIP']/span").text
print('Login As ' + usernamedisplay + '(' + username + ').')
except Exception as e:
print('Login fail? Check your username and password.')
driver.close()
exit()
其中username和userpassword是VIP会员的QQ号码和QQ密码字符串。
获取章节标题及章节链接
要获取特定漫画的内容,首先需要指定漫画的comic_id,即该漫画在腾讯动漫中的数字ID。
前面之所以使用移动版网页端登录界面(登录后会自动跳转到移动版网页端个人中心),除了登录界面简单(桌面版网页端登录界面默认会要求使用手机QQ刷二维码登录,就必须显示界面给用户,没有办法维持Headless了),还因为移动版网页端网页内容简单,comic_id具有单一性[1],后面获取章节图片地址时较容易。
在移动版腾讯动漫通过搜索找到该漫画主页,URL为:1
https://m.ac.qq.com/comic/index/id/[comic_id]
其中最后的一串数字就是comic_id,且这个ID全平台通用。
移动版章节列表的网页URL为:1
https://m.ac.qq.com/comic/chapterList/id/[comic_id]
通过ChromeDriver访问这个URL,就可以使用find_element_by_xpath函数解析出该漫画的名称及每个章节的URL地址(这里模拟点击了一下“正序”按钮使正序的章节列表被显示,这样可以读取到其中的文字):1
2
3
4
5
6
7
8
9
10
11comicurl = 'https://m.ac.qq.com/comic/chapterList/id/'+comic_id
driver.get(comicurl)
correctbtn = driver.find_element_by_xpath("//span[@id='sequence_text']")
correctbtn.click()
comicnamedefault = driver.find_element_by_tag_name('h1').text
print('The Comic Name on ac.qq.com is '+comicnamedefault+'.')
chapterlist = driver.find_elements_by_xpath("//ul[@class='chapter-list reverse']/li[@class='chapter-item']")
chapnumwhole = len(chapterlist)
chaplinks = [chapterlist[x].find_element_by_tag_name('a').get_attribute('href') for x in range(len(chapterlist))]
chapnumtexts = [chapterlist[x].find_element_by_tag_name('a').text for x in range(len(chapterlist))]
print('There are '+str(chapnumwhole)+' Chapters.')
但是,移动版网页在显示章节时,只包含章节序号的信息,不包含作者自定义的章节标题。因此为了获取完整的章节标题,在这一步又倒回来到桌面版网页中[2]:1
2
3
4
5
6comicdesktopurl = 'https://ac.qq.com/Comic/comicInfo/id/'+comic_id
driver.get(comicdesktopurl)
chapterlist2 = driver.find_elements_by_xpath("//span[@class='works-chapter-item']/a")
chapnametexts = [chapterlist2[x].get_attribute('textContent').strip() for x in range(len(chapterlist2)) if chapterlist2[x].is_displayed() == False]
if len(chapnametexts) == chapnumwhole:
chapnames = chapnametexts
获取单章节漫画图片下载链接
取得章节标题和章节链接之后,就可以访问每个章节的链接URL,解析网页内容获得每个图片的地址了:1
2
3
4
5
6
7
8for chapnum in list(range(chapnumwhole)):
chaplink = chaplinks[chapnum]
chapname = chapnames[chapnum]
driver.get(chaplink)
sleep(3)
imgelements = driver.find_elements_by_xpath("//img[@class='comic-pic']")
pnum = len(imgelements)
imglinks = [imgelements[x].get_attribute('data-src') for x in range(len(imgelements))]
imglinks数组就是该章节所有图片的链接,只要依次下载做好文件名编号就行了。下载可以用urllib3库实现,这个就不用赘述了。
iOS端VIP会员下载付费漫画章节
上一节使用爬虫程序访问网页下载漫画的方法,大家基本上都懂,都有开发者写出比我上面还优秀得多的代码了。
然而,对于在网页端即使是VIP会员也需要单独付费才能阅读的漫画章节,上述访问网页端的方式就无法下载了。
但是前面已经提到,只要使用iOS客户端内购付费成为VIP会员,在iOS客户端上就可以阅读这些原本需要单独付费的漫画章节,从而实现基本上全站漫画都可以阅读。[3]
那么,我们只需要有一个iOS端VIP会员账户,然后在电脑上模拟iOS客户端对腾讯动漫服务器的访问请求,就有可能实现对全站漫画的下载,而不再需要考虑该漫画是否付费漫画。
于是我们需要在iOS设备上捕捉记录腾讯动漫的iOS客户端的网络访问行为。
我们需要具备的条件:
- 你要有一台iOS设备。(这里我使用iPad Pro 12.9寸(第一代))
- 在iOS设备上安装腾讯动漫app(这里我使用腾讯动漫HD),并通过该app的内购功能购买VIP会员。
- Surge for iOS或者其他使用NetworkExtension Framework SDK的iOS app,能够在iOS端接管网络通讯并记录腾讯动漫app的网络请求。如果有Surge for mac配合,还可以直接在mac上的Surge Dashboard直接控制Surge for iOS,直接查看这些请求,那就更爽了。(当然你可以在路由器上截留网络请求,但这太折腾了。)
- 你要懂得使用第3条提到的这个iOS app。这个条件不是虚设,尽管Surge的作者一直强调它本质上是个网络开发与调试工具,版本更新时很多新功能也是为此而生的,但是不可否认很多这个app的用户使用它只是为了实现“科学上网”,这些用户其实动手能力有限。
好吧,以上条件基本上已经把那些企图免费看漫画的“低端用户”挡在门外了,留下的基本上是愿意为正版付费的用户。
特别地,第1条的iOS设备,如果你像我的一样是一台12.9寸iPad Pro,那这台设备本来就是最适合读彩色漫画的终端设备,夫复何求?你想把漫画下载下来的需求就会很低了。
因此我折腾这个,除了纯粹技术上的好奇之外,我确实也不想每次都打开腾讯动漫HD的app,就希望一次下载下来之后还是用iPad Pro来慢慢看。
另外我顺便还可以用其他的设备,比如说我的ONYX BOOX MAX 2 13.3寸电纸书来阅读(该设备的Android客户端上阅读权限比不上iOS客户端,这样两台设备上切换的时候阅读体验就会比较割裂),好吧,这个设备从性价比上属于比iPad Pro还“土豪”的设备,越说我的需求越小众了……
Anyway,总之我的实践证明这件事情是可以干成的,但是前提是需要你手头的iOS设备的配合。
获取iOS端app传递的通信请求
在启动Surge for iOS接管网络通信的情况下,使用iPad上的腾讯动漫HD客户端进入相应漫画的章节阅读一两页,然后打开Surge for iOS的界面,进入“最近请求”,在最近请求中找到如下的请求:
上述记录中的这两个URL,就是就是腾讯动漫HD客户端向服务端请求漫画数据的URL,上述图片中被红色涂掉的具体信息,就是代表用户的信息和漫画章节的ID。
获取并解读JSON格式的漫画具体信息
从上述记录可以看到,腾讯动漫HD客户端向服务端请求漫画具体信息的URL为:1
http://hd.ac.qq.com/[app_version]/Comic/comicDetail/channel/[channel]/guest_id/[guest_id]/comic_id/[comic_id]
其中的关键字符为:1
2
3
4[app_version]: 腾讯动漫HD的app版本。我这里是3.2.3。
[channel]: 10进制数字,可能是表示终端信息
[guest_id]: 符合UUID格式的字串,应该是表示用户ID
[comic_id]: 漫画的数字ID
app_version是iOS客户端版本、channel、guest_id同一个客户端同一个用户多次初始化的情况下保持不变,因此这几个信息作为特定用户的ID标识是相对稳定的。而comic_id正是全平台通用的漫画的数字ID。
同时,还可以发现腾讯动漫HD for iOS的User-Agent,在我的iPad Pro上为:1
ComicReaderHD/3.2.3 (iPad; iOS 12.1.1; Scale/2.00)
可以看到有app_version,还有iOS版本信息和显示缩放的倍数信息。
这里可以看到,腾讯动漫HD for iOS是一个Native app,它并不是通过加载WEB界面来渲染的,而是通过访问以上URL得到漫画的详细信息,然后自己显示出来的。
访问以上comicDetail的URL得到的是JSON格式的数据,其形式如下(示例,经格式化整理后的结果,原文无缩进对齐,且原文字符包含转义字符):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{
"ret": "2",
"data": {
"comic_title": "Comic Title",
"comic_id": "[comic_id]",
"comic_pic": "[comic_pic_url]",
"grade_count": "40647",
"comic_score": "9.6",
"coll_count": "3961409",
"yesterday_popular": "5203664",
"artist_name": "[artist_name]",
"comic_tags": "[comic_tags]",
"comic_seqno": "3",
"popular_value": "16333193095",
"popular_rate": "-0.30",
"comic_signed": "2",
"comic_intrd": "内容简介",
"comic_status": "1",
"comic_vip": "2",
"comic_update_time": "2018-12-31 00:00:00",
"nation_state": "4",
"vip_free_state": "2",
"comic_short_intrd": "内容短介绍",
"comic_is_japan": 2,
"comic_is_strip": 1,
"limit_free_state": 1,
"comic_price": "999",
"artist_pic": "[artist_pic]",
"comment_count": 2000,
"chapter_list": [
{
"chapter_title": "第3话标题",
"chapter_id": "19",
"chapter_seqno": "3",
"chapter_price": "49",
"chapter_size": 1669487,
"vip_state": "2",
"buy_state": 1,
"blank_first": "1"
},
{
"chapter_title": "第2话标题",
"chapter_id": "5",
"chapter_seqno": "2",
"chapter_price": "49",
"chapter_size": 1669487,
"vip_state": "2",
"buy_state": 1,
"blank_first": "1"
},
{
"chapter_title": "第1话标题",
"chapter_id": "1",
"chapter_seqno": "1",
"chapter_price": "49",
"chapter_size": 1969361,
"vip_state": "1",
"buy_state": 1,
"blank_first": "1"
}
]
}
}
可以看到里面该漫画的详细信息及其章节信息,如:1
2
3comic_title: 漫画的名称
comic_seqno: 当前章节数
vip_free_state: VIP免费状态,实测表明,此值为1则漫画中的付费章节对VIP不免费,此值为2则为VIP免费漫画
chapter_list数组中有每个章节的信息,其中章节是倒序排列。每个章节的信息中有:1
2
3
4
5chapter_title: 章节标题
chapter_id: 章节的数字ID
chapter_seqno: 章节的序号
vip_state: 此值为1为免费章节;此值为2为付费章节,但此时单看这个值并不能知道这个章节对一般VIP会员是否免费,要结合漫画的vip_free_state参数才能知道。但在iOS客户端上都是可以看的。
buy_state: 因为是iOS客户端请求,这个值好像总是为1,因此无从判断到底是指已经可以看了,还是指其实没有单独购买过这一章。
这里每个章节的chapter_id就是用于进一步获取章节内容的ID,注意它和chapter_seqno很大可能不一致,因此不能混用。
综上,在从典型的comicDetail网址链接记录中找到几个关键字输入程序后,在程序中只需要用urllib3库连接获取数据,然后用json库来解析得到相应信息就可以了。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17import urllib3
import json
import certifi
comicurl = 'https://hd.ac.qq.com/'+app_version+'/Comic/comicDetail/channel/'+channel+'/guest_id/'+guest_id+'/comic_id/'+comic_id
user_agent = "ComicReaderHD/"+app_version+" (iPad; iOS 12.1.1; Scale/2.00)"
reqheaders = {'User-Agent': user_agent}
client = urllib3.PoolManager(cert_reqs='CERT_REQUIRED', ca_certs=certifi.where())
r = client.request('GET', comicurl, headers=reqheaders)
comicdetail = json.loads(r.data)
comicnamedefault = comicdetail["data"]["comic_title"]
print('The Comic Name on ac.qq.com is '+comicnamedefault+'.')
chapterlist = comicdetail['data']['chapter_list']
chapterlist.reverse()
chapnumwhole = len(chapterlist)
print('There are '+str(chapnumwhole)+' Chapters.')
chapnames = [chapterlist[x]["chapter_title"] for x in range(len(chapterlist))]
chapter_ids = [chapterlist[x]["chapter_id"] for x in range(len(chapterlist))]
获取并解读每个章节内容的JSON文件,下载图片
有了chapter_id之后,腾讯动漫HD for iOS客户端访问如下URL得到相应章节的具体内容信息:1
http://hd.ac.qq.com/[app_version]/Comic/chapterPictureList/channel/[channel]/guest_id/[guest_id]/comic_id/[comic_id]/chapter_id/[chapter_id]/uin/[uin]/st/[st]
其中关键字为:1
2
3
4
5
6
7[app_version]: 腾讯动漫HD的app版本。我这里是3.2.3。
[channel]: 10进制数字,可能是表示终端信息
[guest_id]: 符合UUID格式的字串,应该是表示用户ID
[comic_id]: 漫画的数字ID
[chapter_id]: 章节的数字ID
[uin]: 用户的QQ号
[st]: 192位16进制数字,每次程序初始化时都会改变,应为当前用户会话的安全令牌
可以看到除了之前使用过的几个参数和chapter_id外,还使用了uin(目前看来是指用户的QQ号)和st参数,其中每次程序重新初始化(把app的后台杀掉重新打开)后其他参数不变而st参数都会改变。由于可以判断st参数包含每一次当前用户会话的安全令牌。
只有对腾讯动漫HD for iOS客户端进行逆向工程,才能搞清楚它是如何根据用户信息运算(包括与服务器进行HTTPS协议的通信)生成这一安全令牌的,但是这样技术门槛太高,而且工作量太大,对于只是想下载几篇漫画的用户来说没有必要。我们只需要把它当成一个黑盒子,每一次需要下载漫画时,都通过这个记录通信请求的步骤得到现成的st参数,并直接使用就可以了。[4]
该章节的具体内容信息如下(示例,经格式化整理后的结果,原文无缩进对齐,且原文字符包含转义字符):1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23{
"ret": "2",
"data": [
{
"pic_url": "[Page_1_URL]",
"width": "1100",
"height": "1564",
"size":"325838"
},
{
"pic_url": "[Page_2_URL]",
"width": "1100",
"height": "1564",
"size":"325838"
},
{
"pic_url": "[Page_3_URL]",
"width": "1100",
"height": "1564",
"size":"325838"
}
]
}
这里的数组就直接是每个图片的信息了,其中的pic_url就是每个图片的URL地址,直接连接这些URL地址就可以下载图片了。
在程序实现上相关代码如下:1
2
3
4
5
6
7
8
9chaplinks = ['http://hd.ac.qq.com/'+app_version+'/Comic/chapterPictureList/channel/'+channel+'/guest_id/'+guest_id+'/comic_id/'+comic_id+'/chapter_id/'+chapter_ids[x]+'/uin/'+uin+'/st/'+st for x in range(len(chapterlist))]
for chapnum in list(range(chapnumwhole)):
chaplink = chaplinks[chapnum]
chapname = chapnames[chapnum]
r = client.request('GET', chaplink, headers=reqheaders)
chappiclist = json.loads(r.data)
imglinks = [chappiclist["data"][x]["pic_url"] for x in range(len(chappiclist["data"]))]
pnum = str(len(imglinks))
print(chapname+' has '+pnum+' pages. ')
imglinks数组里就是一个章节所有图片的URL地址了。接下来继续用urllib3.PoolManager.request一个一个连接,将内容写入文件就可以了。
这就是iOS客户端VIP会员在iOS客户端的配合下,通过电脑端模拟iOS客户端的访问请求来下载付费漫画的实现过程。
可以看到这个过程也完全适用于全员免费漫画和VIP免费漫画,而且不需要电脑端加载浏览器组件去分析网页内容,因此实际上只要具备使用的条件,直接用这个方法来下载漫画就可以了。
[1] 在桌面版同一个comic_id的漫画网页可能同时包含多个版本,只有其中的主要版本才是使用这个桌面版URL中的comic_id,而特别版本实际上使用的是另外的comic_id,这时需要到移动端网页上去搜索各个单独的版本。如火影忍者(comic_id:505432)的桌面版页面上实际包括正篇章节和全彩版,而真正使用comic_id=505432的实际上是正篇章节,通过这个comic_id在移动版网页获取到的就只有正篇章节,而全彩版实际上使用的是comic_id=512063,只是在桌面版上做了特殊处理,桌面版上comic_id=512063的页面被重定向到comic_id=505432的页面,相当于两个comic_id指向同一个混合型的桌面版页面。因此需要在移动版网页中搜索相应版本才能找到真正对应的comic_id。
[2] 当[1]所描述的情况出现时,桌面版网页会比较特殊,这时要根据页面情况修改find_elements_by_xpath的XPATH代码才能筛选到相应特定版本的章节标题,这里不再赘述。
[3] 我看的漫画就那么几本,没有注意到是否还有连iOS端VIP用户都不能直接阅读的章节,网上也没看到有用户提到有这种情况,姑且不考虑这种情况存在的可能。
[4] 这个安全令牌内容可能包含客户端的外网IP地址信息,最好是iOS设备和电脑端是同一个局域网下,经过同一个路由器访问外网,这样外网IP相同,模拟更加接近真实。一般实际使用的时候基本上都是这个环境,相信很少有特意把iOS设备不连Wi-Fi而是通过蜂窝数据去进行这件事的。