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

在本文的“中篇”,我们已经编写好了一个可以正常工作的 KindleEar 订阅脚本,但是用它生成的电子书存在着很多问题,比如没有设定抓取文章的时间范围,也没有处理文章列表和文章内容的翻页,文章标题还带有冗余信息。本文将继续完善订阅脚本,对这些细节进行处理,让生成的电子书更加完美。

目录

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

以下内容分为三部分:首先是文章列表的翻页和条目限制的处理,然后文章内容页面的翻页以及文章标题的处理,最后介绍了本地上传和 Google Cloud 云端 Shell 上传两种上传 KindleEar 项目的方式。

一、文章列表的翻页和限定条目

通常我们并不需要抓取网站上的所有文章条目,所以要从“文章数量”或“时间范围”这两种纬度限定文章条目。设定条件时,可选择其一,也可选取两者不同范围的交集。本例采用的是后者:先设定抓取 40 篇文章,再在此基础上保留 1 天之内的文章。由于设定值超过了单页文章数量,还需要处理列表翻页。

以下代码根据以上需求做了修改。从中可以看到,在之前代码的基础上,新导入了一个处理时间的模块,并新增了 2 个参数和 3 个自定义函数。下面我们来详细解释一下新增的这些代码都做了些什么。

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

from datetime import datetime # 导入时间处理模块datetime
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 = 'mh_chinadaily.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'),
    ]

    max_articles_per_feed = 40 # 设定每个主题下要最多可抓取的文章数量
    oldest_article = 1 # 设定文章的时间范围。小于等于365则单位为天,否则单位为秒,0为不限制。

    # 提取每个主题页面下所有文章URL
    def ParseFeedUrls(self):
        urls = [] # 定义一个空的列表用来存放文章元组
        # 循环处理fees中两个主题页面
        for feed in self.feeds:
            # 分别获取元组中主题的名称和链接
            topic, url = feed[0], feed[1]
            # 把抽取每个主题页面文章链接的任务交给自定义函数ParsePageContent()
            self.ParsePageContent(topic, url, urls, count=0)
        print urls
        exit(0)
        # 返回提取到的所有文章列表
        return urls

    # 该自定义函数负责单个主题下所有文章链接的抽取,如有翻页则继续处理下一页
    def ParsePageContent(self, topic, url, urls, count):
        # 请求主题页面链接并获取其内容
        result = self.GetResponseContent(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) # 合成文章链接
                count += 1 # 统计当前已处理的文章条目
                # 如果处理的文章条目超过了设定数量则中止抽取
                if count > self.max_articles_per_feed:
                    break
                # 如果文章发布日期超出了设定范围则忽略不处理
                if self.OutTimeRange(item):
                    continue
                # 将符合设定文章数量和时间范围的文章信息作为元组加入列表
                urls.append((topic, title, link, None))

            # 如果主题页面有下一页,且已处理的文章条目未超过设定数量,则继续抓取下一页
            next = soup.find(name='a', string='Next')
            if next and count < self.max_articles_per_feed:
                url = BaseFeedBook.urljoin(url, next.get('href'))
                self.ParsePageContent(topic, url, urls, count)
        # 如果请求失败则打印在日志输出中
        else:
            self.log.warn('Fetch article failed(%s):%s' % \
                (URLOpener.CodeMap(result.status_code), url))

    # 此函数负责判断文章是否超出指定时间范围,是返回 True,否则返回False
    def OutTimeRange(self, item):
        current = datetime.utcnow() # 获取当前时间
        updated = item.find(name='b').string # 获取文章的发布时间
        # 如果设定了时间范围,并且获取到了文章发布时间
        if self.oldest_article > 0 and updated:
            # 将文章发布时间字符串转换成日期对象
            updated = datetime.strptime(updated, '%Y-%m-%d %H:%M')
            delta = current - updated # 当前时间减去文章发布时间
            # 将设定的时间范围转换成秒,小于等于365则单位为天,否则则单位为秒
            if self.oldest_article > 365:
                threshold = self.oldest_article # 以秒为单位的直接使用秒
            else:
                threshold = 86400 * self.oldest_article # 以天为单位的转换为秒
            # 如果文章发布时间超出设定时间范围返回True
            if (threshold < delta.days * 86400 + delta.seconds):
                return True
        # 如果设定时间范围为0,文章没超出设定时间范围(或没有发布时间),则返回False
        return False

    # 此自定义函数负责请求传给它的链接并返回响应内容
    def GetResponseContent(self, url):
        opener = URLOpener(self.host, timeout=self.timeout, headers=self.extra_header)
        return opener.open(url)

首先我们从 Python 标准库中导入了一个时间处理模块 datetime,这在验证文章时间范围时需要用到。

然后新增了 max_articles_per_feedoldest_article 两个参数,前者用来设定从每个主题抓取的文章数量,后者则是用来设定要保留多久时间之内更新的文章。这两个参数的值都是数字。设置文章的时间范围时需要注意:如果设定的数值小于等于 365 单位是天,否则单位为秒,0 表示不限制时间范围。

最后添加了三个自定义函数,分别是 ParsePageContent()OutTimeRange()GetResponseContent()。其中函数 ParsePageContent() 的逻辑是从之前的 ParseFeedUrls() 函数中拆出来再被其调用的,为的是递归处理列表翻页,里面还新增了对文章数量和发布时间的判断,以便按照设定条件过滤文章。

根据翻页链接的 HTML 标签结构,代码中通过查找含有 Next 字符的 a 标签来确定当前列表是否有下一页,如果有的话就继续调用 ParsePageContent() 提取下一页内容,直到达到设定的抓取数量为止。

▲ 翻页链接显示效果和标签结构

▲ 翻页链接结构说明

其它两个函数的功能比较简单:OutTimeRange() 用来判断传入文章的发布时间是否超出了设定范围,然后把结果返回给调用函数使用;GetResponseContent() 用来请求传入的页面链接,然后把响应内容返回给调用它的函数,这主要是为了方便之后复用,因为下面处理文章内容翻页时也需要请求页面内容。

二、文章内容的翻页和细节修改

处理完文章列表的翻页,我们再来处理内容的一些细节:内容页的翻页和移除内容标题上的冗余信息。

下面是完善后的代码,也是本文最终完成的代码。新增的代码主要调用了基类中的 processtitle()preprocess() 两个函数对文章内容做预处理,前者用来处理文章标题,后者用来处理文章内容。

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

from datetime import datetime # 导入时间处理模块datetime
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 = 'mh_chinadaily.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'),
    ]

    max_articles_per_feed = 40 # 设定每个主题下要最多可抓取的文章数量
    oldest_article = 1 # 设定文章的时间范围。小于等于365则单位为天,否则单位为秒,0为不限制。

    # 提取每个主题页面下所有文章URL
    def ParseFeedUrls(self):
        urls = [] # 定义一个空的列表用来存放文章元组
        # 循环处理fees中两个主题页面
        for feed in self.feeds:
            # 分别获取元组中主题的名称和链接
            topic, url = feed[0], feed[1]
            # 把抽取每个主题页面文章链接的任务交给自定义函数ParsePageContent()
            self.ParsePageContent(topic, url, urls, count=0)
        # print urls
        # exit(0)
        # 返回提取到的所有文章列表
        return urls

    # 该自定义函数负责单个主题下所有文章链接的抽取,如有翻页则继续处理下一页
    def ParsePageContent(self, topic, url, urls, count):
        # 请求主题页面链接并获取其内容
        result = self.GetResponseContent(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) # 合成文章链接
                count += 1 # 统计当前已处理的文章条目
                # 如果处理的文章条目超过了设定数量则中止抽取
                if count > self.max_articles_per_feed:
                    break
                # 如果文章发布日期超出了设定范围则忽略不处理
                if self.OutTimeRange(item):
                    continue
                # 将符合设定文章数量和时间范围的文章信息作为元组加入列表
                urls.append((topic, title, link, None))

            # 如果主题页面有下一页,且已处理的文章条目未超过设定数量,则继续抓取下一页
            next = soup.find(name='a', string='Next')
            if next and count < self.max_articles_per_feed:
                url = BaseFeedBook.urljoin(url, next.get('href'))
                self.ParsePageContent(topic, url, urls, count)
        # 如果请求失败则打印在日志输出中
        else:
            self.log.warn('Fetch article failed(%s):%s' % \
                (URLOpener.CodeMap(result.status_code), url))

    # 此函数负责判断文章是否超出指定时间范围,是返回 True,否则返回False
    def OutTimeRange(self, item):
        current = datetime.utcnow() # 获取当前时间
        updated = item.find(name='b').string # 获取文章的发布时间
        # 如果设定了时间范围,并且获取到了文章发布时间
        if self.oldest_article > 0 and updated:
            # 将文章发布时间字符串转换成日期对象
            updated = datetime.strptime(updated, '%Y-%m-%d %H:%M')
            delta = current - updated # 当前时间减去文章发布时间
            # 将设定的时间范围转换成秒,小于等于365则单位为天,否则则单位为秒
            if self.oldest_article > 365:
                threshold = self.oldest_article # 以秒为单位的直接使用秒
            else:
                threshold = 86400 * self.oldest_article # 以天为单位的转换为秒
            # 如果文章发布时间超出设定时间范围返回True
            if (threshold < delta.days * 86400 + delta.seconds):
                return True
        # 如果设定时间范围为0,文章没超出设定时间范围(或没有发布时间),则返回False
        return False

    # 清理文章URL附带字符
    def processtitle(self, title):
        return title.replace(u' - Chinadaily.com.cn', '')

    # 在文章内容被正式处理前做一些预处理
    def preprocess(self, content):
        # 将页面内容转换成BeatifulSoup对象
        soup = BeautifulSoup(content, 'lxml')
        # 调用处理内容分页的自定义函数SplitJointPagination()
        content = self.SplitJointPagination(soup)
        # 返回预处理完成的内容
        return unicode(content)

    # 此函数负责处理文章内容页面的翻页
    def SplitJointPagination(self, soup):
        # 如果文章内容有下一页则继续抓取下一页
        next = soup.find(name='a', string='Next')
        if next:
            # 含文章正文的标签
            tag = dict(name='div', id='Content')
            # 获取下一页的内容
            result = self.GetResponseContent(next.get('href'))
            post = BeautifulSoup(result.content, 'lxml')
            # 将之前的内容合并到当前页面
            soup = BeautifulSoup(unicode(soup.find(**tag)), 'html.parser')
            soup.contents[0].unwrap()
            post.find(**tag).append(soup)
            # 继续处理下一页
            return self.SplitJointPagination(post)
        # 如果有翻页,返回拼接的内容,否则直接返回传入的​内容
        return soup

    # 此自定义函数负责请求传给它的链接并返回响应内容
    def GetResponseContent(self, url):
        opener = URLOpener(self.host, timeout=self.timeout, headers=self.extra_header)
        return opener.open(url)

函数 Items() 抓取文章内容时,是从页面 <title> 标签中获取文章标题的,但是 China Daily 的文章标题都附加了一个重复的尾巴,类似 XXXXX - Chinadaily.com.cn,所以我们需要调用一个现成的预处理函数 processtitle() 把这个尾巴删掉,在函数中我们只需要简单地用 replace() 函数将其替换为空即可。

在函数 readability_by_soup() 清洗页面内容前,我们可以调用另一个现成的预处理函数 preprocess() 对原始的页面内容做些处理,在这里我们就是通过调用此函数来处理含内容页面翻页的。在本例中,含翻页的文章页面虽然不常见,但确实存在,比如“China, Thailand conclude joint naval training”这篇文章,四幅图片被放进了四页,如果不对其做相应处理,推送后就只能看到这篇文章的第一张图片。

自定义函数 SplitJointPagination() 用来递归处理文章页面的翻页。当此函数被 preprocess() 调用时,会查找传入的页面是否有下一页,如果有就读取下一页内容,直至把所有翻页内容拼接在一起返回。

至此就完成了为 China Daily 网站定制的订阅脚本。因为该网站所有板块的 HTML 标签结构几乎是相同的,所以你可以在 feeds 参数中增加你喜欢的其它主题页面链接。不过要注意,Google App Engine 对资源的使用有限制,而且 Gmail 发信对附件的推送也有 20MB 的限制,不建议一次性抓取过多内容。

三、上传到 Google App Engine

订阅脚本编写完成之后就可以上传到正式的 Google App Engine(GAE)环境上使用了。如果你想要在本地上传,可参照《KindleEar 搭建教程:推送 RSS 订阅到 Kindle》这篇文章提供的“手动上传”步骤操作。当然也可以采用另一种方式,即先把修改的源码 Push 到 Github,再用 GAE 的云端 Shell 上传。

上传 KindleEar 源码前,建议检查项目中 app.yaml 和 modul-worker.yaml 两个文件的 application 参数,以及 config.py 中的 SRC_EMAILDOMAIN 参数,确保都已经改成了你自己账号信息。在本地测试推送时,本文示例曾修改过文 config.py 中的 SRC_EMAIL 这个参数,上传前要改回你的 Gmail 邮箱。

1、本地上传注意事项

如果你身在墙内,本地上传前需要在 Google App Engine SDK 的文件 appcfg.py 中添加可用代理。

在 Windows 系统中,可以在 Google App Engin SDK 安装目录找到文件 appcfg.py。默认位置如下:

C:\Program Files (x86)\Google\google_appengine\appcfg.py

在 macOS 系统中,需要进入“应用程序”目录,找到并右键点击 GoogleAppEngineLauncher 应用,在弹出的菜单中点击“显示包内内容”,即可在文件列表中找到 appcfg.py 文件。默认位置如下:

Contents/Resources/GoogleAppEngine-default.bundle/Contents/Resources/google_appengine/appcfg.py

用代码编辑器打开 appcfg.py 文件,并找到下面这行代码,代理相关的代码要添加在它的前面。

"""Convenience wrapper for starting an appengine tool."""

下面提供了 SOCKS5 和 HTTP 两种协议的网络代理添加方法,可根据你所用的代理工具选择:

import socks
import socket
socket.socket = socks.socksocket
socks.setdefaultproxy(socks.PROXY_TYPE_SOCKS5, "127.0.0.1", 8788)

▲ 添加 SOCKS5 代理

import os
proxy = 'http://127.0.0.1:8787'
os.environ['http_proxy'] = proxy
os.environ['https_proxy'] = proxy

▲ 添加 HTTP 代理

提示:使用 HTTP 代理时,在 macOS 和 Linux 系统中也可以在终端直接用 export 命令配置环境变量 http[s]_proxy。

配置好代理后,在终端(或命令提示符)上用 cd 命令定位到 KindleEar 项目,执行如下命令上传:

appcfg.py update ./app.yaml ./module-worker.yaml
appcfg.py update ./

注意,第一次上传会出现一个验证链接,如果是上传源码和访问链接的是同一个电脑,直接访问链接验证即可。否则,需要添加参数 --noauth_local_webserver,打开链接获取并输入验证码进行验证。

2、云端 Shell 上传注意事项

通过云端 Shell 上传,也需要你的浏览器能够通过可用代理上网,以便正常访问 Google Cloud 服务。

从 Github 拉取源码上传过程比较简单。确保你修改的 KindleEar 源代码已成功 Push 到 Github,然后进入 Google App Engine 控制台,点击右上角的“Shell 图标”激活云端 Shell,依次执行如下命令上传:

git clone https://github.com/YOURNANME/KindleEar.git
cd KindleEar
appcfg.py update ./app.yaml ./module-worker.yaml
appcfg.py update ./

上传成功就可以登录 KindleEar 添加和推送新订阅了。如果新添加的订阅没出现,就表示没上传成功,建议仔细检查自己的操作步骤(可根据终端或命令提示符上出现的错误提示排查上传失败的原因)。

▲ 订阅脚本最终推送效果

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

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

发表评论

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

小伙伴们发表了 15 条评论

  1. 我对脚本做了改动要重新上传,执行命令git clone https://github.com/***/**.git后,提示fatal: destination path ** already exists and is not an empty directory.
    执行 rm -rf **也不行

      • 谢谢回复,我搞懂了,我刚开始不会用Github。
        1.我还想知道,所有的网站都可以用这个模板吗?用的时候主要是改哪些东西啊,身为小白,里面的程序理解起来太艰难了。
        2.还有是不是每添加一个类似于Chinadaily.py的源,都得再把所有的文件重新上传到Google shell。

        • 1、对于没有完全没有编程基础的小伙伴来说,确实需要掌握一定的基础知识才行,一两句话说不清楚。等研究一下,看能不能实现用通用模板解决这个问题。2、是的,对源码做了改动(如添加一个 py 订阅脚本)就需要上传一次。

  2. 请问本地调试系统生成的mobi书,推到kindle邮箱后,在阅读器上打开正常;为何把写好的py文件传到GAE上后,推送到kindle上的mobi书打不开,提示打开错误让删除呢?

  3. 我之前按照KindleEar搭建教程成功搭建,现在我也已经编辑好订阅源,并上传到Github,现在在云端输入git那行命令,但是提示我错误,fatl:destination path ‘KindleEar’ already exists and is not an empty directory。请问是什么原因,

      • 你好,之后输入appcfg.py update ./app.yaml ./module-worker.yaml,提示appcfg.py: error: Error parsing ./app.yaml:mapping values are not allowed here in “./app.yam”. line2, column 8.这要怎么解决?