实战:使用asyncio爬取gitbook内容输出pdf

文末附有github源码链接~

梳理一下流程

用到HTML+css转pdf是 https://weasyprint.readthedocs.io/en/stable/index.html。这个工具使用很简单,核心API为:

1
2
3
4
def output_pdf(html_text,css_text):
html = weasyprint.HTML(string=html_text)
css = weasyprint.CSS(string=css_text)
html.write_pdf(fname, stylesheets=[css])

所以我们需要做的,就是获取css文件和html源代码,然后传入output_pdf这个函数就行了。

获取css

css很简单,因为不同的gitbook page使用到的css文件都是一样的,可以复制下来保存到本地的文件,之后从文件中读取就行。

具体内容见:https://github.com/fuergaosi233/gitbook2pdf/blob/master/gitbook.css。可直接复制这个文件的内容。

获取html

需要的html是其中的正文部分,通过页面源代码分析可知,这部分是被<section class='normal markdown-section'></section>包裹住的,这可以很容易得使用bs4或者lxml等工具提取出来。

知道了怎么获取一个页面的内容,接下来要做的就是获取所有章节页面的链接,这部分内容就在左边的侧边栏。

由页面源代码分析,可知这些章节都是一个带有header或chapter的li标签,这也可以通过简易的脚本抓取。

获取了所有章节链接之后,就可以爬取各个页面得正文内容了,然后组装起来。

输出pdf

这部分很简单,上面提到过,就不赘述了。

开始动手

首先是一个提取单页面正文的函数:

1
2
3
4
5
6
7
8
9
10
11
def get_content(index,path):
'''
return path's html
'''
url = urljoin(BASE_URL, path)
content = requests.get(url,headers=headers).text
tree = etree.HTML(content)
context = tree.xpath('//section[@class="normal markdown-section"]')[0]
context.remove(context.find('footer'))
text = etree.tostring(context).decode()
return text

获取章节链接的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
def collect_toc(self, start_utocrl):
text = requests.get(start_url, headers=self.headers).text
soup = BeautifulSoup(text, 'html.parser')
lis = ET.HTML(text).xpath("//ul[@class='summary']//li")
for li in lis:
element_class = li.attrib.get('class')

if not element_class:
continue
if 'header' in element_class:
title = self.titleparse(li)
data_level = li.attrib.get('data-level')
level = len(data_level.split('.')) if data_level else 1
content_urls.append({
'url': "",
'level': level,
'title': title
})
elif "chapter" in element_class:
data_level = li.attrib.get('data-level')
level = len(data_level.split('.'))
if 'data-path' in li.attrib:
data_path = li.attrib.get('data-path')
url = urljoin(self.start_url, data_path)
title = self.titleparse(li)
if url not in found_urls:
content_urls.append(
{
'url': url,
'level': level,
'title': title
}
)
found_urls.append(url)

# Unclickable link
else:
title = self.titleparse(li)
content_urls.append({
'url': "",
'level': level,
'title': title
})

一个gitbook page的章节可能会很多,如果是通过循环一个一个爬的话,那效率太低了,这里我们使用python3.6的新feature asyncio来进行异步抓取。

示例代码如下:

这里还要注意一点,requests本身是block的,要使用asyncio,还需要对对这部分进行一下处理。这里用的是aiohttp。

1
2
3
4
async def request(url, headers, timeout=None):
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers, timeout=timeout) as resp:
return await resp.text()

主函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
async def main():
text_tree, content_urls = collect_toc()
tasks = []
for index, url in enumerate(content_urls):
tasks.append(
get_content(index, url)
)
await asyncio.gather(*tasks)
print("crawl : all done!")

if __name__ == '__main__':
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.close()

其他一些细节

  • 如何生成pdf目录

Weasyprint默认是将h1-h6标签和目录锚点进行对应的,这个和我们的需求不符。

我们想要的目录结构是要和gitbook page左边目录栏一致。在研究了一阵源码之后,我们用monkey patch(猴子补丁的方式)将这部分内容改了一下。

1
2
3
def local_ua_stylesheets(self):
return [weasyprint.CSS('./html5_ua.css')]
weasyprint.HTML._ua_stylesheets = local_ua_stylesheets

这个html5_ua.css的内容在文末给出的github地址里面有。

  • 如何让爬到的内容有序?

这个项目和普通的爬虫有点不一样的地方,那就是最终生成的html是要和章节内容顺序一致的。如果是通过一个for循环的话,这个很容易解决。用到asyncio的话,就要相对复杂很多。
这里我们的解决方案是先获取所有的url列表,然后生成一个一样长度的全局变量CONTENT_LIST列表

1
2
3
4
for index, url in enumerate(content_urls):
tasks.append(
get_content(index, url)
)

通过enumerate函数,我们遍历的同时获取这个url对应的索引,将这个索引信息传入到get_content函数,这个函数不再返回值,而是把数据写入到全局变量CONTENT_LIST相应的index位置上去。

  • 调整代码结构

全局变量的处理是不太好的,一个每次只运行一次的脚本倒是问题不大,如果要做为一个module给其他程序调用的话,这个全局变量会代码很多问题。所以我们抽象成了一个类,改成在__init__里面初始化这个列表。

github项目地址

想直接取工具的小伙伴点这里:https://github.com/fuergaosi233/gitbook2pdf。别忘了点个star哦~有问题欢迎可以在issue中指出