使用饭否新版API编写批量抓取饭否消息的程序
我在断断续续地写一款抓饭程序。预想的功能包括:下载、更新饭否消息,搜索,统计。
近日饭否官方释出搜索功能,可以使用关键字搜索自己曾经发布的消息。作离线版的饭否消息管理工具,似乎没有必要。不过,有的网友习惯将饭否消息列到blog上,因此,我的程序还是有用的。
我原来写的程序,时间都消耗在饭否消息的下载、解析上。好在饭否新版API提供了任意页码的饭否消息,大大简化了抓取难度,因此编写一款饭否消息管理工具不再是一件难事。以python语言为例,我把自己的思路写出来,供各位有类似兴趣的朋友参考。
- 两种导出方式:(网页解析|饭否API)的比较。
- 难易度:使用网页解析的方式,无疑是比较复杂的,不论是使用正则表达式解析,还是使用XML方式解析。现在饭否提供完备的API,可以按页码导出近乎所有的饭否消息,将导出饭否消息程序的难度降至新低。
- 可靠性:我觉得使用手工的网页解析的方式,可以掌控每一个环节、细节,因此,得到的结果也最可靠。而使用API,经过实践,发现还存在漏消息的情况。
- 涵盖面:使用手工网页解析方式,可以抓取普通饭否消息、彩信、“饭否分享”消息等等,当然也可以只抓分享、只抓私信、@me消息,等等。而API方式只允许抓取普通饭否消息。
- 饭否消息的下载。
-
使用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文件中。就可以准备下一步的解析。
-
使用程序下载
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,以及其它参数,下载饭否消息页面。注意,它只是下载完整的页面,还不能解析。
-
- 饭否消息的解析
- 消息格式
我们先观察一下饭否消息的格式,再来做“解剖”:<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>
- 使用xml方式解析
这个相对简单,因为可以使用xpath技术。例如,如果找饭否消息,可以使用表达式//statuses/status/text,定位发送时间,可以用//statuses/status/created_at,诸如此类。 - 正则表达式(python版)
这个相对于xpath是复杂些,不过还算做是比较简单的正则表达式应用,因为所需解析的文本极其“正则”。正则式如下:p=re.compile( r"""<created_at>([^<]+)</created_at>\s* <id>([^<]+)</id>\s* <text>(.*?)</text>\s* <source>([^<]+)</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)
- 消息格式
- 存储
- 建立表格
我使用Sqlite库来处理数据。先存储,再输出。sqlite语句为:cu.execute("""create table if not exists msg( content Text, uuid Varchar(12) NOT NULL PRIMARY KEY, time Time, tool Text )""")
创建时先看一眼该表是否存在。如果不存在才创建。
- 存储:
每解析一页(20条消息),存储一次,再commit()一次,方便、高效。 - 同步更新
谁也不希望每次下载,都需要从第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&page=[N]&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的下载条件中。
- 如果不存在(新建立的表),则上述的条件语句置空。
- 建立表格
- 细节
还有一些细节问题,需要编程者操心,你不能把这些问题留给程序的使用者。- 时区的转换
观察饭否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 东八区),如果你需要,当然你可以将其换成你需要的时间值。
- 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( )
January 6, 2009 - 4:38 am
谢谢SOGG的工作,不过用饭否的API界面比较漂亮,嘿嘿
January 6, 2009 - 4:44 am
@yunying:
请教一下,谁是SOGG?
January 6, 2009 - 8:43 am
sorry,搞错了。一个叫SOGG的朋友也编过这个抓取饭否消息的程序。我搞混了。对不起。