为 Pelican 博客写插件——在文章中插入豆瓣图书

Category 碎碎念

在博客上记录读书笔记是一件寻常事,但是怎么在文章中插入图书信息却是件恼人的事。直接用书名号引出?不不不,这也太不够美观了。参考一下国内图书信息最全面的豆瓣:

n

错不了,就要像这样把图书封面、出版社还有 ISBN 等等全部展示出来,才能让人满意。但是一个个截图也太麻烦了,而且信息没法更新,将这些信息都嵌入在网页中才是最好的实现方式。比较流行的博客框架例如 Hexo 和 WordPress 都有实现类似功能的插件,而我使用的是比较小众的 Pelican,只好自己动手了。

目标

我在博客上写作的流程是 撰写 Markdown使用 Pelican 生成 HTML,为了实现在文章中插入豆瓣图书,比较方便的方法是在 Markdown 写作时,在需要插入图书的地方写下 {GET BOOK URL},其中 BOOK 填写书籍名称,URL 填写书籍链接。当 URL 无效时,保留这条指令,避免遗失原来的信息;当从 URL 成功获取了图书元数据后,就将该条命令转换为 HTML 格式的图书信息,就顺利插入文章中了。

本文介绍的代码可以在 Tseing/pelican-bookshelf 上找到。

Pelican 模块

先从 Pelican 插件的原理讲起,Pelican 插件使用了信号机制,所谓信号机制就是在工作流程中,完成特定任务后就会给出特定信号,可以使用这种信号触发插件,完成正常工作流程以外额外的工作。那么在正式写插件之前,还需要了解一下 Pelican 的正常工作模块,具体可以分为以下 3 种:

  1. Writers:负责写文件的模块,例如生成 HTML、RSS 等,Writers 模块需要创建对象,并将其传递给 Generators 模块。
  2. Readers:用于读文件的模块,读取文件并返回元数据(作者、日期等)和用于生成 HTML 网页的内容。
  3. 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 都重新爬一遍,一来会大大降低生成速度,二来如此大量的请求很容易被网页反爬,所以我将原来的目标分成了两个功能。

  1. 生成网页后,将 HTML 文件中所有 {GET BOOK URL} 字段替换为图书信息。
  2. 如果指定了 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 可以通过信号像管道一样将插件接入正常的工作流中,这里介绍两个信号:

  1. content_object_init(content_object):在读取完所有文件后,准备通过 Generators 模块生成 HTML 时的信号。这个信号传递的参数是 content_object,也就是目前读取完文件的对象,使用 str(content_object) 能直接将文件转换为文件路径,将其传递给 replace 函数就能将修改原始的 Markdown 文件,但由于文件已经读取完成了,所以不会影响输出文件中的内容。
  2. content_written(path, context):Generators 模块输出文件后的信号,每输出一个文件就会引发一次该信号。信号传递的参数是 pathcontextpath 是输出文件的路径,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

最后看一看插件的效果吧 :)

海子的诗
作者:海子
出版社:江西人民出版社
出版年:2017-10
页数:193
定价:42.00元
ISBN:9787210097136

自己的话

其实我之前从未用过爬虫,写这个插件也是一时兴起,最后花了 2 天时间简单地完成了。虽然国内使用 Pelican 的人非常少,甚至在国际上使用的人也不多,但正因为这是一个如此小的社区,并没有丰富的插件,所以我也能自豪地参与其中并为它做贡献。

关于上文所提到的目标,最好的实现方式并不是爬虫,因为爬虫速度慢、发起的大量请求对服务器也并不友善,更好的方式是使用官方的 API。使用官方 API 发起的请求与对网页的请求不是同一个入口,不会影响网站的正常访问,而且需要的数据都在 JSON 中,不用费力不讨好地去解析 HTML。

然而遗憾的是,四五年前豆瓣就并闭了它的 API,原来使用 API 的「互联网难民」也转向了使用爬虫,面对于海量的爬虫,豆瓣与网友之间最终陷入到了反爬虫与爬虫的零和博弈。我相信不用多久,文章中提到的爬虫方式就会无法使用,又不得不继续参加到这场博弈中去。不知在哪看到的一句话:

中国互联网公司之间,是没有 API 的,通用的 API 就是硬爬。

不禁苦笑,真是「萧瑟秋风今又是,换了人间」,我们正在见证着互联网走向封闭,明天又会是什么样呢?