11

作为技术宅的我,是这样追鬼滅の刃的

 3 years ago
source link: https://blog.yuanpei.me/posts/3602353334/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
2020-12-1544 29 min.

有人说,“男人至死都是少年”,而这句听起来有一点中二的话,其实是出自一部同样有一点中二的动漫——银魂。我个人的理解是,知世故而不世故。也许,年轻时那些天马行空的想法,就像堂吉诃德大战风车一样荒诞,可依然愿意去怀着这样的梦想去生活。正如罗曼罗兰所言,“世上只有一种英雄主义,就是在认清生活真相之后依然热爱生活”。所以,继《浪客剑心》之后,我再次被一部叫做《鬼灭之刃》的动漫吸引,毕竟男人的快乐往往就是这么朴实无华且枯燥。一个快三十岁的人,如果还能被一部热血少年番吸引,大概可以说明,他身体里的中二少年连同中二少年魂还活着。最早的印象来自朋友圈里的一位二次元“少年”,他和自己儿子站一起,有种浑然天成的协调感,整个人是非常年轻的感觉。所以,大概,男人至死都是少年。

漫画的抓取

鬼滅の刃的漫画早已更完,令我不舍昼夜去追的,实际上是动画版的鬼滅の刃。虽然B站上提供中配版本,可一周更新两集的节奏,还是让我追得有一点焦灼(PS:我没有大会员呢),甚至熬着夜提前“刷”了无限列车(PS:见文章末尾小程序码)。其实,鬼滅の刃前期并没有特别吸引人的地方,直到那田蜘蛛山那一话开始渐入佳境,鬼杀队和鬼两个阵营所构成的人物群像开始一点一点的展开。它的表达方式有点接近刺客信条,即反派都是在死亡一刹那间有了自我表达的机会,而玩家/观众都可以了解反派的过去。由于鬼是由人转变而来,所以,在热血和厮杀之外,同样有了一点关乎人性的思考。作为一名“自封”的技术宅,我必须要在追番的时候做点什么,从哪里开始好呢?既然漫画版早已更新完毕,我们要不先抓取漫画下来提前过过瘾?

OK,这里博主找了一个动漫网站,它上面有完整的鬼滅の刃漫画。我意识到从网上抓取漫画的行为是不对的,可这家网站提供的漫画明显是通过扫描获得的,因为正常的漫画都是通过购买杂志的方式获得的。所以,如果经济条件允许的情况下,还是希望大家可以支持正版,这里博主主要还是为了研究技术(逃,无意对这些资源做二次加工或者以任何方式盈利,所以,请大家不要向博主索取任何资源,我对自己的定位永远是一名软件工程师,谁让我无法成为尤小右这样的“美妆”博主呢?这一点希望大家可以理解哈!

鬼滅の刃作品页面
鬼滅の刃作品页面
鬼滅の刃章节页面
鬼滅の刃章节页面

简单分析下动漫网站结构,可以发现,它主要有两种界面,即作品页面和章节页面。作品页面里面会显示所有的章节,而每个章节里会显示所有的图片。所以,我们的思路是,首先,通过作品页面获取所有章节的链接。其次,针对每一个章节,获取总页数后逐页下载图片即可。注意到这个网站有部分内容是通过JavaScript动态生成的,所以,requests针对这种情况会有点力不从心。幸好,我们还有Selenium这个神器可以使用,我们一起来看这部分内容如何实现:

import requests
from bs4 import BeautifulSoup
import fake_useragent
import json
import urllib
from selenium import webdriver
from selenium.webdriver.support.ui import Select
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
import os, time
import threading
import threadpool

class DemonSlayer:

def __init__(self, baseUrl):
self.baseUrl = baseUrl
self.session = requests.session()
self.headers = {
'User-Agent': fake_useragent.UserAgent(verify_ssl=False).random
}
# 使用无头浏览器
fireFoxOptions = webdriver.FirefoxOptions()
fireFoxOptions.set_headless()
self.driver = webdriver.Firefox(firefox_options=fireFoxOptions)

# 获取所有章节信息
def getAllChapters(self, opusUrl):
chapters = {}
response = self.session.get(opusUrl, headers=self.headers)
response.encoding = 'utf-8'
response.raise_for_status()
soup = BeautifulSoup(response.content)
soup = soup.find(name='div', attrs={'class','cy_plist'})
if (soup != None):
for li in soup.ul.children:
chapters[li.a.text] = self.baseUrl + li.a['href']
return chapters

# 获取指定章节信息
def getChapter(self, url):
page = 1
maxPage = self.getPageOfChapter(url)
images = []
while page <= maxPage:
reqUrl = url + '?p={page}'.format(page=page)
self.driver.get(reqUrl)
wait = WebDriverWait(self.driver, 10)
# 等待漫画图片加载完成
wait.until(EC.presence_of_element_located((By.ID, "qTcms_pic")))
html = self.driver.page_source
imgSrc = BeautifulSoup(html).find('img')['src']
# 从网页中提取的图片地址需要做一次解码
realSrc = urllib.parse.unquote(imgSrc.split('?')[1].split('&')[0].split('=')[1])
images.append(realSrc)
page += 1
return images

# 获取指定章节页数
def getPageOfChapter(self, url):
self.driver.get(url)
wait = WebDriverWait(self.driver, 10)
# 等待页数加载完成
wait.until(EC.presence_of_element_located((By.ID, "k_pageSelect")))
html = self.driver.page_source
opts = list(BeautifulSoup(html).find('select').children)
return int(opts[-1]['value'])

# 下载漫画图片
def getImage(self, chapterPath, index, url):
imagePath = os.path.join(chapterPath, str(index) + '.jpg')
with open(imagePath, 'wb') as fp:
response = self.session.get(url)
response.raise_for_status()
fp.write(response.content)

# 公共入口
def run(self, url):
chapters = self.getAllChapters(url)
for chapter, url in chapters.items():
images = spider.getChapter(url)
# 为每一个章节建立文件夹
chapterPath = './download/{chapter}/'.format(chapter=chapter)
if (not os.path.exists(chapterPath)):
os.mkdir(chapterPath)
# 使用多线程下载图片
args = []
for index, url in enumerate(images):
args.append((None,{'chapterPath':chapterPath, 'url':url, 'index':index}))
pool = threadpool.ThreadPool(max(10, len(images)))
requests = threadpool.makeRequests(self.getImage, args)
[pool.putRequest(req) for req in requests]
pool.wait()

if __name__ == '__main__':
spider = DemonSlayer('http://www.7edm.com/')
spider.run('http://www.7edm.com/rexue/guimiezhiren/')

在这里,需要注意的Firefox驱动,即GeckoDriver需要提前安装好。同时,确保Firefox主程序和驱动程序所在的目录均已配置到环境变量Path中。在有的资料中提到,GeckoDriver需要和Firefox主程序在同一个目录底下,不过经过博主的验证,需要放在Python主程序的根目录底下。这个驱动程序可以从这里下载:https://github.com/mozilla/geckodriver/releases/。当然,如果你更喜欢使用Chrome,只需要安装Chrome的驱动即可,完全遵从个人意愿即可。在运行这个脚本后,我们就可以获得完整的鬼滅の刃漫画:

鬼灭之刃204话
鬼灭之刃204话

在Kindle上“追”番

好了,到现在为止,我们实现了本文的第一个小目标,即漫画的抓取。那么,在抓取到漫画以后,我们可以做什么呢?可能会有读者朋友忍不住吐槽,“废话,漫画除了看还能干什么”。话虽如此,可这样一张张图片显然不能让我们方便地看漫画啊!早就听说在Kindle上可以看漫画,可一直没有真正尝试过,可能是因为我还不够中二吧!所以,接下来,我们来考虑将这些图片制作成电子书。Kindle本身支持像mobi、docx/doc、pdf等等这样的格式,这里我们选择一种最简单的方式,利用PyMuPDF这个库来生成PDF文件,一起来看具体的代码实现:

def createPdf(self, chapterPath):
doc = fitz.open()
pdfPath = './ebook/' + os.path.basename(chapterPath) + '.pdf'
for image in sorted(os.listdir(chapterPath)):
filePath = os.path.join(chapterPath, image)

# 为每一张图片创建一个单独的PDF
imgdoc = fitz.open(filePath)
pdfbytes = imgdoc.convertToPDF()
imgpdf = fitz.open("pdf", pdfbytes)

# 插入当前页
doc.insertPDF(imgpdf) # 将当前页插

# 保存PDF
doc.save(pdfPath)

经常使用Kindle的朋友,一定对它的邮箱传书功能非常熟悉,这意味着我们此基础上,将生成的PDF文件直接推送到Kindle上,而在Python中发送邮件则是非常简单的,这一点我们不再赘述,那到底能不能实现我们这个想法呢?显然可以,我们一起来看看效果:

由PyMuPDF生成的“漫画书”
由PyMuPDF生成的“漫画书”

我们知道Kindle对PDF的支持是有点差的,主要体现在它不支持重排,在我们这个例子中,我们可以发现图片是横屏显示的,或许是因为我的Kindle屏幕太小,或许是因为我没有一个Pad,或许是因为图片尺寸无法适配,总之,它给人的体验是有点不舒服。博主注意到,Kindle官方提供了一个漫画书制作软件Kindle Comic Creator,它可以生成mobi格式的电子书。作为对比,我们来看一下它在Kindle上的显示效果:

由Kindle Comic Creator生成的“漫画书”
由Kindle Comic Creator生成的“漫画书”

果然还是Kindle自家的格式更好一点,有同样需求的小伙伴们,不妨试一试这个软件!现在,我们终于可以在Kindle上“刷”漫画了,快乐肥宅水,安排!

让漫画“动”起来

一旦看过动画以后,再去看漫画,总觉得没那味儿,果然,“没声音再好的戏都出不来”,除了想念片头OP以外,更想念声优们绘声绘色的配音。有人说,博主你可以戴上耳机,放着片头OP,刷着漫画啊!哎,你可真是小机灵鬼!可作为一名技术宅,我们当然要追求手工耿的无用之美,不就是想看有声音有画面的视频吗?安排!所以,下面我们来通过这些图片生成视频,这里主要用到opencv-pythonMoviePy两个库,前者可以通过OpenCV合成视频,而后者则可以对视频进行剪辑,例如加入片头、片尾、背景音乐等等。

def createVideo(self, chapterPath):
# OpenCV创建视频
videoFps = 40
videoSize = (655, 948)
videoPath = './video/' + os.path.basename(chapterPath) + '.avi'
videoWriter = cv2.VideoWriter(videoPath,
cv2.VideoWriter_fourcc('I', '4', '2', '0'),
videoFps,
videoSize
)

for image in sorted(os.listdir(chapterPath)):
# 读取每张图片,并重复写40帧
image_path = chapterPath + '/' + image
bg = cv2.imread('bg.jpg')
max_h, max_w, _ = bg.shape
img = cv2.imdecode(np.fromfile(image_path ,dtype=np.uint8),-1)
img_h, img_w, _ = img.shape
scale = max_h / img_h
new_w = int(img_w * scale)
new_h = int(img_h * scale)
img = cv2.resize(img ,(new_w ,new_h))
offsetX = int((max_w - new_w) / 2)
offsetY = int((max_h - new_h) / 2)
bg[offsetY:offsetY + img.shape[0], offsetX:offsetX + img.shape[1]] = img

for i in range(0, videoFps):
videoWriter.write(bg)
videoWriter.release()

# 为当前视频加入片头/尾
videoClip = VideoFileClip(videoPath)
titleClip = VideoFileClip('./title.mp4')
finalClip = concatenate_videoclips([
titleClip.subclip(0, 12),
videoClip,
titleClip.subclip(70, 72),
titleClip.subclip(85, 89),
])

# 为当前视频增加BGM
audioClip = AudioFileClip('BGM.mp3').subclip(0, finalClip.duration)
finalClip.audio = audioClip
finalClip.write_videofile('output.avi', codec='libvpx')

在使用OpenCV合成视频这一步,和大多数第一次接触OpenCV的人一样,博主在这里遇到了很多的问题,例如,最典型的问题有:OpenCV合成的视频无法打开、OpenCV无法使用中文路径、OpenCV合成的视频长度较短等等。所以,在这里我简单做一下说明,首先,检查下视频画面的宽/高是否与图片的宽/高一致。其次,选择合适的编码器,在使用OpenCV保存视频的时候,需要综合考虑FOURCC和视频格式两个因素,前提是当前系统已经安装了对应的编码器,在Windows下推荐大家使用DIVX。最后,关于中文路径,大部分的解决方案都是引入NumPy,代码片段如下:

# 读取
cv_img = cv2.imdecode(np.fromfile(file_path, dtype=np.uint8), -1)
# 写入
cv2.imencode('.jpg', cv_img)[1].tofile(file_path)

经过博客的验证,这个方案的确可以解决,不过同样有缺点,imdecode()方法读取的是RGB,而OpenCV需要的则是BGR,需要做一次转换,而转换则意味着会损失图片信息。所以,如果在意生成的视频质量的话,最好还是放在英文的路径下面。

好了,现在来看看效果,当片头OP响起的时候,有没有觉得,那种熟悉感觉的又回来了(PS:此处可与飞驰人生梦幻联动),虽然没有配音,还是有一点视频的样子的。我经常在B站看到那种,黑色背景 + 白色文字的视频,虽然没有特别复杂的转场动画,可搭配上小爱同学或者Siri同学的声音后,居然感觉还不错。知乎上同样提供了文章转视频的功能,所以,我在想这是不是可以作为一个思路,作为一个努力寻找流量方向的技术博客,我好累啊(逃……

这个思路,其实还可以用来制作表情包,作为一部拥有大量“名场面”的动漫作品,它为博主带来了不少的“燃点”和“笑点”,下面例举一二供大家欣赏:

世界名画
安塞腰鼓

对于静态表情包,通常只需要用到PIL库就可以完成,下面是富冈义勇迫害时间(逃:

image = Image.open('鬼灭之刃-09.jpg')
width, height = image.size
textFont = ImageFont.truetype('Deng.ttf', 18)
imageDraw = ImageDraw.Draw(image)
textPos = (width * 0.34, height * 0.8)
text = '我感觉我没有被讨厌'
imageDraw.text(textPos, text, fill='#fff', font=textFont, align='left')
image.save('富岗义勇表情包.jpg','jpeg')

如此,我们就可以得到下面的名场面:

我感觉我没有被讨厌
我感觉我没有被讨厌
冰柱表示不想和你说话
冰柱表示不想和你说话

而对于动态表情包,我们可以考虑使用imageioMoviePy,它们都可以从图片或者视频来生成GIF,一起来看下面的例子:

def createGif(self, framesPath, gifPath, duration=0.5):
# 读取图片
frames = []
for image_name in sorted(os.listdir(framesPath)):
image_path = os.path.join(framesPath, image_name)
frames.append(imageio.imread(image_path))

# 保存GIF
imageio.mimsave(gifPath, frames, 'GIF', duration=duration)

# 读取一组图片并生成GIF
handler = ImageHandler()
handler.createGif('./frames', '登峰造极.gif')

这里唯一需要注意的就是,imageio依赖于Pillow:

集中一点,登峰造极
集中一点,登峰造极

如果使用MoviePy来生成GIF,则可以通过VideoFileClip或者ImageSequenceClip来分别从视频和图片创建GIF。同样,这里有一个简单的例子:

# 从一组图片并生成GIF和视频
fps = 0.25
sequence = ImageSequenceClip('./frames', fps)
sequence.write_gif('登峰造极.gif')
sequence.write_videofile('登峰造极.avi', codec='libvpx')

# 从视频中截取指定片段生成GIF
videoClip = VideoFileClip('input.mp4')
videoClip = videoClip.subclip(33, 45)
videoClip.write_gif('霹雳一闪.gif')

在这里,我选取的是,我个人非常喜欢的角色——我妻善逸的名场面“霹雳一闪”:

霹雳一闪

至此,我们“绞尽脑汁”、“千方百计”、“搜肠刮肚”地想到了各种“刷”鬼滅の刃的方法,总算为这份中二信仰充了值,大家还有什么好的点子吗?欢迎大家在评论区留言、点赞、收藏、一键三连,我仿佛嗅到了Bilibili的味道(逃……

坦白来讲,这篇博客的确有蹭热度的嫌疑,不过以鬼灭之刃在日本妇孺皆知的火热程度,足以令人们在这颇感失落的疫情背景下为之一振,甚至于连日本首相菅义伟在国会质询时,都引用了“全集中呼吸”这一经典台词。“纵有疾风起,人生不言弃”,热血少年漫告诉我们,在面对一个又一个的挫折的时候,不要沉溺于昨天的回忆,去勇敢地接受现实,直面惨淡的人生,这又何尝不是我们面对生活的态度呢?不管是旷日持久的疫情,还是似曾相识的失业,人生的道路上还有数不尽的“魑魅魍魉”呢!希望你活得像炭治郎一样乐观善良,像我妻善逸一样勇敢坚毅,像伊之助一样“猪”突猛进。如果这篇博客里所介绍的“追”动漫的方式,能让大家感到快乐和有趣的话,欢迎大家在评论区留言、点赞、收藏、一键三连。以上,这就是我,作为一个技术宅,在“追”动漫方面所做出的努力。少年,想加入鬼杀队吗?


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK