如何用 KindleEar 推送无 RSS 的网站内容(中篇)

本文详细介绍了 KindleEar 订阅脚本的工作原理,并以新闻网站 China Daily 为例,由浅入深详细说明了如何为该网站编写定制化的订阅脚本,编写好的订阅脚本可将指定主题页面的文章内容转换成电子书。

目录

[ 上篇 ]
一、KindleEar 的订阅方式
二、KindleEar 的订阅脚本
三、KindleEar 的调试环境
1、安装 App Engine SDK
2、获取 KindleEar 源代码
3、在本地运行 KindleEar
[ 中篇 ]
一、新创建一个订阅脚本
二、订阅脚本的工作原理
三、从网站抽取文章 URL
四、分析 HTML 标签结构
1、分析文章列表的 HTML 标签结构
2、分析文章内容的 HTML 标签结构
五、测试订阅脚本的推送
[ 下篇 ]
一、文章列表的翻页和限定条目
二、文章内容的翻页和细节修改
三、上传到 Google App Engine

在开始以下步骤之前,请确保你已经成功在本地运行了 KindleEar 程序,否则,请参考上一篇文章《如何用 KindleEar 推送无 RSS 的网站内容(上篇)》提供的步骤,搭建好运行 KindleEar 的调试环境。

一、新创建一个订阅脚本

首先我们需要向 KindleEar 添加一个新的内置订阅,也就是创建一个新的订阅脚本。具体步骤为:打开代码编辑器,新建一个空文档,输入(或拷贝)如下所示代码,然后将其保存到 KindleEar 项目的 books 目录中。注意,文件名的命名随意,但必须是英文字符,后缀名必须是 .py,如 chinadaily.py。

#!/usr/bin/env python
# -*- coding:utf-8 -*-

from base import BaseFeedBook # 继承基类BaseFeedBook

# 返回此脚本定义的类名
def getBook():
    return ChinaDaily

# 继承基类BaseFeedBook
class ChinaDaily(BaseFeedBook):
    # 设定生成电子书的元数据
    title = u'China Daily' # 设定标题
    __author__ = u'China Daily' # 设定作者
    description = u'Chinadaily.com.cn is the largest English portal in China. ' # 设定简介
    language = 'en' # 设定语言

    # 指定要提取的包含文章列表的主题页面链接
    # 每个主题是包含主题名和主题页面链接的元组
    feeds = [
        (u'National affairs', 'http://www.chinadaily.com.cn/china/governmentandpolicy'),
        (u'Society', 'http://www.chinadaily.com.cn/china/society'),
    ]

这段代码做了 3 件事:导入了 base.py 中的基类 BaseFeedBook 以继承其中的参数和功能函数;为最终生成的电子书设定了书名、作者、简介、语言等元数据信息;指定了两条包含文章列表的主题页面 URL。

现在我们已经为 KindleEar 添加了一个新的内置订阅。在网页浏览器中访问 http://localhost:8080 并登录账号,点击导航上的“我的订阅”进入订阅管理页面,就可以在“未订阅”列表中看到新添加的订阅。

如上图所示,点击新订阅条目后面的【订阅】按钮将其添加到“已订阅”列表。如下图所示,点击导航上的“高级设置”并进入“现在投递”页面,保持新订阅处于勾选状态,点击【推送】按钮即可手动执行新添加的这个订阅脚本。只不过目前脚本还没有实际功能,所以只会生成一条状态为 nonews 的空日志。

在点击【推送】按钮执行订阅脚本后,可以看到终端(或命令提示符)输出了以下两条信息:

INFO     2019-05-12 13:13:37,408 Worker.py:235] No new feeds.
INFO     2019-05-12 13:13:37,425 module.py:861] worker: "GET /worker?u=admin&id=4876402788663296 HTTP/1.1" 200 13

提示:测试脚本可能出现的错误提示都会显示在终端(或命令提示符)上,我们需要根据这些信息来调试代码。

其中的 URL 就是点击【推送】按钮后请求执行脚本的 URL,为避免在测试时频繁点击【推送】按钮,建议直接在浏览器访问和刷新这个 URL 代替点击推送按钮。注意,和访问 KindleEar 的 8080 端口不同,这个 URL 要使用端口是 8081,其中的 ID 的值是脚本的唯一标识,以你自己命令行上出现的为准:

http://localhost:8081/worker?u=admin&id=6192449487634432

至此,我们就已经创建好可正常运行的订阅脚本(虽然还抓取不到任何内容),并且还知道怎样更方便地测试这个脚本。接下来让我们来了解一下订阅脚本的工作原理,以及用它抓取网站内容的思路。

二、订阅脚本的工作原理

之前我们已经为新建的订阅脚本从模块 base.py 中导入了名为 BaseFeedBook 的基础类,这样新建的脚本就已经继承了这个基础类所提供的的各种参数和功能函数,只要我们根据实际情况在新脚本中对其做一些定制和改写,就可以让 KindleEar 按照我们的意愿抓取目标网站上的文章内容并转换成电子书。

提示:其实在模块 base.py 中还有 WebpageBook、BaseUrlBook 和 BaseComicBook 三个类,它们也继承了 BaseFeedBook,只不过是针对不同内容类型做了定制。但是在本文中,为了更精细地控制内容的提取,只选用基础类 BaseFeedBook。

在基础类 BaseFeedBook 中,除了之前已定义(如书名等)以及之后将会定义的一些参数,还有一些可供调用或改写的功能函数。其中最重要的函数是 Item(),正是它负责把抓取到的文章内容交给转换模块生成电子书的。而 Item() 函数抓取文章内容所需要的 URL 则是另一个功能函数 ParseFeedUrls() 提供的,此函数需要返回一个包含文章 URL 的列表。我们的主要工作就是改写 ParseFeedUrls()函数,通过分析目标网站文章列表的 HTML 标签结构,在该函数中编写一些逻辑完成对文章 URL 的抽取。

ParseFeedUrls() 函数返回列表的结构如下所示。这个列表包含了一些元组,每个元组含有文章的“主题”、“标题”、“链接”和“摘要”。KindleEar 生成电子书时会根据这些主题来对文章进行分类。

[
    ('主题A','标题1', 'http://www.sample.com/post-1', None),
    ('主题A','标题2', 'http://www.sample.com/post-2', None),
    ('主题B','标题3', 'http://www.sample.com/post-3', None),
    ('主题B','标题4', 'http://www.sample.com/post-4', None),
    ('主题C','标题5', 'http://www.sample.com/post-5', None),
    ('主题C','标题6', 'http://www.sample.com/post-6', None),
    ...
    ('主题Z','标题n', 'http://www.sample.com/post-n', None),
]

提示:文章元组中的各项参数除了“摘要”之外都是必须的指定的,“摘要”即便不填充内容也要设置成 None 值,不然会出错。本文的例子不设置摘要,因为一旦设置摘要,Item() 函数会直接把摘要作为文章内容,这显然不是我们想要的。

Item() 函数在提取文章内容时,默认会自动调用函数 readability() 对文章内容进行清洗,以优化阅读效果。此函数使用了第三方 Python 库 readability-lxml,它对页面内容的处理是全自动的,一般都可以获得不错的效果。但是为了更精准地处理页面内容,本文选用的是另一个函数 readability_by_soup(),以便用 Beautiful Soup 手动处理页面内容。注意,为了让 Item() 默认调用 readability_by_soup() 函数,需要把在订阅脚本中把参数 fulltext_by_readability 的值设为 False,这在后面还会提到。

另外,KindleEar 还给清洗内容的函数内分别安插了两个函数:preprocess()soupprocessex() 。前者可在处理页面内容的原始 HTML 代码前对其做一些预处理(处理完需要返回处理的内容),而后者则可对处理完成的页面内容的 Beautiful Soup 对象再做一些后处理(只负责处理过程无需返回内容)。

现在我们知道了 KindleEar 订阅脚本抓取网站内容的大体运作流程,下面就让我们来小试身手吧。

三、从网站抽取文章 URL

下面我来完善一下之前写的代码,增加一些必要的参数,并将函数 ParseFeedUrls() 加进去。下面是编写好的完整代码,每一行都有详细注释。后面还会解释这些新添的的代码都做了些什么。

#!/usr/bin/env python
# -*- coding:utf-8 -*-

from base import BaseFeedBook # 继承基类BaseFeedBook
from lib.urlopener import URLOpener # 导入请求URL获取页面内容的模块
from bs4 import BeautifulSoup # 导入BeautifulSoup处理模块

# 返回此脚本定义的类名
def getBook():
    return ChinaDaily

# 继承基类BaseFeedBook
class ChinaDaily(BaseFeedBook):
    # 设定生成电子书的元数据
    title = u'China Daily' # 设定标题
    __author__ = u'China Daily' # 设定作者
    description = u'Chinadaily.com.cn is the largest English portal in China. ' # 设定简介
    language = 'en' # 设定语言

    coverfile = 'cv_chinadaily.jpg' # 设定封面图片
    mastheadfile = 'https://www.shu6.com/wp-content/uploads/2021/06/bbcc838a6651ac0.gif' # 设定标头图片

    # 指定要提取的包含文章列表的主题页面链接
    # 每个主题是包含主题名和主题页面链接的元组
    feeds = [
        (u'National affairs', 'http://www.chinadaily.com.cn/china/governmentandpolicy'),
        (u'Society', 'http://www.chinadaily.com.cn/china/society'),
    ]

    page_encoding = 'utf-8' # 设定待抓取页面的页面编码
    fulltext_by_readability = False # 设定手动解析网页

    # 设定内容页需要保留的标签
    keep_only_tags = [
        dict(name='span', class_='info_l'),
        dict(name='div', id='Content'),
    ]

    # 提取每个主题页面下所有文章URL
    def ParseFeedUrls(self):
        urls = [] # 定义一个空的列表用来存放文章元组
        # 循环处理fees中两个主题页面
        for feed in self.feeds:
            # 分别获取元组中主题的名称和链接
            topic, url = feed[0], feed[1]
            # 请求主题链接并获取相应内容
            opener = URLOpener(self.host, timeout=self.timeout)
            result = opener.open(url)
            # 如果请求成功,并且页面内容不为空
            if result.status_code == 200 and result.content:
                # 将页面内容转换成BeatifulSoup对象
                soup = BeautifulSoup(result.content, 'lxml')
                # 找出当前页面文章列表中所有文章条目
                items = soup.find_all(name='span', class_='tw3_01_2_t')
                # 循环处理每个文章条目
                for item in items:
                    title = item.a.string # 获取文章标题
                    link = item.a.get('href') # 获取文章链接
                    link = BaseFeedBook.urljoin(url, link) # 合成文章链接
                    urls.append((topic, title, link, None)) # 把文章元组加入列表
            # 如果请求失败通知到日志输出中
            else:
                self.log.warn('Fetch article failed(%s):%s' % \
                    (URLOpener.CodeMap(result.status_code), url))
        # 返回提取到的所有文章列表
        return urls

在之前创建的订阅脚本基础上,我们在代码头部新导入了 URLOpenerBeautifulSoup 两个模块,前者是用来请求页面 URL 获取响应内容的,后者则是用来解析响应内容以便提取文章内容数据的。

我们还添加了一些参数。其中 coverfile 用来设定电子书的“封面图片”, mastheadfile 是用来设定期刊样式电子书特有的“标头图片”的。制作这两张图片时,其尺寸和格式可参考 KindleEar 项目 images 目录中已有的图片,制作好的图片也保存在这个目录。注意,参数值需要图片的文件名,不需要额外指定路径,因为 KindleEar 默认图片都在 images 目录下。本例用的是如下所示两张图片,你也可另存使用。

▲ 封面图片:cv_chinadaily.jpg

▲ 标头图片: https://www.shu6.com/wp-content/uploads/2021/06/bbcc838a6651ac0.gif

然后就是 page_encodingfulltext_by_readability 两个参数,前者的作用是设定待抓取页面的编码类型。一般现代的 WEB 页面使用的都是“UTF-8”,但也有一些网站使用了其它编码,具体可在页面源代码中查找 <meta> 标签中 charset 的值。后者是前面提到过,是开启用 Beautiful Soup 手动清洗内容的。

还有一个 keep_only_tags 参数,它告诉清洗内容的函数,需要保留文章页面中的哪些内容元素,从而排除掉其它不需要的元素。该参数的值是一个字典容器 dict(),里面一般可设定两种类型的键值,一个是元素的标签名,即代码中的 name,另一个是前者的选择器,即代码中的 class_(或 id)。这种参数其实就是供 Beautiful Soup 的 find_all()find() 方法解析内容用的(详细介绍参考其文档说明)。

最后添加了这个新建订阅脚本最核心的函数 ParseFeedUrls(),下面我们来详细解释一下它在做什么。

四、分析 HTML 标签结构

在解释函数 ParseFeedUrls() 之前,先让我们来分析一下“文章列表”和“文章内容”的 HTML 标签结构。

1、分析文章列表的 HTML 标签结构

首先是文章列表的标签结构。用 Chrome 访问 China Daily 的 Society 板块,可以看到如下图所示有规律的文章列表。注意,上方的几个方块只是置顶文章,其实也是从列表中挑选出来的,所以不用管它。

▲ 文章列表显示效果

在页面上右键并点击菜单上的“检查”调出开发者工具,即可轻松查看文章列表的代码结构。

▲ 文章列表标签结构

在这个代码结构中可以看出我们所需要文章数据存放在重复出现的 span.tw3_01_2_t 标签中,文章标题在其子标签 a 中,文章链接是这个 a 标签的 href 属性值,文章日期在子标签 b 标签中。如下图所示:

▲ 文章列表结构说明

2、分析文章内容的 HTML 标签结构

和查看文章列表的标签结构一样,我们也可以用同样的方式在文章内容页面找超出我们所需要的数据:文章信息存放在类名为 .info_lspan 标签中,文章内容存放在 idContentdiv 标签中。

▲ 文章内容显示效果

▲ 文章内容标签结构

▲ 文章内容结构说明

在分析示例网站 China Daily 网站时,你可能已经发现,它所有主题页面的文章列表和文章内容的标签结构都是相同的,这也是我们能在 feeds 列表中添加多个主题页面链接,并对其进行统一处理的原因。

搞清文章列表和文章内容的标签结构后就可以轻松解析它们了。回过头看函数 ParseFeedUrls() 做了些什么。它先循环处理 feeds 列表中的每个主题页面的 URL,然后用新导入的函数 URLOpener() 请求当前处理的 URL,成功获取到响应后,把响应的 HTML 代码转换成 Beautiful Soup 对象准备解析。

接着用 find_all() 方法从 Beautiful Soup 对象中找到所有文章条目,并循环处理这些条目,依次把每篇文章的“标题”和“链接”都制成元组,然后再把制成的元组追加到之前预定义好的 urls 列表中。

所有循环运行完毕即可得到一个完整的含有所有文章信息的 urls 列表,最后用关键字 return 将其返回供函数 Item() 使用。至此函数 ParseFeedUrls() 就完成了它的工作,我们的脚本也能正常使用了。

五、测试订阅脚本的推送

最后我们需要测试一下这个订阅脚本的推送。测试前,你需要先准备好一个可用的 SMTP 服务器,这里以 163 邮箱为例。准备好之后,在终端(或命令提示符上)按 Ctrl + C 退出 Google App Engine(如果还在运行的话)。然后在原来的基础上,增加下面这些参数,中文部分换成你自己的邮箱账户信息:

dev_appserver.py \
--smtp_host=smtp.163.com \
--smtp_port=25 \
--smtp_user=邮箱用户名@163.com \
--smtp_password=邮箱授权码 \
--smtp_allow_tls=False \
./app.yaml ./module-worker.yaml

注意,Windows 的命令提示符不支持用反斜杠对命令进行换行,所以需要把命令写进同一行:

dev_appserver.py --smtp_host=smtp.163.com --smtp_port=25 --smtp_user=邮箱用户名@163.com --smtp_password=邮箱授权码 --smtp_allow_tls=False ./app.yaml ./module-worker.yaml

还要修改 KindleEar 项目里的 config.py 文件,将其中的 SRC_EMAIL 参数值暂时改成上面所用的邮箱。

现在,进入 KindleEar 的“设置”页面,把“Kindle邮箱”设置成你的 Kindle 邮箱或任意普通邮箱(注意把上面所用的邮箱加入认可列表),然后刷新测试链接(或进入 KindleEar 的“高级设置”页面,点击“现在投递”上的【推送】按钮),就可以运行订阅脚本了。不出意外的话,你会在终端看到如下的输出:

INFO     2019-05-14 15:15:31,133 resources.py:49] Serializing resources...
INFO     2019-05-14 15:15:31,144 mobioutput.py:149] Creating MOBI 6 output
INFO     2019-05-14 15:15:31,932 manglecase.py:34] Applying case-transforming CSS...
INFO     2019-05-14 15:15:31,944 parse_utils.py:302] Forcing toc.html into XHTML namespace
INFO     2019-05-14 15:15:33,267 mail_stub.py:170] MailService.Send
  From: YOUREMAILNAME@163.com
  To: YOUREMAILNAME@kindle.cn
  Subject: KindleEar 2019-05-14_23-15
  Body:
    Content-type: text/plain
    Data length: 22
  Attachment:
    File name: China Daily(2019-05-14_23-15).mobi
    Data length: 110878
INFO     2019-05-14 15:15:34,306 module.py:861] worker: "GET /worker?u=admin&id=6192449487634432 HTTP/1.1" 200 40

稍后,你填写的 Kindle 邮箱(或普通邮箱)就能收到你编写的脚本所生成的电子书了。如下图所示:

▲ 订阅脚本推送效果

不过,到目前为止,我们生成的电子书还不够完美。比如,文章内容中含有重复的网站名,文章数量总是 20 篇,没有按照时间进行过滤,列表翻页没有处理,文章内容有分页的情况也没有处理……

本来书楼预计两篇文章就可以把本文写完,但写到这儿发现长度超出了预期,所以只能把本文分成上、中、下三篇了。本篇已让 KindleEar 订阅脚本正常运行了,下篇我们再来处理那些不完美的细节。

如果你对本教程有什么疑问,或者发现内容存在谬误或不详尽之处,欢迎留言。

你可继续阅读:《如何用 KindleEar 推送无 RSS 的网站内容(下篇)

未经允许不得转载:书路 » 如何用 KindleEar 推送无 RSS 的网站内容(中篇)

赞 (0) 打赏

觉得文章有用就打赏一下文章作者

微信扫一扫打赏