Tag Archives: Calibre

在Calibre Recipe里保存收录过的文章

一般来说,Calibre Recipe关注的内容是定期更新的,比如说知乎日报的内容,文章列表每天都会全部更新。

但如果某些内容并不是完全更新,只是部分更新,怎么处理呢?
比如说简书的每周热门文章,某些文章因为热门会持续几周都在其中。这样的话Recipe应该怎么处理才能保证只抓取新的文章,而忽略以前收录的文章呢?

一个朴素的想法就是,把之前收录过的文章都保存起来,然后在recipe里添加要抓取文章之前先check一下有没有抓取过了。
然而在尝试这个朴素的想法的时候,发现在Recipe里保存数据是个问题——保存的文件找不到!

稍微debug了一下,然后看了一下calibre的实现,发现它在抓取过程中的working directory是`/tmp/`下的一个临时目录,完成之后还会被删掉,当然找不到了。。。

尝试一:绝对路径,它work,但是太丑了。

尝试二:如下python里一般获取cwd的方法都不work

os.getcwd()
print os.path.dirname(os.path.realpath(__file__))

因为calibre在__init__.py里有`self.cwd = None` 然后`os.chdir()`

尝试三:遍历inpsect一层层的stack,结果只能找到`/usr/bin/`里面去

尝试四:最后突然想到,既然只是个环境变量,那在os.environ里一定有相关的信息嘛,直接打印看一下,发现

os.environ['PWD']

就是我想要的目录。于是,用这个目录当CWD就搞定了。

具体的code直接看这个commit了。

Share

用Calibre下载网页专题内容(非RSS Feed) 以及Referer的处理

神器Calibre有个很强大的Fetch News功能,可以下载RSS Feed然后生成电子书推送给Kindle。关于如何用recipe下载RSS Feed网上已经有不少相关的资料了。

但是如果想下载某一个专题,而这个专题又没有RSS输出怎么办呢?比如说:寻找太阳系的疆界

topic_outer_planets

答案是,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的问题也解决了,生成的文章里也完美包含了图片。

kindle_outer_planets kindle_outer_planets_2

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

Share