追完动画,刚见到波波,战车这是咋了,啥是镇魂曲啊,怎么就完了,要等周六啊啊啊啊啊啊啊,act3附体,小嘴就像抹了蜜......

ヽ(。>д<)p

于是想到看漫画版,但网页体验较差,一次只能看一页,一页只有一张图,还不能存缓。

╮(╯_╰)╭

行吧,没缓存,我自己做。

大致看了下该页面的结构,做的不错,结构清晰、代码整洁、可读性好,那爬取就很方便了。

初步想法

明确下目标,需要的是任意一本漫画的全部内容,也就是图片src列表,然后批量下载到本地。没想过要爬整站(我是良民),入口就设为漫画的详情页好了,目测需要如下几个步骤:

编号 => 基本信息 => 章节列表 => 页列表 => 图片地址

个人比较熟悉node,插件应有尽有,写起来比较顺手,实现如下:

// 根据编号获取url地址
export async function getIndexUrl(number) {
  let url = `${baseUrl}/manhua/`;
  if (number) url += number;
  return url;
}

// 获取漫画基本信息和章节目录
export async function getData(url) {
  const html = await fetch(url).then(res => res.text());
  const $ = cheerio.load(html, { decodeEntities: false });

  // 获取title
  const title = await $('h1.comic-title').text();

  // 获取简介
  const description = await $('p.comic_story').text();

  // 获取creators
  const creators = [];
  function getCreators() {
    creators.push($(this).find('a').text());
  }
  await $('.creators').find('li').map(getCreators);

  // 获取卷
  const list = [];
  function getVlue() {
    const href = `${baseUrl}${$(this).find('a').attr('href')}`;
    list.push({
      href,
    });
  }
  await $('.active .links-of-books.num_div').find('li.sort_div').map(getVlue);

  // 获取数据
  const data = [];
  function getComicData() {
    const key = $(this).find('td').attr('class') || $(this).find('td a').attr('class');
    const value = key === 'comic-cover' ? $(this).find('td img').attr('src') : $(this).find('td').text();
    const label = $(this).find('th').text();
    data.push({
      key,
      label,
      value,
    });
  }
  await $('.table.table-striped.comic-meta-data-table').find('tr').map(getComicData);

  const detail = await $('article.comic-detail-section').html();

  return {
    title,
    creators,
    description,
    list,
    data,
    detail,
  };
}

// 获取章节内全部页
export async function getPageList(url) {
  const html = await fetch(url).then(res => res.text());
  const $ = cheerio.load(html, { decodeEntities: false });
  const list = [];
  function getPage2() {
    list.push(`${baseUrl}${$(this).attr('value')}`);
  }
  await $('select.form-control').eq(0).find('option').map(getPage2);
  return list;
}

// 获取页内图片地址
export async function getPage(url) {
  const html = await fetch(url).then(res => res.text());
  const $ = cheerio.load(html, { decodeEntities: false });
  const src = await $('img.img-fluid').attr('src');
  return `${baseUrl}${src}`;
}
复制代码

先将写好的步骤组合起来:

async function test(number) {
  const indexUrl = await getIndexUrl(number);
  const data = await getData(indexUrl);

  await Promise.all(data.list.map(async (i, idx) => {
    let pageList = await getPageList(i.href);
    const imgs = [];
    await Promise.all(pageList.map(async (pages, pdx) => {
      await Promise.all(pages.map(async (j, jdx) => {
        let src = await getPage(j);
        imgs.push(src);
      }));
    }));
  }));

  fs.writeFileSync(`../jojo/${number}.json`, JSON.stringify(data));
}
复制代码

嗯,很简单嘛。就是看起来太暴力了,这样不好,别给人服务器太大压力。

串行promise

实现类似Promise.all,但不同点在于,如果是数组直接返回promise,众所周知promise在创建时就已经执行了,不可能串行。于是将数组改写,返回一个可执行函数的队列,在串行方法里去执行,这样就可以实现了。

export function sequence(promises) {
  return new Promise((resolve, reject) => {
    let i = 0;
    const result = [];

    function callBack() {
      return promises[i]().then((res) => {
        i += 1;
        result.push(res);
        if (i === promises.length) {
          resolve(result);
        }
        callBack();
      }).catch(reject);
    }

    return callBack();
  });
}

// 使用方法
sequence([1, 2, 3, 4, 5].map(number => () => new Promise((resolve, reject) => {
  setTimeout(() => {
    console.log(`$${number}`);
    resolve(number);
  }, 1000);
}))).then((data) => {
  console.log('成功');
  console.log(data);
}).catch((err) => {
  console.log('失败');
  console.log(err);
});

复制代码

分组并发

完全串行的话,和正常逐步浏览网页没什么区别,性能是没问题了,但那有什么意义,我们得加快速度。于是想到将200页分组,每10个一组,每组串行执行,组内并行执行,相当于10个用户同时访问网站,这总不可能扛不住吧,如此应该能兼顾效率和性能。

分组就没必要自己写了,使用lodash/chunk和Promise.all即可。

异常处理

考虑到爬取一个章节,至少都是200页,还是并行的,不敢保证中途不出任何意外,基本的异常处理还是要做的。

赶时间,直接将利用race来实现,只要没有结果,一律重试,实践起来还是比较稳的。

完整的爬取方法

async function test(number) {
    const indexUrl = await getIndexUrl(number);
    const data = await getData(indexUrl);

    await sequence(data.list.map((i, idx) => async () => {
    await sleep(Math.random() * 1000);
    let pageList = await getPageList(i.href);

    pageList = pageList
        .map(src => ({ src, index: parseInt(src.substr(42, 3).replace('_', ''), 0) }))
        .sort((x, y) => x.index - y.index)
        .map(({ src }) => src);

    const imgs = [];
    const len = 5;

    // 分len一组,串行访问
    await sequence(chunk(pageList, len).map((pages, pdx) => async () => {
        await sleep(Math.random() * 5000);
        // 以len一组,并行访问
        await Promise.all(pages.map(async (j, jdx) => {
        let src;
        async function race() {
            await sleep(Math.random() * 1000);
            const res = await Promise.race([ getPage(j), sleep(5000) ]);
            if (res) {
            src = res;
            console.log(`拿到src: ${src}`);
            } else {
            console.log('重新加入队列');
            await race();
            }
        }
        await race();
        imgs.push(src);
        }));
    }));

    data.list[idx].list = imgs
        .map(src => ({ src, index: parseInt(src.substr(42, 3).replace('_', ''), 0) }))
        .sort((x, y) => x.index - y.index)
        .map(({ src }) => src);
    }));

    console.log('爬取成功!!!');

    fs.writeFileSync(`../jojo/${number}.json`, JSON.stringify(data));
}
复制代码

批量下载

以上,已经拿到了所需的全部信息。

接下来只需要批量下载即可,这好办,先整一个promise化的下载方法:

function downloadFile(url, filepath) {
  return new Promise((resolve, reject) => {
    // 块方式写入文件
    const ws = fs.createWriteStream(filepath);
    ws.on('open', () => {
      console.log('下载:', url, filepath);
    });
    ws.on('error', (err) => {
      console.log('出错:', url, filepath);
      reject(err);
    });
    ws.on('finish', () => {
      console.log('完成:', url, filepath);
      resolve(true);
    });
    request(url).pipe(ws);
  });
}
复制代码

再如法炮制一套串行并行混合的下载规则:

async function readJson() {
// await checkPath('../jojo');
  const data = await readFile('../jojo/128.json');
  const json = JSON.parse(data);
  console.log(json.list[0].list.length);

  const cur = 10;
  await sequence(json.list.map((list, idx) => async () => {
    await sequence(chunk(list.list, cur).map((pages, pdx) => async () => {
      await sleep(Math.random() * 1000);
      console.log('正在获取:', `/jojo/${idx + 1}/`, ` 第${pdx + 1}批`);
      await Promise.all(pages.map(async (j, jdx) => {
        const dirpath = `../jojo/${idx + 1}`;
        const filepath = `../jojo/${idx + 1}/${cur * pdx + jdx + 1}.jpg`;
        await checkPath(dirpath);
        async function race() {
          await sleep(Math.random() * 1000);
          const res = await Promise.race([
            downloadFile(j, filepath),
            sleep(20000),
          ]);
          if (res) {
            console.log('下载成功');
          } else {
            console.log('重新加入队列');
            await race();
          }
        }
        await race();
      }));
    }));
  }));
  console.log('获取成功');
}
复制代码

尝试一下,问题不大,图片会按章节下载到对应目录,速度比自己翻快多了,目标达成!

折腾了几个小时,终于又可以愉快地在ipad上看漫画咯,我真是嗨到不行了~

[]~( ̄▽ ̄)~* (~ ̄▽ ̄)~

完整代码:github.com/liumin1128/…

PS1:仅供学习,请在24小时内删除。

PS2:有机会请支持正版。

PS3:整天看动漫,感觉自己不务正业啊。