diff --git a/gallery_dl/extractor/flickr.py b/gallery_dl/extractor/flickr.py index 9b4b6f60..163320b8 100644 --- a/gallery_dl/extractor/flickr.py +++ b/gallery_dl/extractor/flickr.py @@ -10,6 +10,7 @@ from .common import Extractor, Message from .. import text, util, exception +from . import oauth class FlickrExtractor(Extractor): @@ -213,14 +214,23 @@ class FlickrFavoriteExtractor(FlickrExtractor): class FlickrAPI(): """Minimal interface for the flickr API""" - api_url = "https://api.flickr.com/services/rest/" - formats = [("o", "Original"), ("k", "Large 2048"), + API_URL = "https://api.flickr.com/services/rest/" + API_KEY = "ac4fd7aa98585b9eee1ba761c209de68" + API_SECRET = "3adb0f568dc68393" + FORMATS = [("o", "Original"), ("k", "Large 2048"), ("h", "Large 1600"), ("l", "Large")] - def __init__(self, extractor, api_key="ac4fd7aa98585b9eee1ba761c209de68"): - self.session = extractor.session + def __init__(self, extractor): + token = extractor.config("access-token") + token_secret = extractor.config("access-token-secret") + if token and token_secret: + self.session = oauth.OAuthSession( + extractor.session, + self.API_KEY, self.API_SECRET, token, token_secret) + self.API_KEY = None + else: + self.session = extractor.session self.subcategory = extractor.subcategory - self.api_key = api_key def favorites_getPublicList(self, user_id): """Returns a list of favorite public photos for the given user.""" @@ -285,10 +295,11 @@ class FlickrAPI(): def _call(self, method, params): params["method"] = "flickr." + method - params["api_key"] = self.api_key params["format"] = "json" params["nojsoncallback"] = "1" - data = self.session.get(self.api_url, params=params).json() + if self.API_KEY: + params["api_key"] = self.API_KEY + data = self.session.get(self.API_URL, params=params).json() if "code" in data and data["code"] == 1: raise exception.NotFoundError(self.subcategory) return data @@ -310,7 +321,7 @@ class FlickrAPI(): yield obj - if params["page"] == obj["pages"]: + if params["page"] >= obj["pages"]: break params["page"] += 1 @@ -321,7 +332,7 @@ class FlickrAPI(): yield photo def _extract_format(self, photo): - for fmt, fmtname in self.formats: + for fmt, fmtname in self.FORMATS: key = "url_" + fmt if key in photo: # generate photo info diff --git a/gallery_dl/extractor/oauth.py b/gallery_dl/extractor/oauth.py index 2cff856c..6c8e5b61 100644 --- a/gallery_dl/extractor/oauth.py +++ b/gallery_dl/extractor/oauth.py @@ -9,15 +9,55 @@ """Utility classes to setup OAuth""" from .common import Extractor, Message -from . import reddit +from . import reddit, flickr +import time +import hmac +import base64 import random -import socket import string -import webbrowser +import hashlib import urllib.parse +class OAuthSession(): + """Minimal wrapper for requests.session objects to support OAuth 1.0""" + def __init__(self, session, consumer_key, consumer_secret, + token=None, token_secret=None): + self.session = session + self.consumer_secret = consumer_secret + self.token_secret = token_secret or "" + self.params = session.params + self.params["oauth_consumer_key"] = consumer_key + self.params["oauth_token"] = token + self.params["oauth_signature_method"] = "HMAC-SHA1" + self.params["oauth_version"] = "1.0" + + def get(self, url, params): + params.update(self.params) + params["oauth_nonce"] = self.nonce(16) + params["oauth_timestamp"] = int(time.time()) + params["oauth_signature"] = self.signature(url, params) + return self.session.get(url, params=params) + + def signature(self, url, params): + """Generate 'oauth_signature' value""" + query = urllib.parse.urlencode(sorted(params.items())) + message = self.concat("GET", url, query).encode() + key = self.concat(self.consumer_secret, self.token_secret).encode() + signature = hmac.new(key, message, hashlib.sha1).digest() + return base64.b64encode(signature) + + @staticmethod + def concat(*args): + return "&".join(urllib.parse.quote(item, "") for item in args) + + @staticmethod + def nonce(N, alphabet=string.ascii_letters): + return "".join(random.choice(alphabet) for _ in range(N)) + + class OAuthBase(Extractor): + """Base class for OAuth Helpers""" category = "oauth" redirect_uri = "http://localhost:6414/" @@ -26,6 +66,8 @@ class OAuthBase(Extractor): self.client = None def recv(self): + """Open local HTTP server and recv callback parameters""" + import socket print("Waiting for response. (Cancel with Ctrl+c)") server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) @@ -46,10 +88,20 @@ class OAuthBase(Extractor): } def send(self, msg): + """Send 'msg' to the socket opened in 'recv()'""" print(msg) self.client.send(b"HTTP/1.1 200 OK\r\n\r\n" + msg.encode()) self.client.close() + def open(self, url, params): + """Open 'url' in browser amd return response parameters""" + import webbrowser + url += "?" + urllib.parse.urlencode(params) + if not webbrowser.open(url): + print("Please open this URL in your browser:") + print(url, end="\n\n", flush=True) + return self.recv() + class OAuthReddit(OAuthBase): subcategory = "reddit" @@ -60,13 +112,12 @@ class OAuthReddit(OAuthBase): self.session.headers["User-Agent"] = reddit.RedditAPI.USER_AGENT self.client_id = reddit.RedditAPI.CLIENT_ID self.state = "gallery-dl:{}:{}".format( - self.subcategory, - "".join(random.choice(string.ascii_letters) for _ in range(8)), - ) + self.subcategory, OAuthSession.nonce(8)) def items(self): yield Message.Version, 1 + url = "https://www.reddit.com/api/v1/authorize" params = { "client_id": self.client_id, "response_type": "code", @@ -75,14 +126,9 @@ class OAuthReddit(OAuthBase): "duration": "permanent", "scope": "read", } - url = "https://www.reddit.com/api/v1/authorize?" - url += urllib.parse.urlencode(params) - if not webbrowser.open(url): - print("Please open this URL in your browser:") - print(url, end="\n\n", flush=True) - - params = self.recv() + # receive 'code' + params = self.open(url, params) if self.state != params.get("state"): self.send("'state' mismatch: expected {}, got {}.".format( @@ -92,14 +138,15 @@ class OAuthReddit(OAuthBase): self.send(params["error"]) return + # exchange 'code' for 'refresh_token' url = "https://www.reddit.com/api/v1/access_token" + auth = (self.client_id, "") data = { "grant_type": "authorization_code", "code": params["code"], "redirect_uri": self.redirect_uri, } - response = self.session.post(url, data=data, auth=(self.client_id, "")) - data = response.json() + data = self.session.post(url, auth=auth, data=data).json() if "error" in data: self.send(data["error"]) @@ -107,6 +154,44 @@ class OAuthReddit(OAuthBase): self.send(REDDIT_MSG_TEMPLATE.format(token=data["refresh_token"])) +class OAuthFlickr(OAuthBase): + subcategory = "flickr" + pattern = ["oauth:flickr$"] + + def __init__(self, match): + OAuthBase.__init__(self) + self.session = OAuthSession(self.session, + flickr.FlickrAPI.API_KEY, + flickr.FlickrAPI.API_SECRET) + del self.session.params["oauth_token"] + + def items(self): + yield Message.Version, 1 + + # Get a Request Token + url = "https://www.flickr.com/services/oauth/request_token" + params = {"oauth_callback": self.redirect_uri} + data = self.session.get(url, params=params).text + + data = urllib.parse.parse_qs(data) + self.session.params["oauth_token"] = token = data["oauth_token"][0] + self.session.token_secret = data["oauth_token_secret"][0] + + # Get the User's Authorization + url = "https://www.flickr.com/services/oauth/authorize" + params = {"oauth_token": token, "perms": "read"} + data = self.open(url, params) + + # Exchange the Request Token for an Access Token + url = "https://www.flickr.com/services/oauth/access_token" + data = self.session.get(url, params=data).text + + data = urllib.parse.parse_qs(data) + self.send(FLICKR_MSG_TEMPLATE.format( + token=data["oauth_token"][0], + token_secret=data["oauth_token_secret"][0])) + + REDDIT_MSG_TEMPLATE = """ Your Refresh Token is @@ -123,3 +208,23 @@ Example: }} }} """ + +FLICKR_MSG_TEMPLATE = """ +Your Access Token and Access Token Secret are + +{token} +{token_secret} + +Put these values into your configuration file as +'extractor.flickr.access-token' and 'extractor.flickr.access-token-secret'. + +Example: +{{ + "extractor": {{ + "flickr": {{ + "access-token": "{token}", + "access-token-secret": "{token_secret}" + }} + }} +}} +"""