Source code for tumblr

"""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 request
from google.cloud import ndb
from oauth_dropins import tumblr as oauth_tumblr
from webutil.flask_util import flash
from 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'),
  )


[docs] 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()}'
[docs] @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)
[docs] @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 [], []
[docs] def verified(self): """Returns True if we've found the webmention endpoint and Disqus.""" return self.webmention_endpoint and self.disqus_shortname
[docs] 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()
[docs] 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
[docs] @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 util.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'])