├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── TweetPoster ├── __init__.py ├── reddit.py ├── rehost.py ├── signals.py ├── templates │ ├── footer.txt │ └── tweet.txt ├── test │ ├── __init__.py │ ├── json │ │ ├── reddit_comment.json │ │ ├── reddit_login.json │ │ ├── reddit_twitter_posts.json │ │ └── tweet.json │ ├── test_reddit.py │ ├── test_twitter.py │ └── test_utils.py ├── twitter.py └── utils.py ├── requirements.txt └── run.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .venv/ 3 | config.json 4 | *.db 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: python 3 | install: pip install -r requirements.txt 4 | env: 5 | global: 6 | - secure: |- 7 | Mt65+x+Rd6y01J3l5JUiXiYiWocuq2zomgEtwyzW9Jql+X7GVW1YryrJITRC 8 | JbvREBFDLKtZjF/6kUJ0B06o8ykAMhaRg/xJdqZ8ZXVMUhr9algwzvHt/JN/ 9 | D/kJBgQHVvjirvGtbgwT7JUrc8pn8fMEFvaKI4cKtcL1zdknt2E= 10 | - secure: |- 11 | Aj2rYMFX0pYvRHqS55TgmsNNqdfWY79uSg1s10c5mwcsy8VL01lqAJLZSGuR 12 | t2S9Pqq06wxIj4Cq7sD5eRBEg7LFx1OS6vZZhdyR3xxy4alqYxATPO65mrdd 13 | 30uQhC6mDwnbdzFXkfw1Asfu1iJ/+NQzBtsIZnJFCBqYZ81NnwA= 14 | - secure: |- 15 | B273fo92tYhMUVevOapBF/G9Gkf9rSJr9GUVJdlC3BemSab8k5jh0cR4MpBA 16 | MPeC9NaKX8Z0vUQMGkOmu95M9w+SkbVzBaTKxiOfiotSzUHvP06uomTphzLj 17 | 9Wj6c0bj1TEt2tWv2TDf0LgvF3DX9wzTTgDsMYs69gDK4lvftAY= 18 | - secure: |- 19 | oMKlb+ZUWEo3hgB4xFckTRvQc6wO6hsyJh+ks7t4CrWaO+zOZf3/zqFy1KOa 20 | gITmMuXISAezmKbmSZkPnfWWxvk6IljlpuKQafnHmvPMIsF6amsEzTp4XEpN 21 | FasuQzeFakSkufdJIpZW5VPQHEyy2zIfhYVc4R+PfQvXA07+Axo= 22 | python: 23 | - "2.7" 24 | script: nosetests -v 25 | before_script: python run.py 26 | notifications: 27 | email: false 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Joe Alcorn 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 13 | all 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 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | TweetPoster [![Build Status](https://travis-ci.org/buttscicles/TweetPoster.png?branch=master)](https://travis-ci.org/buttscicles/TweetPoster) 2 | ====== 3 | 4 | TweetPoster is a reddit bot that posts the contents of the submitted tweet (and any parents) 5 | as a comment on the submission itself, as well as rehosts any images. 6 | It is a replacement for the now-defunct [/u/tweet_poster](http://www.reddit.com/user/tweet_poster). 7 | 8 | 9 | ## Adding an image host 10 | 11 | All image hosts must subclass `TweetPoster.rehost.ImageHost`, this allows them to 12 | be automatically picked up when it comes time to rehost an image. 13 | 14 | Each image host has two prerequisites: 15 | 16 | 1. a `url_re` attribute which will be used to match against a url 17 | 2. an `extract` method that recieves a url 18 | 19 | `extract` should return an imgur.com url (obtained using `ImageHost.rehost`) or `None` 20 | 21 | An example can be found below, and further examples can be found in [rehost.py](https://github.com/buttscicles/TweetPoster/blob/master/TweetPoster/rehost.py) 22 | 23 | ```python 24 | class Instagram(ImageHost): 25 | 26 | url_re = 'https?://instagram.com/p/\w+/' 27 | 28 | def extract(self, url): 29 | try: 30 | r = requests.get(url) 31 | except requests.exceptions.RequestException: 32 | return None 33 | 34 | soup = BeautifulSoup(r.content) 35 | photo = soup.find("img", class_="photo")['src'] 36 | return self.rehost(photo) 37 | ``` 38 | 39 | ## Links 40 | - [reddit](http://www.reddit.com/user/TweetPoster) 41 | - [code](https://github.com/buttscicles/TweetPoster) 42 | - [issues](https://github.com/buttscicles/TweetPoster/issues) 43 | - [faq](http://www.reddit.com/r/TweetPoster/comments/13relk/faq/) 44 | -------------------------------------------------------------------------------- /TweetPoster/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | import sqlite3 4 | from os import path, environ 5 | 6 | import requests 7 | from raven import Client 8 | 9 | from TweetPoster import utils 10 | from TweetPoster.signals import pre_request 11 | 12 | 13 | def load_config(): 14 | config = json.loads(open('config.json').read()) 15 | for key in config['twitter'].keys(): 16 | if environ.get(key): 17 | config['twitter'][key] = environ[key] 18 | 19 | return config 20 | 21 | config = load_config() 22 | sentry = Client(config['sentry'].get('dsn', ''), processors=( 23 | 'TweetPoster.utils.SanitizeCredentialsProcessor', 24 | )) 25 | template_path = path.dirname(path.realpath(__file__)) + '/templates/' 26 | 27 | 28 | class Database(object): 29 | @property 30 | def conn(self): 31 | if not hasattr(self, '_connection'): 32 | self._connection = sqlite3.connect(config['database']) 33 | return self._connection 34 | 35 | def cursor(self): 36 | return self.conn.cursor() 37 | 38 | def init(self, clean=False): 39 | cur = self.cursor() 40 | if clean: 41 | cur.execute('DROP TABLE IF EXISTS posts') 42 | 43 | cur.execute( 44 | 'CREATE TABLE IF NOT EXISTS posts (id INTEGER PRIMARY KEY ASC, thing_id TEXT UNIQUE)' 45 | ) 46 | self.conn.commit() 47 | 48 | def has_processed(self, thing_id): 49 | c = self.cursor() 50 | c.execute('SELECT thing_id FROM posts WHERE thing_id = ?', (thing_id,)) 51 | return c.fetchone() is not None 52 | 53 | def mark_as_processed(self, thing_id): 54 | c = self.cursor() 55 | c.execute('INSERT INTO posts (thing_id) VALUES (?)', (thing_id,)) 56 | self.conn.commit() 57 | 58 | 59 | class User(object): 60 | """ 61 | Base user object that takes care of making requests 62 | """ 63 | 64 | timeout = 3 65 | headers = { 66 | 'User-Agent': 'https://github.com/buttscicles/TweetPoster' 67 | } 68 | 69 | def __init__(self): 70 | self.session = requests.session() 71 | 72 | def get(self, url, **kw): 73 | """ 74 | Shortcut function to make a GET request as authed user. 75 | """ 76 | return self.request(url, 'GET', **kw) 77 | 78 | def post(self, url, data, **kw): 79 | """ 80 | Shortcut function to make a POST request as authed user. 81 | """ 82 | return self.request(url, 'POST', data=data) 83 | 84 | def request(self, url, method, **kw): 85 | """ 86 | Makes a request as the authenticated user. 87 | All keyword arguments are passed directly to requests 88 | 89 | """ 90 | assert method in ('POST', 'GET'), 'Unsupported HTTP Method' 91 | 92 | kw['timeout'] = self.timeout 93 | if 'headers' in kw: 94 | # Merge self.headers with headers passed in 95 | # The passed in headers take preference 96 | kw['headers'] = dict(self.headers.items() + kw['headers'].items()) 97 | else: 98 | kw['headers'] = self.headers 99 | 100 | # Send a pre-request signal. 101 | # This allows us to abide by different 102 | # ratelimits for different user accounts. 103 | pre_request.send(self) 104 | 105 | if method == 'POST': 106 | return self.session.post(url, **kw) 107 | elif method == 'GET': 108 | return self.session.get(url, **kw) 109 | 110 | 111 | def main(): 112 | from TweetPoster.reddit import Redditor 113 | from TweetPoster.twitter import Twitter 114 | 115 | db = Database() 116 | db.init() 117 | twitter = Twitter() 118 | reddit = Redditor().login( 119 | config['reddit']['username'], 120 | config['reddit']['password'] 121 | ) 122 | 123 | while True: 124 | try: 125 | posts = reddit.get_new_posts(db) 126 | for post in posts: 127 | handle_submission(post, twitter, reddit) 128 | except KeyboardInterrupt: 129 | import sys 130 | sys.exit(0) 131 | except requests.exceptions.Timeout: 132 | # These are exceptions we don't 133 | # want to tell sentry about 134 | pass 135 | except: 136 | sentry.captureException() 137 | finally: 138 | print 'sleeping' 139 | time.sleep(90) 140 | 141 | 142 | def handle_submission(post, twitter, reddit): 143 | url = twitter.tweet_re.match(post.url) 144 | if not url: 145 | # This post links to the twitter domain 146 | # but not to a tweet or picture 147 | post.mark_as_processed() 148 | return 149 | 150 | tweet_id = url.group(1) 151 | try: 152 | tweet = twitter.get_tweet(tweet_id) 153 | except AssertionError as e: 154 | code = e.args[0] 155 | if code == 429: 156 | # We've hit Twitter's ratelimit 157 | print 'Ratelimited by Twitter, sleeping for 15 minutes' 158 | time.sleep(60 * 15) 159 | 160 | elif code == 404: 161 | post.mark_as_processed() 162 | return 163 | 164 | if utils.tweet_in_title(tweet, post): 165 | print 'Tweet in title, skipping' 166 | post.mark_as_processed() 167 | return 168 | 169 | with open(template_path + 'footer.txt') as f: 170 | footer_markdown = f.read().format(**post.__dict__) 171 | 172 | tweets = [] 173 | while True: 174 | tweets.insert(0, tweet.markdown) 175 | if tweet.reply_to is None: 176 | break 177 | else: 178 | tweet = tweet.reply_to 179 | 180 | tweets_markdown = '\n'.join(tweets) 181 | 182 | full_comment = tweets_markdown + footer_markdown 183 | reddit.comment(post.fullname, full_comment) 184 | post.mark_as_processed() 185 | -------------------------------------------------------------------------------- /TweetPoster/reddit.py: -------------------------------------------------------------------------------- 1 | import time 2 | from socket import timeout 3 | 4 | from requests.exceptions import RequestException 5 | 6 | from TweetPoster import User, Database 7 | from TweetPoster.signals import pre_request 8 | 9 | db = Database() 10 | 11 | 12 | class Redditor(User): 13 | 14 | authenticated = False 15 | last_request = None 16 | 17 | def __init__(self, bypass_ratelimit=False, *a, **kw): 18 | super(Redditor, self).__init__(*a, **kw) 19 | 20 | if not bypass_ratelimit: 21 | pre_request.connect(self._ratelimit, sender=self) 22 | 23 | def login(self, username, password): 24 | """ 25 | Logs a user in, stores modhash in Redditor.modhash 26 | 27 | """ 28 | login_url = 'https://ssl.reddit.com/api/login' 29 | params = { 30 | 'passwd': password, 31 | 'rem': False, 32 | 'user': username, 33 | 'api_type': 'json', 34 | } 35 | 36 | print 'Logging in...' 37 | r = self.post(login_url, params) 38 | if 'data' not in r.json()['json']: 39 | raise Exception('login failed') 40 | 41 | self.modhash = r.json()['json']['data']['modhash'] 42 | self.authenticated = True 43 | return self 44 | 45 | def comment(self, thing_id, comment): 46 | """ 47 | Replies to :thing_id: with :comment: 48 | 49 | """ 50 | url = 'http://www.reddit.com/api/comment' 51 | 52 | params = { 53 | 'uh': self.modhash, 54 | 'thing_id': thing_id, 55 | 'comment': comment, 56 | 'api_type': 'json', 57 | } 58 | 59 | print 'Commenting on ' + thing_id 60 | return self.post(url, params) 61 | 62 | def get_new_posts(self, db=db): 63 | """ 64 | Returns a list of posts that haven't already 65 | been processed 66 | """ 67 | print 'Fetching new posts...' 68 | url = 'http://www.reddit.com/domain/twitter.com/new.json' 69 | 70 | try: 71 | r = self.get(url, params=dict(limit=100)) 72 | assert r.status_code == 200 73 | all_posts = r.json()['data']['children'] 74 | except (RequestException, ValueError, AssertionError, timeout): 75 | return [] 76 | 77 | posts = [ 78 | Submission(p) for p in all_posts 79 | if not db.has_processed(p['data']['name']) 80 | ] 81 | return posts 82 | 83 | def _ratelimit(self, sender): 84 | """ 85 | Helps us abide by reddit's API usage limitations. 86 | https://github.com/reddit/reddit/wiki/API#rules 87 | """ 88 | if self.last_request is not None: 89 | diff = time.time() - self.last_request 90 | if diff < 2: 91 | time.sleep(2 - diff) 92 | 93 | self.last_request = time.time() 94 | 95 | 96 | class Submission(object): 97 | def __init__(self, json): 98 | self.title = json['data']['title'] 99 | self.url = json['data']['url'] 100 | self.id = json['data']['id'] 101 | self.fullname = json['data']['name'] 102 | 103 | def mark_as_processed(self, db=db): 104 | db.mark_as_processed(self.fullname) 105 | -------------------------------------------------------------------------------- /TweetPoster/rehost.py: -------------------------------------------------------------------------------- 1 | import re 2 | import json 3 | 4 | import requests 5 | from bs4 import BeautifulSoup 6 | 7 | import TweetPoster 8 | 9 | 10 | class ImageHost(object): 11 | 12 | url_re = None 13 | 14 | def extract(self, url): 15 | """ 16 | Takes a URL, rehosts an image and returns a new URL. 17 | """ 18 | raise NotImplementedError 19 | 20 | @classmethod 21 | def rehost(self, image_url): 22 | try: 23 | r = requests.post( 24 | 'http://api.imgur.com/2/upload.json', 25 | params={ 26 | 'key': TweetPoster.config['imgur']['key'], 27 | 'image': image_url 28 | } 29 | ) 30 | if not r.status_code == 200: 31 | print r.json()['error']['message'] 32 | return None 33 | 34 | return r.json()['upload']['links']['original'] 35 | except (ValueError, requests.exceptions.RequestException): 36 | return None 37 | 38 | 39 | class PicTwitterCom(object): 40 | 41 | @classmethod 42 | def extract(self, url): 43 | if not url.endswith(':large'): 44 | url = url + ':large' 45 | 46 | return ImageHost.rehost(url) 47 | 48 | 49 | class Instagram(ImageHost): 50 | 51 | url_re = 'https?://instagram.com/p/[\w_-]+/' 52 | 53 | def extract(self, url): 54 | try: 55 | r = requests.get(url) 56 | except requests.exceptions.RequestException: 57 | return None 58 | 59 | j = re.search('("display_src":".*?")', r.content) 60 | if j: 61 | j = json.loads('{' + j.group(1) + '}') 62 | return self.rehost(j['display_src']) 63 | 64 | 65 | class YFrog(ImageHost): 66 | 67 | url_re = 'https?://yfrog.com/\w+' 68 | 69 | def extract(self, url): 70 | url = url.replace('://', '://twitter.') 71 | try: 72 | r = requests.get(url, params={'sa': 0}) 73 | except requests.exceptions.RequestException: 74 | return None 75 | 76 | soup = BeautifulSoup(r.content) 77 | photo = soup.find(id='input-direct')['value'] 78 | 79 | return self.rehost(photo) 80 | 81 | 82 | class Twitpic(ImageHost): 83 | 84 | url_re = 'https?://twitpic.com/\w+' 85 | 86 | def extract(self, url): 87 | url = url + '/full' 88 | 89 | try: 90 | r = requests.get(url) 91 | soup = BeautifulSoup(r.content) 92 | except: 93 | return None 94 | 95 | img = soup.find(id='media-full').find('img') 96 | return self.rehost(img['src']) 97 | 98 | 99 | class Puush(ImageHost): 100 | 101 | url_re = 'https?://puu.sh/[\w0-9]+' 102 | 103 | def extract(self, url): 104 | return self.rehost(url) 105 | 106 | 107 | class Facebook(ImageHost): 108 | 109 | url_re = 'https?://facebook.com/photo.php\?fbid=[0-9]+$' 110 | 111 | def extract(self, url): 112 | try: 113 | r = requests.get(url) 114 | except requests.exceptions.RequestException: 115 | return None 116 | 117 | soup = BeautifulSoup(r.content) 118 | img = soup.find(id='fbPhotoImage') 119 | return self.rehost(img['src']) 120 | -------------------------------------------------------------------------------- /TweetPoster/signals.py: -------------------------------------------------------------------------------- 1 | from blinker import signal 2 | 3 | pre_request = signal('pre-request') 4 | -------------------------------------------------------------------------------- /TweetPoster/templates/footer.txt: -------------------------------------------------------------------------------- 1 | 2 | ---- 3 | 4 | [^[Mistake?]](/message/compose/?to=TweetPoster&subject=Error%20Report&message=/{id}%0A%0APlease leave above link unaltered.) 5 | [^[Suggestion]](/message/compose/?to=TweetPoster&subject=Suggestion) 6 | [^[FAQ]](/r/TweetPoster/comments/13relk/) 7 | [^[Code]](https://github.com/joealcorn/TweetPoster) 8 | [^[Issues]](https://github.com/joealcorn/TweetPoster/issues) 9 | -------------------------------------------------------------------------------- /TweetPoster/templates/tweet.txt: -------------------------------------------------------------------------------- 1 | [**@{user.name}**]({user.link}): 2 | >[{datetime} UTC]({link}) 3 | 4 | >{text} 5 | -------------------------------------------------------------------------------- /TweetPoster/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joealcorn/TweetPoster/b2892417eb3cece2eef3bc48558214065c6be4c0/TweetPoster/test/__init__.py -------------------------------------------------------------------------------- /TweetPoster/test/json/reddit_comment.json: -------------------------------------------------------------------------------- 1 | { 2 | "json": { 3 | "errors": [], 4 | "data": { 5 | "things": [ 6 | { 7 | "kind": "t1", 8 | "data": { 9 | "parent": "t3_1hb15l", 10 | "content": "\n\n\n\n\n\n\n\n<div class=\"thing id-t1_casn34c \n \n odd\n comment \"\n onclick=\"click_thing(this)\"\n \n \n data-fullname=\"t1_casn34c\"\n data-ups=\"1\" data-downs=\"0\"\n>\n <p class=\"parent\">\n \n \n\n <a name=\"casn34c\" ></a>\n\n </p>\n \n\n \n \n\n \n <div class=\"midcol likes\" \n >\n \n\n <div class=\"arrow upmod login-required\"\n onclick=\"$(this).vote('590a07f652f1fe80d5d88949aa66881f248625ce', null, event)\"\n role=\"button\"\n aria-label=\"upvote\"\n tabindex=\"0\"\n >\n </div>\n\n \n\n <div class=\"arrow down login-required\"\n onclick=\"$(this).vote('590a07f652f1fe80d5d88949aa66881f248625ce', null, event)\"\n role=\"button\"\n aria-label=\"downvote\"\n tabindex=\"0\"\n >\n </div>\n\n </div>\n\n\n\n <div class=\"entry likes\">\n \n\n\n<div class=\"collapsed\" style='display:none'>\n \n\n\n \n <a href=\"#\" class=\"expand\"\n onclick=\"return showcomment(this)\">\n [+]\n </a>\n\n \n\n\n\n\n \n \n \n\n \n<a \n href=\"http://www.reddit.com/user/Buttscicles\" class=\"author gray id-t2_4ioh8\" >Buttscicles</a>\n\n\n \n \n\n <span class=\"userattrs\">\n </span>\n\n\n\n &#32;\n\n \n \n <span class=\"score dislikes\">\n -1 points\n </span>\n <span class=\"score unvoted\">\n 0 points\n </span>\n <span class=\"score likes\">\n 1 point\n </span>\n&#32;\n \n \n <time title=\"Sat Jun 29 15:07:14 2013 UTC\" datetime=\"2013-06-29T15:07:14+00:00\">\n 42 milliseconds\n </time>\n\n&#32;ago\n \n\n\n\n &nbsp;<a href=\"#\" class=\"expand\"\n onclick=\"return showcomment(this)\">\n (0 \n children)\n </a>\n \n\n\n\n\n</div>\n<div class=\"noncollapsed\" >\n <p class=\"tagline\">\n \n \n <a href=\"#\" class=\"expand\"\n onclick=\"return hidecomment(this)\">\n [&ndash;]\n </a>\n\n \n\n\n\n\n \n \n \n\n \n<a \n href=\"http://www.reddit.com/user/Buttscicles\" class=\"author id-t2_4ioh8\" >Buttscicles</a>\n\n\n \n \n\n <span class=\"userattrs\">\n </span>\n\n\n\n &#32;\n\n \n \n <span class=\"score dislikes\">\n -1 points\n </span>\n <span class=\"score unvoted\">\n 0 points\n </span>\n <span class=\"score likes\">\n 1 point\n </span>\n&#32;\n \n \n <time title=\"Sat Jun 29 15:07:14 2013 UTC\" datetime=\"2013-06-29T15:07:14+00:00\">\n 42 milliseconds\n </time>\n\n&#32;ago\n \n\n\n\n \n\n\n </p>\n \n \n \n\n\n\n\n\n\n\n <form action=\"#\" class=\"usertext\"\n onsubmit=\"return post_form(this, 'editusertext')\"\n \n id=\"form-t1_casn34cxds\">\n\n <input type=\"hidden\" name=\"thing_id\" value=\"t1_casn34c\"/>\n\n <div class=\"usertext-body\">\n <!-- SC_OFF --><div class=\"md\"><p>test comment</p>\n</div><!-- SC_ON -->\n </div>\n\n <div class=\"usertext-edit\"\n style=\"display: none\">\n <div>\n <textarea rows=\"1\" cols=\"1\"\n name=\"text\"\n >test&#32;comment</textarea>\n </div>\n\n <div class=\"bottom-area\">\n \n\n<span class=\"help-toggle toggle\" style=\"display: none\">\n <a class=\"option active \" href=\"#\" tabindex=\"100\"\n onclick=\"return toggle(this, helpon, helpoff)\"\n >\n formatting help\n </a>\n <a class=\"option \" href=\"#\">\n hide help\n </a>\n</span>\n\n\n <a href=\"/wiki/reddiquette\" class=\"reddiquette\" target=\"_blank\" tabindex=\"100\">reddiquette</a>\n\n \n \n <span class=\"error TOO_LONG field-text\" \n style=\"display:none\">\n </span>\n\n \n \n <span class=\"error RATELIMIT field-ratelimit\" \n style=\"display:none\">\n </span>\n\n \n \n <span class=\"error NO_TEXT field-text\" \n style=\"display:none\">\n </span>\n\n \n \n <span class=\"error TOO_OLD field-parent\" \n style=\"display:none\">\n </span>\n\n \n \n <span class=\"error DELETED_COMMENT field-parent\" \n style=\"display:none\">\n </span>\n\n \n \n <span class=\"error DELETED_LINK field-parent\" \n style=\"display:none\">\n </span>\n\n \n \n <span class=\"error USER_BLOCKED field-parent\" \n style=\"display:none\">\n </span>\n\n <div class=\"usertext-buttons\">\n \n <button type=\"submit\" onclick=\"\" class=\"save\"\n style='display:none'>\n save\n </button>\n\n \n <button type=\"button\" onclick=\"cancel_usertext(this)\" class=\"cancel\"\n style='display:none'>\n cancel\n </button>\n\n <span class=\"status\"></span>\n </div>\n </div>\n\n <div class=\"markhelp\" style=\"display:none\">\n <p>\n <!-- SC_OFF --><p>reddit uses a slightly-customized version of <a href=\"http://daringfireball.net/projects/markdown/syntax\">Markdown</a> for formatting. See below for some basics, or check <a href=\"/wiki/commenting\">the commenting wiki page</a> for more detailed help and solutions to common issues.</p>\n<!-- SC_ON -->\n</p>\n <table class=\"md\">\n <tr style=\"background-color: #ffff99; text-align: center\">\n <td><em>you type:</em></td>\n <td><em>you see:</em></td>\n </tr>\n <tr>\n <td>*italics*</td>\n <td><em>italics</em></td>\n </tr>\n <tr>\n <td>**bold**</td>\n <td><b>bold</b></td>\n </tr>\n <tr>\n <td>[reddit!](http://reddit.com)</td>\n <td><a href=\"http://reddit.com\">reddit!</a></td>\n </tr>\n <tr>\n <td>\n * item 1<br/>\n * item 2<br/>\n * item 3\n </td>\n <td>\n <ul>\n <li>item 1</li>\n <li>item 2</li>\n <li>item 3</li>\n </ul>\n </td>\n </tr>\n <tr>\n <td>&gt; quoted text</td>\n <td><blockquote>quoted text</blockquote></td>\n </tr>\n <tr>\n <td>\n Lines starting with four spaces <br/>\n are treated like code:<br/><br/>\n <span class=\"spaces\">\n &nbsp;&nbsp;&nbsp;&nbsp;\n </span>\n if 1 * 2 &lt; 3:<br/>\n <span class=\"spaces\">\n &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;\n </span>\n print \"hello, world!\"<br/>\n </td>\n <td>Lines starting with four spaces <br/>\n are treated like code:<br/>\n <pre>if 1 * 2 &lt; 3:<br/>&nbsp;&nbsp;&nbsp;&nbsp;print \"hello,\n world!\"</pre>\n </td>\n </tr>\n <tr>\n <td>~~strikethrough~~</td>\n <td><strike>strikethrough</strike></td>\n </tr>\n <tr>\n <td>super^script</td>\n <td>super<sup>script</sup></td>\n </tr>\n </table>\n </div>\n </div>\n\n </form>\n\n\n\n <ul class=\"flat-list buttons\">\n \n \n <li class=\"first\">\n \n \n \n\n \n<a \n href=\"http://www.reddit.com/r/movies/comments/1hb15l/to_mark_orlando_blooms_final_day_of_shooting_for/casn34c\" class=\"bylink\" rel=\"nofollow\" >perma-link</a>\n\n\n\n </li>\n <li class=\"comment-save-button\">\n \n\n \n \n\n <form action=\"/post/save\" method=\"post\" \n class=\"state-button save-button\">\n <input type=\"hidden\" name=\"executed\" value=\"delete from saved\" />\n <span>\n \n <a href=\"javascript:void(0)\"\n onclick=\"toggle_save(this)\">save</a>\n \n </span>\n </form>\n\n </li>\n <li>\n \n <a class=\"edit-usertext\" href=\"javascript:void(0)\" \n onclick=\"return edit_usertext(this)\">edit</a>\n\n </li>\n \n <li>\n \n \n <form class=\"toggle del-button \" action=\"#\" method=\"get\">\n <input type=\"hidden\" name=\"executed\" value=\"deleted\"/>\n <span class=\"option main active\">\n <a href=\"#\" class=\"togglebutton\" onclick=\"return toggle(this)\">delete</a>\n </span>\n <span class=\"option error\">\n are you sure?\n &#32;<a href=\"javascript:void(0)\" class=\"yes\"\n onclick='change_state(this, \"del\", hide_thing, undefined, null)'>\n yes\n </a>&#32;/&#32;\n <a href=\"javascript:void(0)\" class=\"no\"\n onclick=\"return toggle(this)\">no</a>\n </span>\n </form>\n\n </li>\n\n \n\n <li>\n \n <a class=\"\" href=\"javascript:void(0)\" \n onclick=\"return reply(this)\">reply</a>\n\n </li>\n\n \n\n\n </ul>\n</div>\n\n </div>\n \n <div class=\"child\" >\n \n </div>\n\n <div class=\"clearleft\"><!--IE6sux--></div>\n</div>\n<div class=\"clearleft\"><!--IE6sux--></div>\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n", 11 | "contentText": "test comment", 12 | "link": "t3_1hb15l", 13 | "replies": "", 14 | "contentHTML": "<div class=\"md\"><p>test comment</p>\n</div>", 15 | "id": "t1_casn34c" 16 | } 17 | } 18 | ] 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /TweetPoster/test/json/reddit_login.json: -------------------------------------------------------------------------------- 1 | { 2 | "json": { 3 | "errors": [], 4 | "data": { 5 | "modhash": "uh", 6 | "cookie": "nom" 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /TweetPoster/test/json/reddit_twitter_posts.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": "Listing", 3 | "data": { 4 | "modhash": "uj226mhwyn2311a98b98abe2715c2fc35fc55a634b1a0bce60", 5 | "children": [ 6 | { 7 | "kind": "t3", 8 | "data": { 9 | "domain": "twitter.com", 10 | "banned_by": null, 11 | "media_embed": {}, 12 | "subreddit": "bostonceltics", 13 | "selftext_html": null, 14 | "selftext": "", 15 | "likes": null, 16 | "link_flair_text": null, 17 | "id": "1glwgu", 18 | "clicked": false, 19 | "title": "Doc and Danny to talk about plans for the future tomorrow", 20 | "media": null, 21 | "score": 2, 22 | "approved_by": null, 23 | "over_18": false, 24 | "hidden": false, 25 | "thumbnail": "http://c.thumbs.redditmedia.com/xyaQNVGPAX4edKhD.jpg", 26 | "subreddit_id": "t5_2qmkf", 27 | "edited": false, 28 | "link_flair_css_class": null, 29 | "author_flair_css_class": "z0", 30 | "downs": 0, 31 | "saved": false, 32 | "is_self": false, 33 | "permalink": "/r/bostonceltics/comments/1glwgu/doc_and_danny_to_talk_about_plans_for_the_future/", 34 | "name": "t3_1glwgu", 35 | "created": 1371616084.0, 36 | "url": "https://twitter.com/WojYahooNBA/status/347087814833344512", 37 | "author_flair_text": "", 38 | "author": "kjcaton", 39 | "created_utc": 1371587284.0, 40 | "ups": 2, 41 | "num_comments": 2, 42 | "num_reports": null, 43 | "distinguished": null 44 | } 45 | }, 46 | { 47 | "kind": "t3", 48 | "data": { 49 | "domain": "twitter.com", 50 | "banned_by": null, 51 | "media_embed": {}, 52 | "subreddit": "GoogleMaps", 53 | "selftext_html": null, 54 | "selftext": "", 55 | "likes": null, 56 | "link_flair_text": null, 57 | "id": "1glvrp", 58 | "clicked": false, 59 | "title": "This road mirror caught a perfect reflection of the Google Streetview cyclist", 60 | "media": null, 61 | "score": 1, 62 | "approved_by": null, 63 | "over_18": false, 64 | "hidden": false, 65 | "thumbnail": "", 66 | "subreddit_id": "t5_2qqv1", 67 | "edited": false, 68 | "link_flair_css_class": null, 69 | "author_flair_css_class": null, 70 | "downs": 0, 71 | "saved": false, 72 | "is_self": false, 73 | "permalink": "/r/GoogleMaps/comments/1glvrp/this_road_mirror_caught_a_perfect_reflection_of/", 74 | "name": "t3_1glvrp", 75 | "created": 1371615603.0, 76 | "url": "https://twitter.com/FunnyObjects/status/347076068995395584", 77 | "author_flair_text": null, 78 | "author": "streetlite", 79 | "created_utc": 1371586803.0, 80 | "ups": 1, 81 | "num_comments": 0, 82 | "num_reports": null, 83 | "distinguished": null 84 | } 85 | }, 86 | { 87 | "kind": "t3", 88 | "data": { 89 | "domain": "twitter.com", 90 | "banned_by": null, 91 | "media_embed": {}, 92 | "subreddit": "nba", 93 | "selftext_html": null, 94 | "selftext": "", 95 | "likes": null, 96 | "link_flair_text": null, 97 | "id": "1glvpu", 98 | "clicked": false, 99 | "title": "Is it a must win for the Heat or the Spurs?", 100 | "media": null, 101 | "score": 1, 102 | "approved_by": null, 103 | "over_18": false, 104 | "hidden": false, 105 | "thumbnail": "", 106 | "subreddit_id": "t5_2qo4s", 107 | "edited": false, 108 | "link_flair_css_class": null, 109 | "author_flair_css_class": null, 110 | "downs": 0, 111 | "saved": false, 112 | "is_self": false, 113 | "permalink": "/r/nba/comments/1glvpu/is_it_a_must_win_for_the_heat_or_the_spurs/", 114 | "name": "t3_1glvpu", 115 | "created": 1371615564.0, 116 | "url": "https://twitter.com/donovanjmcnabb/status/347068347076182018", 117 | "author_flair_text": null, 118 | "author": "[deleted]", 119 | "created_utc": 1371586764.0, 120 | "ups": 1, 121 | "num_comments": 0, 122 | "num_reports": null, 123 | "distinguished": null 124 | } 125 | }, 126 | { 127 | "kind": "t3", 128 | "data": { 129 | "domain": "twitter.com", 130 | "banned_by": null, 131 | "media_embed": {}, 132 | "subreddit": "hockey", 133 | "selftext_html": null, 134 | "selftext": "", 135 | "likes": null, 136 | "link_flair_text": null, 137 | "id": "1glvdi", 138 | "clicked": false, 139 | "title": "Kings sign Voynov to 6 year deal at a 4.16M cap hit", 140 | "media": null, 141 | "score": 9, 142 | "approved_by": null, 143 | "over_18": false, 144 | "hidden": false, 145 | "thumbnail": "", 146 | "subreddit_id": "t5_2qiel", 147 | "edited": false, 148 | "link_flair_css_class": null, 149 | "author_flair_css_class": "NHLSanJoseSharksOldAlt", 150 | "downs": 0, 151 | "saved": false, 152 | "is_self": false, 153 | "permalink": "/r/hockey/comments/1glvdi/kings_sign_voynov_to_6_year_deal_at_a_416m_cap_hit/", 154 | "name": "t3_1glvdi", 155 | "created": 1371615322.0, 156 | "url": "https://twitter.com/nhlupdate/status/347083875090042880", 157 | "author_flair_text": "NHLSanJoseSharksOldAlt", 158 | "author": "joh408", 159 | "created_utc": 1371586522.0, 160 | "ups": 9, 161 | "num_comments": 6, 162 | "num_reports": null, 163 | "distinguished": null 164 | } 165 | }, 166 | { 167 | "kind": "t3", 168 | "data": { 169 | "domain": "twitter.com", 170 | "banned_by": null, 171 | "media_embed": {}, 172 | "subreddit": "ModerationLog", 173 | "selftext_html": null, 174 | "selftext": "", 175 | "likes": null, 176 | "link_flair_text": null, 177 | "id": "1glv7c", 178 | "clicked": false, 179 | "title": "/r/MensRights [spam filtered] LIL B SUPPORTS MRA!! LOVE U BASED GOD #BASED #MRA #MEN #MENSRIGHTS #FUCKWOMEN", 180 | "media": null, 181 | "score": 1, 182 | "approved_by": null, 183 | "over_18": false, 184 | "hidden": false, 185 | "thumbnail": "", 186 | "subreddit_id": "t5_2tqat", 187 | "edited": false, 188 | "link_flair_css_class": null, 189 | "author_flair_css_class": null, 190 | "downs": 0, 191 | "saved": false, 192 | "is_self": false, 193 | "permalink": "/r/ModerationLog/comments/1glv7c/rmensrights_spam_filtered_lil_b_supports_mra_love/", 194 | "name": "t3_1glv7c", 195 | "created": 1371615200.0, 196 | "url": "https://twitter.com/LILBTHEBASEDGOD/status/347072628743356416", 197 | "author_flair_text": null, 198 | "author": "ModerationLog", 199 | "created_utc": 1371586400.0, 200 | "ups": 1, 201 | "num_comments": 2, 202 | "num_reports": null, 203 | "distinguished": null 204 | } 205 | }, 206 | { 207 | "kind": "t3", 208 | "data": { 209 | "domain": "twitter.com", 210 | "banned_by": null, 211 | "media_embed": {}, 212 | "subreddit": "AnimalCrossing", 213 | "selftext_html": null, 214 | "selftext": "", 215 | "likes": null, 216 | "link_flair_text": null, 217 | "id": "1glv2j", 218 | "clicked": false, 219 | "title": "TIL Scallops are hallucinogenic.", 220 | "media": null, 221 | "score": 1, 222 | "approved_by": null, 223 | "over_18": false, 224 | "hidden": false, 225 | "thumbnail": "http://d.thumbs.redditmedia.com/OjCHuSf9LXo6OAtQ.jpg", 226 | "subreddit_id": "t5_2ro2c", 227 | "edited": false, 228 | "link_flair_css_class": null, 229 | "author_flair_css_class": null, 230 | "downs": 0, 231 | "saved": false, 232 | "is_self": false, 233 | "permalink": "/r/AnimalCrossing/comments/1glv2j/til_scallops_are_hallucinogenic/", 234 | "name": "t3_1glv2j", 235 | "created": 1371615108.0, 236 | "url": "https://twitter.com/gaogaostego/status/347083957692678144/photo/1", 237 | "author_flair_text": null, 238 | "author": "studiopzp", 239 | "created_utc": 1371586308.0, 240 | "ups": 1, 241 | "num_comments": 2, 242 | "num_reports": null, 243 | "distinguished": null 244 | } 245 | }, 246 | { 247 | "kind": "t3", 248 | "data": { 249 | "domain": "twitter.com", 250 | "banned_by": null, 251 | "media_embed": {}, 252 | "subreddit": "dogpictures", 253 | "selftext_html": null, 254 | "selftext": "", 255 | "likes": null, 256 | "link_flair_text": null, 257 | "id": "1gluzd", 258 | "clicked": false, 259 | "title": "Remember Maple bacon dog ;) What do you think of this?", 260 | "media": null, 261 | "score": 0, 262 | "approved_by": null, 263 | "over_18": false, 264 | "hidden": false, 265 | "thumbnail": "default", 266 | "subreddit_id": "t5_2r5qg", 267 | "edited": false, 268 | "link_flair_css_class": null, 269 | "author_flair_css_class": null, 270 | "downs": 1, 271 | "saved": false, 272 | "is_self": false, 273 | "permalink": "/r/dogpictures/comments/1gluzd/remember_maple_bacon_dog_what_do_you_think_of_this/", 274 | "name": "t3_1gluzd", 275 | "created": 1371615051.0, 276 | "url": "https://twitter.com/PetsUnitePeople/status/347082992843366400/photo/1", 277 | "author_flair_text": null, 278 | "author": "[deleted]", 279 | "created_utc": 1371586251.0, 280 | "ups": 1, 281 | "num_comments": 1, 282 | "num_reports": null, 283 | "distinguished": null 284 | } 285 | }, 286 | { 287 | "kind": "t3", 288 | "data": { 289 | "domain": "twitter.com", 290 | "banned_by": null, 291 | "media_embed": {}, 292 | "subreddit": "Defiance", 293 | "selftext_html": null, 294 | "selftext": "", 295 | "likes": null, 296 | "link_flair_text": null, 297 | "id": "1gltg8", 298 | "clicked": false, 299 | "title": "Grant Bowler answering question on Twitter!", 300 | "media": null, 301 | "score": 1, 302 | "approved_by": null, 303 | "over_18": false, 304 | "hidden": false, 305 | "thumbnail": "http://a.thumbs.redditmedia.com/gCl2v7WwX-DB4S8T.jpg", 306 | "subreddit_id": "t5_2siko", 307 | "edited": false, 308 | "link_flair_css_class": null, 309 | "author_flair_css_class": null, 310 | "downs": 0, 311 | "saved": false, 312 | "is_self": false, 313 | "permalink": "/r/Defiance/comments/1gltg8/grant_bowler_answering_question_on_twitter/", 314 | "name": "t3_1gltg8", 315 | "created": 1371614011.0, 316 | "url": "https://twitter.com/DefianceWorld", 317 | "author_flair_text": null, 318 | "author": "DailyGamer66", 319 | "created_utc": 1371585211.0, 320 | "ups": 1, 321 | "num_comments": 4, 322 | "num_reports": null, 323 | "distinguished": null 324 | } 325 | }, 326 | { 327 | "kind": "t3", 328 | "data": { 329 | "domain": "twitter.com", 330 | "banned_by": null, 331 | "media_embed": {}, 332 | "subreddit": "baseball", 333 | "selftext_html": null, 334 | "selftext": "", 335 | "likes": null, 336 | "link_flair_text": null, 337 | "id": "1gltba", 338 | "clicked": false, 339 | "title": "The Trop done the Fen \"Way\"", 340 | "media": null, 341 | "score": 2, 342 | "approved_by": null, 343 | "over_18": false, 344 | "hidden": false, 345 | "thumbnail": "", 346 | "subreddit_id": "t5_2qm7u", 347 | "edited": false, 348 | "link_flair_css_class": null, 349 | "author_flair_css_class": "tb", 350 | "downs": 1, 351 | "saved": false, 352 | "is_self": false, 353 | "permalink": "/r/baseball/comments/1gltba/the_trop_done_the_fen_way/", 354 | "name": "t3_1gltba", 355 | "created": 1371613913.0, 356 | "url": "https://twitter.com/BarryLeBrock/status/347078068290088960/photo/1", 357 | "author_flair_text": "Tampa Bay Rays", 358 | "author": "spiff24", 359 | "created_utc": 1371585113.0, 360 | "ups": 3, 361 | "num_comments": 1, 362 | "num_reports": null, 363 | "distinguished": null 364 | } 365 | }, 366 | { 367 | "kind": "t3", 368 | "data": { 369 | "domain": "twitter.com", 370 | "banned_by": null, 371 | "media_embed": {}, 372 | "subreddit": "rust", 373 | "selftext_html": null, 374 | "selftext": "", 375 | "likes": null, 376 | "link_flair_text": null, 377 | "id": "1glt9a", 378 | "clicked": false, 379 | "title": "Surfing Wikipedia in Servo", 380 | "media": null, 381 | "score": 3, 382 | "approved_by": null, 383 | "over_18": false, 384 | "hidden": false, 385 | "thumbnail": "", 386 | "subreddit_id": "t5_2s7lj", 387 | "edited": false, 388 | "link_flair_css_class": null, 389 | "author_flair_css_class": null, 390 | "downs": 0, 391 | "saved": false, 392 | "is_self": false, 393 | "permalink": "/r/rust/comments/1glt9a/surfing_wikipedia_in_servo/", 394 | "name": "t3_1glt9a", 395 | "created": 1371613873.0, 396 | "url": "https://twitter.com/metajack/status/346822553089761281/photo/1", 397 | "author_flair_text": null, 398 | "author": "kibwen", 399 | "created_utc": 1371585073.0, 400 | "ups": 3, 401 | "num_comments": 1, 402 | "num_reports": null, 403 | "distinguished": null 404 | } 405 | }, 406 | { 407 | "kind": "t3", 408 | "data": { 409 | "domain": "twitter.com", 410 | "banned_by": null, 411 | "media_embed": {}, 412 | "subreddit": "nhl", 413 | "selftext_html": null, 414 | "selftext": "", 415 | "likes": null, 416 | "link_flair_text": null, 417 | "id": "1glt6u", 418 | "clicked": false, 419 | "title": "Update on Iginla's future", 420 | "media": null, 421 | "score": 1, 422 | "approved_by": null, 423 | "over_18": false, 424 | "hidden": false, 425 | "thumbnail": "http://f.thumbs.redditmedia.com/qaqodsfA6OsuB_6k.jpg", 426 | "subreddit_id": "t5_2qrrq", 427 | "edited": false, 428 | "link_flair_css_class": null, 429 | "author_flair_css_class": null, 430 | "downs": 1, 431 | "saved": false, 432 | "is_self": false, 433 | "permalink": "/r/nhl/comments/1glt6u/update_on_iginlas_future/", 434 | "name": "t3_1glt6u", 435 | "created": 1371613824.0, 436 | "url": "https://twitter.com/GM_JayFeaster/status/347052533870641152", 437 | "author_flair_text": null, 438 | "author": "yoimjayfeaster", 439 | "created_utc": 1371585024.0, 440 | "ups": 2, 441 | "num_comments": 1, 442 | "num_reports": null, 443 | "distinguished": null 444 | } 445 | }, 446 | { 447 | "kind": "t3", 448 | "data": { 449 | "domain": "twitter.com", 450 | "banned_by": null, 451 | "media_embed": {}, 452 | "subreddit": "Minecraft", 453 | "selftext_html": null, 454 | "selftext": "", 455 | "likes": null, 456 | "link_flair_text": "pc", 457 | "id": "1glsp1", 458 | "clicked": false, 459 | "title": "New launcher discovery! Any ideas?", 460 | "media": null, 461 | "score": 1, 462 | "approved_by": null, 463 | "over_18": false, 464 | "hidden": false, 465 | "thumbnail": "http://b.thumbs.redditmedia.com/BbHqNSZ_UxUoCgny.jpg", 466 | "subreddit_id": "t5_2r05i", 467 | "edited": false, 468 | "link_flair_css_class": "pc", 469 | "author_flair_css_class": "zombie", 470 | "downs": 1, 471 | "saved": false, 472 | "is_self": false, 473 | "permalink": "/r/Minecraft/comments/1glsp1/new_launcher_discovery_any_ideas/", 474 | "name": "t3_1glsp1", 475 | "created": 1371613482.0, 476 | "url": "https://twitter.com/Dinnerbone/status/346982113305821184", 477 | "author_flair_text": "", 478 | "author": "Glampkoo", 479 | "created_utc": 1371584682.0, 480 | "ups": 2, 481 | "num_comments": 11, 482 | "num_reports": null, 483 | "distinguished": null 484 | } 485 | }, 486 | { 487 | "kind": "t3", 488 | "data": { 489 | "domain": "twitter.com", 490 | "banned_by": null, 491 | "media_embed": {}, 492 | "subreddit": "trapmuzik", 493 | "selftext_html": null, 494 | "selftext": "", 495 | "likes": null, 496 | "link_flair_text": null, 497 | "id": "1glsb1", 498 | "clicked": false, 499 | "title": "[FREE SCOOTER] From the Cell Block to Your Block August 1st", 500 | "media": null, 501 | "score": 1, 502 | "approved_by": null, 503 | "over_18": false, 504 | "hidden": false, 505 | "thumbnail": "http://f.thumbs.redditmedia.com/N7DvO8t1N5EZe0MG.jpg", 506 | "subreddit_id": "t5_2slji", 507 | "edited": false, 508 | "link_flair_css_class": null, 509 | "author_flair_css_class": null, 510 | "downs": 0, 511 | "saved": false, 512 | "is_self": false, 513 | "permalink": "/r/trapmuzik/comments/1glsb1/free_scooter_from_the_cell_block_to_your_block/", 514 | "name": "t3_1glsb1", 515 | "created": 1371613208.0, 516 | "url": "https://twitter.com/1YOUNGSCOOTER/status/347058815499386880", 517 | "author_flair_text": null, 518 | "author": "three_eyes", 519 | "created_utc": 1371584408.0, 520 | "ups": 1, 521 | "num_comments": 1, 522 | "num_reports": null, 523 | "distinguished": null 524 | } 525 | }, 526 | { 527 | "kind": "t3", 528 | "data": { 529 | "domain": "twitter.com", 530 | "banned_by": null, 531 | "media_embed": {}, 532 | "subreddit": "POLITIC", 533 | "selftext_html": null, 534 | "selftext": "", 535 | "likes": null, 536 | "link_flair_text": "MensRights|Serbanned", 537 | "id": "1glrrq", 538 | "clicked": false, 539 | "title": "LIL B SUPPORTS MRA!! LOVE U BASED GOD #BASED #MRA #MEN #MENSRIGHTS #FUCKWOMEN", 540 | "media": null, 541 | "score": 1, 542 | "approved_by": null, 543 | "over_18": false, 544 | "hidden": false, 545 | "thumbnail": "http://d.thumbs.redditmedia.com/rAsj0giU2fOJRxcu.jpg", 546 | "subreddit_id": "t5_2r84s", 547 | "edited": false, 548 | "link_flair_css_class": "meta", 549 | "author_flair_css_class": null, 550 | "downs": 0, 551 | "saved": false, 552 | "is_self": false, 553 | "permalink": "/r/POLITIC/comments/1glrrq/lil_b_supports_mra_love_u_based_god_based_mra_men/", 554 | "name": "t3_1glrrq", 555 | "created": 1371612832.0, 556 | "url": "https://twitter.com/LILBTHEBASEDGOD/status/347072628743356416", 557 | "author_flair_text": null, 558 | "author": "PoliticBot", 559 | "created_utc": 1371584032.0, 560 | "ups": 1, 561 | "num_comments": 2, 562 | "num_reports": null, 563 | "distinguished": null 564 | } 565 | }, 566 | { 567 | "kind": "t3", 568 | "data": { 569 | "domain": "twitter.com", 570 | "banned_by": null, 571 | "media_embed": {}, 572 | "subreddit": "POLITIC", 573 | "selftext_html": null, 574 | "selftext": "", 575 | "likes": null, 576 | "link_flair_text": "CanadaPolitics|MethoxyEthane", 577 | "id": "1glrqi", 578 | "clicked": false, 579 | "title": "Michael Applebaum has stepped down as the Mayor of Montreal", 580 | "media": null, 581 | "score": 1, 582 | "approved_by": null, 583 | "over_18": false, 584 | "hidden": false, 585 | "thumbnail": "http://a.thumbs.redditmedia.com/PGESVq5PENKYii4f.jpg", 586 | "subreddit_id": "t5_2r84s", 587 | "edited": false, 588 | "link_flair_css_class": "meta", 589 | "author_flair_css_class": null, 590 | "downs": 0, 591 | "saved": false, 592 | "is_self": false, 593 | "permalink": "/r/POLITIC/comments/1glrqi/michael_applebaum_has_stepped_down_as_the_mayor/", 594 | "name": "t3_1glrqi", 595 | "created": 1371612812.0, 596 | "url": "https://twitter.com/global_montreal/status/347074004969979905", 597 | "author_flair_text": null, 598 | "author": "PoliticBot", 599 | "created_utc": 1371584012.0, 600 | "ups": 1, 601 | "num_comments": 1, 602 | "num_reports": null, 603 | "distinguished": null 604 | } 605 | } 606 | ], 607 | "after": "t3_1glrqi", 608 | "before": null 609 | } 610 | } -------------------------------------------------------------------------------- /TweetPoster/test/json/tweet.json: -------------------------------------------------------------------------------- 1 | { 2 | "created_at": "Tue Jun 18 20:25:58 +0000 2013", 3 | "id": 347087814833344512, 4 | "id_str": "347087814833344512", 5 | "text": "Doc Rivers will be in Boston on Wednesday and plans to sit down with Danny Ainge, league source says.", 6 | "source": "Twitter for iPhone", 7 | "truncated": false, 8 | "in_reply_to_status_id": null, 9 | "in_reply_to_status_id_str": null, 10 | "in_reply_to_user_id": null, 11 | "in_reply_to_user_id_str": null, 12 | "in_reply_to_screen_name": null, 13 | "user": { 14 | "id": 50323173, 15 | "id_str": "50323173", 16 | "name": "Adrian Wojnarowski", 17 | "screen_name": "WojYahooNBA", 18 | "location": "Butler Gymnasium", 19 | "description": "NBA columnist for Yahoo! Sports/NBA Insider for NBC Sports Network.", 20 | "url": "http://t.co/XPxneapcLW", 21 | "entities": { 22 | "url": { 23 | "urls": [ 24 | { 25 | "url": "http://t.co/XPxneapcLW", 26 | "expanded_url": "http://sports.yahoo.com/author/adrian-wojnarowski/", 27 | "display_url": "sports.yahoo.com/author/adrian-…", 28 | "indices": [ 29 | 0, 30 | 22 31 | ] 32 | } 33 | ] 34 | }, 35 | "description": { 36 | "urls": [] 37 | } 38 | }, 39 | "protected": false, 40 | "followers_count": 406019, 41 | "friends_count": 227, 42 | "listed_count": 9968, 43 | "created_at": "Wed Jun 24 14:43:40 +0000 2009", 44 | "favourites_count": 47, 45 | "utc_offset": -18000, 46 | "time_zone": "Eastern Time (US & Canada)", 47 | "geo_enabled": false, 48 | "verified": true, 49 | "statuses_count": 6005, 50 | "lang": "en", 51 | "contributors_enabled": false, 52 | "is_translator": false, 53 | "profile_background_color": "9AE4E8", 54 | "profile_background_image_url": "http://a0.twimg.com/profile_background_images/667656964/0957d0281be9beab05a71bf88de03c05.png", 55 | "profile_background_image_url_https": "https://si0.twimg.com/profile_background_images/667656964/0957d0281be9beab05a71bf88de03c05.png", 56 | "profile_background_tile": true, 57 | "profile_image_url": "http://a0.twimg.com/profile_images/2062589385/butlergym_normal.jpg", 58 | "profile_image_url_https": "https://si0.twimg.com/profile_images/2062589385/butlergym_normal.jpg", 59 | "profile_link_color": "0084B4", 60 | "profile_sidebar_border_color": "FFFFFF", 61 | "profile_sidebar_fill_color": "DDFFCC", 62 | "profile_text_color": "333333", 63 | "profile_use_background_image": true, 64 | "default_profile": false, 65 | "default_profile_image": false, 66 | "following": false, 67 | "follow_request_sent": false, 68 | "notifications": false 69 | }, 70 | "geo": null, 71 | "coordinates": null, 72 | "place": null, 73 | "contributors": null, 74 | "retweet_count": 100, 75 | "favorite_count": 11, 76 | "entities": { 77 | "hashtags": [], 78 | "symbols": [], 79 | "urls": [], 80 | "user_mentions": [] 81 | }, 82 | "favorited": false, 83 | "retweeted": false, 84 | "lang": "en" 85 | } -------------------------------------------------------------------------------- /TweetPoster/test/test_reddit.py: -------------------------------------------------------------------------------- 1 | import time 2 | from os import path 3 | 4 | import httpretty 5 | 6 | from TweetPoster import Database, config 7 | from TweetPoster.reddit import Redditor 8 | 9 | json_dir = path.dirname(path.realpath(__file__)) + '/json/' 10 | config.update({ 11 | 'database': ':memory:' 12 | }) 13 | 14 | login_url = 'https://ssl.reddit.com/api/login' 15 | comment_url = 'http://www.reddit.com/api/comment' 16 | twitter_posts_url = 'http://www.reddit.com/domain/twitter.com/new.json' 17 | 18 | 19 | def mock_login(): 20 | f = open(json_dir + 'reddit_login.json') 21 | body = f.read() 22 | f.close() 23 | 24 | httpretty.register_uri( 25 | httpretty.POST, 26 | login_url, 27 | body=body, 28 | content_type='application/json', 29 | set_cookie='reddit_session=nom; Domain=reddit.com; Path=/; HttpOnly', 30 | ) 31 | 32 | 33 | def mock_comment(): 34 | f = open(json_dir + 'reddit_comment.json') 35 | body = f.read() 36 | f.close() 37 | 38 | httpretty.register_uri( 39 | httpretty.POST, 40 | comment_url, 41 | body=body, 42 | content_type='application/json' 43 | ) 44 | 45 | 46 | def mock_index(): 47 | httpretty.register_uri( 48 | httpretty.GET, 49 | 'http://www.reddit.com' 50 | ) 51 | 52 | 53 | def mock_posts(): 54 | f = open(json_dir + 'reddit_twitter_posts.json') 55 | body = f.read() 56 | f.close() 57 | 58 | httpretty.register_uri( 59 | httpretty.GET, 60 | twitter_posts_url, 61 | body=body, 62 | content_type='application/json' 63 | ) 64 | 65 | 66 | @httpretty.activate 67 | def test_ratelimit(): 68 | mock_index() 69 | url = 'http://www.reddit.com/' 70 | r = Redditor() 71 | start = time.time() 72 | r.get(url) 73 | r.get(url) 74 | end = time.time() 75 | assert end - start > 2 76 | 77 | 78 | @httpretty.activate 79 | def test_login(): 80 | mock_login() 81 | r = Redditor(bypass_ratelimit=True) 82 | r.login('TweetPoster', 'hunter2') 83 | req = httpretty.last_request() 84 | 85 | assert r.authenticated 86 | assert 'api_type=json' in req.body 87 | assert 'passwd=hunter2' in req.body 88 | assert 'user=TweetPoster' in req.body 89 | assert 'nom' == r.session.cookies['reddit_session'] 90 | 91 | 92 | @httpretty.activate 93 | def test_comment(): 94 | mock_login() 95 | mock_comment() 96 | 97 | thing_id = 't3_1hb15l' 98 | comment = 'test comment' 99 | 100 | r = Redditor(bypass_ratelimit=True).login('tp', 'hunter2') 101 | c = r.comment(thing_id, comment) 102 | 103 | thing = c.json()['json']['data']['things'][0] 104 | assert comment == thing['data']['contentText'] 105 | assert thing_id == thing['data']['parent'] 106 | 107 | 108 | @httpretty.activate 109 | def test_useragent(): 110 | mock_index() 111 | r = Redditor(bypass_ratelimit=True) 112 | r.get('http://www.reddit.com') 113 | h = httpretty.last_request().headers 114 | assert r.headers['User-Agent'] == h['User-Agent'] 115 | 116 | 117 | @httpretty.activate 118 | def test_get_new(): 119 | mock_posts() 120 | db = Database() 121 | db.init(clean=True) 122 | r = Redditor(bypass_ratelimit=True) 123 | all_posts = r.get_new_posts(db) 124 | assert len(all_posts) == 15 125 | 126 | # Mark some as processed 127 | for i, p in enumerate(all_posts): 128 | if i % 5 == 0: 129 | p.mark_as_processed(db) 130 | 131 | # Now check if only returns new ones 132 | posts = r.get_new_posts(db) 133 | assert len(posts) == 12 134 | -------------------------------------------------------------------------------- /TweetPoster/test/test_twitter.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | 3 | import httpretty 4 | from nose.plugins.attrib import attr 5 | 6 | from TweetPoster.twitter import Twitter 7 | 8 | json_dir = path.dirname(path.realpath(__file__)) + '/json/' 9 | tweet_url = 'https://api.twitter.com/1.1/statuses/show.json' 10 | 11 | 12 | def mock_tweet(): 13 | f = open(json_dir + 'tweet.json') 14 | body = f.read() 15 | f.close() 16 | 17 | httpretty.register_uri( 18 | httpretty.GET, 19 | tweet_url, 20 | body=body, 21 | content_type='application/json' 22 | ) 23 | 24 | 25 | @httpretty.activate 26 | def test_oauth(): 27 | mock_tweet() 28 | Twitter().get_tweet('347087814833344512') 29 | req = httpretty.last_request() 30 | assert 'Authorization' in req.headers 31 | assert 'oauth_token="' in req.headers['Authorization'] 32 | assert 'oauth_consumer_key="' in req.headers['Authorization'] 33 | 34 | 35 | @attr('network') 36 | def test_get_tweet(): 37 | t = Twitter().get_tweet('352056725160988672') 38 | assert t.id == 352056725160988672 39 | assert isinstance(t.markdown, unicode) 40 | 41 | 42 | @attr('network') 43 | def test_unicode_tweet(): 44 | Twitter().get_tweet('351969339991277568') 45 | -------------------------------------------------------------------------------- /TweetPoster/test/test_utils.py: -------------------------------------------------------------------------------- 1 | import httpretty 2 | 3 | from TweetPoster.utils import ( 4 | canonical_url, 5 | replace_entities, 6 | sanitize_markdown, 7 | ) 8 | 9 | 10 | class FakeTweet(object): 11 | 12 | def __init__(self, **kw): 13 | self.entities = { 14 | 'hashtags': [], 15 | 'symbols': [], 16 | 'user_mentions': [], 17 | 'urls': [], 18 | } 19 | 20 | for key, val in kw.items(): 21 | setattr(self, key, val) 22 | 23 | if 'text' in kw: 24 | self.text = sanitize_markdown(kw['text']) 25 | 26 | for word in kw['text'].split(' '): 27 | if word.startswith(('#', '\#')): 28 | tag = word.split('#', 1)[-1] 29 | self.entities['hashtags'].append({'text': tag}) 30 | 31 | elif word.startswith(('@', '\@')): 32 | name = word.split('@', 1)[-1] 33 | self.entities['user_mentions'].append({'screen_name': name}) 34 | 35 | elif word.startswith('http'): 36 | self.entities['urls'].append({ 37 | 'url': word, 38 | 'expanded_url': 'https://github.com/buttscicles/TweetPoster' 39 | }) 40 | 41 | 42 | def mock_redirect(): 43 | httpretty.register_uri( 44 | httpretty.HEAD, 45 | 'https://github.com/buttscicles/TweetPoster', 46 | location='http://yl.io', 47 | status=301, 48 | ) 49 | 50 | httpretty.register_uri( 51 | httpretty.HEAD, 52 | 'http://yl.io', 53 | ) 54 | 55 | 56 | @httpretty.activate 57 | def test_replace_entities(): 58 | t = replace_entities(FakeTweet(text='#hashtag')) 59 | assert t.text == '[#hashtag](https://twitter.com/search?q=%23hashtag)' 60 | 61 | t = replace_entities(FakeTweet(text='@username')) 62 | assert t.text == '[@username](https://twitter.com/username)' 63 | 64 | httpretty.register_uri( 65 | httpretty.HEAD, 66 | 'https://github.com/buttscicles/TweetPoster', 67 | ) 68 | 69 | t = replace_entities(FakeTweet(text='https://t.co/1')) 70 | assert t.text == '[*github.com*](https://github.com/buttscicles/TweetPoster)' 71 | 72 | mock_redirect() 73 | 74 | t = replace_entities(FakeTweet(text='http')) 75 | assert t.text == '[*yl.io*](http://yl.io)' 76 | 77 | 78 | def test_canonical(): 79 | u = canonical_url('https://github.com.') 80 | assert u == 'github.com' 81 | 82 | u = canonical_url('https://www.github.com/') 83 | assert u == 'github.com' 84 | 85 | u = canonical_url('github.com/buttscicles') 86 | assert u == 'github.com' 87 | 88 | u = canonical_url('http://example.com') 89 | assert u == 'example.com' 90 | 91 | 92 | def test_sanitize_markdown(): 93 | s = sanitize_markdown('[link](http://believe.in)') 94 | assert s == '\[link\]\(http://believe.in\)' 95 | 96 | s = sanitize_markdown('>some quote') 97 | assert s == '>some quote' 98 | 99 | s = sanitize_markdown('*bold*') 100 | assert s == '\*bold\*' 101 | 102 | s = sanitize_markdown('_bold_') 103 | assert s == '\_bold\_' 104 | 105 | s = sanitize_markdown('first, second and third') 106 | assert s == 'first, second and third' 107 | 108 | -------------------------------------------------------------------------------- /TweetPoster/twitter.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import datetime 3 | 4 | from requests_oauthlib import OAuth1 5 | 6 | from TweetPoster import User, config, utils, sentry 7 | 8 | 9 | class Twitter(User): 10 | 11 | tweet_re = re.compile( 12 | r'https?://(?:www\.|mobile\.)?twitter.com/.+/status(?:es)?/([0-9]{18})' 13 | ) 14 | 15 | def __init__(self, *a, **kw): 16 | super(Twitter, self).__init__(*a, **kw) 17 | 18 | self.session.auth = OAuth1( 19 | config['twitter']['consumer_key'], 20 | config['twitter']['consumer_secret'], 21 | config['twitter']['access_token'], 22 | config['twitter']['access_secret'], 23 | signature_type='auth_header' 24 | ) 25 | 26 | def get_tweet(self, tweet_id): 27 | url = 'https://api.twitter.com/1.1/statuses/show.json' 28 | params = { 29 | 'id': tweet_id, 30 | 'include_entities': 1, 31 | } 32 | 33 | r = self.get(url, params=params) 34 | assert r.status_code == 200, r.status_code 35 | 36 | return Tweet(r.json()) 37 | 38 | 39 | class Tweet(object): 40 | 41 | def __init__(self, json): 42 | self.user = TwitterUser(json['user']) 43 | self.text = json['text'] 44 | self.id = json['id'] 45 | self.reply_to = None 46 | self.entities = json['entities'] 47 | self.link = 'https://twitter.com/{0}/status/{1}'.format(self.user.name, self.id) 48 | self.datetime = datetime.strptime(json['created_at'], '%a %b %d %H:%M:%S +0000 %Y') 49 | self.markdown = utils.tweet_to_markdown(self) 50 | 51 | if json['in_reply_to_status_id'] is not None: 52 | try: 53 | self.reply_to = Twitter().get_tweet(json['in_reply_to_status_id_str']) 54 | except AssertionError: 55 | pass 56 | except: 57 | sentry.captureException() 58 | 59 | def __repr__(self): 60 | return ''.format(self.id) 61 | 62 | 63 | class TwitterUser(object): 64 | 65 | def __init__(self, json): 66 | self.name = json['screen_name'] 67 | self.link = 'https://twitter.com/' + self.name 68 | 69 | def __repr__(self): 70 | return ''.format(self.name) 71 | -------------------------------------------------------------------------------- /TweetPoster/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | import traceback 4 | 5 | from fuzzywuzzy import fuzz 6 | import requests 7 | 8 | import TweetPoster 9 | from TweetPoster import rehost 10 | 11 | from raven.processors import SanitizePasswordsProcessor 12 | 13 | 14 | class SanitizeCredentialsProcessor(SanitizePasswordsProcessor): 15 | FIELDS = frozenset([ 16 | 'authorization', 17 | 'password', 18 | 'secret', 19 | 'passwd', 20 | 'token', 21 | 'key', 22 | 'dsn', 23 | ]) 24 | 25 | 26 | def tweet_in_title(tweet, submission): 27 | similarity = fuzz.ratio(tweet.text, submission.title) 28 | if (similarity >= 85 or 29 | tweet.text.lower() in submission.title.lower()): 30 | return True 31 | return False 32 | 33 | 34 | def canonical_url(url): 35 | url = url.lower() 36 | 37 | if url.startswith('http://'): 38 | url = url[7:] 39 | if url.startswith('https://'): 40 | url = url[8:] 41 | if url.startswith('www.'): 42 | url = url[4:] 43 | if url.endswith('/'): 44 | url = url[:-1] 45 | if url.endswith('.'): 46 | url = url[:-1] 47 | 48 | url = url.split('/', 1)[0] 49 | return url 50 | 51 | 52 | def replace_entities(tweet): 53 | """ 54 | Rehosts images, expands urls and links 55 | hashtags and @mentions 56 | """ 57 | 58 | # Link hashtags 59 | for tag in tweet.entities['hashtags']: 60 | replacement = u'[#{tag}](https://twitter.com/search?q=%23{tag})'.format(tag=tag['text']) 61 | source = sanitize_markdown('#' + tag['text']) 62 | tweet.text = tweet.text.replace(source, replacement) 63 | 64 | # Link mentions 65 | for mention in tweet.entities['user_mentions']: 66 | replacement = u'[@{name}](https://twitter.com/{name})'.format(name=mention['screen_name']) 67 | tweet.text = re.sub('(?i)\@{0}'.format(mention['screen_name']), replacement, tweet.text) 68 | 69 | # Rehost pic.twitter.com images 70 | if 'media' in tweet.entities: 71 | # Photos using Twitter's own image sharing 72 | # will be in here. We need to match an re 73 | # against urls to grab the rest of them 74 | for media in tweet.entities['media']: 75 | if media['type'] != 'photo': 76 | continue 77 | 78 | imgur = rehost.PicTwitterCom.extract(media['media_url']) 79 | if not imgur: 80 | # We still want to unshorten the t.co link 81 | replacement = '[*pic.twitter.com*]({0})'.format(media['url']) 82 | else: 83 | replacement = u'[*pic.twitter.com*]({url}) [^[Imgur]]({imgur})' 84 | replacement = replacement.format(url=media['media_url'], imgur=imgur) 85 | 86 | source = sanitize_markdown(media['url']) 87 | tweet.text = tweet.text.replace(source, replacement) 88 | 89 | # Replace t.co with actual urls, unshorten any 90 | # other urls shorteners and rehost other images 91 | for url in tweet.entities['urls']: 92 | # check for redirects 93 | try: 94 | # requests will follow any redirects 95 | # and allow us to check for them 96 | r = requests.head(url['expanded_url'], allow_redirects=True) 97 | except requests.exceptions.RequestException: 98 | sys.stderr.write('Exception when checking url: {0}\n'.format( 99 | url['expanded_url'] 100 | )) 101 | traceback.print_exc() 102 | else: 103 | if r.history != []: 104 | url['expanded_url'] = r.url 105 | 106 | replacement = u'[*{canonical}*]({url})'.format( 107 | canonical=canonical_url(url['expanded_url']), 108 | url=url['expanded_url'] 109 | ) 110 | 111 | # Check if this link is to an image we can rehost 112 | for host in rehost.ImageHost.__subclasses__(): 113 | if re.match(host.url_re, url['expanded_url']): 114 | imgur = host().extract(url['expanded_url']) 115 | if imgur: 116 | replacement = replacement + ' [^[Imgur]]({0})'.format( 117 | imgur 118 | ) 119 | 120 | source = sanitize_markdown(url['url']) 121 | tweet.text = tweet.text.replace(source, replacement) 122 | 123 | return tweet 124 | 125 | 126 | def sanitize_markdown(unescaped): 127 | # This prevents newlines breaking out of a markdown quote 128 | # and also escapes markdown's special characters 129 | return re.sub(r'([\\`*_{}[\]()#+-])', r'\\\1', 130 | '\n>'.join(unescaped.splitlines())) 131 | 132 | 133 | def tweet_to_markdown(tweet): 134 | with open(TweetPoster.template_path + 'tweet.txt') as f: 135 | tweet_template = f.read().decode('utf8') 136 | 137 | # Sanitize markdown before processing twitter entities 138 | tweet.text = sanitize_markdown(tweet.text) 139 | 140 | # Link hashtags, expand urls, rehost images etc 141 | tweet = replace_entities(tweet) 142 | 143 | return tweet_template.format(**tweet.__dict__) 144 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | blinker==1.2 2 | oauthlib==0.4.2 3 | requests==1.2.3 4 | requests-oauthlib==0.3.2 5 | httpretty==0.6.2 6 | nose==1.3.0 7 | raven==3.3.12 8 | fuzzywuzzy==0.2 9 | beautifulsoup4==4.2.1 10 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import sys 4 | 5 | default_conf = { 6 | 'database': 'tp.db', 7 | 'sentry': { 8 | 'dsn': '' 9 | }, 10 | 'reddit': { 11 | 'username': '', 12 | 'password': '', 13 | }, 14 | 'twitter': { 15 | 'consumer_key': '', 16 | 'consumer_secret': '', 17 | 'access_token': '', 18 | 'access_secret': '', 19 | }, 20 | 'imgur': { 21 | 'key': '', 22 | }, 23 | } 24 | 25 | 26 | def write_conf(conf): 27 | config = json.dumps(conf, indent=4, sort_keys=True) 28 | with open('config.json', 'w') as f: 29 | f.write(config) 30 | 31 | 32 | if __name__ == '__main__': 33 | if not os.path.isfile('config.json'): 34 | write_conf(default_conf) 35 | print 'Created default config in config.json, please edit' 36 | 37 | elif 'updateconf' in sys.argv: 38 | with open('config.json', 'r') as f: 39 | config = json.loads(f.read()) 40 | 41 | default_conf.update(config) 42 | write_conf(default_conf) 43 | 44 | else: 45 | import TweetPoster 46 | TweetPoster.main() 47 | --------------------------------------------------------------------------------