Python爬虫逆向 爬取拷贝漫画网页端漫画图片
折跃完成
2024年09月28日 23:45

拷贝漫画的漫画多,但有时候会崩,手机端没法直接在网站上看,对我这种在校内软件被管控,校园网依托石的的高中畜很不友好。把漫画爬下来看可以解决问题。

分析网站

我们要达成的目标就是能把一部或几部漫画所有章节的图片下载下来。

那首先就要分析漫画的章节信息。打开f12查看发包,但你会发现Sources界面被debugger断住,往下走还会跳转到空页面:

(跳过断点后)

这种情况下根本没法调试。好在妨碍调试的逻辑很明确,都在loop函数里。我们右键文件,选择override代码,然后把loop里的代码删了:

这样就不会卡在这了。


接着我们需要找到获取数据的接口。

试了试后发现搜不出章节名,不过在Fetch/XHR里还是能很快找到接口:

这就很明显了,拷贝漫画的网站对数据进行了加密,导致我们搜不出数据。

这种情况下解决方法很多,不过我们先得判断加密的类型,一般这种数据的特别长的密文大概率是对称加密,其中很大可能是AES。

所以我们搜一下`decrypt`,看看能不能找到解密的位置:

(按Shift+Ctrl+S)

嗯……乱七八糟一坨不想看。

那接下来再试试相对简单的方法:Json Hook。

由于前后端的数据传输基本都靠json,所以基本上前端都会有将json数据解码为对象的地方。如果传过来的加密数据解密后也是json格式,那也会有json解码的过程。

因此,如我们在进行json解码的时候下断点,就能定位到解码的位置。


在油猴里创建一个脚本,输入代码:

代码块
JavaScript
自动换行
复制代码
// ==UserScript==
// @name         JSON Hook
// @namespace    http://tampermonkey.net/
// @version      0.1
// @description  try to take over the world!
// @author       You
// @match        *://*/*
// @grant        none
// ==/UserScript==

(function() {
   'use strict';

   var _parse = JSON.parse;
   JSON.parse = function (obj) {
       console.log(`Hook Parse: ${obj}`);
       debugger;
       return _parse(obj);
   };
})();
复制成功

这段代码简单来说就是重写了一遍JSON.parse,在真正执行逻辑前加入一个断点。

然后刷新漫画页面,发现它断住了:

打印obj是返回的加密数据,看来是能断到。

再跳一次:

这次就直接把明文数据给断出来了。这下肯定能定位解密位置了。

我们顺着栈往上找一层:

根据数据我们可以知道数据解密肯定是在这一片。

这个文件的代码全部混淆过了,乱七八糟的,很难看。

不过通过`iv` `mode` `padding` 这三个关键词,我们也能知道这是标准的crypto-js库AES算法。

这段混淆过的代码按理应该用AST狠狠地解混淆,但AST的篇幅太长了,在这是写不完了(。

当然如果我们挨个执行`_0x4faf`函数也能把代码解混淆,这里直接贴代码了:

代码块
JavaScript
自动换行
复制代码
var _0x3ddce2 = CryptoJS
 , _0x47b277 = _0xdbd77a   // 这行是加密的数据
 , _0x2fa00c = _0x47b277["substring"](0x0, 0x10)
 , _0x163194 = _0x47b277["substring"](0x10, _0x47b277['length'])
 , _0x5a1673 = _0x3ddce2["enc"]["Utf8"]['parse'](dio)
 , _0x5da915 = _0x3ddce2["enc"]["Utf8"]['parse'](_0x2fa00c)
 , _0x4ef3e2 = function(_0x533ce7) {
   var _0x48a2b8 = _0x3ddce2["enc"]["Hex"]['parse'](_0x533ce7)
     , _0x3d5a51 = _0x3ddce2["enc"]["Base64"]['stringify'](_0x48a2b8);
   return _0x3ddce2["AES"]["decrypt"](_0x3d5a51, _0x5a1673, {
       'iv': _0x5da915,
       'mode': _0x3ddce2['mode']['CBC'],
       'padding': _0x3ddce2['pad']['Pkcs7']
   })['toString'](_0x3ddce2['enc']["Utf8"])["toString"]()
}(_0x163194)
复制成功

事实上没必要解,因为反正我们是复制粘贴使用(。

这里唯一的未知变量是`dio`(我不做人啦!),但我们往代码上面找找就能得到dio是个定值`"xxxmanga.woo.key"`。

这样我们就能通过这段代码处理加密的数据了。

至于怎么处理解密后的数据?写Python的时候再说。


分析漫画页面时,我们很快就会遇到一个问题:Network里找不到获取图片url的api

找不到api可难顶,根本找不到切入口。

我当时使用DOM断点解的,不过现在看来没必要。我们继续用Json Hook:

可以看到直接hook住了所有图片的url列表。

我们还是沿着栈找:

它还是一大串解密的代码,刚刚解过,不分析了。

只是刚刚的解密代码,加密数据是api给的,那这的加密数据在哪?

我们可以搜,当然这里直接打开Elements界面,我们就能看到这一串玩意儿:

这就是拷贝漫画图片url的存放位置……

真是迷迷又惑惑啊,你们其他网站有这样的反爬吗?(瑞数怎么你了?)

不过这设计某种意义上挺令人欢喜的(,至少这段密文不是一段JS代码。

这样,如何爬取网站已经分析完了,可以写Python了。


爬取数据

(因为这代码是很久以前写的,所以可能有些变量名等与上面的分析不一样,看官见谅)

为了去处理那加密数据,我使用了execjs库,通过执行js代码来解密数据。

execjs库用`pip install pyexecjs`就可以安装,注意pycharm没法帮你安装。


我们把解析过了的代码稍微整理包装一下:

代码块
JavaScript
自动换行
复制代码
CryptoJS = require('crypto-js');

var jojo = "xxxmanga.woo.key";  // 我改的变量名,属于玩梗(

function decrypt(_0x22f450) {
   var _0x513f33 = _0x22f450["substring"](0x0, 0x10);
   var _0x2b7558 = _0x22f450["substring"](0x10, _0x22f450["length"]);
   var _0x50dba9 = CryptoJS["enc"]["Utf8"]['parse'](jojo);
   var _0x2f9554 = CryptoJS["enc"]["Utf8"]['parse'](_0x513f33);

   var content = function (_0x2bee4f) {
       var _0x1a54bf = CryptoJS["enc"]["Hex"]['parse'](_0x2bee4f)
           , _0x5c64e4 = CryptoJS["enc"]["Base64"]['stringify'](_0x1a54bf);
       return CryptoJS["AES"]["decrypt"](_0x5c64e4, _0x50dba9, {
           'iv': _0x2f9554,
           'mode': CryptoJS["mode"]['CBC'],
           'padding': CryptoJS['pad']["Pkcs7"]
       })["toString"](CryptoJS['enc']["Utf8"])["toString"]()
   }(_0x2b7558)

   return content
}

function decrypt_image_paths(imageData) {
   var _0x7db7b2 = CryptoJS
       , _0x2774f9 = imageData
       , _0x4f1bc2 = _0x2774f9["substring"](0x0, 0x10)
       , _0x18aa36 = _0x2774f9["substring"](0x10, _0x2774f9['length'])
       , _0xa3011c = _0x7db7b2['enc']["Utf8"]["parse"](jojo)
       , _0x1b5a66 = _0x7db7b2["enc"]['Utf8']["parse"](_0x4f1bc2)
       , _0x556014 = function (_0x361948) {
           var _0x5673da = _0x7db7b2["enc"]["Hex"]["parse"](_0x361948)
               , _0x46e939 = _0x7db7b2["enc"]["Base64"]["stringify"](_0x5673da);
           return _0x7db7b2["AES"]['decrypt'](_0x46e939, _0xa3011c, {
               'iv': _0x1b5a66,
               'mode': _0x7db7b2["mode"]['CBC'],
               'padding': _0x7db7b2['pad']["Pkcs7"]
           })["toString"](CryptoJS['enc']["Utf8"])["toString"]()
       }(_0x18aa36)
       , _0x55bb85 = JSON['parse'](_0x556014);
   
   return _0x55bb85;
}
复制成功

这里需要先安装`crypto-js`库。我使用的是node.js环境,用`npm install crypto-js`安装。

这段代码里`require`是旧的CommonJS写法,python里执行也是用的这个。

如果在nodejs环境下运行报错,把你目录下`package.json`文件里`"type": "module"`这一行删了就行了。

运行完之后可以得到解密的数据,格式大概长下面的样子。

章节信息:

图片信息:

下来思路就明确了:

  1. 通过漫画名请求漫画页面解密获取漫画章节列表

  2. 通过章节ID请求章节网页获取信息后解密获得图片列表

  3. 请求图片url下载

这里为了提高下载的速度,使用了协程。但我不打算展开说了,因为没啥时间了(。

直接上代码:

代码块
Python
自动换行
复制代码
import json
import os
import asyncio

import aiohttp
import execjs
from bs4 import BeautifulSoup


class MangaCopySpider(object):
   def __init__(self):
       # 所有要下载的漫画名放在这里
       self.__comic_names = ["zongzhijiushifeichangkeaiflymetothemoon"]

       # 对于不同的信息有不同的url
       # 这个是获取漫画的名称等基础信息
       self.__comic_page = 'https://www.mangacopy.com/comic/{}'
       # 这个是获取漫画所有章节的信息
       self.__comic_info = 'https://www.mangacopy.com/comicdetail/{}/chapters'
       # 这个是获取一章漫画的信息
       self.__chapter_info = "https://www.mangacopy.com/comic/{}/chapter/{}"

       self.__headers = {
           'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) '
                         'Chrome/78.0.3904.108 Safari/537.36 '
       }

       with open("./mangacopy.js", "r") as f:
           js = f.read()
           self.__ctx: execjs._external_runtime.ExternalRuntime.Context = execjs.compile(js)

       # session不能放在__init__中,否则会报错
       # 所以先在__init__声明变量
       self._session: aiohttp.ClientSession = None

   def run(self):
       asyncio.run(self._crawl())

   async def _crawl(self):
       self._session = aiohttp.ClientSession()

       for comic_name in self.__comic_names:
           asyncio.create_task(self.__get_comic_info(comic_name))

       # 等待所有任务完成后,退出堵塞
       # 这里实在是不知道怎么写,而且这么写之后还是会停不下来出bug
       # 求大佬指点
       while True:
           tasks = asyncio.all_tasks()

           if len(tasks) == 1:
               break

           await asyncio.sleep(0.1)

       await self._session.close()
       print("爬取结束")

   async def __get_comic_info(self, comic_name: str):
       """
       爬并解析漫画的信息
       :param url:
       :return:
       """

       # 先爬取页面,获取漫画名称,并创建文件夹
       page_url = self.__comic_page.format(comic_name)

       # 用async with,可以不用关闭response
       async with self._session.get(page_url, headers=self.__headers) as page_response:
           if page_response.status == 200:
               data = await page_response.text()
           else:
               print(f"请求{page_url}失败")
               return

       # 为什么我不用正则表达式?
       # 因为写起来真的很麻烦
       bs = BeautifulSoup(data, 'html.parser')
       title_element = bs.select_one("body > main > div.container.comicParticulars-title > div > "
                                     "div.col-9.comicParticulars-title-right > ul > li:nth-child(1) > h6")
       if title_element is not None:
           title = title_element.text
       else:
           print(f"请求{page_url}后,未能找到漫画名称")
           return

       comic_path = f"./{title}/"

       if not os.path.isdir(comic_path):
           os.mkdir(comic_path)

       info_url = self.__comic_info.format(comic_name)

       async with self._session.get(info_url, headers=self.__headers) as info_response:
           if info_response.status == 200:
               data = await info_response.json()
           else:
               print(f"请求{info_url}失败")
               return

       # 根据返回的message数据判断是否请求成功
       if data["message"] != "请求成功":
           print(f"请求{info_url}失败")
           return

       encrypt_data = data['results']
       decrypt_data = json.loads(self.__ctx.call('decrypt', encrypt_data))

       chapters_info = decrypt_data["groups"]["default"]["chapters"]
       for chapter_info in chapters_info:
           chapter_name = chapter_info["name"]
           chapter_id = chapter_info["id"]
           
           # create_task可以在队列中增加异步的任务而不堵塞当前任务
           asyncio.create_task(self.__get_chapter_info(chapter_id, chapter_name, title, comic_name))

   async def __get_chapter_info(self, chapter_id: str, chapter_name: str, comic_title: str, comic_name: str):
       """
       爬取并获取一章漫画的所有图片url
       :param chapter_id:
       :param chapter_name:
       :param comic_name:
       :return:
       """

       chapter_path = f"./{comic_title}/{chapter_name}/"
       if not os.path.isdir(chapter_path):
           os.mkdir(chapter_path)

       url = self.__chapter_info.format(comic_name, chapter_id)
       async with self._session.get(url, headers=self.__headers) as response:
           if response.status == 200:
               data = await response.text()
           else:
               print(f"请求{url}失败")
               return

       bs = BeautifulSoup(data, 'html.parser')

       # 获取存在页面里的图片地址加密数据,并解密
       image_data = bs.select_one("div.imageData").attrs["contentkey"]
       images_path = self.__ctx.call("decrypt_image_paths", image_data)

       for image_info in images_path:
           image_url = image_info["url"]
           asyncio.create_task(self.__download_image(image_url, chapter_path))

   async def __download_image(self, image_url, image_path):
       """
       下载图片
       :param image_url:
       :param image_path:
       :return:
       """

       async with self._session.get(image_url, headers=self.__headers) as response:
           if response.status == 200:
               image_name = "".join(image_url.split("/")[4:])
               image_type = image_url.split(".")[-1]
               with open(f"{image_path}{image_name}.{image_type}", "wb") as f:
                   f.write(await response.read())
           else:
               print(f"请求图片{image_url}失败")
               return


if __name__ == '__main__':
   spider = MangaCopySpider()
   spider.run()
复制成功

运行程序,可以看到很快便爬取了所有图片:

结尾

很早之前我就想写这篇,结果一直咕咕,拖到了今天(。

今天是放假,明天又要上学,正好晚上想起了今天是自己生日,觉得生日啥事不干也不合适吧,就赶了一篇。

写得很糙,Python代码都没有细展开,只能说没啥时间了,再拖就要到明天了。

说起来上一篇爬dmzj的文,我当时只是一时兴起,记录了一下自己的爬虫经历,没想到居然有人看了,而且还有人关注了我,真的,非常感谢。