diff --git a/docs/configuration.rst b/docs/configuration.rst index c0ca9431..d57cd831 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -461,6 +461,7 @@ Description * ``e6ai`` (*) * ``e926`` (*) * ``exhentai`` + * ``girlswithmuscle`` * ``horne`` (R) * ``idolcomplex`` * ``imgbb`` diff --git a/docs/gallery-dl.conf b/docs/gallery-dl.conf index f7773cd2..e472378d 100644 --- a/docs/gallery-dl.conf +++ b/docs/gallery-dl.conf @@ -328,6 +328,11 @@ { "enabled": false }, + "girlswithmuscle": + { + "username": "", + "password": "" + }, "gofile": { "api-token": null, diff --git a/docs/supportedsites.md b/docs/supportedsites.md index 17f7d3c6..3598ae4d 100644 --- a/docs/supportedsites.md +++ b/docs/supportedsites.md @@ -319,6 +319,12 @@ Consider all listed sites to potentially be NSFW. Favorites, Pools, Posts, Redirects, Tag Searches + + Girls with Muscle + https://www.girlswithmuscle.com/ + Posts, Search Results + Supported + Gofile https://gofile.io/ diff --git a/gallery_dl/extractor/__init__.py b/gallery_dl/extractor/__init__.py index c5733bd8..e656718f 100644 --- a/gallery_dl/extractor/__init__.py +++ b/gallery_dl/extractor/__init__.py @@ -63,6 +63,7 @@ modules = [ "gelbooru", "gelbooru_v01", "gelbooru_v02", + "girlswithmuscle", "gofile", "hatenablog", "hentai2read", diff --git a/gallery_dl/extractor/girlswithmuscle.py b/gallery_dl/extractor/girlswithmuscle.py new file mode 100644 index 00000000..c76abfdd --- /dev/null +++ b/gallery_dl/extractor/girlswithmuscle.py @@ -0,0 +1,179 @@ +# -*- coding: utf-8 -*- + +# 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. + +from .common import Extractor, Message +from .. import text, util, exception +from ..cache import cache + +BASE_PATTERN = r"(?:https?://)?(?:www\.)?girlswithmuscle\.com" + + +class GirlswithmuscleExtractor(Extractor): + """Base class for girlswithmuscle extractors""" + category = "girlswithmuscle" + root = "https://www.girlswithmuscle.com" + directory_fmt = ("{category}", "{model}") + filename_fmt = "{model}_{id}.{extension}" + archive_fmt = "{type}_{model}_{id}" + + def login(self): + username, password = self._get_auth_info() + if username: + self.cookies_update(self._login_impl(username, password)) + + @cache(maxage=14*86400, keyarg=1) + def _login_impl(self, username, password): + self.log.info("Logging in as %s", username) + + url = self.root + "/login/" + page = self.request(url).text + csrf_token = text.extr(page, 'name="csrfmiddlewaretoken" value="', '"') + + headers = { + "Origin" : self.root, + "Referer": url, + } + data = { + "csrfmiddlewaretoken": csrf_token, + "username": username, + "password": password, + "next": "/", + } + response = self.request( + url, method="POST", headers=headers, data=data) + + if not response.history: + raise exception.AuthenticationError() + + page = response.text + if ">Wrong username or password" in page: + raise exception.AuthenticationError() + if ">Log in<" in page: + raise exception.AuthenticationError("Account data is missing") + + return {c.name: c.value for c in response.history[0].cookies} + + +class GirlswithmusclePostExtractor(GirlswithmuscleExtractor): + """Extractor for individual posts on girlswithmuscle.com""" + subcategory = "post" + pattern = BASE_PATTERN + r"/(\d+)" + example = "https://www.girlswithmuscle.com/12345/" + + def items(self): + self.login() + + url = "{}/{}/".format(self.root, self.groups[0]) + page = self.request(url).text + if not page: + raise exception.NotFoundError("post") + + metadata = self.metadata(page) + + url = text.extr(page, 'class="main-image" src="', '"') + if url: + metadata["type"] = "picture" + else: + url = text.extr(page, '', "")) + image_info = text.extr( + page, '
', "
") + uploader = text.remove_html(text.extr( + image_info, '', "")) + + tags = text.extr(page, 'id="tags-text">', "") + score = text.parse_int(text.remove_html(text.extr( + page, "Score: ", "", "") + return "unknown" if model.startswith("Picture #") else model + + def _parse_model_list(self, model): + if model == "unknown": + return [] + else: + return [name.strip() for name in model.split(",")] + + def _parse_is_favorite(self, page): + fav_button = text.extr( + page, 'id="favorite-button">', "") + unfav_button = text.extr( + page, 'class="actionbutton unfavorite-button">', "") + + is_favorite = None + if unfav_button == "Unfavorite": + is_favorite = True + if fav_button == "Favorite": + is_favorite = False + + return is_favorite + + def _extract_comments(self, page): + comments = text.extract_iter( + page, '
', "
") + return [comment.strip() for comment in comments] + + +class GirlswithmuscleSearchExtractor(GirlswithmuscleExtractor): + """Extractor for search results on girlswithmuscle.com""" + subcategory = "search" + pattern = BASE_PATTERN + r"/images/(.*)" + example = "https://www.girlswithmuscle.com/images/?name=MODEL" + + def pages(self): + query = self.groups[0] + url = "{}/images/{}".format(self.root, query) + response = self.request(url) + if response.history: + msg = 'Request was redirected to "{}", try logging in'.format( + response.url) + raise exception.AuthorizationError(msg) + page = response.text + + match = util.re(r"Page (\d+) of (\d+)").search(page) + current, total = match.groups() + current, total = text.parse_int(current), text.parse_int(total) + + yield page + for i in range(current + 1, total + 1): + url = "{}/images/{}/{}".format(self.root, i, query) + yield self.request(url).text + + def items(self): + self.login() + for page in self.pages(): + data = { + "_extractor" : GirlswithmusclePostExtractor, + "gallery_name": text.unescape(text.extr(page, "", "<")), + } + for imgid in text.extract_iter(page, 'id="imgid-', '"'): + url = "{}/{}/".format(self.root, imgid) + yield Message.Queue, url, data diff --git a/scripts/supportedsites.py b/scripts/supportedsites.py index cd2d1978..9f2993f5 100755 --- a/scripts/supportedsites.py +++ b/scripts/supportedsites.py @@ -55,6 +55,7 @@ CATEGORY_MAP = { "fashionnova" : "Fashion Nova", "furaffinity" : "Fur Affinity", "furry34" : "Furry 34 com", + "girlswithmuscle": "Girls with Muscle", "hatenablog" : "HatenaBlog", "hbrowse" : "HBrowse", "hentai2read" : "Hentai2Read", @@ -458,6 +459,7 @@ AUTH_MAP = { "flickr" : _OAUTH, "furaffinity" : _COOKIES, "furbooru" : "API Key", + "girlswithmuscle": "Supported", "horne" : "Required", "idolcomplex" : "Supported", "imgbb" : "Supported", diff --git a/test/results/girlswithmuscle.py b/test/results/girlswithmuscle.py new file mode 100644 index 00000000..aac5208d --- /dev/null +++ b/test/results/girlswithmuscle.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- + +# 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. + +from gallery_dl.extractor import girlswithmuscle + + +__tests__ = ( +{ + "#url" : "https://www.girlswithmuscle.com/2526619/", + "#category": ("", "girlswithmuscle", "post"), + "#class" : girlswithmuscle.GirlswithmusclePostExtractor, + "#results" : "https://www.girlswithmuscle.com/images/full/2526619.jpg", + + "comments" : [], + "date" : "dt:2025-05-21 20:01:03", + "extension": "jpg", + "filename" : "2526619", + "id" : "2526619", + "is_favorite": None, + "model" : "Vladislava Galagan", + "model_list" : [ + "Vladislava Galagan" + ], + "score" : range(190, 250), + "source_filename": "", + "type" : "picture", + "uploader" : "mrt", + "tags": [ + "delts/shoulders", + "abs", + "casual", + "triceps", + "traps", + "bikini/competition suit", + "white", + "figure/fitness", + "bodybuilder", + "slavic", + "women's physique", + "russian", + ], +}, + +{ + "#url" : "https://www.girlswithmuscle.com/images/?name=Harmony%20Doughty", + "#category": ("", "girlswithmuscle", "search"), + "#class" : girlswithmuscle.GirlswithmuscleSearchExtractor, + "#pattern" : girlswithmuscle.GirlswithmusclePostExtractor.pattern, + "#count" : range(130, 150), +}, + +)