试过用Rust写,但是不知道为什么总是403forbidden
我之前是不会写爬虫的,但是因为自己用仓鼠癖,也即是把东西都囤到本地,然后因为之前有某站突然倒掉的事件(虽然之后又重开了),所以看到喜欢的漫画我都会想办法下载下来,就自己搜教程写出了一个爬虫脚本
注意: 由于该漫画网站是外网网站,且爬取图片会损害其利益,故本文章不会涉及该网站实际网址,脚本也不会放github上
首先看看这个漫画网站的结构:
- 具体某个漫画的url: https://xxx.com/漫画id/ 比如某漫画的id是2333,那么对应url就是 https://xxx.com/2333/
- 漫画可以分为多话,url: https://xxx.com/漫画id/话数 但是第一话不用指明话数
- 漫画的标题,作者等信息仅在第一话中展示,这些信息可以在url对应的html代码中找到
- 图片的url: https://yyy.com/漫画对应的固定信息/漫画id/话数/图片编号.jpg 可以在html代码中找到
根据以上信息,脚本大致就要做一下工作:
- 打开url,获取网页的html代码
- 根据漫画第一话的html,获得标题,作者,话数等信息.因为有比较固定的格式,可以使用正则表达式去获取
- 根据各话的html获得所有图片url,也可以使用正则表达式去获取
- 根据漫画的信息显示下载进度
那就开始吧
各模块介绍
一些准备
我们需要一些包和一些准备来支持上述工作,下面给出代码,python版本为3.11
import os # 用于创建文件/目录/代理等
import re # 导入正则表达式模块
import urllib.request # 导入用于打开URL的扩展库模块
import urllib.error # 导入错误,用于try catch
import opencc # 把繁体转为简体,因为网站是繁体中文
os.environ["http_proxy"] = "http://127.0.0.1:7890"
os.environ["https_proxy"] = "http://127.0.0.1:7890"
t2s = opencc.OpenCC("t2s.json")
headers = {
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36",
}
因为是外网网站,这里使用os.environ
指定代理,我用的是clash,默认端口是7890
第10行的t2s
使用后续繁体转简体
第12行的headers用于打开url所使用的UA,如果没有UA,一些网站会拒绝python访问
urllib也可以用requests库代替,网上有其他教程,我觉得前者用起来简单一些
打开URL
def open_url(url: str) -> str:
req = urllib.request.Request(
url,
headers=headers,
) # 将Request类实例化并传入url为初始值,然后赋值给req 添加header,伪装成浏览器
# 访问url,并将页面的二进制数据赋值给page
page = urllib.request.urlopen(req)
# 将page中的内容转换为utf-8编码
html = page.read().decode("utf-8")
return html
简单直接,不解释
解析第一话获得漫画信息
def get_info(first_html: str) -> tuple[str, str, str, bool]:
pattern = r'<meta name="keywords" content="(.+)">'
result = re.search(pattern, first_html)
assert result is not None
result = result[1].split(",")
vip_pattern = r'rel="tag">會員專區'
vip_result = re.search(vip_pattern, first_html)
is_vip = vip_result is not None
title = t2s.convert(result[0])
author = result[1]
other_name_p = r"其他名稱:(.+)?</p>"
other_name_result = re.search(other_name_p, first_html)
other_name = other_name_result[1] if other_name_result is not None else ""
return title, author, other_name, is_vip
输入漫画第一话html代码,返回漫画的标题,作者,别名,是否为vip专享
为什么是第一话? 因为只有第一话的html才有这些信息,其他话就没有了
各种pattern是正则表达式匹配html中的内容,比如第2行,假如漫画标题是aaa,作者是bbb,那么html的内容就会是<meta name="keywords" content="aaa,bbb">
.我们用正则匹配出aaa,bbb
之后就可以split
出来了
因为漫画大都是日/韩漫,所以有日/韩文名,这时候别名就是它的日/韩文名,而标题一般是该漫画的中文名,但也有可能没有别名
is_vip
表明了该漫画是否为vip专享,如果是vip专享,我们只能看到漫画的一些内容,之后的就看不到了(虽然也不是完全没办法,但我不会特地公开)
解析其他话的URL
def get_rest_url(html) -> list[str]:
# 取得剩下集数的html,这个pattern不会搜索到当前的html
pattern = r'<a href="(.+?)" class="post-page-numbers"'
result = re.findall(pattern, html)
return result
假如该漫画有5话,我们把第一话的html丢给该函数,就会返回2,3,4,5话的url,而不包含1话
解析URL是哪一话
def get_episode(url: str) -> tuple[str, int]:
pattern = r"https://xxx.com/([0-9]+)/([0-9]+)"
result = re.search(pattern, url)
if result is None:
return url, 1
episode = int(result[2])
main_url = f"https://xxx.com/{result[1]}"
return main_url, episode
输入漫画任意一话的url,返回该漫画的主url(也就是第一话)和当前话数
为什么会有这个需求? 因为我们并不总是想要从第1话开始下载,如果该漫画现在共有5话,但是没有完结,你下载了5话.之后漫画又更新了2话,那你就应该从5话开始下载,这时候传入第5话的url,就知道你想从第5话开始下载,同时获得第1话的url(因为需要漫画的信息)
下载图片并保存
req = urllib.request.Request(img_url, headers=headers)
img = urllib.request.urlopen(req)
with open(path, "wb") as f:
f.write(img.read())
这里没有把它们整合成函数,是因为实作中1-2行和3-4行因某些原因需要分开,后面会解释
总体代码
if __name__ == "__main__":
while True:
url = input("输入网址: ").strip()
try:
comic = Comic(url)
except Exception as e:
print(e)
else:
comic.download()
print("下载完成!\n")
我们循坏获取url的输入,并创建一个Comic
类,然后调用download
方法. 这里用try catch让一些小错误不至于直接崩溃并打印出错误.比如输错url
Comic类
我们一段一段来看
class Comic:
def __init__(self, url: str):
self.episode_list: list[Episode] = [] # 存放每话的url
self.first_html = open_url(url) #传入url的html,不一定总是第1话的url
rest_html = [open_url(x) for x in get_rest_url(self.first_html)]
self.total_episode = len(rest_html) + 1 # rest_html少了first_html自身的一话
self.main_url, self.first_episode = get_episode(url)
self.episode_html_list = [self.first_html] + rest_html[self.first_episode - 1 :]
最后一行有一点绕,我们从头到尾来看下:
假设该漫画现在共有7话,我们想要第5话开始下载
那我们就传入第5话的url来创建Comic类.self.first_html
就是第5话的html,rest_html
是除第5话外其他话的url,也就是1,2,3,4,6,7话(其他话的url不用一定在第1话的html获取,其他话也有,不然实际浏览时怎么跳转?).self.total_episode
就是6+1=7话,然后self.main_url, self.first_episode
就是第一话的url和当前url的话数(5). 我们需要第一话的url来提供漫画信息和拼接其他话数的url(https://xxx.com/漫画id/话数 但是第一话不用指明话数,所以话数为空,利于拼接).最后self.episode_html_list
就是self.first_html
加上rest_html
从第五话开始之后的所有话数,也就是**[5]**+[1,2,3,4,6,7]中加粗部分
之后是一些os的工作和信息打印
self.title, self.author, self.other_name, self.is_vip = get_info(
open_url(self.main_url)
)
dir_name = f"[{self.author}]{self.title}"
if self.other_name != "":
dir_name += f"|{self.other_name}"
# win的目录不能包含一些符号
dir_name = re.sub(r"[\/\\\:\*\?\"\<\>\|]", " ", dir_name)
self.dir_path = f"D:/zzz/{dir_name}"
if not os.path.exists(self.dir_path):
os.mkdir(self.dir_path)
print(f"作者: {self.author} 漫画名: {self.title}")
if self.other_name != []:
print(f"别名: {self.other_name}")
if self.is_vip:
print("vip漫画")
print(f"共{self.total_episode}话,从第{self.first_episode}话开始下载")
应该挺好懂的,不多解释了
最后对各个话创建Episode
类,在该类里单独处理一话的内容
self.total_img = 0
for i, url in enumerate(self.episode_html_list, self.first_episode):
episode = Episode(url, i, self.dir_path)
self.episode_list.append(episode)
self.total_img += len(episode)
Episode类需要该话的url,该话的话数,下载目录.
self.total_img
是图片总数,用于后续打印信息
Comic类的download方法
def download(self):
already_downloaded = 0
for e in self.episode_list:
e.download(already_downloaded, self.total_img)
already_downloaded += len(e)
只是记录已经下载了多少图片,具体下载要给到Episode类,Episode类也有一个download方法
Episode类
Episode类也挺长,也一段一段看
class Episode:
def __init__(self, html: str, episode: int, dir_path: str):
self.html = html
self.episode = episode
self.is_vip = is_vip_chapter(self.html)
self.dir_path = dir_path
self.set_img_list()
def __len__(self):
return len(self.img_list)
def set_img_list(self):
p = r'data-src="(https://yyy/.*?.jpg)"'
self.img_list = re.findall(p, self.html)
if self.is_vip:
pass
self.is_vip
用于表示本话是不是vip话,前面说过有些漫画是vip话,但该漫画不是所有话都没法看,有些还是能免费看的,所以需要分别处理
set_img_list
用于获取该话全部图片的url,然后根据该话是否为vip话进行处理(该文章不会涉及)
下载部分
def download(self, already_downloaded: int, total_img: int):
for i, img_url in enumerate(self.img_list, 1):
self.try_download_img(img_url)
print(
"\033[K"
+ "\r"
+ f"第{self.episode}话:{i}/{len(self)} 总共:{already_downloaded+i}/{total_img} ",
end="",
flush=True,
)
def try_download_img(self, img_url: str) -> bool:
img_fullname = f'{self.episode}_{img_url.split("/")[-1]}'
path = f"{self.dir_path}/{img_fullname}"
if os.path.exists(path):
return True
try_time = 0
while True:
try:
req = urllib.request.Request(img_url, headers=headers)
img = urllib.request.urlopen(req)
except urllib.error.URLError as e:
try_time += 1
if try_time >= 5:
print(f"{e} at {img_url}")
raise e
else:
save_photo(img, path)
return True
这里我感觉挺屎山的,但是能用(
download
基本是打印信息用的,print
中的"\033[K""\r"
用于把光标退到改行的开头,flush=True
允许覆盖当前行的内容
try_download_img
输入图片的url,返回图片下载是否成功.但其实download
里并没有使用到这个bool值,其实这个bool值在其他地方有用,但是这里不会涉及(
因为我们是用代理下载图片的,有可能会抽风,这里except urllib.error.URLError as e:
就是用来重试url错误的,以免抽风一次就直接不干了
自此,整个脚本就完成了
脚本运行的截图: