用Puppeteer扒漫画

Puppeteer是Google搞的爬虫框架,其特点就是我们可以直接通过程序来操作Google浏览器(服务器没装图形界面也没关系,这个Google浏览器不需要图形界面)。通过这个框架,我们就可以像正常使用浏览器一样爬网站,并且像进控制台那样操作页面获取信息,甚至还可以截图。只要网站不使用验证码或reCAPTCHA之类的大杀器,而且运营者不希望正常使用浏览器的用户也无法访问页面,那么我们就可以随便扒网站了。例如本文从https://tw.manhuagui.com网站扒漫画。

下面直接贴代码:

/**
 * https://tw.manhuagui.com 扒漫画工具
 * 
 * @author infnan
 * @version 1.0
 */

'use strict';
const fs = require('fs');
const path = require('path');
const puppeteer = require('puppeteer');
const request = require('request');
const winston = require('winston');

// 用await延时
const sleep = (timeout) => new Promise((resolve, reject) => { setTimeout(() => resolve(), timeout); });

// Logger
const logger = winston.createLogger({
    level: 'info',
    format: winston.format.combine(
        winston.format(info => {
            info.level = info.level.toUpperCase();
            return info;
        })(),
        winston.format.colorize(),
        winston.format.timestamp({
            format: 'YYYY-MM-DD HH:mm:ss'
        }),
        winston.format.printf(info => `[${info.timestamp}] <${info.level}> ${info.message}`)
    ),
    transports: [new winston.transports.Console()]
});

/**
 * 显示用法
 */
const showUsage = () => {
    console.log('tw.manhuagui.com 漫画下载器 v1.0');
    console.log('目前支持单话或整本。下载整部作品时建议在VPS上跑。');
    console.log();
    console.log('node manhuagui.js [--rate 频率] [--dest 存储位置] [--override] [--help] <url>');
    console.log();
    console.log('  url:            例如 https://tw.manhuagui.com/comic/22843/314335.html(某一话)或 https://tw.manhuagui.com/comic/22843/(整部作品)');
    console.log('  --rate 0.5:     每秒下载多少页,默认值0.5。建议两秒一张,超过这个速度很容易被网站封IP。');
    console.log('  --dest 存储位置: 把漫画存到哪个地方。');
    console.log('  --override:     即使下过也要重新下载(仅限下整部作品的时候判断)');
};

/**
 * 建立目录。已存在的时候忽略报错。
 * @param {string} path 
 */
const mkdir = (path) => new Promise((resolve, reject) => {
    fs.mkdir(path, (err) => {
        if (err && err.code !== 'EEXIST') {     // 目录已存在不要当成错误
            reject(err);
        } else {
            resolve();
        }
    });
});

/**
 * 下载单个文件
 * 说明: 正常情况下这样就能下载下来,但是漫画网站服务器有个配置不对,
 * 而且Chromium有个bug( https: //github.com/GoogleChrome/puppeteer/issues/795),
 * 下载下来的东西里头带有锟斤拷,所以后面不使用这个方法下载。
 * @param {Browser} browser 
 * @param {string} url 
 * @param {string} filename 
 */
const downloadFile = async (browser, url, filename) => {
    // 打开新标签页
    const page = await browser.newPage();

    // 开始下载
    const img = await page.goto(url);
    fs.writeFileSync(filename, await img.buffer());
    await page.close();
};

/**
 * 单话
 * @param {Page} page 
 * @param {Object} options 
 */
const processSingleManga = async (page, url, options) => {
    // 跳转到页面
    try {
        await page.goto(url, { timeout: 10000 });
    } catch {
        try {
            await page.evaluate((_) => window.stop());
        } catch (e) {
            logger.error('下载错误', e);
            return;
        }
    }

    // 借助页面本身的jQuery取漫画名称和第几话
    const title = await page.evaluate(`$('div.w980.title h1 a').text()`);
    const subtitle = await page.evaluate(`$('div.w980.title h2').text()`);
    if (!title) {
        logger.error('未知内容,PASS!');
        return;
    }

    // 取漫画页数
    const count = await page.evaluate(`$('#pageSelect option').length`);
    if (!count) {
        logger.error('未获取到漫画页数,PASS!');
        return;
    } else {
        logger.info(`加载完成,页数:${count}`);
    }

    // 在本机建立目录
    const dirName = `${title.trim()} ${subtitle.trim()}`;
    const destPath = path.join(options.dest, dirName);
    await mkdir(destPath);

    // 用jQuery控制点击“第1页”
    await page.evaluate(`$('#pageSelect').val('1').change();`);

    // 下载漫画
    for (let i = 1; i <= count; i++) {
        // 获取图片URL
        const imgSrc = (await page.evaluate(`$('#mangaFile').prop('src')`)).replace('.webp', '');

        // 下载文件
        const basePath = path.join(destPath, `${i}`);
        logger.info(`${i}/${count}: url = ${imgSrc}`);

        // 因为这家网站服务器有个设置不对,而且Chromium有个bug,直接爬buffer会整出锟斤拷,所以没法像下面这样下载
        // await downloadFile(browser, imgSrc, `${basePath}.jpg`);

        // 虽然上面方法不能用,但是此网站服务器只校验Referer,不校验Cookie,所以直接请求更简单
        request({
            uri: imgSrc,
            headers: {
                'User-Agent': options.userAgent,
                'Referer': page.url(),
            },
        }).pipe(fs.createWriteStream(`${basePath}.jpg`));

        // 模拟按向右箭头按钮,进入下一页
        await page.keyboard.press('ArrowRight');

        // 延迟,以免因为速度太快被封IP
        await sleep(options.delay);
    }
};

/**
 * 下载整部作品
 * @param {Page} page 
 * @param {Object} options 
 */
const processWholeManga = async (page, url, options) => {
    // 跳转到页面
    await page.goto(url);

    // 借助页面本身的jQuery取漫画名称和第几话
    const title = await page.evaluate(`$('div.book-cont div.book-title').text()`);
    if (!title) {
        logger.error('未知内容,PASS!');
        return;
    }

    logger.info(`开始下载《${title}》...`);

    // 爬页面上的链接,准备一个一个地点击
    const mangalist = [];
    const linklist = await page.$$('div.chapter div.chapter-list li a');
    for (let link of linklist) {
        let countEle = await link.$('i');
        let countStr = await (await countEle.getProperty('textContent')).jsonValue();
        mangalist.push({
            title: await (await link.getProperty('title')).jsonValue(),
            url: await (await link.getProperty('href')).jsonValue(),
            count: parseInt(countStr)
        });
    }
    logger.info(`已发现${mangalist.length}`);

    for (let manga of mangalist) {
        let dirName = `${title} ${manga.title}`;

        // 判断是不是已经下载过了,以节省时间
        if (!options.override) {
            let ok = true;
            for (let i = 1; i < manga.count; i++) {
                if (!fs.existsSync(path.join(options.dest, dirName, `${i}.jpg`))) {
                    ok = false;
                    break;
                }
            }

            if (ok) {
                logger.info(`${manga.title} 已下载过,PASS`);
                continue;
            }
        }

        logger.info(`开始下载 ${manga.title}`);
        await processSingleManga(page, manga.url, options);
    }
};

/**
 * 开始下载
 * @param {Array} urllist 
 * @param {Object} options 
 */
const doWork = async (urllist, options) => {
    // 开启无头浏览器
    const browser = await puppeteer.launch({
        headless: true,
    });
    options.userAgent = await browser.userAgent();

    for (let url of urllist) {
        try {
            logger.info(`【漫画URL】${url}`);

            const page = (await browser.pages())[0];                            // 取浏览器第一个Tab页
            await page.setViewport({ width: 1366, height: 768 });    // 浏览器窗口大小

            // 判断是单话还是整个作品
            if (url.match(/\/comic\/\d+\/\d+/)) {
                await processSingleManga(page, url, options);
            } else {
                logger.info('检测到要下载整部作品');
                await processWholeManga(page, url, options);
            }
        } catch (e) {
            logger.error(`下载过程中出现错误: `, e);
        }
    }

    // 关闭浏览器
    await browser.close();
};

// 命令行参数
const argv = require('minimist')(process.argv.slice(2));
if (!argv._ || argv._.length === 0 || argv.help || argv.version) {
    showUsage();
} else {
    // 开工
    doWork(argv._, {
        delay: 1000 / (argv.rate || 0.5),
        dest: argv.dest || '.',
        override: argv.override,
    });
}

package.json:

{
  "name": "manhuagui",
  "version": "1.0.0",
  "description": "",
  "main": "manhuagui.js",
  "dependencies": {
    "minimist": "^1.2.0",
    "puppeteer": "^1.14.0",
    "request": "^2.88.0",
    "winston": "^3.2.1"
  },
  "devDependencies": {},
  "scripts": {
    "start": "node manhuagui.js",
    "install-start": "npm install && npm start"
  },
  "author": "infnan",
  "license": "AGPL-3.0-or-later"
}