2019-05-16 23:56:48 +02:00
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
2022-02-17 18:23:59 +01:00
|
|
|
# Copyright 2019-2022 Mike Fährmann
|
2019-05-16 23:56:48 +02:00
|
|
|
#
|
|
|
|
# This program is free software; you can redistribute it and/or modify
|
|
|
|
# it under the terms of the GNU General Public License version 2 as
|
|
|
|
# published by the Free Software Foundation.
|
|
|
|
|
|
|
|
"""Extractors for https://www.patreon.com/"""
|
|
|
|
|
|
|
|
from .common import Extractor, Message
|
2019-12-12 01:14:32 +01:00
|
|
|
from .. import text, exception
|
2019-05-16 23:56:48 +02:00
|
|
|
from ..cache import memcache
|
2019-08-17 23:20:26 +02:00
|
|
|
import collections
|
2020-02-05 22:47:20 +01:00
|
|
|
import itertools
|
2019-08-17 23:20:26 +02:00
|
|
|
import json
|
2019-05-16 23:56:48 +02:00
|
|
|
|
|
|
|
|
|
|
|
class PatreonExtractor(Extractor):
|
|
|
|
"""Base class for patreon extractors"""
|
|
|
|
category = "patreon"
|
|
|
|
root = "https://www.patreon.com"
|
2022-01-01 03:52:01 +01:00
|
|
|
cookiedomain = ".patreon.com"
|
2019-05-17 13:13:11 +02:00
|
|
|
directory_fmt = ("{category}", "{creator[full_name]}")
|
2019-05-16 23:56:48 +02:00
|
|
|
filename_fmt = "{id}_{title}_{num:>02}.{extension}"
|
|
|
|
archive_fmt = "{id}_{num}"
|
2021-02-27 16:26:42 +01:00
|
|
|
browser = "firefox"
|
2022-02-01 01:37:03 +01:00
|
|
|
tls12 = False
|
2019-05-16 23:56:48 +02:00
|
|
|
_warning = True
|
|
|
|
|
|
|
|
def items(self):
|
|
|
|
|
2019-05-17 13:13:11 +02:00
|
|
|
if self._warning:
|
2021-12-16 02:21:16 +01:00
|
|
|
if not self._check_cookies(("session_id",)):
|
2019-05-17 13:13:11 +02:00
|
|
|
self.log.warning("no 'session_id' cookie set")
|
2019-05-16 23:56:48 +02:00
|
|
|
PatreonExtractor._warning = False
|
2021-10-17 04:14:58 +02:00
|
|
|
generators = self._build_file_generators(self.config("files"))
|
2019-05-16 23:56:48 +02:00
|
|
|
|
|
|
|
for post in self.posts():
|
2021-02-14 16:03:11 +01:00
|
|
|
|
|
|
|
if not post.get("current_user_can_view", True):
|
|
|
|
self.log.warning("Not allowed to view post %s", post["id"])
|
|
|
|
continue
|
2021-10-17 04:14:58 +02:00
|
|
|
yield Message.Directory, post
|
|
|
|
|
2019-05-16 23:56:48 +02:00
|
|
|
post["num"] = 0
|
2020-02-05 22:47:20 +01:00
|
|
|
hashes = set()
|
2021-10-17 04:14:58 +02:00
|
|
|
for kind, url, name in itertools.chain.from_iterable(
|
|
|
|
g(post) for g in generators):
|
2020-04-28 21:40:22 +02:00
|
|
|
fhash = self._filehash(url)
|
|
|
|
if fhash not in hashes or not fhash:
|
2020-02-05 22:47:20 +01:00
|
|
|
hashes.add(fhash)
|
|
|
|
post["hash"] = fhash
|
|
|
|
post["type"] = kind
|
|
|
|
post["num"] += 1
|
|
|
|
yield Message.Url, url, text.nameext_from_url(name, post)
|
2020-02-11 19:01:07 +01:00
|
|
|
else:
|
|
|
|
self.log.debug("skipping %s (%s %s)", url, fhash, kind)
|
2019-08-18 23:21:57 +02:00
|
|
|
|
2020-02-05 22:47:20 +01:00
|
|
|
@staticmethod
|
|
|
|
def _postfile(post):
|
|
|
|
postfile = post.get("post_file")
|
|
|
|
if postfile:
|
|
|
|
return (("postfile", postfile["url"], postfile["name"]),)
|
|
|
|
return ()
|
|
|
|
|
|
|
|
def _images(self, post):
|
|
|
|
for image in post["images"]:
|
|
|
|
url = image.get("download_url")
|
|
|
|
if url:
|
|
|
|
name = image.get("file_name") or self._filename(url) or url
|
|
|
|
yield "image", url, name
|
2019-08-18 23:21:57 +02:00
|
|
|
|
2022-03-06 17:07:13 +01:00
|
|
|
def _image_large(self, post):
|
|
|
|
image = post.get("image")
|
|
|
|
if image:
|
|
|
|
url = image.get("large_url")
|
|
|
|
if url:
|
|
|
|
name = image.get("file_name") or self._filename(url) or url
|
|
|
|
return (("image_large", url, name),)
|
|
|
|
return ()
|
|
|
|
|
2020-02-05 22:47:20 +01:00
|
|
|
def _attachments(self, post):
|
|
|
|
for attachment in post["attachments"]:
|
|
|
|
url = self.request(
|
|
|
|
attachment["url"], method="HEAD",
|
|
|
|
allow_redirects=False, fatal=False,
|
|
|
|
).headers.get("Location")
|
2019-05-16 23:56:48 +02:00
|
|
|
|
2020-02-05 22:47:20 +01:00
|
|
|
if url:
|
|
|
|
yield "attachment", url, attachment["name"]
|
2019-05-16 23:56:48 +02:00
|
|
|
|
2021-10-16 22:58:28 +02:00
|
|
|
def _content(self, post):
|
2020-02-05 22:47:20 +01:00
|
|
|
content = post.get("content")
|
|
|
|
if content:
|
|
|
|
for img in text.extract_iter(
|
|
|
|
content, '<img data-media-id="', '>'):
|
2022-11-04 23:39:38 +01:00
|
|
|
url = text.extr(img, 'src="', '"')
|
2020-02-05 22:47:20 +01:00
|
|
|
if url:
|
2021-10-16 22:58:28 +02:00
|
|
|
yield "content", url, self._filename(url) or url
|
2019-08-17 23:20:26 +02:00
|
|
|
|
2019-05-16 23:56:48 +02:00
|
|
|
def posts(self):
|
|
|
|
"""Return all relevant post objects"""
|
|
|
|
|
|
|
|
def _pagination(self, url):
|
2022-12-01 09:52:36 +01:00
|
|
|
headers = {
|
|
|
|
"Referer" : self.root + "/",
|
|
|
|
"Content-Type": "application/vnd.api+json",
|
|
|
|
}
|
2019-05-16 23:56:48 +02:00
|
|
|
|
|
|
|
while url:
|
2020-05-19 21:25:07 +02:00
|
|
|
url = text.ensure_http_scheme(url)
|
2019-05-20 15:46:59 +02:00
|
|
|
posts = self.request(url, headers=headers).json()
|
2019-05-16 23:56:48 +02:00
|
|
|
|
2019-08-17 23:20:26 +02:00
|
|
|
if "included" in posts:
|
|
|
|
included = self._transform(posts["included"])
|
|
|
|
for post in posts["data"]:
|
|
|
|
yield self._process(post, included)
|
2019-05-16 23:56:48 +02:00
|
|
|
|
|
|
|
if "links" not in posts:
|
|
|
|
return
|
|
|
|
url = posts["links"].get("next")
|
|
|
|
|
2019-08-17 23:20:26 +02:00
|
|
|
def _process(self, post, included):
|
|
|
|
"""Process and extend a 'post' object"""
|
|
|
|
attr = post["attributes"]
|
|
|
|
attr["id"] = text.parse_int(post["id"])
|
2021-02-14 16:03:11 +01:00
|
|
|
|
2021-05-18 00:31:01 +02:00
|
|
|
if attr.get("current_user_can_view", True):
|
|
|
|
|
|
|
|
relationships = post["relationships"]
|
2021-02-14 16:03:11 +01:00
|
|
|
attr["images"] = self._files(post, included, "images")
|
|
|
|
attr["attachments"] = self._files(post, included, "attachments")
|
|
|
|
attr["date"] = text.parse_datetime(
|
|
|
|
attr["published_at"], "%Y-%m-%dT%H:%M:%S.%f%z")
|
2021-05-18 00:31:01 +02:00
|
|
|
|
|
|
|
tags = relationships.get("user_defined_tags")
|
|
|
|
attr["tags"] = [
|
|
|
|
tag["id"].replace("user_defined;", "")
|
|
|
|
for tag in tags["data"]
|
|
|
|
if tag["type"] == "post_tag"
|
|
|
|
] if tags else []
|
|
|
|
|
|
|
|
user = relationships["user"]
|
2021-02-14 16:03:11 +01:00
|
|
|
attr["creator"] = (
|
|
|
|
self._user(user["links"]["related"]) or
|
|
|
|
included["user"][user["data"]["id"]])
|
|
|
|
|
2019-08-17 23:20:26 +02:00
|
|
|
return attr
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def _transform(included):
|
|
|
|
"""Transform 'included' into an easier to handle format"""
|
|
|
|
result = collections.defaultdict(dict)
|
|
|
|
for inc in included:
|
|
|
|
result[inc["type"]][inc["id"]] = inc["attributes"]
|
|
|
|
return result
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def _files(post, included, key):
|
|
|
|
"""Build a list of files"""
|
|
|
|
files = post["relationships"].get(key)
|
|
|
|
if files and files.get("data"):
|
|
|
|
return [
|
|
|
|
included[file["type"]][file["id"]]
|
|
|
|
for file in files["data"]
|
|
|
|
]
|
2021-11-01 02:58:53 +01:00
|
|
|
return []
|
2019-08-17 23:20:26 +02:00
|
|
|
|
2019-05-16 23:56:48 +02:00
|
|
|
@memcache(keyarg=1)
|
|
|
|
def _user(self, url):
|
2019-08-17 23:20:26 +02:00
|
|
|
"""Fetch user information"""
|
2019-12-12 00:31:35 +01:00
|
|
|
response = self.request(url, fatal=False)
|
|
|
|
if response.status_code >= 400:
|
|
|
|
return None
|
|
|
|
user = response.json()["data"]
|
2019-05-16 23:56:48 +02:00
|
|
|
attr = user["attributes"]
|
|
|
|
attr["id"] = user["id"]
|
2019-05-17 13:13:11 +02:00
|
|
|
attr["date"] = text.parse_datetime(
|
|
|
|
attr["created"], "%Y-%m-%dT%H:%M:%S.%f%z")
|
2019-05-16 23:56:48 +02:00
|
|
|
return attr
|
|
|
|
|
2019-08-17 23:20:26 +02:00
|
|
|
def _filename(self, url):
|
2020-04-28 21:40:22 +02:00
|
|
|
"""Fetch filename from an URL's Content-Disposition header"""
|
2019-08-17 23:20:26 +02:00
|
|
|
response = self.request(url, method="HEAD", fatal=False)
|
|
|
|
cd = response.headers.get("Content-Disposition")
|
2022-11-04 23:39:38 +01:00
|
|
|
return text.extr(cd, 'filename="', '"')
|
2019-08-17 23:20:26 +02:00
|
|
|
|
2020-04-28 21:40:22 +02:00
|
|
|
@staticmethod
|
|
|
|
def _filehash(url):
|
|
|
|
"""Extract MD5 hash from a download URL"""
|
|
|
|
parts = url.partition("?")[0].split("/")
|
|
|
|
parts.reverse()
|
|
|
|
|
|
|
|
for part in parts:
|
|
|
|
if len(part) == 32:
|
|
|
|
return part
|
|
|
|
return ""
|
|
|
|
|
2019-05-16 23:56:48 +02:00
|
|
|
@staticmethod
|
|
|
|
def _build_url(endpoint, query):
|
|
|
|
return (
|
|
|
|
"https://www.patreon.com/api/" + endpoint +
|
|
|
|
|
2022-12-01 10:02:28 +01:00
|
|
|
"?include=campaign,access_rules,attachments,audio,images,media,"
|
|
|
|
"native_video_insights,poll.choices,"
|
|
|
|
"poll.current_user_responses.user,"
|
|
|
|
"poll.current_user_responses.choice,"
|
|
|
|
"poll.current_user_responses.poll,"
|
|
|
|
"user,user_defined_tags,ti_checks"
|
|
|
|
|
|
|
|
"&fields[campaign]=currency,show_audio_post_download_links,"
|
|
|
|
"avatar_photo_url,avatar_photo_image_urls,earnings_visibility,"
|
|
|
|
"is_nsfw,is_monthly,name,url"
|
|
|
|
|
|
|
|
"&fields[post]=change_visibility_at,comment_count,commenter_count,"
|
|
|
|
"content,current_user_can_comment,current_user_can_delete,"
|
|
|
|
"current_user_can_view,current_user_has_liked,embed,image,"
|
|
|
|
"insights_last_updated_at,is_paid,like_count,meta_image_url,"
|
|
|
|
"min_cents_pledged_to_view,post_file,post_metadata,published_at,"
|
|
|
|
"patreon_url,post_type,pledge_url,preview_asset_type,thumbnail,"
|
|
|
|
"thumbnail_url,teaser_text,title,upgrade_url,url,"
|
|
|
|
"was_posted_by_campaign_owner,has_ti_violation,moderation_status,"
|
|
|
|
"post_level_suspension_removal_date,pls_one_liners_by_category,"
|
|
|
|
"video_preview,view_count"
|
|
|
|
|
|
|
|
"&fields[post_tag]=tag_type,value"
|
2019-05-16 23:56:48 +02:00
|
|
|
"&fields[user]=image_url,full_name,url"
|
2022-12-01 10:02:28 +01:00
|
|
|
"&fields[access_rule]=access_rule_type,amount_cents"
|
|
|
|
"&fields[media]=id,image_urls,download_url,metadata,file_name"
|
|
|
|
"&fields[native_video_insights]=average_view_duration,"
|
|
|
|
"average_view_pct,has_preview,id,last_updated_at,num_views,"
|
|
|
|
"preview_views,video_duration" + query +
|
2019-05-16 23:56:48 +02:00
|
|
|
|
|
|
|
"&json-api-version=1.0"
|
|
|
|
)
|
|
|
|
|
2021-10-17 04:14:58 +02:00
|
|
|
def _build_file_generators(self, filetypes):
|
|
|
|
if filetypes is None:
|
2022-03-06 17:07:13 +01:00
|
|
|
return (self._images, self._image_large,
|
|
|
|
self._attachments, self._postfile, self._content)
|
2021-10-17 04:14:58 +02:00
|
|
|
genmap = {
|
|
|
|
"images" : self._images,
|
2022-03-06 17:07:13 +01:00
|
|
|
"image_large": self._image_large,
|
2021-10-17 04:14:58 +02:00
|
|
|
"attachments": self._attachments,
|
|
|
|
"postfile" : self._postfile,
|
|
|
|
"content" : self._content,
|
|
|
|
}
|
|
|
|
if isinstance(filetypes, str):
|
|
|
|
filetypes = filetypes.split(",")
|
|
|
|
return [genmap[ft] for ft in filetypes]
|
|
|
|
|
2022-11-30 11:57:52 +01:00
|
|
|
def _extract_bootstrap(self, page):
|
|
|
|
return json.loads(text.extr(
|
|
|
|
page, "window.patreon.bootstrap,", "\n});") + "}")
|
|
|
|
|
2019-05-16 23:56:48 +02:00
|
|
|
|
|
|
|
class PatreonCreatorExtractor(PatreonExtractor):
|
|
|
|
"""Extractor for a creator's works"""
|
|
|
|
subcategory = "creator"
|
|
|
|
pattern = (r"(?:https?://)?(?:www\.)?patreon\.com"
|
2020-10-22 23:12:59 +02:00
|
|
|
r"/(?!(?:home|join|posts|login|signup)(?:$|[/?#]))"
|
|
|
|
r"([^/?#]+)(?:/posts)?/?(?:\?([^#]+))?")
|
2019-12-12 01:14:32 +01:00
|
|
|
test = (
|
|
|
|
("https://www.patreon.com/koveliana", {
|
|
|
|
"range": "1-25",
|
|
|
|
"count": ">= 25",
|
|
|
|
"keyword": {
|
|
|
|
"attachments" : list,
|
|
|
|
"comment_count": int,
|
|
|
|
"content" : str,
|
|
|
|
"creator" : dict,
|
|
|
|
"date" : "type:datetime",
|
|
|
|
"id" : int,
|
|
|
|
"images" : list,
|
|
|
|
"like_count" : int,
|
|
|
|
"post_type" : str,
|
|
|
|
"published_at" : str,
|
|
|
|
"title" : str,
|
|
|
|
},
|
|
|
|
}),
|
2020-04-28 23:56:48 +02:00
|
|
|
("https://www.patreon.com/koveliana/posts?filters[month]=2020-3", {
|
|
|
|
"count": 1,
|
|
|
|
"keyword": {"date": "dt:2020-03-30 21:21:44"},
|
|
|
|
}),
|
2019-12-12 01:14:32 +01:00
|
|
|
("https://www.patreon.com/kovelianot", {
|
|
|
|
"exception": exception.NotFoundError,
|
|
|
|
}),
|
2020-04-26 22:19:10 +02:00
|
|
|
("https://www.patreon.com/user?u=2931440"),
|
|
|
|
("https://www.patreon.com/user/posts/?u=2931440"),
|
2019-12-12 01:14:32 +01:00
|
|
|
)
|
2019-05-16 23:56:48 +02:00
|
|
|
|
|
|
|
def __init__(self, match):
|
|
|
|
PatreonExtractor.__init__(self, match)
|
2020-04-28 23:56:48 +02:00
|
|
|
self.creator, self.query = match.groups()
|
2019-05-16 23:56:48 +02:00
|
|
|
|
|
|
|
def posts(self):
|
2020-04-28 23:56:48 +02:00
|
|
|
query = text.parse_query(self.query)
|
|
|
|
|
|
|
|
creator_id = query.get("u")
|
|
|
|
if creator_id:
|
2021-02-27 16:37:41 +01:00
|
|
|
url = "{}/user/posts?u={}".format(self.root, creator_id)
|
2020-04-26 22:19:10 +02:00
|
|
|
else:
|
2021-02-27 16:37:41 +01:00
|
|
|
url = "{}/{}/posts".format(self.root, self.creator)
|
2019-12-12 01:14:32 +01:00
|
|
|
page = self.request(url, notfound="creator").text
|
2022-11-30 11:57:52 +01:00
|
|
|
|
|
|
|
try:
|
|
|
|
data = self._extract_bootstrap(page)
|
|
|
|
campaign_id = data["creator"]["data"]["id"]
|
|
|
|
except (KeyError, ValueError):
|
2019-12-12 01:14:32 +01:00
|
|
|
raise exception.NotFoundError("creator")
|
|
|
|
|
2020-04-28 23:56:48 +02:00
|
|
|
filters = "".join(
|
|
|
|
"&filter[{}={}".format(key[8:], text.escape(value))
|
|
|
|
for key, value in query.items()
|
|
|
|
if key.startswith("filters[")
|
|
|
|
)
|
|
|
|
|
2019-05-16 23:56:48 +02:00
|
|
|
url = self._build_url("posts", (
|
2022-12-01 10:02:28 +01:00
|
|
|
"&filter[campaign_id]=" + campaign_id +
|
2019-05-16 23:56:48 +02:00
|
|
|
"&filter[contains_exclusive_posts]=true"
|
2022-12-01 10:02:28 +01:00
|
|
|
"&filter[is_draft]=false" + filters +
|
|
|
|
"&sort=" + query.get("sort", "-published_at")
|
2019-05-16 23:56:48 +02:00
|
|
|
))
|
|
|
|
return self._pagination(url)
|
|
|
|
|
|
|
|
|
|
|
|
class PatreonUserExtractor(PatreonExtractor):
|
|
|
|
"""Extractor for media from creators supported by you"""
|
|
|
|
subcategory = "user"
|
|
|
|
pattern = r"(?:https?://)?(?:www\.)?patreon\.com/home$"
|
|
|
|
test = ("https://www.patreon.com/home",)
|
|
|
|
|
|
|
|
def posts(self):
|
|
|
|
url = self._build_url("stream", (
|
|
|
|
"&page[cursor]=null"
|
|
|
|
"&filter[is_following]=true"
|
2022-12-01 10:02:28 +01:00
|
|
|
"&json-api-use-default-includes=false"
|
2019-05-16 23:56:48 +02:00
|
|
|
))
|
|
|
|
return self._pagination(url)
|
2019-08-17 23:20:26 +02:00
|
|
|
|
|
|
|
|
|
|
|
class PatreonPostExtractor(PatreonExtractor):
|
|
|
|
"""Extractor for media from a single post"""
|
|
|
|
subcategory = "post"
|
2020-10-22 23:12:59 +02:00
|
|
|
pattern = r"(?:https?://)?(?:www\.)?patreon\.com/posts/([^/?#]+)"
|
2019-12-12 01:14:32 +01:00
|
|
|
test = (
|
2020-02-05 22:47:20 +01:00
|
|
|
# postfile + attachments
|
2019-12-12 01:14:32 +01:00
|
|
|
("https://www.patreon.com/posts/precious-metal-23563293", {
|
|
|
|
"count": 4,
|
|
|
|
}),
|
2020-02-05 22:47:20 +01:00
|
|
|
# postfile + content
|
2021-10-16 22:58:28 +02:00
|
|
|
("https://www.patreon.com/posts/56127163", {
|
|
|
|
"count": 3,
|
|
|
|
"keyword": {"filename": r"re:^(?!1).+$"},
|
2019-12-14 22:06:08 +01:00
|
|
|
}),
|
2021-05-18 00:31:01 +02:00
|
|
|
# tags (#1539)
|
|
|
|
("https://www.patreon.com/posts/free-post-12497641", {
|
|
|
|
"keyword": {"tags": ["AWMedia"]},
|
|
|
|
}),
|
2019-12-12 01:14:32 +01:00
|
|
|
("https://www.patreon.com/posts/not-found-123", {
|
|
|
|
"exception": exception.NotFoundError,
|
|
|
|
}),
|
|
|
|
)
|
2019-08-17 23:20:26 +02:00
|
|
|
|
|
|
|
def __init__(self, match):
|
|
|
|
PatreonExtractor.__init__(self, match)
|
2019-12-14 22:06:08 +01:00
|
|
|
self.slug = match.group(1)
|
2019-08-17 23:20:26 +02:00
|
|
|
|
|
|
|
def posts(self):
|
2019-12-14 22:06:08 +01:00
|
|
|
url = "{}/posts/{}".format(self.root, self.slug)
|
2019-12-12 01:14:32 +01:00
|
|
|
page = self.request(url, notfound="post").text
|
2022-11-30 11:57:52 +01:00
|
|
|
post = self._extract_bootstrap(page)["post"]
|
2019-08-17 23:20:26 +02:00
|
|
|
|
|
|
|
included = self._transform(post["included"])
|
|
|
|
return (self._process(post["data"], included),)
|