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

近日饭否官方释出搜索功能,可以使用关键字搜索自己曾经发布的消息。作离线版的饭否消息管理工具,似乎没有必要。不过,有的网友习惯将饭否消息列到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,我只要使用下面的命令,就能下载自己的饭否消息:

      curl -o #1.xml "http://api.fanfou.com/statuses/user_timeline.xml?id=zhasm&page=[1-180]"

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

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

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

      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. 消息格式
      我们先观察一下饭否消息的格式,再来做“解剖”:
      <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是复杂些,不过还算做是比较简单的正则表达式应用,因为所需解析的文本极其“正则”。正则式如下:
      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*来匹配。

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

      p.match(text)
      return p.findall(text)
  4. 存储
    1. 建立表格
      我使用Sqlite库来处理数据。先存储,再输出。sqlite语句为:
      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

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

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

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

      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时区。
      可是绝大多数饭否用户使用的时区是东八区。上面的时间格式、时区,都需要调整。我写函数是:

      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,我当然也在输出页面加上:
      <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />

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

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

#!/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( )