├── .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 [](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  \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 \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 ago\n \n\n\n\n <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 [–]\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  \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 \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 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 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>> 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 \n </span>\n if 1 * 2 < 3:<br/>\n <span class=\"spaces\">\n \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 < 3:<br/> 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  <a href=\"javascript:void(0)\" class=\"yes\"\n onclick='change_state(this, \"del\", hide_thing, undefined, null)'>\n yes\n </a> / \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 |
--------------------------------------------------------------------------------