使用饭否新版API编写批量抓取饭否消息的程序

January 6th, 2009 Categories: 杂项

我在断断续续地写一款抓饭程序。预想的功能包括:下载、更新饭否消息,搜索,统计。

近日饭否官方释出搜索功能,可以使用关键字搜索自己曾经发布的消息。作离线版的饭否消息管理工具,似乎没有必要。不过,有的网友习惯将饭否消息列到blog上,因此,我的程序还是有用的。

我原来写的程序,时间都消耗在饭否消息的下载、解析上。好在饭否新版API提供了任意页码的饭否消息,大大简化了抓取难度,因此编写一款饭否消息管理工具不再是一件难事。以python语言为例,我把自己的思路写出来,供各位有类似兴趣的朋友参考。

  1. 两种导出方式:(网页解析|饭否API)的比较。
    1. 难易度:使用网页解析的方式,无疑是比较复杂的,不论是使用正则表达式解析,还是使用XML方式解析。现在饭否提供完备的API,可以按页码导出近乎所有的饭否消息,将导出饭否消息程序的难度降至新低。
    2. 可靠性:我觉得使用手工的网页解析的方式,可以掌控每一个环节、细节,因此,得到的结果也最可靠。而使用API,经过实践,发现还存在漏消息的情况。
    3. 涵盖面:使用手工网页解析方式,可以抓取普通饭否消息、彩信、“饭否分享”消息等等,当然也可以只抓分享、只抓私信、@me消息,等等。而API方式只允许抓取普通饭否消息。
  2. 饭否消息的下载。
    1. 使用curl命令行模式。
      根据饭否官方API文档网页,(旧版饭否API新版饭否API),有这样一句话:

      如果你的系统中有 cURL,就可以通过非常简单的方式使用这些API了。

      正是由于这句话的指引,我才认识了curl,并让它在我在程序中发挥了巨大的作用。cURL具有windows/linux版本,支持php/python/perl语言,是一种强烈推荐的下载利器。我习惯使用http://api.fanfou.com/statuses/user_timeline.[json|xml|rss]这条api来下载饭否消息。由于它支持id、since_id、page,我只要使用下面的命令,就能下载自己的饭否消息:


      GeSHi Error: GeSHi could not find the language txt (using path /home/zhasm/www/iregex.org/wp-content/plugins/codecolorer/lib/geshi/) (code 2)

      它的作用是:下载id为zhasm的饭否消息,第1-180页,保存为”页码.xml”网页。第1页就是 1.xml,依次类推。

      之后,可以cat *.xml >complete.xml,将所有的饭否消息合并到complete.xml文件中。就可以准备下一步的解析。

    2. 使用程序下载
      python,perl,php,无甚区别。我还是习惯使用curl模块来实现。以python为例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      def download(id,page=1,other=""):
          c = pycurl.Curl()
          url="http://api.fanfou.com/statuses/user_timeline.xml?id=%s%s&page=%d"%(id,other,page)
          c.setopt(pycurl.URL, url)
          c.setopt(pycurl.HTTPHEADER, ["Accept:"])
          b = StringIO.StringIO()
          c.setopt(pycurl.WRITEFUNCTION, b.write)
          c.perform()
          return b.getvalue()

      这个python函数能够接受饭友ID,页码page,以及其它参数,下载饭否消息页面。注意,它只是下载完整的页面,还不能解析。

  3. 饭否消息的解析
    1. 消息格式
      我们先观察一下饭否消息的格式,再来做“解剖”:
      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
      <statuses>
          <status>
              <created_at>Mon Jan 05 05:56:36 +0000 2009</created_at>
              <id>M6pa52Ykb1s</id>
              <text>[抓饭]由于饭否释出新的API,我用python重写了抓饭工具,共150行(包括注释)。功能:下载、同步、输出饭否消息(不重复下载旧消息;不处理彩信、分享)。命令行版已经写完。GUI太烦琐了。现在网速慢,今晚还要聚会,只好明晚上传程序。</text>
              <source>网页</source>
              <truncated>false</truncated>
              <in_reply_to_status_id></in_reply_to_status_id>
              <in_reply_to_user_id></in_reply_to_user_id>
              <favorited>false</favorited>
              <in_reply_to_screen_name></in_reply_to_screen_name>
              <user>
                  <id>zhasm</id>
                  <name>.rex</name>
                  <screen_name>.rex</screen_name>
                  <location>北京</location>
                  <description>?【内测ing】好玩、有用的饭否批量处理程序:

      http://code.google.com/p/fanfoufans/?</description>
                  <profile_image_url>http://avatar.fanfou.com/s0/00/57/sg.jpg?1225428475</profile_image_url>
                  <url>http://fanfou.com/zhasm</url>
                  <protected>false</protected>
                  <followers_count>229</followers_count>
              </user>
          </status>
          ...
          </statuses>
    2. 使用xml方式解析
      这个相对简单,因为可以使用xpath技术。例如,如果找饭否消息,可以使用表达式//statuses/status/text,定位发送时间,可以用//statuses/status/created_at,诸如此类。
    3. 正则表达式(python版)
      这个相对于xpath是复杂些,不过还算做是比较简单的正则表达式应用,因为所需解析的文本极其“正则”。正则式如下:
      1
      2
      3
      4
      5
      p=re.compile(
              r"""<created_at>([^&lt;]+)</created_at>\s*
              <id>([^&lt;]+)</id>\s*
              <text>(.*?)</text>\s*
              <source>([^&lt;]+)</source>\s*"""
      , re.DOTALL | re.VERBOSE)

      说明:

      • 使用了re.VERBOSE,来指定空格宽松模式,便于将一条长长的正则式折行来写;
      • 使用了re.DOTALL模式,来指定点号”.”可以匹配包括换行符在内的所有文本。饭否的text字段会出现特殊字符,正则式可以处理,xml却会折戟沉沙。以前我使用xpath解析时可费了不少力气处理特殊字符。而正则式一个点号就能解决。
      • 其它字段,例如created_at,source,来来回回就那几个可以预测的字符,我使用([^<]+)来匹配和捕获。它表示,捕获在下一个<之前的所有文本。
      • 由于>和<之间会有不定数量的(0个或多个)空白字符,我加入了\s*来匹配。

      写好正则表达式后,解析只需要两行:

      1
      2
      p.match(text)
      return p.findall(text)
  4. 存储
    1. 建立表格
      我使用Sqlite库来处理数据。先存储,再输出。sqlite语句为:
      1
      2
      3
      4
      5
      6
      cu.execute("""create table if not exists msg(
                  content Text,
                  uuid Varchar(12) NOT NULL PRIMARY KEY,
                  time Time,
                  tool Text
                  )"
      "")

      创建时先看一眼该表是否存在。如果不存在才创建。  

    2. 存储:
      每解析一页(20条消息),存储一次,再commit()一次,方便、高效。
    3. 同步更新
      谁也不希望每次下载,都需要从第1条,一直下载到当前的第3333条;当你更新至第3344条时,其实只需更新最新的11条即可,没必要再重复下载前边的3333条。这一点对于用户来说,是节约下载时间;对于饭否官方服务器来说,是节省负荷。

      看一下饭否官方为此而新释出的api参数:since_id 

      * since_id (可选) – 仅返回比此 ID 大的消息。 示例: http://api.fanfou.com/statuses/user_timeline.xml?since_id=6IAZmgy1TzA1

      有了这枚参数的支持,我们就很省事了:

      1
      curl -#1 http://api.fanfou.com/statuses/user_timeline.xml?id=zhasm&amp;page=[N]&amp;since_id=6IAZmgy1TzA1 (N可变;since_id不变。)

      这样,就可以持续下载,一直到上次更新的那条了。我设定的退出条件是,下载函数返回的条数为0。这时该页已经不再返回新的消息,视为结束。
      怎样找到上次更新的临界点呢?我用的sql语句是:

      1
      2
      select distinct uuid from msg order by time DESC limit 1
      #在msg消息表中,以时间为序,找到1项最新的uuid,返回之。
      • 如果存在(非空表),我就让它生成&since_id=uuid格式的条件语句,加在curl的下载条件中。
      • 如果不存在(新建立的表),则上述的条件语句置空。 
  5. 细节
    还有一些细节问题,需要编程者操心,你不能把这些问题留给程序的使用者。
    1. 时区的转换
      观察饭否API返回的文本,它的created_at字段给出的时间格式是这样的:

      Mon Jan 05 11:35:27 +0000 2009

      它表示的是,2009年1月5日11:35:27,周一。时区是0时区。
      可是绝大多数饭否用户使用的时区是东八区。上面的时间格式、时区,都需要调整。我写函数是:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      def time_from_0_to_8(timestr,timezone=8):

          TIMEFORMAT="%a %b %d %X +0000 %Y"
          #Sat Jan 03 23:08:54 +0000 2009
          ISOTIMEFORMAT='%Y-%m-%d %X'
          x=time.strptime(timestr, TIMEFORMAT)
          m=time.mktime(x)+60*60*timezone
          p=time.strftime(ISOTIMEFORMAT,time.localtime(m))
          return p

      其中timezone的默认值是8(for 东八区),如果你需要,当然你可以将其换成你需要的时间值。

    2. escape编码
      为了让饭否消息更加安全(html语法上),许多字符都被转义为其对应的escape编码,例如小于号<会被替换成<,以免与网页格式所需要的<混淆。我利用了这一点(而不是自己再转回来),将所输出的消息使用html方式输出,这样原来被转义的字符,在浏览器中还会显出原形。由于饭否消息默认的编码格式是UTF8,我当然也在输出页面加上:
      1
      <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />

至此,解析、下载、输出的工作就都解释完毕。在饭否强大的API的支持下,编写饭否程序,尤其是以下载消息为基础的程序,其门槛已经降到新低。至于各位编程爱好者能做出什么应用,那就八仙过海,各显神通吧。我把自己的程序附在文后,以资参考。编译好的命令行版程序就先不发了。我目前在做GUI。

附:python程序。需要安装若干调用模块,请自行下载。

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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
#!/bin/env python
# -*- coding: utf-8 -*-

import sys
reload(sys)
sys.setdefaultencoding("utf-8")             #ensure the utf8 encoding
import pysqlite2.dbapi2 as sqlite           #sqlite3
import re                                   #regular expression to parse msg
import pycurl                               #downloading engine
import StringIO                             #to  receive the downloaded text
import time                                 #time zone convertion

# important regex to parse the xml file
p=re.compile(
        r"""<created_at>([^<]+)</created_at>\s*
        <id>([^<]+)</id>\s*
        <text>(.*?)</text>\s*
        <source>([^<]+)</source>\s*"""
, re.DOTALL | re.VERBOSE)

###############################################################################
def time_from_0_to_8(timestr,timezone=8):
    '''convert fanfou +0000 time string to locole chinese time string.
     if you live in another timezone, please modify the timezone parameter.
    '
''
    TIMEFORMAT="%a %b %d %X +0000 %Y"
    #Sat Jan 03 23:08:54 +0000 2009
    ISOTIMEFORMAT='%Y-%m-%d %X'
    x=time.strptime(timestr, TIMEFORMAT)
    m=time.mktime(x)+60*60*timezone
    p=time.strftime(ISOTIMEFORMAT,time.localtime(m))
    return p

###############################################################################
def download(id,page=1,other=""):
    """
    to download user id's message by page number. the default
    page is the 1st one.
    """

    c = pycurl.Curl()
    url="http://api.fanfou.com/statuses/user_timeline.xml?id=%s%s&page=%d"%(id,other,page)
    c.setopt(pycurl.URL, url)
    c.setopt(pycurl.HTTPHEADER, ["Accept:"])
    b = StringIO.StringIO()
    c.setopt(pycurl.WRITEFUNCTION, b.write)
    c.perform()
    return b.getvalue()


def parsemsg(text,p):
    '''
    parse all the messeges from the given text,
    return the message timestamp, msg tex, and uuid.
    the structure of the returned list:
    list[(time,id,msg,tool),(time,id,msg,tool)...]
    '
''
    p.match(text)
    return p.findall(text)

###############################################################################
def initdb(id):
    '''
        init the database, create if not exists.
    '
''
    dbname=id+'.db3'
    cx=sqlite.connect(dbname)
    cu=cx.cursor()
    cu.execute("""create table if not exists msg(
            content Text,
            uuid Varchar(12) NOT NULL PRIMARY KEY,
            time Time,
            tool Text
            )"""
)
    return cx

def latest_uid(db):
    cu=db.cursor()
    cu.execute('select distinct uuid from msg order by time DESC limit 1')
    rs=cu.fetchone()
    if rs:
        return rs[0]
    else:
        return ''

def store(list,db):
    '''
    list[(time,id,msg,tool),(time,id,msg,tool)...]
    '
''
    cu=db.cursor()
    index=0
    for item in list:
        time=time_from_0_to_8(item[0])
        id=item[1]
        msg=item[2]
        tool=item[3]
        try:
            cu.execute('''insert into msg values("%s","%s","%s","%s")''' % (msg,id,time,tool))
            index+=1
        except:
            print 'insert error'
    db.commit()
    print "%d messages parsed" % index

def printmsg(db,index,sep=" "):
    cu=db.cursor()
    cu.execute('select content, time from msg where 1 order by time')
    rs=cu.fetchone()
    result=""
    while rs:
        result+=str(index)+sep+rs[0]+sep+rs[1]+"<br />\n"
        rs=cu.fetchone()
        index+=1
    return result
 ###############################################################################
id=sys.argv[1]
if len(id)<2:
    print "please start this program with your id"
    print "for example: ff.exe zhasm, where zhasm is the fanfou id"
    exit()

db=initdb(id)
since=latest_uid(db)
if since:
    condition="&since_id="+since
else:
    condition=''
page=160
while 1:
    print 'downloading page',page
    msg=download(id,page,'')
    list=parsemsg(msg,p)    
    store(list,db)
    if len(list)==0:
        break
    page+=1
filename=id+".html"
file = open(filename,"w")
file.write('''
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
</head>
<body>'
'' )
file.write(printmsg(db,1))
file.write('''
    </body>
    </html>'
'')
file.close( )
Tags: , , , ,

3 Responses to “使用饭否新版API编写批量抓取饭否消息的程序”

  1. yunying
    January 6th, 2009 at 04:38
    1

    谢谢SOGG的工作,不过用饭否的API界面比较漂亮,嘿嘿

    [Reply]

  2. January 6th, 2009 at 04:44
    2

    @yunying:

    请教一下,谁是SOGG?

    [Reply]

  3. yunying
    January 6th, 2009 at 08:43
    3

    sorry,搞错了。一个叫SOGG的朋友也编过这个抓取饭否消息的程序。我搞混了。对不起。

    [Reply]

Leave a Comment