0%

自动抓取经济学人

作为信息源建设的一部分,最近突然想把「经济学人」阅读捡起来。为了能够固定获取「经济学人」文本,研究了一下其爬取过程,记录如下,希望能够有所帮助。

经济学人是什么

经济学人是一份创办于 1843 年 9 月 2 日的英国杂志。这是一本涉及全球政治、经济、文化、科技等多方面事务的综合性新闻评论刊物,着重于对这些议题提供深入的分析和评论。它的社论持有古典自由主义经济自由主义的立场,倾向于支持自由贸易全球化人口自由流动社会自由主义

  • 在创刊之初,其议题主要囊括英国及其殖民地的政治、经济及自由贸易等范围。
  • 1941 年珍珠港事件后首次开辟美国专栏,这是其第一个专门报道某个国家的栏目。
  • 2012年1月,首次开设了中国专栏,这是继1941年的美国栏目之后的首个国家栏目。

神奇的calibre

calibre是一个开源的多平台(支持 Linux,macOS,Windows)电子书管理工具,它支持以下功能:

  • 强大的电子书管理工具,支持从网络上下载电子书的元数据。
  • 强大的电子书阅读工具,支持 ToC,搜索,复制等功能。
  • 支持从网络上下载新闻杂志,比如 The Economist, New York Times等,甚至是 RSS源。
  • 内置 web 服务器,可以更加方便的分享和备份图书库
  • 支持编辑电子书,进行格式转换,包括 epub,mobi 等格式

calibre 支持一种 recipe的概念,calibre 可以通过 recipe从网络上下载新闻,并且组织成电子书。

A recipe is a set of instructions that teach calibre how to convert an online news source, such as a magazine or a blog, into an e-book.

recipe本质上都是 Python 代码,最简单的时候他们只是一些变量,比如 URL,用来告诉 calibre 要从哪里下载新闻。

下面以 LWN 为例(对的,calibre 也可以爬 LWN,但是我太穷了,还订不起)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#!/usr/bin/env  python2

from calibre.web.feeds.news import BasicNewsRecipe

class LWN(BasicNewsRecipe):
title = u'lwn'
__author__ = 'Oliver Niesner'
description = 'Linux Weekly News'
oldest_article = 54
language = _('English')
max_articles_per_feed = 100
needs_subscription = True
language = 'en'
remove_javascript = True
simultaneous_downloads = 1
delay = 1
LOGIN = 'https://lwn.net/login'

def get_browser(self):
br = BasicNewsRecipe.get_browser(self)
if self.username is not None and self.password is not None:
br.open(self.LOGIN)
br.select_form(name='loginform')
br['Username'] = self.username
br['Password'] = self.password
br.submit()
return br

remove_tags = [
dict(name='td', attrs={'class': 'LeftColumn'}),
dict(name='td', attrs={'class': 'NavLink'}),
dict(name='div', attrs={'class': 'FormattedComment'}),
dict(name='td', attrs={'class': 'MCTopBanner'}),
dict(name='div', attrs={'class': 'CommentBox'})
]

feeds = [
(u'lwn', u'http://lwn.net/headlines/newrss'),
]

def postprocess_html(self, soup, first):
for tag in soup.findAll(name=['table', 'tr', 'td']):
tag.name = 'div'
return soup

def print_version(self, url):
return url.replace('rss', '?format=printable')

首先我们看到 LWN 这个 recipe 继承自 BasicRecipe 这个基本的类, BasicRecipe 实现了所有 recipe 都会用到的逻辑,比如 get_browserpostprocess_html 等方法,LWN 只需要根据自己的逻辑去重载即可。

我们还可以看到,LWN 这个类下面也定义了很多的参数,这些参数都是根据自己的这次抓取的定制参数。比如最关键的 feeds,这是一个告诉 calibre 从哪下载的 list。

到现在应该明白 calibre 的工作原理了吧。所有下载的源文件都在互联网上以 HTML 的形式你存在,不论是订阅还是非订阅。calibre 通过将这些源文件(HTML 以及图片等)下载后,对 HTML 文本进行再组织,最常用的工具就是 BeautifulSoup。最后,根据 epub 或者 mobi 等格式的要求,进行格式转换即可。

这里你可以看到经济学人的 recipe,比较复杂,主要是对 html文本的处理,有时间可以仔细研究。

calibre 的官方文档可以看到关于下载新闻更加详细的教程,以后折腾 RSS 的时候可以再看看。

自动定时抓取经济学人

ebook-convert

从 calibre manual 中可以看到,calibre 给我们提供了众多的命令行工具,但是如果只是下载经济学人的话,我们只需要用到 ebook-convert这一工具。其使用方法如下:

1
$ ebook-convert input_file output_file [options]

一般情况下,这里的 input_file是需要进行格式转换的输入文件,output_file是格式转换的输出文件。比如我们把一个 epub 格式转换成 mobi格式。但是,calibre 也能够对于 recipe文件进行自动解析,去网络上下载对应的 HTML 文件,并将其输出成需要的格式。具体的,对于经济学人:

1
$ ebook-convert "The Economist.recipe" "te2020-04-06.epub'

在这里,calibre 会自动根据输出文件的后缀名,将输出文件转换成相应格式,真棒。

对于不同格式的输入输出文件,还有相应的options。

  • 对于 Recipe 的输入格式,有以下参数
    • --username--password,如果需要用户名和密码的话,可以用于指定相关参数
  • --output-profile可以针对不同设备对输出文件进行优化,比如 --output-profile=kindle_pw3

什么时候抓取

根据经济学人的recipe,我们可以看到,其实它每次是以 https://www.economist.com/printedition 为根节点进行遍历,爬取下面所有的文章。

1
2
3
4
5
6
7
8
9
10
11
class Economist(BasicNewsRecipe):

title = 'The Economist'
language = 'en'

__author__ = "Kovid Goyal"
INDEX = 'https://www.economist.com/printedition'
description = (
'Global news and current affairs from a European'
' perspective. Best downloaded on Friday mornings (GMT)'
)

什么时候爬取最合适呢,它也写了最好是在 GMT 时间的星期五上午,北京时间是 GMT + 8,所以我们设定自动定时就可以了。

如何抓取音频

经济学人在官方已经开源了所有的音频文件,其文件地址格式如下:

1
https://audiocdn.economist.com/sites/default/files/AudioArchive/2013/2013MMDD/Issue_****_2013MMDD_The_Economist_Full_edition.zip
  • MMDD为每周发行日(周六)的月份与日期
  • ****为该周六距离1843年9月2日的周数,也就是其期数。

为了便于计算,我们计算和查找 2020-01-04是其出版的第 9175 期,之后的期数以这一天为起点计算:

1
2
3
4
5
6
7
8
9
import time

start_date = '2020-01-04'
end_date = '2020-04-04'
week_start = 9175
start_sec = time.mktime(time.strptime(start_date,'%Y-%m-%d'))
end_sec = time.mktime(time.strptime(end_date,'%Y-%m-%d'))
week_delta = int((end_sec - start_sec)/(24*60*60*7))
week_now = week_start + week_delta

这里的期数应该不是严格按照每一周一期,因为我们发现 20131109这一期的期号为8861,而 20200404这一期的期号为 9188,其间周数间隔为334,而期数间隔为 327,应该是中间有几期没有音频。

容器化主宰世界

将 calibre 容器化,Dockerfile 如下,这里主要是在 alpine 系统上安装了 calibre,然后启动 crontab 定时调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
FROM ljnelson/docker-calibre-alpine
MAINTAINER houmin <houmin.wei@pku.edu.cn>

RUN apk add --no-cache python3 && \
if [ ! -e /usr/bin/python ]; then ln -sf python3 /usr/bin/python ; fi

ADD crontab.txt /crontab.txt
ADD main.py /main.py
COPY entry.sh /entry.sh
RUN chmod 755 /entry.sh
RUN /usr/bin/crontab /crontab.txt

VOLUME ["/data"]

CMD ["/entry.sh"]

为了让外界能够访问这些爬取到的数据,启动一个 FTP 服务器。整体的 docker-compose.yaml如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
version: '3'
services:
vsftpd:
container_name: vsftpd
image: "fauria/vsftpd"
volumes:
- ~/calibre/data:/home/vsftpd
ports:
- "20:20"
- "21:21"
- "21100-21110:21100-21110"
environment:
- FTP_USER=[user]
- FTP_PASS=[passwd]
- PASV_ADDRESS=127.0.0.1
- PASV_MIN_PORT=21100
- PASV_MAX_PORT=21110
calibre:
container_name: calibre
image: "unicosmos/calibre"
restart: always
volumes:
- ~/calibre/data/[user]:/data

把这里的 userpasswd 改成自己服务器的就好了。

程序主函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#!/usr/bin/python3

import datetime, time, os
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.application import MIMEApplication

mail_host = "smtp.163.com"
mail_user = "houmin_wei@163.com"
mail_pass = "********"

sender = 'houmin_wei@163.com'
receivers = ['houmin.wei@pku.edu.cn']

def cal_week(date):
start_date = '20200104'
week_start = 9175
start_sec = time.mktime(time.strptime(start_date, '%Y%m%d'))
date_sec = time.mktime(time.strptime(date, '%Y%m%d'))
week_delta = int((date_sec - start_sec)/(24*60*60*7))
week_now = week_start + week_delta
return week_now

def get_audio(date, path):
week_now = cal_week(date)
url = "https://audiocdn.economist.com/sites/default/files/AudioArchive/{year}/{date}/Issue_{week}_{date}_The_Economist_Full_edition.zip".format(year=date[0:4], date=date, week=week_now)
print(url)
cmd = "cd {path}/audio && wget {url} && cd -;".format(path=path, url=url)
os.system(cmd)

def get_book(date, path):
filename = path + "/ebook/te" + date
#cmd = 'ebook-convert \"The Economist.recipe\" ' + filename + '.mobi --output-profile=kindle'
cmd = 'ebook-convert \"The Economist.recipe\" ' + filename + '.epub'
os.system(cmd)

def send_mail(date, path, sender, receivers):
epubFile = path + "/ebook/te" + date + '.epub'
epubApart = MIMEApplication(open(epubFile, 'rb').read())
epubApart.add_header('Content-Disposition', 'attachment', filename=epubFile)

message = MIMEMultipart()
message.attach(epubApart)
message['Subject'] = "The Economist " + date

try:
smtpObj = smtplib.SMTP(mail_host, 465)
smtpObj.login(mail_user, mail_pass)
smtpObj.sendmail(sender, receivers, message.as_string())
print("sending mail success!")
except smtplib.SMTPException:
print("Error: cannot send mail")

def operator(path):
today = datetime.date.today()
tomorrow = today + datetime.timedelta(days=1)
date = tomorrow.strftime('%Y%m%d')
print("===============================================")
print("Fetch The Economist {date} begin...".format(date=date))
get_book(date, path)
#get_audio(date, path)
#send_mail(date, path, sender, receivers)
print("Fetch The Economist {date} end.".format(date=date))
print("===============================================\n\n")


if __name__ == "__main__":
operator("./data")

更多具体的内容,可以参考我的 Github Repo

参考资料