神器Calibre有个很强大的Fetch News功能,可以下载RSS Feed然后生成电子书推送给Kindle。关于如何用recipe下载RSS Feed网上已经有不少相关的资料了。
但是如果想下载某一个专题,而这个专题又没有RSS输出怎么办呢?比如说:寻找太阳系的疆界
答案是,recipe还有更逆天的手动parse index功能,正好用来分析一个单独的网页,提取出我们想要的文章。
这篇博客将会简要介绍这个功能。
另外,由于某些网站的图片会要求有Referer的HTTP header,如果没有下载时会403 Forbidden,所以下载图片里还要特别注意处理Referer。
Background:
Calibre内置了Fetch News功能,本质上是python实现的一个继承自 calibre.web.feeds.news.BasicNewsRecipe 的类,并且override了一些成员/函数的实现,以实现定制的功能。
不过一般来说所有的News都是通过抓取feed来实现的,比如说内置的“人民日报”的实现:
class AdvancedUserRecipe1277129332(BasicNewsRecipe):
title = u'人民日报' # 标题
oldest_article = 2 # 最近几天之前的文章
max_articles_per_feed = 100 # 最大的文章数
...
feeds = [
(u'时政', u'http://www.people.com.cn/rss/politics.xml'),
(u'国际', u'http://www.people.com.cn/rss/world.xml'),
(u'经济', u'http://www.people.com.cn/rss/finance.xml'),
(u'体育', u'http://www.people.com.cn/rss/sports.xml'),
...
] # 定义具体的feed名称和url
keep_only_tags = [...] # 抓取到的网页里保留的内容
remove_tags = [...] # 抓取到的网页里去除的内容
remove_tags_after = [...] # 抓取到的网页里去除这个tag之后的内容
...
一般来说抄一下Calibre内置的recipe,定义以上的变量就可以搞定绝大部分RSS Feed了。
那么如何搞定像“寻找太阳系的疆界”这样的没有RSS输出的专题文章呢?
Solution:
查看 Calibre的API Document,注意到parse_index()这个函数,它说:
This method should be implemented in recipes that parse a website instead of feeds to generate a list of articles.<br />
...
It must return a list. Each element of the list must be a 2-element tuple of the form ('feed title', list of articles).<br />
Each list of articles must contain dictionaries of the form:
{
'title' : article title,
'url' : URL of print version,
'date' : The publication date of the article as a string,
'description' : A summary of the article
'content' : The full article (can be an empty string). Obsolete
do not use, instead save the content to a temporary
file and pass a file:///path/to/temp/file.html as
the URL.
}
并且提到:
For an example, see the recipe for downloading The Atlantic.
也就是说,可以参考内置的 The Atlantic的recipe,它是parse一个website而不是一个feed。打开这个recipe:
class TheAtlantic(BasicNewsRecipe):
title = 'The Atlantic'
__author__ = 'Kovid Goyal and Sujata Raman'
description = 'Current affairs and politics focussed on the US'
INDEX = 'http://www.theatlantic.com/magazine/toc/0/'
language = 'en'
remove_tags_before = dict(name='div', id='articleHead')
remove_tags_after = dict(id='copyright')
remove_tags = [dict(id=['header', 'printAds', 'pageControls'])]
...
def parse_index(self):
... # Parse the webpage and return feeds
很清楚,主要实现了parse_index这个函数。
那么我们就可以参考这个实现,来写自己parse changhai.org的代码。
Step1 实现parse_index
直接看code吧,注释很容易明白:
def parse_index(self):
articles = [] # 文章列表
feeds = [] # 最终要返回的
soup = self.index_to_soup(self.INDEX) # 获取网页完整的内容
feed_title = self.tag_to_string(soup.find('title')) # 网页的标题
self.log.debug("Feed Title: " + feed_title)
for table in soup.findAll('table', attrs={'class':'inside-article-noborder'}):
# Main articles
for post in table.findAll('td'): # 文章列表都在 inside-article-noborder 的 table 里,每一个td是一项
a = post.find('a', href=True) # 取出链接
title = self.tag_to_string(post) # 文章标题
url = a['href']
if "#" not in url: # 如果url里包含"#",其实是指向了同一个网页的某个anchor,忽略这样的项
self.log.debug("Article title and url: ")
self.log.debug(title + ": " + url)
url = self.INDEX + url # 生成完整的url
articles.append((
{'title':title,
'url':url,
'description':'',
'date':''})) # 添加到文章列表里
for post in soup.findAll('p', attrs={'class':'center'}): # 术语简介 | 参考文献 在 class: center的p里面
for a in post.findAll('a', href=True): # 同理取出文章的标题和url
title = self.tag_to_string(a)
url = a['href']
self.log.debug("Article title and url: ")
self.log.debug(title + ": " + url)
url = self.INDEX + a['href']
articles.append((
{'title':title,
'url':url,
'description':'',
'date':''}))
if articles:
feeds.append((feed_title, articles)) # 最后生成feed,这里只有一个feed,包含多篇文章
return feeds
这样每篇文章都能被download下来了,下面要处理每篇文章的内容。
Step 2 处理正文内容
分析正文的网页内容,会发现:
正文内容都在 main-body 的table里,所以
keep_only_tags = [
dict(name='table', attrs={'id':['main-body']}),
]
“返回目录 | 下一篇”这样的link可以忽略,都在 class left 和right的p里面
remove_tags = [
dict(name='p', attrs={'class':['left', 'right']}),
]
“站长近期发表的作品”及以后的内容应该忽略,它位于article-data-claim的后面,所以
remove_tags_after = [
dict(name='p', attrs = {'class' : ['article-date-claim']}),
]
好了,到这一步,文章的内容也能完美提取出来了。
但是,正文里的图片还是没有显示,原因在calibre的log里:
Could not fetch image http://www.changhai.org/articles/science/astronomy/outer_planets/images/kepler_platonic.png
reply: 'HTTP/1.1 403 Forbidden\r\n'
webserver直接返回403错误了,这一般是因为图片下载时没有Referer的HTTP header,算是一算防盗链的功能吧。
所以我们还需要处理Referer。
Step3 在HTTP Request里添加Referer
通过Google,发现可以通过自定义browser.open_novisit这个函数来实现添加HTTP header
def get_browser(self):
br = BasicNewsRecipe.get_browser(self)
orig_open_novisit = br.open_novisit
def my_open_no_visit(url, **kwargs):
req = mechanize.Request( url, headers = { 'Referer': self.INDEX, }) # 添加Referer的header
return orig_open_novisit(req)
br.open_novisit = my_open_no_visit # 把open_novisit这个函数重新定义成自己的函数
return br
理论上这样就OK了,然后实际运行的时候会发现,下载文章和图片的时候,根本就没有调用到my_open_no_visit(),图片仍然下载失败。
这下就只能看Calibre的源代码了——通过代码和打log,注意到_fetch_article()里面会调用clone_browser(),而这个clone完的browser的open_no_visit函数还是原生的,并不是自定义的函数。假如这个不是bug,就说明俺的recipe是有点exotic了:
Clone the browser br. Cloned browsers are used for multi-threaded downloads, since mechanize is not thread safe. The default cloning routines should capture most browser customization, but if you do something exotic in your recipe, you should override this method in your recipe and clone manually.
好吧,那就自己实现一个clone_browser()吧,同时也更新自己的get_browser():
_cachedBrowser = None # 自己的browser
def get_browser(self):
if self._cachedBrowser is None:
br = BasicNewsRecipe.get_browser(self)
orig_open_novisit = br.open_novisit
def my_open_no_visit(url, **kwargs):
req = mechanize.Request( url, headers = { 'Referer': self.INDEX, })
return orig_open_novisit(req)
br.open_novisit = my_open_no_visit
self._cachedBrowser = br # 保存browser
return self._cachedBrowser
def clone_browser(self, br):
if self._cachedBrowser is None:
raise Exception("No browser")
br = BasicNewsRecipe.clone_browser(self, br)
br.open_novisit = self._cachedBrowser.open_novisit # 设置clone出来的browser的open_novisit函数
return br
OK, 这下Referer的问题也解决了,生成的文章里也完美包含了图片。
Finally:
完整的code放在github(见calibre_recipes)上了,当然code还有待完善,比如说可以合并common的code,以后再慢慢改了。
目前实现了3个专题的recipe:
Q.E.D.
p.s. 顺便推荐一下卢昌海老师的书吧,上面的这些文章都已经出版了,并且有的也有Kindle的版本,推荐购买 🙂
p.p.s 在微博上发现@敲代码的张洋已经有了一些抓取网页的Recipe,包括著名的MIT的SICP,以及O’Reilly的开放书籍,很不错。代码也在Github