用GAE访问fanfou API,支持发图

可下载版本在这里:https://gist.github.com/789880

# -*- coding: utf-8 -*-
from django.utils import simplejson as json
from google.appengine.api import urlfetch

import urllib
import mimetypes
import base64
import random

_api_url = 'http://api.fanfou.com/'

_http_methods={
    'GET':urlfetch.GET,
    'POST':urlfetch.POST,
    'HEAD':urlfetch.HEAD,
    'PUT':urlfetch.PUT,
    'DELETE':urlfetch.DELETE
}

def _generate_boundary(length=16):
    s = '1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'
    a = []
    for i in range(length):
        a.append(random.choice(s))
    return ''.join(a)

def _get_content_type(filename):
    return mimetypes.guess_type(filename)[0] or 'application/octet-stream'

def _encode_multipart_formdata(fields, files=[]):
    """
    fields is a sequence of (name, value) elements for regular form fields.
    files is a sequence of (name, filename, value) elements for data to be uploaded as files
    Return (boundary, body)
    """
    boundary = _generate_boundary()
    crlf = '\r\n'

    l = []
    for k, v in fields:
        l.append('--' + boundary)
        l.append('Content-Disposition: form-data; name="%s"' % k)
        l.append('')
        l.append(str(v))
    for (k, f, v) in files:
        l.append('--' + boundary)
        l.append('Content-Disposition: form-data; name="%s"; filename="%s"' % (k, f))
        l.append('Content-Type: %s' % _get_content_type(f))
        l.append('')
        l.append(str(v))
    l.append('--' + boundary + '--')
    l.append('')
    body = crlf.join(l)
    return boundary, body


class Fanfou:
    def __init__(self, username, password):
        self.username=username
        self.password=password
        self.http_status=0
        self.http_headers={}
        self.http_body=''

    def _fetch(self, method, url, params={}, headers={}, files=None):
        payload=None
        if method.upper() in ['POST','PUT']:
            if files and type(files) == list:
                boundary, payload = _encode_multipart_formdata(params.items(), files)
                headers['Content-Type']='multipart/form-data; boundary=%s' % boundary
            else:
                payload=urllib.urlencode(params)
        res=urlfetch.fetch(url, payload, _http_methods[method.upper()], headers)
        self.http_status=res.status_code
        self.http_headers=res.headers
        self.http_body=res.content
        return res.content

    def api_call(self, http_method, api_method, params={}, files=None):
        raw_content=self._fetch(
            http_method,
            ''.join([_api_url,api_method,'.json']),
            params,
            {'Authorization':'Basic %s' % base64.b64encode('%s:%s' % (self.username, self.password))},
            files
        )
        return json.loads(raw_content)

用法:

from fanfou import Fanfou 

fanfou=Fanfou('name','pass') 

#时间线
fanfou.api_call('GET', 'statuses/public_timeline') 

#传图 
fanfou.api_call('POST', 'photos/upload', {'status':'test...'}, files=[('photo','aaa.jpg', photo_data )])
More

给 urlfetch 加上自动重试功能

在使用Google App Engine 的 urlfetch 请求其他服务时。时不时会遇到因目标服务出现错误(如超时、500 等)导致自己的程序出现错误。

例如 Twitter API 不稳定时,经常会返回一个500错误,如果自己的程序不加处理,一定会跟着出错。即便加入错误处理,也会给用户一个错误页面,这个体验很不好。实际上这样的错误通常只是暂时的随机出现的。只要重试几次就能获得一个满意的结果。

原理:
把urlfetch.fetch放入一个循环中。若请求成功,则返回数据;若抛出 DownloadError 异常,则开始新一轮循环。如果出错次数大于一定数值,那么意味着对方服务器差不多已经死彻底了,不想出错都不行了,就退出这个循环并抛出异常。

代码:

# xfetch.py
from google.appengine.api import urlfetch

_max_fetch_count = 5

def fetch(url, payload=None, method=urlfetch.GET,
headers={}, allow_truncated=False, follow_redirects=True,
deadline=None, validate_certificate=None):
    http_body=''
    for count in range(_max_fetch_count):
        try:
            http_body = urlfetch.fetch(url, payload, method, headers, allow_truncated, follow_redirects, deadline, validate_certificate)    
            return http_body
        except urlfetch.DownloadError, e:
            continue
    raise Exception('Max fetch count exceeded.')

注意事项:
  • _max_fetch_count*deadline 必须小于等于 request duration
  • 必须考虑到请求次数的限额,在个人负担允许范围内使用。

More

破解 Twitter Search API 的请求限制

You have been rate limited. Enhance your calm.

在Google App Engine上使用过Twitter Search API 的人可能会经常遇到这个头疼的问题,就是API的请求限额经常被刷爆。

出现这个问题有两个原因:首先要从urlfetch的实现原理来看。因为GAE是个云平台,它将资源整合分配。每次通过urlfetch发出一个请求时,首先GAE会在IP地址池里取得一个IP,然后用这个IP访问网络。而IP地址池中的IP都是给所有应用共享的,你也用,我也用,大家都用。另外一个原因,就是Twitter Search API的请求策略是按照 每IP*小时(基于IP的请求限制) 计算,而不是REST API的按照 每用户*小时(基于认证的请求限制)计算。由于GAE发出请求的IP是所有应用共享的,所以给这些IP的请求限额会必然会被海量的应用连续不断的请求刷爆。

有人提出过给search api加入认证(authenticate)就可以实现 每(用户*小时) 的请求策略。但经过实践证明这样做是不行的。

这个问题困扰了我很久,搜索各种资料都没找到什么有用的结果。后来发现了一个一直没有注意到的现象,那就是twitter官网的新界面搜索功能特别好用,而且只要修改了hosts,就可以用https在墙内使用。可Search API所用的域名search.twitter.com没有https支持,而且我在hosts中也没有设置任何search.twitter.com这个域名的IP。那这个搜索结果是怎么传过来的呢? 如果twitter官网使用的是 search.twitter.com 这个域名作为搜索api的地址,那肯定search.twitter.com也支持https,但事实上是不行的。显然,twitter官网肯定使用了别twitter.com域名来搜索!

接下来就简单了,用Firefox/chrome打开https://twitter.com,观察网络活动。于是抓到了一个名叫 pheonix_search 的隐藏API。大概是这样的:


一、API地址
https://twitter.com/phoenix_search.phoenix


二、参数
q - 必须,要搜索的关键字。
since_id - 可选,返回结果的id应全都大于since_id。
page - 可选,这是一个很奇怪的参数,twitter用它来获取搜索结果的下一页。它本身又包含了很多其他参数。
include_entities - 可选,是否包含推文的“实体”。
contributor_details - 未知,在Httpfox中观察它的值一直是:true
domain - 未知,在Httpfox中观察它的值一直是:https://twitter.com
format - 未知,在Httpfox中观察它的值一直是:phoenix

详解page参数:
page是个很奇怪的参数,它本身又包含了很多其他参数(大致)。
   page - 这个不是上边说的page而是真正的页码。
   max_id - 返回结果应全都小于max_id
   rpp - 每页返回的推文个数
   q - 请求关键字
举例(注意这里page参数必须是urlencode过的):
page=%3Fpage%3D2%26max_id%3D44034426279165952%26rpp%3D20%26q%3Dtwitdao
解码后:
?page=2&max_id=44034426279165952&rpp=20&q=twitdao


三、返回数据
json格式,
{
	"statuses":[...], //推文实体数组
	"next_page":false, //下一页,也就是翻页时的page参数
	"error":null //出错信息
}


四、用法
假设要搜索 "twitdao" ,
第一次请求:
https://twitter.com/phoenix_search.phoenix?q=twitdao&format=phoenix
刷新(最新符合关键字的推文):
https://twitter.com/phoenix_search.phoenix?q=twitdao&since_id=43865302966075392&format=phoenix
翻页(更早符合关键字的推文):
https://twitter.com/phoenix_search.phoenix?q=twitdao&page=%3Fpage%3D2%26max_id%3D44034426279165952%26rpp%3D20%26q%3Dtwitdao&format=phoenix

实例代码:
http://code.google.com/p/twitdao/source/browse/trunk/twitter.py?spec=svn62&r=61#238


五、一些特性
1、可以通过加入认证提升Rate Limit
这也是本文主要的目的,就是破解 Twitter Search API 的请求限制。经过一番测试,在pheonix_search的请求中加入OAuth header后,果然可以从“基于IP的请求限制”转化成“基于认证的请求限制”!
2、返回真正的推文实体列表
pheonix_search API 的另外一个优点就是它返回的推文实体是完整的,与home_timline方法返回的推文实体相同。而普通Search API返回的推文实体并不是完整的,并且有些id数据可能会不正确。
Twitter Search API文档中的警告:
Warning: The user ids in the Search API are different from those in the REST API (about the two APIs). This defect is being tracked by Issue 214. This means that the to_user_id and from_user_id field vary from the actualy user id on Twitter.com. Applications will have to perform a screen name-based lookup with the users/show method to get the correct user id if necessary.


六、测试办法
如何知道一个API是“基于IP的请求限制”还是“基于认证的请求限制”呢?每次请求 twitter api 的时候都会返回几个有关请求限制(Rate Limit)的http首部,主要有:
X-RateLimit-Limit - 每小时限制请求数
X-RateLimit-Remaining - 剩余的请求数
X-RateLimit-Reset - 请求限制重置时刻

假设所有请求都用同一个IP。

1、首先,对api发出请求,假设返回:
X-RateLimit-Limit: 150
X-RateLimit-Remaining: 149
X-RateLimit-Reset: 1299344165

2、然后,换一个用户,再次对该api发出请求,
--若返回:
X-RateLimit-Limit: 150
X-RateLimit-Remaining: 148 (仍然在减少)
X-RateLimit-Reset: 1299344165 (注意这里不能变,如果变了要重新开始)
继续更换用户并发出请求,如果X-RateLimit-Remaining持续减少,并且X-RateLimit-Reset一直未变。
则该api请求限制是“基于IP的请求限制”。
--若返回:
X-RateLimit-Limit: 150
X-RateLimit-Remaining: 149 (重新开始计数)
X-RateLimit-Reset: 1299399168 (变了!)
继续更换用户并发出请求,如果X-RateLimit-Remaining按照的减少是基于用户的,并且每个用户都有自己独有的X-RateLimit-Reset。
则该api请求限制是“基于认证的请求限制”。


参考:
  1. https://dev.twitter.com/pages/rate-limiting
  2. https://groups.google.com/d/msg/google-appengine/okY7XSO3EFQ/37AvPss4K88J
  3. http://codeleaks.net/2010/08/19/%E8%AF%B4%E8%AF%B4-twitter-for-iphone-%E7%9A%84-n-%E5%AE%97-%E2%80%9C%E7%BD%AA%E2%80%9D/
  4. http://dev.twitter.com/pages/tweet_entities
More

在GAE中迁移 django 0.96到1.2时遇到的模板问题

django 0.96 与 django 1.2 之间的细微差别造成很多麻烦事。


一、自动转换(autoescape)的问题

autoescape 主要是为了防止模板变量中含有标签等html元素,它会自动将html标签等元素转换实体。
例如:把
<script>
转换成
&lt;script&gt;

但是 django 0.96 没有autoescape,而django 1.2默认会自动对模板变量进行过滤。从0.96更换至1.2的时候,会发现很多不想被过滤的标签全都被过滤了,可以使用safe标记成安全字符。
如果使用了escape过滤器,则不管它出现在过滤链条的哪个位置,一律只会在链条的最后执行。并且如果变量已经标记为safe,那么escape则不会做任何事情。
例如:
{{ val|safe }}

解决办法有两种,一种是将原来模板中的escape去掉,再把不需要过滤的挨个修改成safe。另一种就是全局关闭autoescape:

{% autoescape off %}
....模板其他部分...
{% endautoescape %}


二、{% spaceless %}标签默认行为差别

在 django 0.96 中,去掉所有标签间的空白符号,仅标签间留一个空格。
在 django 1.2 中,去掉所有标签间的空白符号,标签间什么都不留。

举例:
<a href="#">This</a> <a href="#">is</a> <a href="#">mine</a>.
django 0.96 显示为:
This is mine.
django 1.2 显示成:
Thisismine.

这个解决办法很简单,不用这个标签即可,用与不用一般没什么区别。


三、{% include %}与{% base %}标签的路径问题

django 1.2 的标签中,不能使用上层路径,只能从模板根目录向下找。
在 django 0.96 中,可以这样使用:

{% include "../home.html" %}
而在 django 1.2 你必须这样:

{% include "path/to/home.html" %}

对比一下0.96与1.2的 django.template.loaders.file_system 模块,就会发现 get_template_sources 方法的文件路径结合方式不同。0.96 用的是一般的 os.path.join 方法,而 1.2 用的是 django.utils._os.safe_join 方法,后者限制不允许使用 base path 外的路径。而 include 和 extends 标签会先从base path开始寻找模板(这也许是bug?),如果路径里边有 “../” 可能会直接跳出这个 base path ,于是会触发异常:
>>> from django.utils._os import safe_join 
>>> safe_join('templates','../bbb.html') 
Traceback (most recent call last): 
File "<stdin>", line 1, in <module> 
File "C:\Python26\lib\site-packages\django\utils\_os.py", line 44, in safe_joi 
n 
raise ValueError('the joined path is located outside of the base path' 
ValueError: the joined path is located outside of the base path component 
>>> 

另外一个麻烦事就是在django 1.2中必须使用一个空app进行初始化设置才能正确找到模板。
举例,
目录结构:

project 
|--templates 
| |--outter.html
| |--bbb
| | |--in_b.html 
| |--aaa
| | |--in_a.html 
outter.html :
{% include "bbb/in_b.html"%}
in_a.html :
{% include "bbb/in_b.html"%}
main.py:

from google.appengine.dist import use_library
use_library('django', '1.2')

from django.conf import settings
#必须找个空文件(nothing.py)作为引子才能正确找到模板文件
settings.configure(INSTALLED_APPS=('nothing',))

from google.appengine.ext import webapp
from google.appengine.ext.webapp import util, template
import os

class MainHandler(webapp.RequestHandler):
    def get(self):
        #不论settings是否设置INSTALLED_APPS=('nothing',)
        #在outter.html中总能找到bbb/in_b.html或aaa/in_b.html
        self.render('outter.html', debug=True)

        #在aaa/in_a.html中,只有在settings中设置了
        #INSTALLED_APPS=('nothing',)时
        #才能正确找到 bbb/in_b.html ,反过来也是一样。
        self.render('aaa/in_a.html', debug=True)

    def render(self, tpl, vals={},debug=False):
        directory=os.path.dirname(__file__)
        path=os.path.join(directory, os.path.join('templates',tpl))
        self.response.out.write(template.render(path, vals, debug))

def main():
    application = webapp.WSGIApplication([('/', MainHandler)],
                                         debug=True)
    util.run_wsgi_app(application)


if __name__ == '__main__':
    main()


参考:
1、https://groups.google.com/forum/#!msg/google-appengine-python/YaqfeygoiaI/RtDh9pL6XJQJ

More