diff --git a/docs/supportedsites.md b/docs/supportedsites.md index 0f7d40b4..dc7552b7 100644 --- a/docs/supportedsites.md +++ b/docs/supportedsites.md @@ -700,7 +700,7 @@ Consider all listed sites to potentially be NSFW. Patreon https://www.patreon.com/ - Creators, Posts, User Profiles + Collections, Creators, Posts, User Profiles Cookies diff --git a/gallery_dl/extractor/patreon.py b/gallery_dl/extractor/patreon.py index fb2f32c1..cf1a6d61 100644 --- a/gallery_dl/extractor/patreon.py +++ b/gallery_dl/extractor/patreon.py @@ -230,6 +230,16 @@ class PatreonExtractor(Extractor): attr["created"], "%Y-%m-%dT%H:%M:%S.%f%z") return attr + def _collection(self, collection_id): + url = f"{self.root}/api/collection/{collection_id}" + data = self.request_json(url) + coll = data["data"] + attr = coll["attributes"] + attr["id"] = coll["id"] + attr["date"] = text.parse_datetime( + attr["created_at"], "%Y-%m-%dT%H:%M:%S.%f%z") + return attr + def _filename(self, url): """Fetch filename from an URL's Content-Disposition header""" response = self.request(url, method="HEAD", fatal=False) @@ -333,6 +343,33 @@ class PatreonExtractor(Extractor): raise exception.AbortExtraction("Unable to extract bootstrap data") +class PatreonCollectionExtractor(PatreonExtractor): + """Extractor for a patreon collection""" + subcategory = "collection" + directory_fmt = ("{category}", "{creator[full_name]}", + "Collections", "{collection[title]} ({collection[id]})") + pattern = r"(?:https?://)?(?:www\.)?patreon\.com/collection/(\d+)" + example = "https://www.patreon.com/collection/12345" + + def posts(self): + collection_id = self.groups[0] + self.kwdict["collection"] = collection = \ + self._collection(collection_id) + campaign_id = text.extr( + collection["thumbnail"]["url"], "/campaign/", "/") + + url = self._build_url("posts", ( + # patreon returns '400 Bad Request' without campaign_id filter + f"&filter[campaign_id]={campaign_id}" + "&filter[contains_exclusive_posts]=true" + "&filter[is_draft]=false" + f"&filter[collection_id]={collection_id}" + "&filter[include_drops]=true" + "&sort=collection_order" + )) + return self._pagination(url) + + class PatreonCreatorExtractor(PatreonExtractor): """Extractor for a creator's works""" subcategory = "creator" diff --git a/test/results/patreon.py b/test/results/patreon.py index 654109fb..f2aeee8b 100644 --- a/test/results/patreon.py +++ b/test/results/patreon.py @@ -191,4 +191,45 @@ __tests__ = ( "#exception": exception.NotFoundError, }, +{ + "#url" : "https://www.patreon.com/collection/15764", + "#class" : patreon.PatreonCollectionExtractor, + "#range" : "1-3", + "#pattern" : ( + r"https://c10.patreonusercontent.com/4/patreon-media/p/post/32798362/957d49296e4f48ef80718d0de98c15a4/eyJhIjoxLCJwIjoxfQ%3D%3D/2.jpg\?token-hash=.+", + r"ytdl:https://stream.mux.com/h4DYqFU901qkkAwYmRWZPraVk5DvTJTlcSdhGV00006KBE.m3u8\?token=ey.+", + r"https://c10.patreonusercontent.com/4/patreon-media/p/post/32798374/357b0133a476427a99169b4400ee03d4/eyJhIjoxLCJwIjoxfQ%3D%3D/2.jpg\?token-hash=.+", + ), + + "campaign" : { + "currency" : "USD", + "is_monthly" : True, + "is_nsfw" : False, + "name" : "YaBoyRoshi", + }, + "collection" : { + "created_at" : "2023-08-31T14:10:41.000+00:00", + "date" : "dt:2023-08-31 14:10:41", + "default_layout" : "grid", + "description" : "", + "edited_at" : "2025-07-16T22:58:10.834+00:00", + "id" : "15764", + "num_draft_posts": 0, + "num_posts" : 207, + "num_posts_visible_for_creation": 207, + "num_scheduled_posts": 8, + "post_sort_type" : "custom", + "title" : "JoJo's Bizarre Adventure", + "post_ids" : list, + "thumbnail" : dict, + }, + "creator" : { + "date" : "dt:2018-10-17 05:45:19", + "first_name": "YaBoyRoshi", + "full_name" : "YaBoyRoshi", + "id" : "14264111", + "vanity" : "yaboyroshi", + }, +}, + )