├── .coveragerc ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── fanpy ├── __init__.py ├── ansi.py ├── api.py ├── archiver.py ├── auth.py ├── cli.py ├── fanfou_globals.py ├── logger.py ├── oauth.py ├── oauth_dance.py └── util.py ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── test.png ├── test_internals.py └── test_sanity.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | omit = 3 | */python?.?/* 4 | */site-packages/nose/* 5 | tests/* 6 | 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.3" 5 | - "3.4" 6 | - "3.5" 7 | - "3.6" 8 | sudo: false 9 | install: 10 | - pip install coveralls 11 | script: 12 | - nosetests --with-coverage --cover-package=fanpy 13 | after_success: 14 | - coveralls 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Mookrs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fanpy 2 | 3 | [![Coverage Status](https://coveralls.io/repos/github/mookrs/fanpy/badge.svg?branch=master)](https://coveralls.io/github/mookrs/fanpy?branch=master) 4 | 5 | `fanpy` is a Python tool that allows you to interact with [fanfou.com](http://fanfou.com/). This project is a clone from sixohsix's [Python Twitter Tools](https://github.com/sixohsix/twitter). 6 | 7 | ## 安装 8 | 9 | `pip(3) install fanpy` 10 | 11 | ## fanpy 12 | 13 | `fanpy` 是一个命令行工具,可实现以下功能: 14 | 15 | - 查看个人时间轴(friends)和收到的回复(replies),并以不同的格式输出 16 | - 使用关键词搜索(search)消息 17 | - 关注(follow)和取关(leave)好友 18 | - 发送(set)新消息 19 | 20 | 输入 `fanpy -h` 查看更多帮助。 21 | 22 | ## fanpy-archiver 23 | 24 | `fanpy-archiver` 可以备份你的消息、收到的回复、私信、收藏,他人的消息、收藏。输入 `fanpy-archiver -h` 查看更多帮助。该工具仅供测试,如果想更好地备份消息,推荐使用 Windows 下超方便的 [饭盒](http://www.aoisnow.net/blog/fanhe)。 25 | 26 | ## fanpy-log 27 | 28 | `fanpy-log` 可以在终端显示某个用户的全部消息。输入 `fanpy-log -h` 查看更多帮助。 29 | 30 | ## 与 Fanfou API 交互 31 | 32 | 饭否 API 文档请参考: 33 | 34 | https://github.com/FanfouAPI/FanFouAPIDoc/wiki 35 | 36 | 示例: 37 | 38 | ```python 39 | from fanpy import * 40 | 41 | f = Fanfou(auth=OAuth( 42 | oauth_token, oauth_token_secret, consumer_key, consumer_secret)) 43 | 44 | # Get your home timeline 45 | f.statuses.home_timeline() 46 | 47 | # Get a particular friend's timeline 48 | # To pass in the GET/POST parameter `id` you need to use `_id` 49 | f.statuses.user_timeline(_id='ifanfou') 50 | 51 | # To pass in GET/POST parameters, such as `count` 52 | f.statuses.home_timeline(count=5) 53 | 54 | # Update your status 55 | f.statuses.update(status='Hello, world!') 56 | 57 | # Send a direct message 58 | f.direct_messages.new(user='ifanfou', text='I miss you!') 59 | 60 | # An *optional* `_timeout` parameter can also be used for API 61 | # calls which take much more time than normal: 62 | f.search.public_timeline(q='|'.join(A_LIST_OF_100_WORDS), _timeout=1) 63 | 64 | # Overriding Method: GET/POST 65 | # you should not need to use this method as this library properly 66 | # detects whether GET or POST should be used, Nevertheless 67 | # to force a particular method, use `_method` 68 | t.statuses.update(status='Hello, world!', _method='POST') 69 | 70 | 71 | # Send image with your status: 72 | # - Just read image from the web or from file the regular way: 73 | with open('example.png', 'rb') as imagefile: 74 | imagedata = imagefile.read() 75 | # - Then send the image with a status. 76 | f.photos.upload(photo=imagedata, status='Upload image.') 77 | ``` 78 | 79 | ### 使用返回的数据 80 | 81 | 调用饭否 API 后默认返回 JSON 对象,并被自动转换成 `list` 或 `dict`: 82 | 83 | ```python 84 | x = fanfou.statuses.home_timeline() 85 | 86 | # The first status in the timeline 87 | x[0] 88 | 89 | # The name of the user who wrote the first status 90 | x[0]['user']['name'] 91 | ``` 92 | 93 | ### 获取 XML 数据 94 | 95 | 如果你需要获取 XML 格式的数据,可以在初始化 Fanfou 对象时传入 `format='xml'` 参数: 96 | 97 | ```python 98 | fanfou = Fanfou(format='xml') 99 | ``` 100 | 101 | ## 授权 102 | 103 | 支持通过 OAuth 进行授权。 104 | 105 | ### OAuth 的认证流程 106 | 107 | 访问饭否开放平台并创建应用: 108 | 109 | http://fanfou.com/apps.new 110 | 111 | 创建成功后,你将会得到 `CONSUMER_KEY` 和 `CONSUMER_SECRET`。 112 | 113 | 用户在运行你的程序时,需要将账户授权给你的应用。具体的实现请查看 `fanpy.oauth_dance` 模块。如果你编写的是命令行程序,可以直接使用 `oauth_dance()` 函数。 114 | 115 | 执行 `oauth_dance()` 将获得授权所必需的 oauth token 和 oauth token secret,可以将这些信息保存在本地,之后就不用重复授权步骤了。 116 | 117 | `read_token_file()` 和 `write_token_file()` 是读取和写入 oauth token 和 oauth token secret 的方法,其值以字符串形式存在文件中。 118 | 119 | 示例: 120 | 121 | ```python 122 | from fanpy import * 123 | 124 | MY_FANFOU_CREDS = os.path.expanduser('~/.my_app_credentials') 125 | if not os.path.exists(MY_FANFOU_CREDS): 126 | oauth_dance('My App Name', CONSUMER_KEY, CONSUMER_SECRET, MY_FANFOU_CREDS) 127 | 128 | oauth_token, oauth_token_secret = read_token_file(MY_FANFOU_CREDS) 129 | 130 | fanfou = Fanfou(auth=OAuth( 131 | oauth_token, oauth_token_secret, CONSUMER_KEY, CONSUMER_SECRET)) 132 | 133 | # Now work with Fanfou 134 | fanfou.statuses.update(status='Hello, world!') 135 | ``` 136 | 137 | ## 其他饭友制作的工具 138 | 139 | 网上还有很多与 `fanpy` 项目类似的工具,`fanpy` 在改造 [Python Twitter Tools](https://github.com/sixohsix/twitter) 的过程从中获取了灵感,列于下方表示感谢,同时以供备用参考: 140 | 141 | - [fanfou](https://github.com/akgnah/fanfou.bot/blob/master/fanfou.py) 饭否 OAuth (XAuth) 模块 142 | - [饭盒](http://www.aoisnow.net/blog/fanhe) Windows 下的饭否用户数据管理工具集 143 | - [pyfan](https://github.com/raptorz/pyfan) Fanfou client for python 144 | - [pyfanfou](https://github.com/mcxiaoke/pyfanfou) 饭否数据备份和导出工具 145 | - [fanfou-backup](https://github.com/heedless/fanfou-backup) 饭否消息备份工具 146 | - [Treeholes](https://github.com/fanzeyi/Treeholes) An anonymous bot for Fanfou 147 | 148 | ## License 149 | 150 | MIT 151 | -------------------------------------------------------------------------------- /fanpy/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | fanpy 3 | ~~~~~ 4 | fanpy is a Python tool that allows you to interact with 5 | fanfou.com. It is a clone from `Python Twitter Tools`. 6 | 7 | :license: MIT. 8 | """ 9 | 10 | from .api import Fanfou, FanfouError, FanfouHTTPError, FanfouResponse 11 | from .auth import NoAuth 12 | from .oauth import OAuth, read_token_file, write_token_file 13 | from .oauth_dance import oauth_dance 14 | 15 | __all__ = [ 16 | 'NoAuth', 17 | 'OAuth', 18 | 'oauth_dance', 19 | 'read_token_file', 20 | 'Fanfou', 21 | 'FanfouError', 22 | 'FanfouHTTPError', 23 | 'FanfouResponse', 24 | 'write_token_file', 25 | ] 26 | 27 | __version__ = '0.2.0' 28 | __license__ = 'MIT' 29 | -------------------------------------------------------------------------------- /fanpy/ansi.py: -------------------------------------------------------------------------------- 1 | """Support for ANSI colors in command-line client.""" 2 | 3 | import itertools 4 | import sys 5 | 6 | ESC = chr(0x1B) 7 | RESET = '0' 8 | 9 | COLORS_NAMED = dict(list(zip( 10 | ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'], 11 | [str(x) for x in range(30, 38)] 12 | ))) 13 | COLORS_MIDS = [ 14 | color for name, color in COLORS_NAMED.items() 15 | if name not in ('black', 'white') 16 | ] 17 | 18 | 19 | class AnsiColorException(Exception): 20 | pass 21 | 22 | 23 | class ColorMap(object): 24 | """Object that allows for mapping strings to ansi color values""" 25 | def __init__(self, colors=COLORS_MIDS): 26 | """uses the list of ansi `colors` values to initialize the map""" 27 | self.color_map = {} 28 | self.color_iter = itertools.cycle(colors) 29 | 30 | def color_for(self, string): 31 | """Returns an ansi color value given a `string`. 32 | The same ansi color value is always returned for the same string 33 | """ 34 | if string not in self.color_map: 35 | self.color_map[string] = next(self.color_iter) 36 | return self.color_map[string] 37 | 38 | 39 | class AnsiCmd(object): 40 | def __init__(self, force_ansi): 41 | self.force_ansi = force_ansi 42 | 43 | def cmd_reset(self): 44 | """Returns the ansi cmd color for a RESET""" 45 | if sys.stdout.isatty() or self.force_ansi: 46 | return ESC + '[0m' 47 | else: 48 | return '' 49 | 50 | def cmd_color(self, color): 51 | """Return the ansi cmd color (i.e. escape sequence) 52 | for the ansi `color` value 53 | """ 54 | if sys.stdout.isatty() or self.force_ansi: 55 | return ESC + '[' + color + 'm' 56 | else: 57 | return '' 58 | 59 | def cmd_color_named(self, color): 60 | """Return the ansi cmd_color for a given named `color`""" 61 | try: 62 | return self.cmd_color(COLORS_NAMED[color]) 63 | except KeyError: 64 | raise AnsiColorException('Unknown Color {}'.format(color)) 65 | 66 | def cmd_bold(self): 67 | if sys.stdout.isatty() or self.force_ansi: 68 | return ESC + '[1m' 69 | else: 70 | return '' 71 | 72 | def cmd_underline(self): 73 | if sys.stdout.isatty() or self.force_ansi: 74 | return ESC + '[4m' 75 | else: 76 | return '' 77 | -------------------------------------------------------------------------------- /fanpy/api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import print_function, unicode_literals 3 | 4 | try: 5 | import http.client as http_client 6 | except ImportError: 7 | import httplib as http_client 8 | 9 | try: 10 | from io import BytesIO 11 | except ImportError: 12 | from cStringIO import StringIO as BytesIO 13 | 14 | try: 15 | import json 16 | except ImportError: 17 | import simplejson as json 18 | 19 | try: 20 | import urllib.request as urllib_request 21 | import urllib.error as urllib_error 22 | except ImportError: 23 | import urllib2 as urllib_request 24 | import urllib2 as urllib_error 25 | 26 | import gzip 27 | import re 28 | import sys 29 | from time import sleep, time 30 | 31 | from .auth import NoAuth 32 | from .fanfou_globals import POST_ACTIONS 33 | from .util import PY3, actually_bytes 34 | 35 | 36 | class _DEFAULT(object): 37 | pass 38 | 39 | 40 | class FanfouError(Exception): 41 | """Base Exception thrown by the Fanfou object when there is a 42 | general error interacting with the API. 43 | """ 44 | pass 45 | 46 | 47 | class FanfouHTTPError(FanfouError): 48 | """Exception thrown by the Fanfou object when there is an 49 | HTTP error interacting with fanfou.com. 50 | """ 51 | 52 | def __init__(self, e, uri, format, uriparts): 53 | self.e = e 54 | self.uri = uri 55 | self.format = format 56 | self.uriparts = uriparts 57 | try: 58 | data = self.e.fp.read() 59 | except http_client.IncompleteRead as e: 60 | data = e.partial 61 | if self.e.headers.get('Content-Encoding') == 'gzip': 62 | buf = BytesIO(data) 63 | f = gzip.GzipFile(fileobj=buf) 64 | data = f.read() 65 | if len(data) == 0: 66 | data = {} 67 | else: 68 | data = data.decode('utf-8') 69 | if self.format == 'json': 70 | try: 71 | data = json.loads(data) 72 | except ValueError: 73 | pass 74 | self.response_data = data 75 | super(FanfouHTTPError, self).__init__(str(self)) 76 | 77 | def __str__(self): 78 | fmt = ('.' + self.format) if self.format else '' 79 | return ('Fanfou HTTP Error {} for URL: {}{}, using parameters: ({})\ndetails: {}' 80 | .format(self.e.code, self.uri, fmt, self.uriparts, self.response_data)) 81 | 82 | 83 | class FanfouResponse(object): 84 | """Response from a Fanfou request. Behaves like a list or a string 85 | (depending on requested format). 86 | """ 87 | 88 | @property 89 | def x_auth_user(self): 90 | """The X-AuthUser for that request.""" 91 | return self.headers.get('X-AuthUser', '') 92 | 93 | 94 | class FanfouDictResponse(dict, FanfouResponse): 95 | pass 96 | 97 | 98 | class FanfouListResponse(list, FanfouResponse): 99 | pass 100 | 101 | 102 | def wrap_response(response, headers): 103 | response_type = type(response) 104 | if response_type is dict: 105 | res = FanfouDictResponse(response) 106 | res.headers = headers 107 | elif response_type is list: 108 | res = FanfouListResponse(response) 109 | res.headers = headers 110 | else: 111 | res = response 112 | return res 113 | 114 | 115 | POST_ACTIONS_RE = re.compile('(' + '|'.join(POST_ACTIONS) + r')(/\d+)?$') 116 | 117 | 118 | def method_for_uri(uri): 119 | """Choose METHOD for uri according to `fanfou_globals.py`.""" 120 | if POST_ACTIONS_RE.search(uri): 121 | return 'POST' 122 | return 'GET' 123 | 124 | 125 | def build_uri(orig_uriparts, kwargs): 126 | """Build the URI from the original uriparts and kwargs. Modifies kwargs. 127 | 128 | :param orig_uriparts: eg: ('statuses', 'user_timeline') 129 | :param kwargs: eg: {'_id': 'ifanfou'} 130 | """ 131 | uriparts = [] 132 | for uripart in orig_uriparts: 133 | # If this part matches a keyword argument (starting with _), use 134 | # the supplied value. Otherwise, just use the part. 135 | if uripart.startswith('_'): 136 | part = str(kwargs.pop(uripart, uripart)) 137 | else: 138 | part = uripart 139 | uriparts.append(part) 140 | uri = '/'.join(uriparts) 141 | 142 | # If an id kwarg is present and there is no id to fill in in 143 | # the list of uriparts, assume the id goes at the end. 144 | id = kwargs.pop('id', None) 145 | if id: 146 | uri += '/{}'.format(id) 147 | 148 | # eg: `fanfou.statuses.update(status='Hello, world!')` to 149 | # `statuses/update` 150 | # eg: `fanfou.statuses.user_timeline._id(_id='ifanfou')` or 151 | # `fanfou.statuses.user_timeline(id='ifanfou')` to 152 | # `statuses/user_timeline/ifanfou` 153 | return uri 154 | 155 | 156 | class FanfouCall(object): 157 | # Delay after HTTP codes 502, 503 or 504. 158 | FANFOU_UNAVAILABLE_WAIT = 30 159 | 160 | def __init__( 161 | self, auth, format, domain, callable_cls, uri='', 162 | uriparts=None, secure=False, timeout=None, gzip=False): 163 | self.auth = auth 164 | self.format = format 165 | self.domain = domain 166 | self.callable_cls = callable_cls 167 | self.uri = uri 168 | self.uriparts = uriparts 169 | self.secure = secure 170 | self.timeout = timeout 171 | self.gzip = gzip 172 | 173 | # object.__getattr__(self, name) 174 | # Called when an attribute lookup has not found the attribute in the 175 | # usual places (eg: it is not an instance attribute nor is it found in 176 | # the class tree for `self`). `name` is the attribute name. 177 | # This method should return the (computed) attribute value or 178 | # raise an `AttributeError` exception. 179 | # See: https://docs.python.org/3/reference/datamodel.html#object.__getattr__ 180 | def __getattr__(self, k): 181 | try: 182 | return object.__getattr__(self, k) 183 | except AttributeError: 184 | # eg: `fanfou.statuses.update` will raise this exception, 185 | # attribute then add to `uriparts`. 186 | def extend_call(arg): 187 | return self.callable_cls( 188 | auth=self.auth, format=self.format, domain=self.domain, 189 | callable_cls=self.callable_cls, secure=self.secure, 190 | timeout=self.timeout, gzip=self.gzip, 191 | uriparts=self.uriparts + (arg,)) 192 | if k == '_': 193 | return extend_call 194 | else: 195 | return extend_call(k) 196 | 197 | # object.__call__(self[, args...]) 198 | # Called when the instance is "called" as a function; if this method is defined, 199 | # `x(arg1, arg2, ...)` is a shorthand for `x.__call__(arg1, arg2, ...)`. 200 | # See: https://docs.python.org/3/reference/datamodel.html#object.__call__ 201 | def __call__(self, **kwargs): 202 | kwargs = dict(kwargs) 203 | uri = build_uri(self.uriparts, kwargs) 204 | method = kwargs.pop('_method', None) or method_for_uri(uri) 205 | domain = self.domain 206 | 207 | # If an _id kwarg is present, this is treated as id as a CGI 208 | # param. 209 | _id = kwargs.pop('_id', None) 210 | if _id: 211 | kwargs['id'] = _id 212 | 213 | # If an _timeout is specified in kwargs, use it. 214 | _timeout = kwargs.pop('_timeout', None) 215 | 216 | secure_str = 's' if self.secure else '' 217 | dot = '.' if self.format else '' 218 | 219 | # eg: http://api.fanfou.com/1.1/statuses/update.json 220 | url_base = 'http{}://{}/{}{}{}'.format( 221 | secure_str, domain, uri, dot, self.format) 222 | 223 | photo = kwargs.pop('photo', None) 224 | 225 | headers = {'Accept-Encoding': 'gzip'} if self.gzip else dict() 226 | body = None 227 | arg_data = None 228 | 229 | if self.auth: 230 | headers.update(self.auth.generate_headers()) 231 | # Because the method uses multipart POST, OAuth is handled a 232 | # little differently. POST or query string parameters are not 233 | # used when calculating an OAuth signature basestring or signature. 234 | arg_data = self.auth.encode_params( 235 | url_base, method, {} if photo else kwargs) 236 | if method == 'GET' or photo: 237 | url_base += '?' + arg_data 238 | else: 239 | body = arg_data.encode('utf-8') 240 | 241 | # See: http://www.ietf.org/rfc/rfc1867.txt 242 | if photo: 243 | BOUNDARY = b'###Python-Fanfou###' 244 | bod = [] 245 | bod.append(b'--' + BOUNDARY) 246 | # Never omit `filename`, otherwise will meet 247 | # 'lack of photo parameter' or else errors. 248 | bod.append( 249 | b'Content-Disposition: form-data; name="photo"; ' 250 | + b'filename="filename"') 251 | bod.append(b'Content-Type: application/octet-stream') 252 | bod.append(b'') 253 | bod.append(actually_bytes(photo)) 254 | for k, v in kwargs.items(): 255 | k = actually_bytes(k) 256 | v = actually_bytes(v) 257 | bod.append(b'--' + BOUNDARY) 258 | bod.append( 259 | b'Content-Disposition: form-data; name="' + k + b'"') 260 | bod.append(b'Content-Type: text/plain;charset=utf-8') 261 | bod.append(b'') 262 | bod.append(v) 263 | bod.append(b'--' + BOUNDARY + b'--') 264 | bod.append(b'') 265 | bod.append(b'') 266 | body = b'\r\n'.join(bod) 267 | headers['Content-Type'] = \ 268 | b'multipart/form-data; boundary=' + BOUNDARY 269 | 270 | if not PY3: 271 | url_base = url_base.encode('utf-8') 272 | for k in headers: 273 | headers[actually_bytes(k)] = actually_bytes(headers.pop(k)) 274 | 275 | # `url_base` eg: http://api.fanfou.com/statuses/user_timeline.json? 276 | # id=ifanfou&oauth_consumer_key=&oauth_nonce= 277 | # &oauth_signature_method=HMAC-SHA1&oauth_timestamp= 278 | # &oauth_token=&oauth_version=1.0&oauth_signature= 279 | # or `http://api.fanfou.com/statuses/update.json` 280 | req = urllib_request.Request(url_base, data=body, headers=headers) 281 | return self._handle_response(req, uri, arg_data, _timeout) 282 | 283 | def _handle_response(self, req, uri, arg_data, _timeout=None): 284 | kwargs = {} 285 | if _timeout: 286 | kwargs['timeout'] = _timeout 287 | try: 288 | handle = urllib_request.urlopen(req, **kwargs) 289 | if handle.headers['Content-Type'] in ['image/jpeg', 'image/png', 'image/gif']: 290 | print(handle.headers['Content-Type']) 291 | return handle 292 | try: 293 | data = handle.read() 294 | except http_client.IncompleteRead as e: 295 | # Even if we don't get all the bytes we should have there 296 | # may be a complete response in e.partial 297 | data = e.partial 298 | if handle.info().get('Content-Encoding') == 'gzip': 299 | # Handle gzip decompression. 300 | buf = BytesIO(data) 301 | f = gzip.GzipFile(fileobj=buf) 302 | data = f.read() 303 | if len(data) == 0: 304 | return wrap_response({}, handle.headers) 305 | elif 'json' == self.format: 306 | res = json.loads(data.decode('utf-8')) 307 | return wrap_response(res, handle.headers) 308 | else: 309 | return wrap_response( 310 | data.decode('utf-8'), handle.headers) 311 | except urllib_error.HTTPError as e: 312 | if (e.code == 304): 313 | return [] 314 | else: 315 | raise FanfouHTTPError(e, uri, self.format, arg_data) 316 | 317 | 318 | class Fanfou(FanfouCall): 319 | """Examples:: 320 | 321 | from fanpy import * 322 | 323 | f = Fanfou(auth=OAuth(oauth_token, oauth_token_secret, consumer_key, consumer_secret)) 324 | 325 | # Get your home timeline 326 | f.statuses.home_timeline() 327 | 328 | # Get a particular friend's timeline 329 | f.statuses.user_timeline(_id='ifanfou') 330 | 331 | # To pass in GET/POST parameters, such as `count` 332 | f.statuses.home_timeline(count=5) 333 | 334 | # Update your status 335 | f.statuses.update(status='Hello, world!') 336 | 337 | # Send a direct message 338 | f.direct_messages.new(user='ifanfou', text='I miss you!') 339 | 340 | # An *optional* `_timeout` parameter can also be used for API 341 | # calls which take much more time than normal: 342 | f.search.public_timeline(q='|'.join(A_LIST_OF_100_WORDS), _timeout=1) 343 | 344 | # Overriding Method: GET/POST 345 | # you should not need to use this method as this library properly 346 | # detects whether GET or POST should be used, Nevertheless 347 | # to force a particular method, use `_method` 348 | t.statuses.update(status='Hello, world!', _method='POST') 349 | 350 | 351 | # Send image with your status: 352 | # - Just read image from the web or from file the regular way: 353 | with open('example.png', 'rb') as imagefile: 354 | imagedata = imagefile.read() 355 | # - Then send the image with a status. 356 | fanfou.photos.upload(photo=imagedata, status='Upload image.') 357 | 358 | 359 | Using the data returned 360 | ----------------------- 361 | 362 | Fanfou API calls return decoded JSON. This is converted into 363 | a bunch of Python lists, dicts, ints, and strings. For example:: 364 | 365 | x = fanfou.statuses.home_timeline() 366 | 367 | # The first status in the timeline 368 | x[0] 369 | 370 | # The name of the user who wrote the first status 371 | x[0]['user']['name'] 372 | 373 | 374 | Getting raw XML data 375 | -------------------- 376 | 377 | If you prefer to get your Fanfou data in XML format, pass 378 | format='xml' to the Fanfou object when you instantiate it:: 379 | 380 | fanfou = Fanfou(format='xml') 381 | 382 | The output will not be parsed in any way. It will be a raw string 383 | of XML. 384 | """ 385 | 386 | def __init__( 387 | self, auth=None, format='json', 388 | domain='api.fanfou.com', secure=False, 389 | api_version=None): 390 | """ 391 | Create a new fanfou API connector. 392 | 393 | Pass an `auth` parameter to use the credentials of a specific 394 | user. Generally you'll want to pass an `OAuth` 395 | instance:: 396 | 397 | fanfou = Fanfou(auth=OAuth( 398 | token, token_secret, consumer_key, consumer_secret)) 399 | 400 | 401 | `domain` lets you change the domain you are connecting. By 402 | default it's `api.fanfou.com`. 403 | 404 | If `secure` is False you will connect with HTTP instead of 405 | HTTPS. (Fanfou doesn't support HTTPS until now.) 406 | 407 | `api_version` is used to set the base uri. By default it's 408 | None. 409 | """ 410 | if not auth: 411 | auth = NoAuth() 412 | 413 | if (format not in ('json', 'xml', '')): 414 | raise ValueError('Unknown data format "{}"'.format(format)) 415 | 416 | if api_version is _DEFAULT: 417 | api_version = '1.1' 418 | 419 | uriparts = () 420 | if api_version: 421 | uriparts += (str(api_version),) 422 | 423 | FanfouCall.__init__( 424 | self, auth=auth, format=format, domain=domain, 425 | callable_cls=FanfouCall, 426 | secure=secure, uriparts=uriparts) 427 | 428 | 429 | # If a package's `__init__.py` code defines a list named `__all__`, 430 | # it is taken to be the list of module names that should be imported 431 | # when `from package import *` is encountered. 432 | # See: https://docs.python.org/3/tutorial/modules.html#importing-from-a-package 433 | __all__ = ['Fanfou', 'FanfouError', 'FanfouHTTPError', 'FanfouResponse'] 434 | -------------------------------------------------------------------------------- /fanpy/archiver.py: -------------------------------------------------------------------------------- 1 | """USAGE 2 | fanpy-archiver [options] <-|user> [ ...] 3 | 4 | DESCRIPTION 5 | Archive statuses of users, sorted by date from oldest to newest, in 6 | the following format: <> 7 | Date format is: YYYY-MM-DD HH:MM:SS TZ. Status is used to 8 | resume archiving on next run. Archive file name is the user name. 9 | Provide "-" instead of users to read users from standard input. 10 | 11 | OPTIONS 12 | -s --save-dir directory to save archives (default: current dir) 13 | -t --timeline archive own timeline into given file name 14 | -m --mentions archive own mentions into given file name 15 | -p --privatemsg archive own private messages (both received and 16 | sent) into given file name. 17 | -f --favorites archive user's favorites instead of timeline 18 | -i --isoformat store dates in ISO format (specifically RFC 3339) 19 | 20 | AUTHENTICATION 21 | Authenticate to Fanfou using OAuth. OAuth authentication tokens are stored 22 | in ~/.fanfou_oauth. 23 | """ 24 | from __future__ import print_function, unicode_literals 25 | 26 | import codecs 27 | import datetime 28 | import os 29 | import sys 30 | import time 31 | from getopt import gnu_getopt, GetoptError 32 | 33 | from .api import Fanfou, FanfouError 34 | from .oauth import OAuth, read_token_file 35 | from .oauth_dance import oauth_dance 36 | from .util import Fail 37 | 38 | 39 | # Registered by mookrs 40 | CONSUMER_KEY = '1469b495a824c7abb2bf9fd2c75930e8' 41 | CONSUMER_SECRET = '9095f46ecf5ede903fa79a57263fd153' 42 | 43 | 44 | def parse_args(args, options): 45 | """Parse arguments from command-line to set options.""" 46 | short_opts = 's:t:m:fp:ih' 47 | long_opts = ['save-dir=', 'timeline=', 'mentions=', 48 | 'favorites', 'privatemsg=', 'isoformat', 'help'] 49 | opts, extra_args = gnu_getopt(args, short_opts, long_opts) 50 | 51 | for opt, arg in opts: 52 | if opt in ('-s', '--save-dir'): 53 | options['save-dir'] = arg 54 | elif opt in ('-t', '--timeline'): 55 | options['timeline'] = arg 56 | elif opt in ('-m', '--mentions'): 57 | options['mentions'] = arg 58 | elif opt in ('-f', '--favorites'): 59 | options['favorites'] = True 60 | elif opt in ('-p', '--privatemsg'): 61 | options['privatemsg'] = arg 62 | elif opt in ('-i', '--isoformat'): 63 | options['isoformat'] = True 64 | elif opt in ('-h', '--help'): 65 | print(__doc__) 66 | sys.exit(0) 67 | 68 | options['extra_args'] = extra_args 69 | 70 | 71 | def save_statuses(filename, statuses): 72 | """Save statuses from list to file. 73 | 74 | Save statuses from list to file, one per line: 75 | <> 76 | 77 | :param filename: A string representing the file name to save statuses to 78 | :param statuses: A status text list 79 | """ 80 | if not statuses: 81 | return 82 | 83 | try: 84 | archive = codecs.open(filename, 'w', encoding='utf-8') 85 | for s in reversed(statuses): 86 | archive.write('{}\n'.format(s)) 87 | except IOError as e: 88 | print('Cannot save statuses: {}'.format(e)) 89 | return 90 | else: 91 | archive.close() 92 | 93 | 94 | def format_date(created_at, isoformat=False): 95 | """Parse Fanfou's UTC date.""" 96 | t = time.strptime(created_at, '%a %b %d %H:%M:%S +0000 %Y') 97 | timezones = time.timezone if not time.daylight else time.altzone 98 | dt = datetime.datetime(*t[:-3]) - datetime.timedelta(seconds=timezones) 99 | t = dt.timetuple() 100 | if isoformat: 101 | return dt.isoformat() 102 | else: 103 | return time.strftime('%Y-%m-%d %H:%M:%S %Z', t) 104 | 105 | 106 | def format_text(text): 107 | """Transform special chars in text to have only one line.""" 108 | return text.replace('\n', '\\n').replace('\r', '\\r') 109 | 110 | 111 | def get_statuses_portion(fanfou, user_id, max_id=None, mentions=None, received_privatemsg=None, 112 | favorites=False, isoformat=False): 113 | """Get a portion of the statuses of a screen name.""" 114 | kwargs = dict(id=user_id, count=60, mode='lite') 115 | if max_id: 116 | kwargs['max_id'] = max_id 117 | 118 | statuses = [] 119 | if mentions: 120 | status_list = fanfou.statuses.mentions(**kwargs) 121 | elif received_privatemsg is not None: 122 | if received_privatemsg: 123 | status_list = fanfou.direct_messages.inbox(**kwargs) 124 | else: 125 | status_list = fanfou.direct_messages.sent(**kwargs) 126 | elif favorites: 127 | status_list = fanfou.favorites(**kwargs) 128 | else: 129 | status_list = fanfou.statuses.user_timeline(**kwargs) 130 | 131 | for s in status_list: 132 | text = s['text'] 133 | max_id = s['id'] 134 | if received_privatemsg is None: 135 | statuses.append( 136 | '{} {} <{}> {}'.format( 137 | max_id, 138 | format_date(s['created_at'], isoformat=isoformat), 139 | s['user']['screen_name'], 140 | format_text(text))) 141 | else: 142 | statuses.append( 143 | '{} {} <{}> @{} {}'.format( 144 | max_id, 145 | format_date(s['created_at'], isoformat=isoformat), 146 | s['sender_screen_name'], 147 | s['recipient_screen_name'], 148 | format_text(text))) 149 | return statuses, max_id 150 | 151 | 152 | def get_statuses(fanfou, user_id, mentions=None, received_privatemsg=None, 153 | favorites=False, isoformat=False): 154 | """Get all the statuses for a user id.""" 155 | statuses = [] 156 | max_id = None 157 | fail = Fail() 158 | 159 | while True: 160 | try: 161 | portion, max_id = get_statuses_portion( 162 | fanfou, user_id, max_id, mentions, received_privatemsg, favorites, isoformat) 163 | except FanfouError as e: 164 | if e.e.code == 401: 165 | print('Fail: {} Unauthorized (statuses of that user are protected)'.format( 166 | e.e.code)) 167 | break 168 | elif e.e.code == 404: 169 | print('Fail: {} This profile does not exist'.format(e.e.code)) 170 | break 171 | else: 172 | print('Fail: {}\nRetrying...'.format(e[:500])) 173 | fail.wait(3) 174 | except KeyError as e: 175 | print('Fail: KeyError {} - Retrying...'.format(e)) 176 | fail.wait(3) 177 | except KeyboardInterrupt: 178 | print('\n[Keyboard Interrupt]', file=sys.stderr) 179 | sys.exit(1) 180 | else: 181 | statuses.extend(portion) 182 | num = len(portion) 183 | if (num == 0) or (favorites and num < 60): 184 | break 185 | print('Browsing {} statuses ({})'.format(user_id if user_id else 'home', num)) 186 | fail = Fail() 187 | 188 | return statuses 189 | 190 | 191 | def main(args=sys.argv[1:]): 192 | options = { 193 | 'save-dir': '.', 194 | 'timeline': '', 195 | 'mentions': '', 196 | 'privatemsg': '', 197 | 'favorites': False, 198 | 'isoformat': False, 199 | } 200 | try: 201 | parse_args(args, options) 202 | except GetoptError as e: 203 | print("I can't do that, {}.".format(e), file=sys.stderr) 204 | sys.exit(1) 205 | 206 | if (not options['extra_args'] and 207 | not (options['timeline'] or options['mentions'] or options['privatemsg'])): 208 | print(__doc__) 209 | sys.exit(1) 210 | 211 | oauth_filename = os.environ.get('HOME', os.environ.get('USERPROFILE', '')) + os.sep + '.fanfou_oauth' 212 | if not os.path.exists(oauth_filename): 213 | oauth_dance('Fanfou-Archiver', CONSUMER_KEY, CONSUMER_SECRET, oauth_filename) 214 | oauth_token, oauth_token_secret = read_token_file(oauth_filename) 215 | fanfou = Fanfou(auth=OAuth(oauth_token, oauth_token_secret, CONSUMER_KEY, CONSUMER_SECRET)) 216 | 217 | if options['timeline']: 218 | filename = options['save-dir'] + os.sep + options['timeline'] 219 | print('* Archiving own timeline in {}'.format(filename)) 220 | 221 | statuses = get_statuses(fanfou, user_id='', isoformat=options['isoformat']) 222 | 223 | save_statuses(filename, statuses) 224 | print('Total statuses in own timeline: {}'.format(len(statuses))) 225 | 226 | if options['mentions']: 227 | filename = options['save-dir'] + os.sep + options['mentions'] 228 | print('* Archiving own mentions in {}'.format(filename)) 229 | 230 | statuses = get_statuses(fanfou, user_id='', mentions=options['mentions'], 231 | isoformat=options['isoformat']) 232 | 233 | save_statuses(filename, statuses) 234 | print('Total mentions: {}'.format(len(statuses))) 235 | 236 | if options['privatemsg']: 237 | filename = options['save-dir'] + os.sep + options['privatemsg'] 238 | print('* Archiving own private messages in {}'.format(filename)) 239 | 240 | msg_received = get_statuses(fanfou, user_id='', received_privatemsg=True, 241 | isoformat=options['isoformat']) 242 | msg_sent = get_statuses(fanfou, user_id='', received_privatemsg=False, 243 | isoformat=options['isoformat']) 244 | msg = msg_received + msg_sent 245 | 246 | save_statuses(filename, msg) 247 | print('Total private messages received and sent: {}'.format(len(msg))) 248 | 249 | # Read users from command-line or stdin 250 | users = options['extra_args'] 251 | if len(users) == 1 and users[0] == '-': 252 | users = [line.strip() for line in sys.stdin.readlines() if line.strip()] 253 | total = 0 254 | for user in users: 255 | filename = options['save-dir'] + os.sep + user 256 | if options['favorites']: 257 | filename = filename + '-favorites' 258 | print('* Archiving {} statuses in {}'.format(user, filename)) 259 | 260 | statuses = get_statuses(fanfou, user, favorites=options['favorites'], 261 | isoformat=options['isoformat']) 262 | 263 | save_statuses(filename, statuses) 264 | total += len(statuses) 265 | print('Total statuses for {}: {}'.format(user, len(statuses))) 266 | 267 | if users: 268 | print('Total: {} statuses for {} users'.format(total, len(users))) 269 | 270 | 271 | if __name__ == '__main__': 272 | main() 273 | -------------------------------------------------------------------------------- /fanpy/auth.py: -------------------------------------------------------------------------------- 1 | try: 2 | from urllib.parse import urlencode 3 | except ImportError: 4 | from urllib import urlencode 5 | 6 | 7 | class Auth(object): 8 | """ABC for Authenticator objects.""" 9 | def encode_params(self, base_url, method, params): 10 | """ 11 | Encode parameters for a request suitable for including in a URL 12 | or POST body. This method may also add new params to the request 13 | if required by the authentication scheme in use. 14 | """ 15 | raise NotImplementedError() 16 | 17 | def generate_headers(self): 18 | """ 19 | Generates headers which should be added to the request if required 20 | by the authentication scheme in use. 21 | """ 22 | raise NotImplementedError() 23 | 24 | 25 | class NoAuth(Auth): 26 | """No authentication authenticator.""" 27 | def __init__(self): 28 | pass 29 | 30 | def encode_params(self, base_url, method, params): 31 | return urlencode(params) 32 | 33 | def generate_headers(self): 34 | return {} 35 | 36 | 37 | class MissingCredentialsError(Exception): 38 | pass 39 | -------------------------------------------------------------------------------- /fanpy/cli.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | USAGE: 4 | 5 | fanpy [action] [options] 6 | 7 | 8 | ACTIONS: 9 | authorize authorize the command-line tool to interact with Fanfou 10 | follow follow a user 11 | friends get latest statuses from your friends (default action) 12 | help print this help text that you are currently reading 13 | leave stop following a user 14 | replies get latest replies to you 15 | search search fanfou (Beware: octothorpe, escape it) 16 | set set your fanfou status 17 | repl begin a read-eval-print loops with a configured fanfou 18 | object 19 | 20 | OPTIONS: 21 | 22 | -r --refresh run this command forever, polling every once 23 | in a while (default: every 5 minutes) 24 | -R --refresh-rate set the refresh rate (in seconds) 25 | -f --format specify the output format for status updates 26 | -c --config read options from given config file 27 | (default ~/.fanfou) 28 | -l --length specify number of status updates shown 29 | (default: 20, max: 60) 30 | -t --timestamp show time before status lines 31 | -d --datestamp show date before status lines 32 | --oauth filename to read/store oauth credentials 33 | 34 | FORMATS for the --format option 35 | 36 | default one line per status 37 | verbose multiple lines per status, more verbose status info 38 | json raw json data from the api on each line 39 | urls nothing but URLs 40 | ansi ansi color (rainbow mode) 41 | 42 | 43 | CONFIG FILES 44 | 45 | The config file should be placed in your home directory and be named .fanfou. 46 | It must contain a [fanfou] header, and all the desired options you wish to 47 | set, like so: 48 | 49 | [fanfou] 50 | format: 51 | timestamp: true 52 | 53 | OAuth authentication tokens are stored in the file `.fanfou_oauth` in your 54 | home directory. 55 | """ 56 | 57 | from __future__ import print_function, unicode_literals 58 | 59 | try: 60 | input = raw_input 61 | except NameError: 62 | pass 63 | 64 | import code 65 | import datetime 66 | from getopt import gnu_getopt, GetoptError 67 | import json 68 | import locale 69 | import os.path 70 | import re 71 | import sys 72 | import time 73 | 74 | try: 75 | from configparser import ConfigParser 76 | except ImportError: 77 | from ConfigParser import SafeConfigParser as ConfigParser 78 | try: 79 | from urllib.parse import quote 80 | except ImportError: 81 | from urllib2 import quote 82 | try: 83 | import HTMLParser 84 | except ImportError: 85 | import html.parser as HTMLParser 86 | 87 | from . import ansi 88 | from .api import Fanfou, FanfouError 89 | from .oauth import OAuth, read_token_file 90 | from .oauth_dance import oauth_dance 91 | from .util import print_nicely 92 | 93 | # Registered by mookrs 94 | CONSUMER_KEY = '1469b495a824c7abb2bf9fd2c75930e8' 95 | CONSUMER_SECRET = '9095f46ecf5ede903fa79a57263fd153' 96 | 97 | OPTIONS = { 98 | 'action': 'friends', 99 | 'refresh': False, 100 | 'refresh-rate': 300, 101 | 'format': 'default', 102 | 'length': 20, 103 | 'timestamp': False, 104 | 'datestamp': False, 105 | 'config-filename': os.environ.get('HOME', os.environ.get('USERPROFILE', '')) + 106 | os.sep + '.fanfou', 107 | 'oauth-filename': os.environ.get('HOME', os.environ.get('USERPROFILE', '')) + 108 | os.sep + '.fanfou_oauth', 109 | 'extra-args': [], 110 | 'invert-split': False, 111 | 'force-ansi': False, 112 | } 113 | 114 | html_parser = HTMLParser.HTMLParser() 115 | hashtag_re = re.compile(r'(?P#\S+#)') 116 | profile_re = re.compile(r'(?P\@\S+)') 117 | 118 | 119 | def parse_args(args): 120 | options = {} 121 | 122 | short_opts = 'rR:f:l:tdc:h' 123 | long_opts = ['refresh', 'refresh-rate=', 124 | 'format=', 'length=', 'timestamp', 'datestamp', 125 | 'config=', 'oauth=', 'help', 'invert-split', 'force-ansi'] 126 | opts, extra_args = gnu_getopt(args, short_opts, long_opts) 127 | # decode Non-ASCII args for Python 2 128 | if extra_args and hasattr(extra_args[0], 'decode'): 129 | extra_args = [arg.decode(locale.getpreferredencoding()) for arg in extra_args] 130 | 131 | for opt, arg in opts: 132 | if opt in ('-r', '--refresh'): 133 | options['refresh'] = True 134 | elif opt in ('-R', '--refresh-rate'): 135 | options['refresh-rate'] = int(arg) 136 | elif opt in ('-f', '--format'): 137 | options['format'] = arg 138 | elif opt in ('-l', '--length'): 139 | options['length'] = int(arg) 140 | elif opt in ('-t', '--timestamp'): 141 | options['timestamp'] = True 142 | elif opt in ('-d', '--datestamp'): 143 | options['datestamp'] = True 144 | elif opt in ('-c', '--config'): 145 | options['config-filename'] = arg 146 | elif opt == '--oauth': 147 | options['oauth-filename'] = arg 148 | elif opt in ('-h', '--help'): 149 | options['action'] = 'help' 150 | elif opt == '--invert-split': 151 | options['invert-split'] = True 152 | elif opt == '--force-ansi': 153 | options['force-ansi'] = True 154 | 155 | if extra_args and 'action' not in options: 156 | options['action'] = extra_args[0] 157 | options['extra-args'] = extra_args[1:] 158 | 159 | return options 160 | 161 | 162 | def load_config(filename): 163 | options = {} 164 | if os.path.exists(filename): 165 | cp = ConfigParser() 166 | cp.read(filename) 167 | if cp.has_section('fanfou'): 168 | for key in cp['fanfou']: 169 | if key in ('refresh', 'timestamp', 'datestamp', 'invert-split', 'force-ansi'): 170 | options[key] = cp.getboolean('fanfou', key) 171 | elif key in ('refresh-rate', 'length'): 172 | options[key] = cp.getint('fanfou', key) 173 | elif key in ('format', 'config-filename', 'oauth-filename', 'action'): 174 | options[key] = cp.get('fanfou', key) 175 | return options 176 | 177 | 178 | def get_time_string(created_at, options, format='%a %b %d %H:%M:%S +0000 %Y'): 179 | is_timestamp = options['timestamp'] 180 | is_datestamp = options['datestamp'] 181 | t = time.strptime(created_at, format) 182 | timezones = time.timezone if not time.daylight else time.altzone 183 | dt = datetime.datetime(*t[:-3]) - datetime.timedelta(seconds=timezones) 184 | t = dt.timetuple() 185 | if is_timestamp and is_datestamp: 186 | return time.strftime('%Y-%m-%d %H:%M:%S ', t) 187 | elif is_timestamp: 188 | return time.strftime('%H:%M:%S ', t) 189 | elif is_datestamp: 190 | return time.strftime('%Y-%m-%d ', t) 191 | return '' 192 | 193 | 194 | class StatusFormatter(object): 195 | def __call__(self, status, options): 196 | return '{}@{} {}'.format( 197 | get_time_string(status['created_at'], options), 198 | status['user']['screen_name'], 199 | html_parser.unescape(status['text'])) 200 | 201 | 202 | class VerboseStatusFormatter(object): 203 | def __call__(self, status, options): 204 | return '-- {} on {}\n{}\n'.format( 205 | status['user']['screen_name'], 206 | status['created_at'], 207 | html_parser.unescape(status['text'])) 208 | 209 | 210 | class JSONStatusFormatter(object): 211 | def __call__(self, status, options): 212 | status['text'] = html_parser.unescape(status['text']) 213 | return json.dumps(status) 214 | 215 | 216 | class URLStatusFormatter(object): 217 | def __call__(self, status, options): 218 | url_re = re.compile(r'https?://\S+') 219 | urls = url_re.findall(status['text']) 220 | return '\n'.join(urls) if urls else '' 221 | 222 | 223 | class AnsiStatusFormatter(object): 224 | def __init__(self): 225 | self.color_map = ansi.ColorMap() 226 | 227 | def __call__(self, status, options): 228 | color = self.color_map.color_for(status['user']['screen_name']) 229 | return '{}{}{}{} {} '.format( 230 | get_time_string(status['created_at'], options), 231 | ansi_formatter.cmd_color(color), 232 | status['user']['screen_name'], 233 | ansi_formatter.cmd_reset(), 234 | self.replace_in_status(status['text'])) 235 | 236 | def replace_in_status(self, status): 237 | txt = html_parser.unescape(status) 238 | txt = re.sub(hashtag_re, self.repl, txt) 239 | txt = re.sub(profile_re, self.repl, txt) 240 | return txt 241 | 242 | def repl(self, match): 243 | ansi_types = { 244 | 'clear': ansi_formatter.cmd_reset(), 245 | 'hashtag': ansi_formatter.cmd_bold(), 246 | 'profile': ansi_formatter.cmd_underline(), 247 | } 248 | 249 | s = None 250 | try: 251 | key = match.lastgroup 252 | if match.group(key): 253 | s = '{}{}{}'.format(ansi_types[key], match.group(key), ansi_types['clear']) 254 | except IndexError: 255 | pass 256 | return s 257 | 258 | 259 | class AdminFormatter(object): 260 | def __call__(self, action, user): 261 | user_str = '{} ({})'.format(user['screen_name'], user['id']) 262 | if action == 'follow': 263 | return 'You are now following {}.\n'.format(user_str) 264 | else: 265 | return 'You are no longer following {}.\n'.format(user_str) 266 | 267 | 268 | class VerboseAdminFormatter(object): 269 | def __call__(self, action, user): 270 | return('-- {}: {} ({})'.format( 271 | 'Following' if action == 'follow' else 'Leaving', 272 | user['screen_name'], 273 | user['id'])) 274 | 275 | 276 | class JSONAdminFormatter(object): 277 | def __call__(self, action, user): 278 | return json.dumps(user) 279 | 280 | 281 | formatters = {} 282 | status_formatters = { 283 | 'default': StatusFormatter, 284 | 'verbose': VerboseStatusFormatter, 285 | 'json': JSONStatusFormatter, 286 | 'urls': URLStatusFormatter, 287 | 'ansi': AnsiStatusFormatter 288 | } 289 | formatters['status'] = status_formatters 290 | 291 | admin_formatters = { 292 | 'default': AdminFormatter, 293 | 'verbose': VerboseAdminFormatter, 294 | 'json': JSONAdminFormatter, 295 | 'urls': AdminFormatter, 296 | 'ansi': AdminFormatter 297 | } 298 | formatters['admin'] = admin_formatters 299 | 300 | formatters['search'] = status_formatters 301 | 302 | 303 | def get_formatter(action_type, options): 304 | formatters_dict = formatters.get(action_type) 305 | if not formatters_dict: 306 | raise FanfouError( 307 | 'There was an error finding a class of formatters for your type ({})'.format( 308 | action_type)) 309 | f = formatters_dict.get(options['format']) 310 | if not f: 311 | raise FanfouError( 312 | "Unknown formatter '{}' for {} action".format(options['format'], action_type)) 313 | return f() 314 | 315 | 316 | class Action(object): 317 | def ask(self, subject='perform this action', careful=False): 318 | """Requests from the user using `input` if `subject` should be 319 | performed. When `careful`, the default answer is NO, otherwise YES. 320 | Returns the user answer in the form `True` or `False`. 321 | """ 322 | sample = '(y/N)' if careful else '(Y/n)' 323 | prompt = 'You really want to {} {}? '.format(subject, sample) 324 | try: 325 | answer = input(prompt).lower() 326 | if careful: 327 | return answer in ('yes', 'y') 328 | else: 329 | return answer not in ('no', 'n') 330 | except EOFError: 331 | print(file=sys.stderr) 332 | default = False if careful else True 333 | return default 334 | 335 | def __call__(self, fanfou, options): 336 | action = ACTIONS.get(options['action'], NoSuchAction)() 337 | try: 338 | do_action = lambda: action(fanfou, options) 339 | if options['refresh']: 340 | while True: 341 | do_action() 342 | sys.stdout.flush() 343 | time.sleep(options['refresh-rate']) 344 | else: 345 | do_action() 346 | except KeyboardInterrupt: 347 | print('\n[Keyboard Interrupt]', file=sys.stderr) 348 | 349 | 350 | class NoSuchActionError(Exception): 351 | pass 352 | 353 | 354 | class NoSuchAction(Action): 355 | def __call__(self, fanfou, options): 356 | raise NoSuchActionError('No such action: {}'.format(options['action'])) 357 | 358 | 359 | class DoNothingAction(Action): 360 | def __call__(self, fanfou, options): 361 | pass 362 | 363 | 364 | class StatusAction(Action): 365 | def __call__(self, fanfou, options): 366 | statuses = self.get_statuses(fanfou, options) 367 | fmt = get_formatter('status', options) 368 | for status in statuses: 369 | status = fmt(status, options) 370 | if status.strip(): 371 | print_nicely(status) 372 | 373 | 374 | class FriendsAction(StatusAction): 375 | def get_statuses(self, fanfou, options): 376 | return reversed(fanfou.statuses.home_timeline(count=options['length'])) 377 | 378 | 379 | class RepliesAction(StatusAction): 380 | def get_statuses(self, fanfou, options): 381 | return reversed(fanfou.statuses.mentions(count=options['length'])) 382 | 383 | 384 | class AdminAction(Action): 385 | def __call__(self, fanfou, options): 386 | if not (options['extra-args'] and options['extra-args'][0]): 387 | raise FanfouError('You need to specify a user (user_id)') 388 | fmt = get_formatter('admin', options) 389 | try: 390 | user = self.get_user(fanfou, options['extra-args'][0]) 391 | except FanfouError as e: 392 | print('There was a problem following or leaving the specified user.') 393 | print('- You may be trying to follow a user you are already following;') 394 | print('- Leaving a user you are not currently following;') 395 | print('- Or the user may not exist.') 396 | print() 397 | print(e) 398 | else: 399 | print_nicely(fmt(options['action'], user)) 400 | 401 | 402 | class FollowAction(AdminAction): 403 | def get_user(self, fanfou, user_id): 404 | return fanfou.friendships.create(_id=user_id) 405 | 406 | 407 | class LeaveAction(AdminAction): 408 | def get_user(self, fanfou, user_id): 409 | return fanfou.friendships.destroy(_id=user_id) 410 | 411 | 412 | class SearchAction(Action): 413 | def __call__(self, fanfou, options): 414 | try: 415 | query_string = '+'.join(map(quote, options['extra-args'])) 416 | except KeyError: 417 | # Python 2 thorws KeyError 418 | query_string = '+'.join([quote(term.encode(locale.getpreferredencoding())) 419 | for term in options['extra-args']]) 420 | 421 | results = fanfou.search.public_timeline(q=query_string) 422 | fmt = get_formatter('search', options) 423 | for result in results: 424 | result = fmt(result, options) 425 | if result.strip(): 426 | print_nicely(result) 427 | 428 | 429 | class SetStatusAction(Action): 430 | def __call__(self, fanfou, options): 431 | status_text = ' '.join( 432 | options['extra-args']) if options['extra-args'] else input('message: ') 433 | splitted = [] 434 | while status_text: 435 | splitted.append(status_text[:140]) 436 | status_text = status_text[140:] 437 | 438 | if options['invert-split']: 439 | splitted.reverse() 440 | for status in splitted: 441 | fanfou.statuses.update(status=status) 442 | 443 | 444 | class HelpAction(Action): 445 | def __call__(self, fanfou, options): 446 | print(__doc__) 447 | 448 | 449 | class ReplAction(Action): 450 | def __call__(self, fanfou, options): 451 | print_nicely( 452 | "\nUse the 'fanfou' object to interact with the Fanfou REST API.\n\n") 453 | code.interact(local={'fanfou': fanfou, 'f': fanfou}) 454 | 455 | 456 | ACTIONS = { 457 | 'authorize': DoNothingAction, 458 | 'follow': FollowAction, 459 | 'friends': FriendsAction, 460 | 'help': HelpAction, 461 | 'leave': LeaveAction, 462 | 'replies': RepliesAction, 463 | 'search': SearchAction, 464 | 'set': SetStatusAction, 465 | 'repl': ReplAction, 466 | } 467 | 468 | 469 | def main(args=sys.argv[1:]): 470 | try: 471 | arg_options = parse_args(args) 472 | except GetoptError as e: 473 | print("I can't do that, {}.".format(e), file=sys.stderr) 474 | sys.exit(1) 475 | 476 | config_path = os.path.expanduser( 477 | arg_options.get('config-filename') or OPTIONS.get('config-filename')) 478 | config_options = load_config(config_path) 479 | 480 | options = OPTIONS.copy() 481 | for d in config_options, arg_options: 482 | for k, v in d.items(): 483 | options[k] = v 484 | 485 | if options['refresh'] and options['action'] not in ('friends', 'replies'): 486 | print('You can only refresh the friends or replies actions.', file=sys.stderr) 487 | print("Use 'fanpy -h' for help.", file=sys.stderr) 488 | sys.exit(1) 489 | 490 | oauth_filename = os.path.expanduser(options['oauth-filename']) 491 | if options['action'] == 'authorize' or not os.path.exists(oauth_filename): 492 | oauth_dance('The Command-Line Tool', CONSUMER_KEY, CONSUMER_SECRET, oauth_filename) 493 | 494 | global ansi_formatter 495 | ansi_formatter = ansi.AnsiCmd(options['force-ansi']) 496 | 497 | oauth_token, oauth_token_secret = read_token_file(oauth_filename) 498 | fanfou = Fanfou(auth=OAuth(oauth_token, oauth_token_secret, CONSUMER_KEY, CONSUMER_SECRET)) 499 | 500 | try: 501 | Action()(fanfou, options) 502 | except NoSuchActionError as e: 503 | print(e, file=sys.stderr) 504 | sys.exit(1) 505 | except FanfouError as e: 506 | print(str(e), file=sys.stderr) 507 | print("Use 'fanpy -h' for help.", file=sys.stderr) 508 | sys.exit(1) 509 | 510 | 511 | if __name__ == '__main__': 512 | main() 513 | -------------------------------------------------------------------------------- /fanpy/fanfou_globals.py: -------------------------------------------------------------------------------- 1 | """ 2 | List of Fanfou method names that require the use of POST. 3 | """ 4 | 5 | POST_ACTIONS = [ 6 | 7 | # Status Methods 8 | 'update', 9 | 10 | # Direct-messages Methods 11 | 'new', 12 | 13 | # Account Methods 14 | 'update_notify_num', 'update_profile', 'update_profile_image', 15 | 16 | # Blocks Methods, Friendships Methods, Favorites Methods, 17 | # Saved-searches Methods 18 | 'create', 19 | 20 | # Statuses Methods, Blocks Methods, Direct-messages Methods, 21 | # Friendships Methods, Favorites Methods, Saved-searches Methods 22 | 'destroy', 23 | 24 | # Friendships Methods 25 | 'accept', 'deny', 26 | 27 | # Users Methods 28 | 'cancel_recommendation', 29 | 30 | # Photo Methods 31 | 'upload', 32 | 33 | # OAuth Methods 34 | 'token', 'access_token', 35 | 'request_token', 'invalidate_token', 36 | ] 37 | -------------------------------------------------------------------------------- /fanpy/logger.py: -------------------------------------------------------------------------------- 1 | """ 2 | fanpy-log - Fanfou Logger 3 | 4 | USAGE: 5 | 6 | fanpy-log 7 | 8 | DESCRIPTION: 9 | 10 | Produce a complete archive in text form of a user's statuses. The 11 | archive format is: 12 | 13 | screen_name 14 | Date: 15 | [In-Reply-To: a_status_id] 16 | [Repost: a_status_id] 17 | 18 | Status text possibly spanning multiple lines with 19 | each line indented by four spaces. 20 | 21 | 22 | Each status is separated by two blank lines. 23 | 24 | """ 25 | 26 | from __future__ import print_function, unicode_literals 27 | 28 | import os 29 | import sys 30 | from time import sleep 31 | try: 32 | import HTMLParser 33 | except ImportError: 34 | import html.parser as HTMLParser 35 | 36 | from .api import Fanfou, FanfouError 37 | from .oauth import OAuth, read_token_file 38 | from .oauth_dance import oauth_dance 39 | from .util import print_nicely 40 | 41 | # Registered by mookrs 42 | CONSUMER_KEY = '1469b495a824c7abb2bf9fd2c75930e8' 43 | CONSUMER_SECRET = '9095f46ecf5ede903fa79a57263fd153' 44 | OAUTH_FILENAME = os.environ.get('HOME', os.environ.get('USERPROFILE', '')) + os.sep + '.fanfou_oauth' 45 | html_parser = HTMLParser.HTMLParser() 46 | 47 | 48 | def log_debug(msg): 49 | print(msg, file=sys.stderr) 50 | 51 | 52 | def get_statuses(fanfou, user_id, max_id=None): 53 | kwargs = dict(id=user_id, count=60, mode='lite') 54 | if max_id: 55 | kwargs['max_id'] = max_id 56 | n_statuses = 0 57 | statuses = fanfou.statuses.user_timeline(**kwargs) 58 | for status in statuses: 59 | print('{} {}\nDate: {}'.format(status['user']['screen_name'], 60 | status['id'], 61 | status['created_at'])) 62 | if status.get('in_reply_to_status_id'): 63 | print('In-Reply-To: {}'.format(status['in_reply_to_status_id'])) 64 | elif status.get('repost_status_id'): 65 | print('Repost: {}'.format(status['repost_status_id'])) 66 | print() 67 | for line in html_parser.unescape(status['text']).splitlines(): 68 | print_nicely(' ' + line) 69 | print() 70 | print() 71 | max_id = status['id'] 72 | n_statuses += 1 73 | return n_statuses, max_id 74 | 75 | 76 | def main(args=sys.argv[1:]): 77 | if not args: 78 | print(__doc__) 79 | sys.exit(1) 80 | 81 | if not os.path.exists(OAUTH_FILENAME): 82 | oauth_dance('The Python Fanfou Logger', CONSUMER_KEY, CONSUMER_SECRET, OAUTH_FILENAME) 83 | 84 | oauth_token, oauth_token_secret = read_token_file(OAUTH_FILENAME) 85 | fanfou = Fanfou(auth=OAuth(oauth_token, oauth_token_secret, CONSUMER_KEY, CONSUMER_SECRET)) 86 | 87 | user_id = args[0] 88 | max_id = args[1] if args[1:] else None 89 | n_statuses = 0 90 | while True: 91 | try: 92 | statuses_processed, max_id = get_statuses(fanfou, user_id, max_id) 93 | n_statuses += statuses_processed 94 | log_debug('Processed {} statuses (max_id {})'.format(n_statuses, max_id)) 95 | if statuses_processed == 0: 96 | log_debug("That's it, we got all the statuses we could. Done.") 97 | break 98 | except FanfouError as e: 99 | log_debug("Fanfou bailed out. I'm going to sleep a bit then try again.") 100 | sleep(3) 101 | 102 | if __name__ == '__main__': 103 | main() 104 | -------------------------------------------------------------------------------- /fanpy/oauth.py: -------------------------------------------------------------------------------- 1 | """ 2 | Visit the Fanfou open platform and create a new application: 3 | 4 | http://fanfou.com/apps.new 5 | 6 | This will get you a CONSUMER_KEY and CONSUMER_SECRET. 7 | 8 | When users run your application they have to authenticate your app 9 | with their Fanfou account. A few HTTP calls to Fanfou are required 10 | to do this. Please see the fanpy.oauth_dance module to see how this 11 | is done. If you are making a command-line app, you can use the 12 | oauth_dance() function directly. 13 | 14 | Performing the "oauth dance" gets you an ouath token and oauth token secret 15 | that authenticate the user with Fanfou. You should save these for 16 | later so that the user doesn't have to do the oauth dance again. 17 | 18 | read_token_file() and write_token_file() are utility methods to read and 19 | write OAuth token and secret key values. The values are stored as 20 | strings in the file. 21 | 22 | Finally, you can use the OAuth authenticator to connect to Fanfou. In 23 | code it all goes like this: 24 | 25 | from fanpy import * 26 | 27 | MY_FANFOU_CREDS = os.path.expanduser('~/.my_app_credentials') 28 | if not os.path.exists(MY_FANFOU_CREDS): 29 | oauth_dance('My App Name', CONSUMER_KEY, CONSUMER_SECRET, 30 | MY_FANFOU_CREDS) 31 | 32 | oauth_token, oauth_token_secret = read_token_file(MY_FANFOU_CREDS) 33 | 34 | fanfou = Fanfou(auth=OAuth( 35 | oauth_token, oauth_token_secret, CONSUMER_KEY, CONSUMER_SECRET)) 36 | 37 | # Now work with Fanfou 38 | fanfou.statuses.update(status='Hello, world!') 39 | 40 | """ 41 | 42 | from __future__ import print_function 43 | 44 | import base64 45 | import hashlib 46 | import hmac 47 | 48 | from random import getrandbits 49 | from time import time 50 | 51 | from .auth import Auth, MissingCredentialsError 52 | from .util import PY3 53 | 54 | try: 55 | from urllib.parse import quote, urlencode 56 | except ImportError: 57 | from urllib2 import quote 58 | from urllib import urlencode 59 | 60 | 61 | def write_token_file(filename, oauth_token, oauth_token_secret): 62 | """Write a token file to hold the oauth token and oauth token secret.""" 63 | with open(filename, 'w') as oauth_file: 64 | print(oauth_token, file=oauth_file) 65 | print(oauth_token_secret, file=oauth_file) 66 | 67 | 68 | def read_token_file(filename): 69 | """Read a token file and return the oauth token and oauth token secret.""" 70 | with open(filename) as oauth_file: 71 | return oauth_file.readline().strip(), oauth_file.readline().strip() 72 | 73 | 74 | class OAuth(Auth): 75 | """An OAuth 1.0a authenticator.""" 76 | 77 | def __init__(self, token, token_secret, consumer_key, consumer_secret): 78 | """ 79 | Create the authenticator. If you are in the initial stages of 80 | the OAuth dance and don't yet have a token or token_secret, 81 | pass empty strings for these params. 82 | """ 83 | self.token = token 84 | self.token_secret = token_secret 85 | self.consumer_key = consumer_key 86 | self.consumer_secret = consumer_secret 87 | 88 | if token_secret is None or consumer_secret is None: 89 | raise MissingCredentialsError( 90 | 'You must supply strings for token_secret and consumer_secret, not None.') 91 | 92 | def encode_params(self, base_url, method, params): 93 | """ 94 | 95 | :param base_url: eg: http://api.fanfou.com/statuses/update.json 96 | :param method: GET or POST or others 97 | :param params: eg: {'status': 'test'} 98 | """ 99 | params = params.copy() 100 | 101 | if self.token: 102 | params['oauth_token'] = self.token 103 | 104 | params['oauth_consumer_key'] = self.consumer_key 105 | params['oauth_signature_method'] = 'HMAC-SHA1' 106 | params['oauth_version'] = '1.0' 107 | params['oauth_timestamp'] = str(int(time())) 108 | params['oauth_nonce'] = str(getrandbits(64)) 109 | 110 | enc_params = urlencode_noplus(sorted(params.items())) 111 | message = '&'.join( 112 | oauth_escape(i) for i in [method.upper(), base_url, enc_params]) 113 | 114 | key = self.consumer_secret + '&' + oauth_escape(self.token_secret) 115 | 116 | hash_obj = hmac.new(key.encode(), message.encode(), hashlib.sha1) 117 | signature = base64.b64encode(hash_obj.digest()) 118 | 119 | return enc_params + '&' + 'oauth_signature=' + oauth_escape(signature) 120 | 121 | def generate_headers(self): 122 | return {} 123 | 124 | 125 | # Apparently contrary to the HTTP RFCs, spaces in arguments must be encoded as 126 | # '%20' rather than '+' when constructing an OAuth signature. 127 | # So here is a specialized version which does exactly that. 128 | # In Python 2, since there is no safe option for urlencode, we force it by hand. 129 | def urlencode_noplus(query): 130 | if not PY3: 131 | new_query = [] 132 | TILDE = '____TILDE____' 133 | for k, v in query: 134 | if type(k) is unicode: 135 | k = k.encode('utf-8') 136 | k = str(k).replace('~', TILDE) 137 | if type(v) is unicode: 138 | v = v.encode('utf-8') 139 | v = str(v).replace('~', TILDE) 140 | new_query.append((k, v)) 141 | return urlencode(new_query).replace(TILDE, '~').replace('+', '%20') 142 | 143 | return urlencode(query, safe='~').replace('+', '%20') 144 | 145 | 146 | def oauth_escape(val): 147 | return quote(val, safe='~') 148 | -------------------------------------------------------------------------------- /fanpy/oauth_dance.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | try: 4 | input = raw_input 5 | except NameError: 6 | pass 7 | 8 | import webbrowser 9 | import time 10 | 11 | from .api import Fanfou, json 12 | from .oauth import OAuth, write_token_file 13 | 14 | 15 | def get_oauth_pin(oauth_url, open_browser=True): 16 | """Prompt the user for the OAuth PIN. 17 | 18 | By default, a browser will open the authorization page. If `open_browser` 19 | is false, the authorization URL will just be printed instead. 20 | """ 21 | 22 | print('Opening: {}\n'.format(oauth_url)) 23 | 24 | if open_browser: 25 | print(""" 26 | In the web browser window that opens please choose to Allow 27 | access. Copy the PIN number that appears on the next page and paste or 28 | type it here: 29 | """) 30 | 31 | try: 32 | r = webbrowser.open(oauth_url) 33 | time.sleep(2) 34 | if not r: 35 | raise Exception() 36 | except: 37 | print(""" 38 | Uh, I couldn't open a browser on your computer. Please go here to get 39 | your PIN: 40 | 41 | """ + oauth_url) 42 | 43 | else: 44 | print(""" 45 | Please go to the following URL, authorize the app, and copy the PIN: 46 | 47 | """ + oauth_url) 48 | 49 | return input('Please enter the PIN: ').strip() 50 | 51 | 52 | def oauth_dance(app_name, consumer_key, consumer_secret, token_filename=None, open_browser=True): 53 | """Perform the OAuth dance with some command-line prompts. Return the 54 | oauth_token and oauth_token_secret. 55 | 56 | Provide the name of your app in `app_name`, your consumer_key, and 57 | consumer_secret. This function will let the user allow your app to access 58 | their Fanfou account using PIN authentication. 59 | 60 | If a `token_filename` is given, the oauth tokens will be written to 61 | the file. 62 | 63 | By default, this function attempts to open a browser to request access. If 64 | `open_browser` is false it will just print the URL instead. 65 | """ 66 | print("Hi there! We're gonna get you all set up to use {}.".format(app_name)) 67 | fanfou = Fanfou( 68 | auth=OAuth('', '', consumer_key, consumer_secret), 69 | format='', domain='fanfou.com') 70 | oauth_token, oauth_token_secret = parse_oauth_tokens( 71 | fanfou.oauth.request_token(oauth_callback='oob')) 72 | oauth_url = 'http://fanfou.com/oauth/authorize?oauth_token=' + oauth_token 73 | oauth_verifier = get_oauth_pin(oauth_url, open_browser) 74 | 75 | fanfou = Fanfou( 76 | auth=OAuth(oauth_token, oauth_token_secret, consumer_key, consumer_secret), 77 | format='', domain='fanfou.com') 78 | oauth_token, oauth_token_secret = parse_oauth_tokens( 79 | fanfou.oauth.access_token(oauth_verifier=oauth_verifier)) 80 | if token_filename: 81 | write_token_file(token_filename, oauth_token, oauth_token_secret) 82 | print() 83 | print("That's it! Your authorization keys have been written to {}.".format(token_filename)) 84 | return oauth_token, oauth_token_secret 85 | 86 | 87 | def parse_oauth_tokens(result): 88 | for r in result.split('&'): 89 | k, v = r.split('=') 90 | if k == 'oauth_token': 91 | oauth_token = v 92 | elif k == 'oauth_token_secret': 93 | oauth_token_secret = v 94 | return oauth_token, oauth_token_secret 95 | -------------------------------------------------------------------------------- /fanpy/util.py: -------------------------------------------------------------------------------- 1 | """Internal utility functions.""" 2 | 3 | import sys 4 | import time 5 | 6 | PY3 = sys.version_info >= (3, 0) 7 | 8 | 9 | def actually_bytes(s): 10 | if PY3: 11 | if type(s) == bytes: 12 | pass 13 | elif type(s) != str: 14 | s = str(s) 15 | if type(s) == str: 16 | s = s.encode('utf-8') 17 | else: 18 | if type(s) == str: 19 | pass 20 | elif type(s) != unicode: 21 | s = str(s) 22 | if type(s) == unicode: 23 | s = s.encode('utf-8') 24 | return s 25 | 26 | 27 | def print_nicely(s): 28 | if hasattr(sys.stdout, 'buffer'): 29 | sys.stdout.buffer.write(s.encode('utf-8')) 30 | print() 31 | sys.stdout.buffer.flush() 32 | sys.stdout.flush() 33 | else: 34 | print(s.encode('utf-8')) 35 | 36 | 37 | class Fail(object): 38 | """A class to count fails during a repetitive task. 39 | 40 | Args: 41 | maximum: An integer for the maximum of fails to allow. 42 | exit: An integer for the exit code when maximum of fail is reached. 43 | 44 | Methods: 45 | count: Count a fail, exit when maximum of fails is reached. 46 | wait: Same as count but also sleep for a given time in seconds. 47 | """ 48 | def __init__(self, maximum=10, exit=1): 49 | self.i = maximum 50 | self.exit = exit 51 | 52 | def count(self): 53 | self.i -= 1 54 | if self.i == 0: 55 | print('Too many consecutive fails, exit.') 56 | sys.exit(self.exit) 57 | 58 | def wait(self, delay=0): 59 | self.count() 60 | if delay > 0: 61 | time.sleep(delay) 62 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | from setuptools import setup 3 | 4 | install_requires = [] 5 | 6 | tests_require = ['nose'] 7 | 8 | with open('fanpy/__init__.py', 'r') as fd: 9 | version = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', 10 | fd.read(), re.MULTILINE).group(1) 11 | 12 | if not version: 13 | raise RuntimeError('Cannot find version information') 14 | 15 | setup( 16 | name='fanpy', 17 | version=version, 18 | description='An API and cli toolset for Fanfou.com', 19 | long_description=open('README.md').read(), 20 | keywords='fanfou, cli', 21 | author='mookrs', 22 | author_email='mookrs+fanpy@gmail.com', 23 | url='https://github.com/mookrs/fanpy', 24 | packages=['fanpy'], 25 | license='MIT', 26 | install_requires=install_requires, 27 | tests_require=tests_require, 28 | classifiers=[ 29 | 'Development Status :: 5 - Production/Stable', 30 | 'Environment :: Console', 31 | 'Intended Audience :: End Users/Desktop', 32 | 'Natural Language :: English', 33 | 'Natural Language :: Chinese (Simplified)', 34 | 'Operating System :: OS Independent', 35 | 'Programming Language :: Python :: 2', 36 | 'Programming Language :: Python :: 2.7', 37 | 'Programming Language :: Python :: 3', 38 | 'Programming Language :: Python :: 3.3', 39 | 'Programming Language :: Python :: 3.4', 40 | 'Programming Language :: Python :: 3.5', 41 | 'Programming Language :: Python :: 3.6', 42 | 'Topic :: Communications :: Chat :: Internet Relay Chat', 43 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content :: CGI Tools/Libraries', 44 | 'Topic :: Utilities', 45 | 'License :: OSI Approved :: MIT License', 46 | ], 47 | entry_points={ 48 | 'console_scripts': [ 49 | 'fanpy=fanpy.cli:main', 50 | 'fanpy-log=fanpy.logger:main', 51 | 'fanpy-archiver=fanpy.archiver:main', 52 | ], 53 | } 54 | ) 55 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mookrs/fanpy/b6eae2e71f842df9cec3c0b3fd043256e23bdb46/tests/__init__.py -------------------------------------------------------------------------------- /tests/test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mookrs/fanpy/b6eae2e71f842df9cec3c0b3fd043256e23bdb46/tests/test.png -------------------------------------------------------------------------------- /tests/test_internals.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from fanpy.api import method_for_uri, build_uri 5 | from fanpy.util import PY3, actually_bytes 6 | 7 | 8 | def test_method_for_uri__lookup(): 9 | assert 'POST' == method_for_uri('/blocks/create') 10 | assert 'POST' == method_for_uri('/statuses/update') 11 | assert 'POST' == method_for_uri('/account/update_profile_image') 12 | assert 'GET' == method_for_uri('/friendships/requests') 13 | 14 | 15 | def test_build_uri(): 16 | uri = build_uri(['1.1', 'foo', 'bar'], {}) 17 | assert uri == '1.1/foo/bar' 18 | 19 | # Interpolation works 20 | uri = build_uri(['1.1', '_foo', 'bar'], {'_foo': 'asdf'}) 21 | assert uri == '1.1/asdf/bar' 22 | 23 | # But only for strings beginning with _. 24 | uri = build_uri(['1.1', 'foo', 'bar'], {'foo': 'asdf'}) 25 | assert uri == '1.1/foo/bar' 26 | 27 | 28 | def test_actually_bytes(): 29 | out_type = str 30 | if PY3: 31 | out_type = bytes 32 | for inp in [b'asdf', 'asdf', 'asdfüü', 1234]: 33 | assert type(actually_bytes(inp)) == out_type 34 | -------------------------------------------------------------------------------- /tests/test_sanity.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import random 5 | import pickle 6 | import json 7 | 8 | from fanpy import Fanfou, FanfouHTTPError, NoAuth 9 | from fanpy.api import FanfouDictResponse, FanfouListResponse, POST_ACTIONS, method_for_uri 10 | 11 | noauth = NoAuth() 12 | fanfou_na = Fanfou(auth=noauth) 13 | 14 | AZaz = 'abcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ' 15 | 16 | 17 | def get_random_str(): 18 | return ''.join(random.choice(AZaz) for _ in range(10)) 19 | 20 | 21 | def test_FanfouHTTPError_raised_for_invalid_oauth(): 22 | test_passed = False 23 | try: 24 | fanfou_na.statuses.mentions() 25 | except FanfouHTTPError: 26 | test_passed = True 27 | assert test_passed 28 | 29 | 30 | def test_pickle_ability(): 31 | res = FanfouDictResponse({'a': 'b'}) 32 | p = pickle.dumps(res) 33 | res2 = pickle.loads(p) 34 | assert res == res2 35 | assert res2['a'] == 'b' 36 | 37 | res = FanfouListResponse([1, 2, 3]) 38 | p = pickle.dumps(res) 39 | res2 = pickle.loads(p) 40 | assert res == res2 41 | assert res2[2] == 3 42 | 43 | 44 | def test_json_ability(): 45 | res = FanfouDictResponse({'a': 'b'}) 46 | p = json.dumps(res) 47 | res2 = json.loads(p) 48 | assert res == res2 49 | assert res2['a'] == 'b' 50 | 51 | res = FanfouListResponse([1, 2, 3]) 52 | p = json.dumps(res) 53 | res2 = json.loads(p) 54 | assert res == res2 55 | assert res2[2] == 3 56 | 57 | 58 | def test_method_for_uri(): 59 | for action in POST_ACTIONS: 60 | assert method_for_uri(get_random_str() + '/' + action) == 'POST' 61 | assert method_for_uri('statuses/home_timeline') == 'GET' 62 | --------------------------------------------------------------------------------