-
Notifications
You must be signed in to change notification settings - Fork 52
/
tumblr.py
279 lines (224 loc) · 9.49 KB
/
tumblr.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
"""Tumblr + Disqus blog webmention implementation.
To use, go to your Tumblr dashboard, click Customize, Edit HTML, then put this
in the head section::
<link rel="webmention" href="https://brid.gy/webmention/tumblr">
Misc notes and background:
* http://disqus.com/api/docs/
* http://disqus.com/api/docs/posts/create/
* http://help.disqus.com/customer/portal/articles/466253-what-html-tags-are-allowed-within-comments-
Guest post (w/arbitrary author, url):
* http://spirytoos.blogspot.com/2013/12/not-so-easy-posting-as-guest-via-disqus.html
* http://stackoverflow.com/questions/15416688/disqus-api-create-comment-as-guest
* http://jonathonhill.net/2013-07-11/disqus-guest-posting-via-api/
Can send url and not look up disqus thread id!
* http://stackoverflow.com/questions/4549282/disqus-api-adding-comment
* https://disqus.com/api/docs/forums/listThreads/
Test command line::
curl localhost:8080/webmention/tumblr -d 'source=http://localhost/response.html&target=http://snarfed.tumblr.com/post/60428995188/glen-canyon-http-t-co-fzc4ehiydp?foo=bar#baz'
"""
import collections
import logging
import re
import urllib.parse
from flask import render_template, request
from google.cloud import ndb
from oauth_dropins import tumblr as oauth_tumblr
from oauth_dropins.webutil.flask_util import flash
from oauth_dropins.webutil.util import json_dumps, json_loads
from werkzeug.exceptions import BadRequest
from flask_app import app
import models
import superfeedr
import util
logger = logging.getLogger(__name__)
TUMBLR_AVATAR_URL = 'http://api.tumblr.com/v2/blog/%s/avatar/512'
DISQUS_API_CREATE_POST_URL = 'https://disqus.com/api/3.0/posts/create.json'
DISQUS_API_THREAD_DETAILS_URL = 'http://disqus.com/api/3.0/threads/details.json'
DISQUS_ACCESS_TOKEN = util.read('disqus_access_token')
DISQUS_API_KEY = util.read('disqus_api_key')
DISQUS_API_SECRET = util.read('disqus_api_secret')
# Tumblr has no single standard markup or JS for integrating Disqus. It does
# have a default way, but themes often do it themselves, differently. Sigh.
# Details in https://github.com/snarfed/bridgy/issues/278
DISQUS_SHORTNAME_RES = (
re.compile(r"""
(?:https?://disqus\.com/forums|disqus[ -_]?(?:user|short)?name)
\ *[=:/]\ *['"]?
([^/"\' ]+) # the actual shortname
""", re.IGNORECASE | re.VERBOSE),
re.compile(r'https?://([^./"\' ]+)\.disqus\.com/embed\.js'),
)
class Tumblr(models.Source):
"""A Tumblr blog.
The key name is the blog domain.
"""
GR_CLASS = collections.namedtuple('FakeGrClass', ('NAME',))(NAME='Tumblr')
OAUTH_START = oauth_tumblr.Start
SHORT_NAME = 'tumblr'
disqus_shortname = ndb.StringProperty()
def feed_url(self):
# http://www.tumblr.com/help (search for feed)
return urllib.parse.urljoin(self.silo_url(), '/rss')
def silo_url(self):
return self.domain_urls[0]
def edit_template_url(self):
return f'http://www.tumblr.com/customize/{self.auth_entity.id()}'
@staticmethod
def new(auth_entity=None, blog_name=None, **kwargs):
"""Creates and returns a :class:`Tumblr` for the logged in user.
Args:
auth_entity (oauth_dropins.tumblr.TumblrAuth):
blog_name (str): which blog, optional, passed to :meth:`urls_and_domains`
"""
urls, domains = Tumblr.urls_and_domains(auth_entity, blog_name=blog_name)
if not urls or not domains:
flash('Tumblr blog not found. Please create one first!')
return None
id = domains[0]
return Tumblr(id=id,
auth_entity=auth_entity.key,
domains=domains,
domain_urls=urls,
name=auth_entity.user_display_name(),
picture=TUMBLR_AVATAR_URL % id,
superfeedr_secret=util.generate_secret(),
**kwargs)
@staticmethod
def urls_and_domains(auth_entity, blog_name=None):
"""Returns this blog's URL and domain.
Args:
auth_entity (oauth_dropins.tumblr.TumblrAuth)
blog_name (str): which blog, optional, matches the ``name`` field for one
of the blogs in ``auth_entity.user_json['user']['blogs']``
Returns:
([str url], [str domain]):
"""
for blog in json_loads(auth_entity.user_json).get('user', {}).get('blogs', []):
if ((blog_name and blog_name == blog.get('name')) or
(not blog_name and blog.get('primary'))):
return [blog['url']], [util.domain_from_link(blog['url']).lower()]
return [], []
def verified(self):
"""Returns True if we've found the webmention endpoint and Disqus."""
return self.webmention_endpoint and self.disqus_shortname
def verify(self):
"""Checks that Disqus is installed as well as the webmention endpoint.
Stores the result in webmention_endpoint.
"""
if self.verified():
return
super().verify(force=True)
html = getattr(self, '_fetched_html', None) # set by Source.verify()
if not self.disqus_shortname and html:
self.discover_disqus_shortname(html)
def discover_disqus_shortname(self, html):
# scrape the disqus shortname out of the page
logger.info("Looking for Disqus shortname in fetched HTML")
for regex in DISQUS_SHORTNAME_RES:
match = regex.search(html)
if match:
self.disqus_shortname = match.group(1)
logger.info(f"Found Disqus shortname {self.disqus_shortname}")
self.put()
def create_comment(self, post_url, author_name, author_url, content):
"""Creates a new comment in the source silo.
Must be implemented by subclasses.
Args:
post_url (str)
author_name (str)
author_url (str)
content (str)
Returns:
dict: JSON response with ``id`` and other fields
"""
if not self.disqus_shortname:
resp = util.requests_get(post_url)
resp.raise_for_status()
self.discover_disqus_shortname(resp.text)
if not self.disqus_shortname:
raise BadRequest("Your Bridgy account isn't fully set up yet: we haven't found your Disqus account.")
# strip slug, query and fragment from post url
parsed = urllib.parse.urlparse(post_url)
path = parsed.path.split('/')
if not util.is_int(path[-1]):
path.pop(-1)
post_url = urllib.parse.urlunparse(parsed[:2] + ('/'.join(path), '', '', ''))
# get the disqus thread id. details on thread queries:
# http://stackoverflow.com/questions/4549282/disqus-api-adding-comment
# https://disqus.com/api/docs/threads/details/
resp = self.disqus_call(util.requests_get, DISQUS_API_THREAD_DETAILS_URL,
{'forum': self.disqus_shortname,
# ident:[tumblr_post_id] should work, but doesn't :/
'thread': f'link:{post_url}',
})
thread_id = resp['id']
# create the comment
message = f'<a href="{author_url}">{author_name}</a>: {content}'
resp = self.disqus_call(util.requests_post, DISQUS_API_CREATE_POST_URL,
{'thread': thread_id,
'message': message,
# only allowed when authed as moderator/owner
# 'state': 'approved',
})
return resp
@staticmethod
def disqus_call(method, url, params, **kwargs):
"""Makes a Disqus API call.
Args:
method (callable): requests function to use, e.g. :func:`requests.get`
url (str)
params (dict): query parameters
kwargs: passed through to method
Returns:
dict: JSON response
"""
logger.info(f"Calling Disqus {url.split('/')[-2:]} with {params}")
params.update({
'api_key': DISQUS_API_KEY,
'api_secret': DISQUS_API_SECRET,
'access_token': DISQUS_ACCESS_TOKEN,
})
resp = method(url, params=params, **kwargs)
resp.raise_for_status()
resp = resp.json().get('response', {})
logger.info(f'Response: {resp}')
return resp
class ChooseBlog(oauth_tumblr.Callback):
def finish(self, auth_entity, state=None):
if not auth_entity:
util.maybe_add_or_delete_source(Tumblr, auth_entity, state)
return
vars = {
'action': '/tumblr/add',
'state': state,
'auth_entity_key': auth_entity.key.urlsafe().decode(),
'blogs': [{'id': b['name'],
'title': b.get('title', ''),
'domain': util.domain_from_link(b['url'])}
# user_json is the user/info response:
# http://www.tumblr.com/docs/en/api/v2#user-methods
for b in json_loads(auth_entity.user_json)['user']['blogs']
if b.get('name') and b.get('url')],
}
logger.info(f'Rendering choose_blog.html with {vars}')
return render_template('choose_blog.html', **vars)
@app.route('/tumblr/add', methods=['POST'])
def tumblr_add():
util.maybe_add_or_delete_source(
Tumblr,
ndb.Key(urlsafe=request.form['auth_entity_key']).get(),
request.form['state'],
blog_name=request.form['blog'],
)
class SuperfeedrNotify(superfeedr.Notify):
SOURCE_CLS = Tumblr
# Tumblr doesn't seem to use scope
# http://www.tumblr.com/docs/en/api/v2#oauth
start = util.oauth_starter(oauth_tumblr.Start).as_view(
'tumblr_start', '/tumblr/choose_blog')
app.add_url_rule('/tumblr/start', view_func=start, methods=['POST'])
app.add_url_rule('/tumblr/choose_blog', view_func=ChooseBlog.as_view(
'tumblr_choose_blog', 'unused'))
app.add_url_rule('/tumblr/delete/finish', view_func=oauth_tumblr.Callback.as_view(
'tumblr_delete_finish', '/delete/finish'))
app.add_url_rule('/tumblr/notify/<id>', view_func=SuperfeedrNotify.as_view('tumblr_notify'), methods=['POST'])