在博客上记录读书笔记是一件寻常事,但是怎么在文章中插入图书信息却是件恼人的事。直接用书名号引出?不不不,这也太不够美观了。参考一下国内图书信息最全面的豆瓣:
错不了,就要像这样把图书封面、出版社还有 ISBN 等等全部展示出来,才能让人满意。但是一个个截图也太麻烦了,而且信息没法更新,将这些信息都嵌入在网页中才是最好的实现方式。比较流行的博客框架例如 Hexo 和 WordPress 都有实现类似功能的插件,而我使用的是比较小众的 Pelican,只好自己动手了。
目标
我在博客上写作的流程是 撰写 Markdown
⇨ 使用 Pelican 生成 HTML
,为了实现在文章中插入豆瓣图书,比较方便的方法是在 Markdown 写作时,在需要插入图书的地方写下 {GET BOOK URL}
,其中 BOOK
填写书籍名称,URL
填写书籍链接。当 URL 无效时,保留这条指令,避免遗失原来的信息;当从 URL 成功获取了图书元数据后,就将该条命令转换为 HTML 格式的图书信息,就顺利插入文章中了。
本文介绍的代码可以在 Tseing/pelican-bookshelf 上找到。
Pelican 模块
先从 Pelican 插件的原理讲起,Pelican 插件使用了信号机制,所谓信号机制就是在工作流程中,完成特定任务后就会给出特定信号,可以使用这种信号触发插件,完成正常工作流程以外额外的工作。那么在正式写插件之前,还需要了解一下 Pelican 的正常工作模块,具体可以分为以下 3 种:
- Writers:负责写文件的模块,例如生成 HTML、RSS 等,Writers 模块需要创建对象,并将其传递给 Generators 模块。
- Readers:用于读文件的模块,读取文件并返回元数据(作者、日期等)和用于生成 HTML 网页的内容。
- Generators:产生各种各样的输出,是工作流程中的最后部分。
回头考虑我的目标,我所要编写的插件显然是一个 Reader 模块,当读取到 Markdown 中的图书指令时开始工作,并将替换的内容传递给 Generators 模块生成最终页面。
但是由于 Pelican 文档中给出的相关介绍太少,再读了几遍源码之后仍觉得无从下手,最后我并没有使用官方推荐的方法,用了更简单粗暴的办法,可能以后有精力了会尝试再改成 Reader 模块。
豆瓣爬虫
从指定的 URL 获取图书信息就要用爬虫了,网上关于爬虫的介绍多如牛毛,我只简单讲述一下我的设计过程。爬网解析 HTML 页面可以使用 Beautiful Soup 和 lxml 两个库,因为我需要使用的这个爬虫将在 Pelican 生成网页的过程中调用,虽然 Beautiful Soup 的功能强大也更好上手,但它的性能是不如 lxml 的,所以最后我还是选择使用 lxml。
爬虫并不复杂,通过 request 获取 HTML 信息后,使用 Xpath 定位到目标信息的节点,例如目标信息的结构为
<div id="info" class="">
<span>
<span class="pl"> 作者</span>:
<a class="" href="/author/4550936">海子</a>
</span><br/>
<span class="pl">出版社:</span>
<a href="https://book.douban.com/press/2145">江西人民出版社</a>
<br>
<span class="pl">出品方:</span>
<a href="https://book.douban.com/producers/10">果麦文化</a>
<br>
<span class="pl">出版年:</span> 2017-10<br/>
<span class="pl">页数:</span> 193<br/>
<span class="pl">定价:</span> 42.00元<br/>
<span class="pl">装帧:</span> 精装<br/>
<span class="pl">丛书:</span>
<a href="https://book.douban.com/series/43038">果麦经典</a><br>
<span class="pl">ISBN:</span> 9787210097136<br/>
</div>
获取出版社信息的脚本就可以这么写:
def get_press(meta, selector):
regex = '//div[@id="info"]/child::span[contains(text(), "出版社")]/following-sibling::*[1]/text()'
match = selector.xpath(regex)
if match:
meta["出版社"] = str(match[0])
else:
meta["出版社"] = "暂无"
return meta
regex
是需要匹配的 Xpath 路径,//div[@id="info"]
为全文查找属性为 id="info"
的 <div>
节点。child::span[contains(text(), "出版社")]
为在其子节点查找包含「出版社」文字的 <span>
节点,在这个节点之后的一个节点就是出版社信息了。
用类似的方法就可以得到全部的出版信息,我把所有 Xpath 路径列在下方,随时可以取用,但是注意如果豆瓣更改了网页结构可能就会失效。
# 封面图片
regex = '//img[@rel="v:photo"]/@src'
# 作者
regex = '//div[@id="info"]/span[child::span[@class="pl"][contains(text(), "作者")]]//text()'
# 出版社、出品方、丛书等 <a> 标签内容
tags = ["出版社", "出品方", "丛书"]
regex = f'//div[@id="info"]/child::span[contains(text(), "{tag}")]/following-sibling::*[1]/text()'
# 其他非 <a> 标签出版信息
tags = ["出版年", "页数", "定价", "装帧", "ISBN"]
regex = f'//text()[preceding-sibling::span[1][contains(text(),"{tag}")]][following-sibling::br[1]]'
核心代码
在解决了爬虫之后剩下的就是替换文本的一个简单任务了,由于爬虫的速度相对较慢,如果每次生成网页都要把所有 URL 都重新爬一遍,一来会大大降低生成速度,二来如此大量的请求很容易被网页反爬,所以我将原来的目标分成了两个功能。
- 生成网页后,将 HTML 文件中所有
{GET BOOK URL}
字段替换为图书信息。 - 如果指定了
SAVE_TO_MD
,那么除生成的网页之外,原始的 Markdown 文件也会被修改,下一次生成网页时就不需要重新爬虫。
这两个功能的具体操作相同,所以用同一个函数实现即可:
def replace(path, context=None):
suffix = os.path.splitext(str(path))[-1]
if suffix != ".html" and suffix != ".md":
pass
elif suffix == ".md" and not BOOKSHELF_SETTING["SAVE_TO_MD"]:
pass
else:
pattern = r"\{GET\s\S+\s[a-zA-z]+://[^\s]*\}"
with open(str(path), 'r', encoding="utf-8") as f:
s = f.read()
search_target = re.search(pattern, s)
while search_target is not None:
_, book, url = search_target.group().strip("{}").split()
html = get_page(url)
time.sleep(BOOKSHELF_SETTING["WAIT_TIME"])
if html is not None:
meta = parse_page(html)
s = s.replace(search_target.group(), generate_bookshelf(meta, book))
search_target = re.search(pattern, s)
else:
search_target = re.search(pattern, s)
continue
with open(str(path), 'w', encoding="utf-8") as f:
f.write(s)
代码也十分简单,就是根据路径打开文件,通过反复通过正则表达式搜索匹配的内容并用生成的 HTML 片段替换。在每次爬虫请求之后,我加入了一个 2 秒钟的延时,否则太容易被豆瓣屏蔽了。
注册插件
就像上文所说的,这是因为 Pelican 可以通过信号像管道一样将插件接入正常的工作流中,这里介绍两个信号:
content_object_init(content_object)
:在读取完所有文件后,准备通过 Generators 模块生成 HTML 时的信号。这个信号传递的参数是content_object
,也就是目前读取完文件的对象,使用str(content_object)
能直接将文件转换为文件路径,将其传递给replace
函数就能将修改原始的 Markdown 文件,但由于文件已经读取完成了,所以不会影响输出文件中的内容。content_written(path, context)
:Generators 模块输出文件后的信号,每输出一个文件就会引发一次该信号。信号传递的参数是path
和context
,path
是输出文件的路径,context
是例如修改日期等的其他信息,所以replace
函数同样接受path
参数进行处理,Pelican 每生成一个文件,replace
函数就检查是否有匹配的命令并替换,而不会改变原始 Markdown 文件。
最后通过以下方式将 replace
注册到相应的信号上,就可以使用了。
def register():
pelican.signals.initialized.connect(init_config)
pelican.signals.content_object_init.connect(replace)
pelican.signals.content_written.connect(replace)
当然插件的功能只是在文件中插入了 HTML 片段,以下是个样例;
<div class="bookshelf">
<div class="book">
<img src="https://img2.doubanio.com/view/subject/s/public/s29610741.jpg" referrerPolicy="no-referrer"/>
<div class="infos">
<a class="title" href="https://book.douban.com/subject/27154094/">海子的诗</a>
<div class="作者">作者:海子</div>
<div class="出版社">出版社:江西人民出版社</div>
<div class="出版年">出版年:2017-10</div>
<div class="页数">页数:193</div>
<div class="定价">定价:42.00元</div>
<div class="ISBN">ISBN:9787210097136</div>
</div>
</div>
</div>
只要编写好 CSS 样式再套上去,就能得到很不错的效果啦。
Demo
最后看一看插件的效果吧 :)
自己的话
其实我之前从未用过爬虫,写这个插件也是一时兴起,最后花了 2 天时间简单地完成了。虽然国内使用 Pelican 的人非常少,甚至在国际上使用的人也不多,但正因为这是一个如此小的社区,并没有丰富的插件,所以我也能自豪地参与其中并为它做贡献。
关于上文所提到的目标,最好的实现方式并不是爬虫,因为爬虫速度慢、发起的大量请求对服务器也并不友善,更好的方式是使用官方的 API。使用官方 API 发起的请求与对网页的请求不是同一个入口,不会影响网站的正常访问,而且需要的数据都在 JSON 中,不用费力不讨好地去解析 HTML。
然而遗憾的是,四五年前豆瓣就并闭了它的 API,原来使用 API 的「互联网难民」也转向了使用爬虫,面对于海量的爬虫,豆瓣与网友之间最终陷入到了反爬虫与爬虫的零和博弈。我相信不用多久,文章中提到的爬虫方式就会无法使用,又不得不继续参加到这场博弈中去。不知在哪看到的一句话:
中国互联网公司之间,是没有 API 的,通用的 API 就是硬爬。
不禁苦笑,真是「萧瑟秋风今又是,换了人间」,我们正在见证着互联网走向封闭,明天又会是什么样呢?