Calibre 使用教程之抓取网站页面制成电子书

之前书伴曾写过一篇文章《Calibre 使用教程之抓取 RSS 制成电子书》,介绍了利用 Calibre 的“抓取新闻”功能把网站的新闻源制期刊样式电子书的方法。不过软件界面上也只提供了直接添加 RSS 地址的方法,也就是说网站必须有 RSS 供稿才行,否则就无法抓取。那对于不提供 RSS 的网站是否能够抓取它上面的内容制成电子书呢?本文就来介绍一种进阶技巧来解决这个问题。

在开始具体步骤之前,先简单的描述一下工作流程:首先编写一个 Calibre Recipe 脚本文件,根据 Calibre 指定的规范定义具体的抓取行为,然后使用 Calibre 把此脚本转化成 mobi 格式电子书文件。

注意,本文的相关操作是在命令行中进行的,并且会牵涉到简单的代码编写,为了让更多没有编程基础的小伙伴能直接上手使用,本文会尽可能详细的解释每一条代码的作用,以便套用。

一、认识 Calibre Recipe 脚本

Recipe 这个单词的含义为“食谱”、“处方”,顾名思义,它为 Calibre 定义了抓取新闻源这一动作的执行细节。Calibre 也为 Recipe 脚本提供了一份详尽的文档“API documentation for recipes”,对所能使用的参数或函数做了详细的说明。如果你有编程基础,可能感觉直接查看它的源代码会更清晰一些。

在抓取 RSS 制成电子书那篇文章中,我们只需要在 Calibre 软件界面上,通过“添加自定义新闻源(Add or edit a custom news source)”菜单项调出操作面板,在里面添加 RSS 地址就完事儿了,剩下的抓取、转换工作就全部交给 Calibre 自动处理了。其实在这个过程的背后,Calibre 也是根据你添加的 RSS 地址自动生成了一个 Recipe 脚本,并根据此脚本抓取内容的。可以点击“添加自定义新闻来源”操作面板左下角的【切换到高级模式】(Switch to advanced mode)按钮,便可以看到如下所示代码:

#!/usr/bin/env python2
# vim:fileencoding=utf-8
from __future__ import unicode_literals, division, absolute_import, print_function
from calibre.web.feeds.news import BasicNewsRecipe

class AdvancedUserRecipe1504764482(BasicNewsRecipe):
    title          = 'My news source'
    oldest_article = 7
    max_articles_per_feed = 100
    auto_cleanup   = True

    feeds          = [
        ('科学松鼠会', 'http://songshuhui.net/feed'),
        ('泛科学', 'http://pansci.tw/feed'),
    ]

从以上这个简单的 Recipe 脚本中,我们可以看到此脚本继承了 Calibre 提供的 BasicNewsRecipe 这个类,并简单的重写了一下这个类的某些参数。由于 Calibre 可以自动处理标准的 RSS 结构,所以不需要我们额外修改就可以轻松抓取内容。但是对于不提供 RSS 的网站内容又该如何处理呢?

对于不提供 RSS 的网站,我们可以通过解析页面内容,获取一个数据结构再进行转换。Calibre Recipe 脚本提供的 parse_index() 方法就可以用来做这件事。下面我们就来编写一个简单的 Recipe 脚本。

提示:Calibre Recipe 脚本的 parse_index() 方法需要解析网站页面的代码结构来提取数据,但由于不同网站的代码结构也不相同,从而处理逻辑也会有所差异,所以抓取不同的网站内容,可能就需要写一个与之相对应的 Recipe 脚本。

二、编写 Calibre Recipe 脚本

下面以王垠的博客“当然我在扯淡”为例,编写一个 Recipe 脚本,将整个博客内容转制成 mobi 格式的电子书。这个博客页面结构比较简单,个人感觉比较适合上手,初步了解一些基本的 Recipe 脚本写法。

在开始编写代码之前我们先来分析一下这个博客的页面结构:博客的首页即是全部文章列表,列表中每一篇文章的标题被被类选择器 li.list-group-item 包裹着。这样我们就可以提取出所有文章的标题和文章链接,并据此循环处理每一篇文章内容,组合成可供 Calibre 转换的数据结构。

下面是可用的 Recipe 脚本代码,代码中每一行都做了注释。看不懂可以看下面的详细解释。

#!/usr/bin/python
# encoding: utf-8

from calibre.web.feeds.recipes import BasicNewsRecipe # 引入 Recipe 基础类

class Wang_Yin_Blog(BasicNewsRecipe): # 继承 BasicNewsRecipe 类的新类名

    #///////////////////
    # 设置电子书元数据
    #///////////////////
    title = '当然我在扯淡' # 电子书名
    description = u'王垠的博客' # 电子书简介
    #cover_url = '' # 电子书封面
    #masthead_url = '' # 页头图片
    __author__ = '王垠' # 作者
    language = 'zh' # 语言
    encoding = 'utf-8' # 编码

    #///////////////////
    # 抓取页面内容设置
    #///////////////////
    #keep_only_tags = [{ 'class': 'example' }] # 仅保留指定选择器包含的内容
    no_stylesheets = True # 去除 CSS 样式
    remove_javascript = True # 去除 JavaScript 脚本
    auto_cleanup = True # 自动清理 HTML 代码
    delay = 5 # 抓取页面间隔秒数
    max_articles_per_feed = 999 # 抓取文章数量

    #///////////////////
    # 页面内容解析方法
    #///////////////////
    def parse_index(self):
        site = 'http://www.yinwang.org' # 页面列表页
        soup = self.index_to_soup(site) # 解析列表页返回 BeautifulSoup 对象
        links = soup.findAll("li",{"class":"list-group-item title"}) # 获取所有文章链接
        articles = [] # 定义空文章资源数组
        for link in links: # 循环处理所有文章链接
            title = link.a.contents[0].strip() # 提取文章标题
            url = site + link.a.get("href") # 提取文章链接
            a = {'title': title , 'url':url} # 组合标题和链接
            articles.append(a) # 累加到数组中
        ans = [(self.title, articles)] # 组成最终的数据结构
        return ans # 返回可供 Calibre 转换的数据结构

首先引入 Calibre 提供的基础类 BasicNewsRecipe 并创建一个继承基础类的新类 Wang_Yin_Blog

接下来重写一些可作为电子书的元数据的参数。如标题、简介、作者、语言、编码之类。注意上面代码中 cover_urlmasthead_url 这两个参数被注释掉了,这样 Calibre 会自动生成封面和期刊头。如果你想要自定义电子书封面和期刊头,可以使用这两个参数指定图片的路径。

然后还需要设置控制抓取页面所需要的一些参数。如去除电子书不需要的 CSS 样式和 Javascript 脚本,设定抓取页面的时间间隔(避免对目标服务器造成负担),设定抓取文章的数量(如果想要抓取所有文章设置一个足够大的数值即可)等。注意以上代码中有一个 auto_cleanup 参数,它会用可读性算法自动清理 HTML 标签提取页面中的有用内容。如果页面内容比较复杂,还可以使用 keep_only_tags 这个参数,指定仅提取页面中某个标签中的内容,因为本例页面内容较简单就注释掉了。

相关参数设置完毕后,就可以编写处理页面内容的 parse_index() 方法了。在此方法中 Calibre 使用了内置的 Python 模块 BeautifulSoup。首先把首页的文章列表解析成 BeautifulSoup 对象,然后提取出所有标题列表,循环处理这些列表后,最终合并成一个完整的数据结构交给 Calibre 转换处理。

这样一个简单的 Recipe 脚本就写完了,将其保存为 .recipe 文件备用,本例保存为 wangyin.recipe。接下来就可以把这个“小处方”转换成 mobi 格式的电子书文件了。

提示:当然有些网站的情况要复杂得多,比如处理带分页的页面、复杂内容类型,还有多内容来源的合并等,这些进阶技巧限于篇幅暂不展开。如果感兴趣,也可以翻一翻 Calibre 提供的 API 文档“API documentation for recipes”自行研究一下。

三、认识命令行工具 ebook-convert

有了写好的 Recipe 脚本,接下来的工作就是将其转化成 mobi 格式的电子书文件了。

在《Calibre 使用教程之批量获取电子书元数据》这篇文章中,我们认识了 Calibre 的一个命令行工具 ebook-meta,它可以获取电子书的元数据。现在要接触到另外一个命令行工具 ebook-convert,此工具可以把某种格式转换成另一种格式。比如想要把某个 epub 转换成 mobi,只需要输入以下命令即可:

ebook-convert BookName.epub BookName.mobi

当然想要使用 ebook-convert 命令需要预先在电脑里安装 Calibre。在 Windows 系统中,一般安装完成后即可直接在“命令提示符”中使用。对于 macOS 系统则需要设置一下环境变量,设置方法和 ebook-meta 一样,参考《Calibre 使用教程之批量获取电子书元数据》这篇文章中的“准备 ebook-meta 工具”。

四、把 Recipe 脚本转化为 mobi 文件

和转换普通的电子书的格式一样,只需要输入以下命令即可开始进行转化。转换所需要的时间和文章条目和网速相关,如果你抓取的站点不幸被墙了,还需要使用网络代理。

ebook-convert wangyin.recipe wangyin.mobi --output-profile kindle

注意上面的代码中增加了一个参数 --output-profile kindle,这个参数的意思是根据 Kindle 设备做适配,如果不添加这个参数,转换出来的电子书会有一个对 Kindle 来说多余的翻页导航。

另外在转换的过程中也会有意外情况,比如由于资源链接被墙,或由于网络不稳定导致页面抓取失败。本例中抓取的博客由于引用了两张 Google 服务器上的图片,不使用代理就会抓取失败。

以上命令执行完毕后便可以得到最终的电子书文件 wangyin.mobi,拷贝或推送到 Kindle 即可阅读。

提示:如果你不想使用命令行工具,当然也可以使用 Calibre 界面上的“抓取新闻”功能来完成同样的工作。你只需要把编写好的 Recipe 代码粘贴到新建的 Recipe 脚本中,或者直接导入已有的 Recipe 脚本文件,然后和抓取 RSS 的操作一样,在“定期新闻下载”面板上选中“自定义脚本”,点击【立即下载】按钮即可完成转换。不过这种方法会始终带有翻页导航。

五、现成的 Calibre Recipe 脚本

除了自己手动针对某个网站的内容编写 Recipe 脚本外,对于一些知名度较高的站点,已经有很多现成的 Recipe 脚本可用,比如 Calibre 项目自身就提供了一个 Recipe 脚本库(Calibre 的“抓取新闻”内置的那些就是使用的这些 Recipe 脚本)。另外也有很多网友也分享了自己编写的的 Recipe 脚本,你可以访问 GitHub 搜索关键字“calibre recipe”来查找感兴趣的脚本。当然也欢迎你的分享。

以上就是利用 Recipe 脚本抓取不提供 RSS 的网站内容并制成电子书的方法。以上内容尽量兼顾没有任何编程经验的小伙伴,如果按照你的理解方式对那些地方不太明白,请留言,确认有误区后会按照你的意见进行更改。如果你发现本文存在错误,也欢迎留言指正。有更好的玩儿法,也欢迎分享。

参考资料:

有帮助,[ 捐助本站 ] 或分享给小伙伴:

发表评论

标注为 * 的是必填项。您填写的邮箱地址将会被保密。如果是在本站首次留言,审核后才能显示。
若提问,请务必描述清楚该问题的前因后果,提供尽可能多的对分析该问题有帮助的线索。

小伙伴们发表了 39 条评论

    • 这种没有明显页面结构的,建议自己把目录页的代码重新编辑一下,使其结构适合 Calibre 抓取,然后抓取自己编辑的这个页面。

        • 比如你给出的这个网页的目录页面是 library.htm,先把它另存到本地,然后编辑它的结构适合 Calibre 抓取,最后编辑 recipe 的时候,设置抓取的页面为本地路径(比如 file:///Users/yourname/Desktop/library.htm)就可以了。

          • 真机智哈!
            但是问题来了,如何对each article 进行内容筛选,它的内容也很乱,老外的网页格式是什么鬼,感觉css 做出来的网页

            • 内容没有明显结构不太好处理。可以尝试在 Recipe 脚本中添加一个配置 auto_cleanup = True,让程序自动清理一下代码,看内容的可读性能不能接受。

              • from calibre.web.feeds.recipes import BasicNewsRecipe
                
                class automotiveRepair(BasicNewsRecipe):
                    title = 'automotiveRepair'
                    no_stylesheets = True
                    language = 'en'
                    max_articles_per_feed = 999999
                    simultaneous_downloads = 10
                    remove_javascript = True   
                    auto_cleanup = True
                    remove_tags = [{'class':'gadget-wrapper cleanslate'},
                    
                    ]
                
                
                
                    def parse_index(self):
                        soup = self.index_to_soup('file:///C:\Users\supernova\Desktop/library.htm')
                        articles = []
                        div = soup.find('div', { 'class': 'gogogo' })   # 我在开始目录正文的地方放的div标签
                        
                        for link in div.findAll('a'):  
                            
                            if 'www.aa1cars.com' in link['href']:
                                title = link.contents[0].strip()   
                                print(title)  
                                url = link['href']
                                print(url)
                                articles.append( {'title': title, 'url':url})
                
                        return [(self.title, articles)]

                结果是:

                Conversion options changed from defaults:
                  test: None
                  output_profile: u'kindle'
                1% 将输入转换为HTML中...
                InputFormatPlugin: Recipe Input running
                Using custom recipe
                1% 正在抓取源...
                1% 从索引页面获取了源
                1% 正在尝试下载封面...
                1% 正在生成刊头...
                Synthesizing mastheadImage
                1% 开始下载 [10 线程]…
                34% 新闻源已下载到 C:\Users\SUPERN~1\AppData\Local\Temp\calibre_zqmwr7\cr_46c_plumber\index.html
                34% 下载完成
                Parsing all content...
                Forcing index.html into XHTML namespace
                Forcing feed_0/index.html into XHTML namespace
                34% 正在对电子书进行转换...
                Merging user specified metadata...
                Detecting structure...
                Flattening CSS and remapping font sizes...
                Source base font size is 12.00000pt
                Removing fake margins...
                Cleaning up manifest...
                Trimming unused files from manifest...
                Creating AZW3 Output...
                67% 正在运行 AZW3 Output 插件
                Serializing resources...
                Splitting markup on page breaks and flow limits, if any...
                Creating KF8 output
                        Generating KF8 markup...
                        Compressing markup...
                        Creating indices...
                Document has no ToC, MOBI will have no NCX index
                AZW3 output written to C:\Users\supernova\Desktop\auto.azw3
                输出保存到   C:\Users\supernova\Desktop\auto.azw3
              • up主,这个问题能不能解决?感谢感谢你了,这网站对我们修车的很重要,我好想在kindle上看呀!

      • 那篇文章给出了解释:使用两个循环,第一个 for 分卷,生成卷标题,第二个for 生成本卷 article,即 (title, url)list(图中的 Chapters),然后加上卷标题,生成 volume(图中的 Tuple),通过 append 把各卷组合成 ans0,即图中的 book(List),返回这个值。遇到不懂的值,可以在运行代码的时候 print 出来以便理解。

      • 谢谢你的回复 速度好快。
        代码跑了起来 但是有的书下不了

        Could not fetch link http://www.liujiangblog.com/course/django/131
        Failed to download article: 不返回QuerySets的API from http://www.liujiangblog.com/course/django/131

          • 我试了两次,下面是我两次获取失败的列表
            看着每次都是不一样的失败链接啊

            ”’
            第一次:

            字段查询参数及聚合函数 from Django教程
            URL反向解析和命名空间 from Django教程
            Django内置模板标签 from Django教程
            人类可读性 from Django教程
            第四章:Django表单 from Django教程
            自定制Admin from Django教程
            自定义Admin actions from Django教程
            Admin文档生成器 from Django教程
            网站地图sitemap from Django教程
            3. admin后台 from Django教程
            6. 登录视图 from Django教程
            2.模型设计 from Django教程
            5.Linux下收集数据 from Django教程
            9.前端框架AdminLTE from Django教程
            10.资产总表 from Django教程
            11.资产详细页面 from Django教程

            第二次:
            34% 下载完成
            下载下列文章失败:
            模型和字段 from Django教程
            HttpRequest对象 from Django教程
            配置 Django from Django教程
            Django与CSRF 、AJAX from Django教程
            Django 国际化和本地化 from Django教程
            6.新资产待审批区 from Django教程
            ”’

            • 我测试了一下没有问题。如果是你本地的网络连接这个网站有问题,每次失败的链接不一样是有可能的。

              • 这。。。。
                会不会是引文这个是5线程下载的原因。。
                Synthesizing mastheadImage
                1% 开始下载 [5 线程]…
                既然你能下载,能不能发我一份,我试了n多次,最好成绩3 个fail

                • 之前那个:使用calibre多卷抓取《随园食单》 加子标题的是需要有子标签才可以使用两个forloop 做成return(标题,(子标题,文章))的,但是这个网站的是在一个标签下用了标签 您有没有好的算法?

          • 你好,
            能否帮我解决章节不嵌套的这个问题,不行的话可以先全部下载下来在后期用calibre 搞一下目录。

            我这边网络可能不好,你能不能帮我下一个azw3 profile kindle的django 刘江博客再发给我呢?

            万分感谢

  1. 问一下,我这个代码怎么回事内容都可以下载,标题输出“未知”,21行
    如果是
    title = link.string 输出“未知” 标题
    title = link.contents[0].strip() 输出error
    title = link.a.contents[0].strip() 输出error
    AttributeError: ‘NoneType’ object has no attribute ‘contents’

    python 3.6

    # -*- coding:UTF-8 -*-  
    from calibre.web.feeds.recipes import BasicNewsRecipe
    from urlparse import urljoin
    
    class Downloader(BasicNewsRecipe):
        title = u'Django教程'
        description = u'哈啊 '
        url_prefix = 'http://www.liujiangblog.com'
        no_stylesheets = True
        language = 'zh-CN'
        keep_only_tags = [{ 'class': 'row' }]
        max_articles_per_feed = 5
    
    
        def parse_index(self):
            soup = self.index_to_soup('http://www.liujiangblog.com/course/django/')
            articles = []
            div = soup.find('div', { 'class': 'navbar-sider list-group-item' })
            
            for link in div.findAll('a',{'class':'list-group-item'}):  
                title = link.a.contents[0].strip()
                articles.append({'title': title , 'url':urljoin(self.url_prefix,link["href"])})
            return [(self.title, articles)]
    • 这是因为有的标题 <a> 标签嵌套了 <strong><b> 标签导致的。可以加一个判断,对有子标签的标题额外处理一下:

      # ...
      for link in div.findAll('a', {'class' : ['list-group-item active', 'list-group-item']}):
          if link.find('b') or link.find('strong'):
              title = link.contents[0].contents[0].strip()
          else :
              title = link.contents[0].strip()
      # ...
  2. 能写一个新浪博客的抓取案例吗?比如:http://blog.sina.com.cn/s/articlelist_1664227735_0_1.html。谢谢

    • 这个博客的结构和之前那位小伙伴提供的网站其实是一样的,同样是有翻页的列表,查看一下源代码,找到要保留的区域,然后修改一下代码就行了。还有就是,新浪博客的图片用了懒加载,所以抓取不到。下面是抓取这个博客的完整 Recipe 脚本代码:

      #!/usr/bin/python
      # encoding: utf-8
      
      from calibre.web.feeds.recipes import BasicNewsRecipe
      
      class Zi_Liu_De_Blog(BasicNewsRecipe):
          title = '子柳的博客'
          description = u'子柳的博客'
          __author__ = '子柳'
          language = 'zh'
          encoding = 'utf-8'
      
          content_regex = '^.*articalContent.*$'
          keep_only_tags = [
              dict(name='div', attrs={'class':'articalTitle'}),
              dict(name='div', attrs={'class':'sina_keyword_ad_area2'}),
              dict(name='div', attrs={'class':re.compile(content_regex, re.IGNORECASE)})
          ]
          # time_regex = '^.*time.*$'
          # remove_tags = [dict(name='span', attrs={'class':re.compile(time_regex, re.IGNORECASE)})]
          no_stylesheets = True
          remove_javascript = True
          auto_cleanup = True
          auto_cleanup_keep = '//div[@class="articalTitle"]'
          max_articles_per_feed = 999
      
          def parse_index(self):
              site = 'http://blog.sina.com.cn'
              page = site +'/s/articlelist_1664227735_0_'
              start = 1
              end = 2
              articles = []
              for p in range(start, end + 1) :
                  soup = self.index_to_soup(page + str(p) + '.html')
                  links = soup.findAll("span",{"class":"atc_title"})
                  for link in links:
                      title = link.a.contents[0].strip()
                      url = link.a.get("href")
                      a = {'title': title , 'url':url}
                      articles.append(a)
              ans = [(self.title, articles)]
              return ans
  3. 在centos下运行“ebook-convert wangyin.recipe wangyin.mobi –output-profile kindle”,报错:

    /home/dev/tmp>ebook-convert ‘wangyin.recipe’ ‘wangyin.mobi’ –output-profile kindle
    Conversion options changed from defaults:
    test: None
    output_profile: u’kindle’
    1% Converting input to HTML…
    InputFormatPlugin: Recipe Input running
    Using custom recipe
    1% Fetching feeds…
    1% Got feeds from index page
    1% Trying to download cover…
    1% Generating masthead…
    Synthesizing mastheadImage
    This application failed to start because it could not find or load the Qt platform plugin “headless”
    in “/opt/calibre/lib/python2.7/site-packages/calibre/plugins”.

    Available platform plugins are: headless (from /opt/calibre/lib/python2.7/site-packages/calibre/plugins), linuxfb, minimal, offscreen, xcb.

    Reinstalling the application may fix this problem.
    Aborted

    请问这个是什么问题?怎么解决?谢谢

  4. 把site行跟links行改过之后套用这个recipe运行还是会提示下载失败文章,查看运行日志里错误信息是【Exception: 无法获取文章。 调试用信息出现在日志文件前部】
    我抓取的站点是https://www.merriam-webster.com/words-at-play/see-all,links行soup.findAll内写的是”h5″,{“class”:”typo10″},请问还有哪里需要修改的吗?

    • 这个站点需要对文中的代码做一下修改,因为列表页不是网站首页。把 site、soup、links 这三个变量替换成下面这四个变量就可以了:

      site = 'https://www.merriam-webster.com' # 网站地址
      page = site + '/words-at-play/see-all' # 页面列表页
      soup = self.index_to_soup(page) # 解析列表页返回 BeautifulSoup 对象
      links = soup.findAll("h5",{"class":"typo10"}) # 获取所有文章链接

      另外,这样代码也只能抓取一个页面的链接,这个文章列表是有分页的,想要抓取更多链接还需要处理分页。处理分页,把代码中的 parse_index() 函数代码块替换成如下代码块即可。注意代码中的页面范围设定,变量 start 是开始页码,变量 end 是结束页码。

      def parse_index(self):
          site = 'https://www.merriam-webster.com' # 网站地址
          page = site +'/words-at-play/see-all' # 页面列表页
          start = 1 # 开始页码
          end = 3 # 结束页码
          articles = [] # 定义空文章资源数组
          for p in range(start, end + 1) :
              soup = self.index_to_soup(page + '?p=' + str(p)) # 解析列表页返回 BeautifulSoup 对象
              links = soup.findAll("h5",{"class":"typo10"}) # 获取所有文章链接
              for link in links: # 循环处理所有文章链接
                  title = link.a.contents[0].strip() # 提取文章标题
                  url = site + link.a.get("href") # 提取文章链接
                  a = {'title': title , 'url':url} # 组合标题和链接
                  articles.append(a) # 累加到数组中
          ans = [(self.title, articles)] # 组成最终的数据结构
          return ans # 返回可供 Calibre 转换的数据结构
      • 十分感谢!!!我按照参考资料中抓取乌云知识库那一篇文也改过一下自己的语句,但也还是一直抓取失败,今天再看到回复突然意识到?p=1时url失效导致抓取失败,改过之后果然成功了xD