diff --git a/docs/configuration.rst b/docs/configuration.rst index 633c913f..d99a8686 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -4536,6 +4536,29 @@ Description Download video files. +extractor.weverse.embeds +------------------------ +Type + ``bool`` +Default + ``true`` +Description + Download YouTube embeds found in ``Media`` posts. + + Note: Setting `extractor.weverse.videos`_ to ``false`` will + override this setting. + + +extractor.weverse.videos +------------------------ +Type + ``bool`` +Default + ``true`` +Description + Download video files. + + extractor.ytdl.cmdline-args --------------------------- Type diff --git a/docs/gallery-dl.conf b/docs/gallery-dl.conf index 2a7f8f20..a32be99f 100644 --- a/docs/gallery-dl.conf +++ b/docs/gallery-dl.conf @@ -366,6 +366,14 @@ "retweets": true, "videos": true }, + "weverse": + { + "username": null, + "password": null, + "cookies": null, + "embeds": true, + "videos": true + }, "ytdl": { "enabled": false, diff --git a/docs/supportedsites.md b/docs/supportedsites.md index ea0b7ae4..b8b6fb6f 100644 --- a/docs/supportedsites.md +++ b/docs/supportedsites.md @@ -997,6 +997,12 @@ Consider all listed sites to potentially be NSFW. Albums, Articles, Feeds, Images from Statuses, User Profiles, Videos + + Weverse + https://weverse.io/ + Feed Tab, Artist Tab, Media Files, Media Categories, Media Tabs, Member Profiles, Moments, Posts + Cookies + WikiArt.org https://www.wikiart.org/ diff --git a/gallery_dl/extractor/__init__.py b/gallery_dl/extractor/__init__.py index e103cb1b..432d74ab 100644 --- a/gallery_dl/extractor/__init__.py +++ b/gallery_dl/extractor/__init__.py @@ -178,6 +178,7 @@ modules = [ "webmshare", "webtoons", "weibo", + "weverse", "wikiart", "wikifeet", "wikimedia", diff --git a/gallery_dl/extractor/weverse.py b/gallery_dl/extractor/weverse.py new file mode 100644 index 00000000..aa2c002c --- /dev/null +++ b/gallery_dl/extractor/weverse.py @@ -0,0 +1,640 @@ +# -*- 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. + +"""Extractors for https://weverse.io/""" + +from .common import Extractor, Message +from .. import text, exception +from ..cache import cache +import binascii +import hashlib +import hmac +import time +import urllib.parse +import uuid +from collections import OrderedDict +from http import HTTPStatus + +BASE_PATTERN = r"^(?:https?://)?(?:m\.)?weverse\.io/([^/?#]+)" +MEMBER_ID_PATTERN = r"/([a-f0-9]+)" +POST_ID_PATTERN = r"/(\d-\d+)" + + +class WeverseExtractor(Extractor): + """Base class for weverse extractors""" + + category = "weverse" + filename_fmt = "{category}_{id}.{extension}" + archive_fmt = "{category}_{post_id}_{id}" + cookies_domain = ".weverse.io" + cookies_names = ("we2_access_token",) + root = "https://weverse.io" + + def __init__(self, match): + Extractor.__init__(self, match) + self.post_url = match.group(0) + self.community_keyword = match.group(1) + + def _init(self): + self.embeds = self.config("embeds", True) + self.videos = self.config("videos", True) + + def items(self): + self.login() + self.api = WeverseAPI(self) + + post = self.post() + data = self.metadata(post) + + files = [] + if post["attachment"]: + self._extract_post(post, files) + elif post["extension"]: + if isinstance(self, WeverseMomentExtractor): + self._extract_moment(post, files) + else: + self._extract_media(post["extension"], files) + data["count"] = len(files) + + yield Message.Directory, data + for file in files: + file.update(data) + url = file.pop("url") + yield Message.Url, url, file + + def _extract_image(self, image): + url = image["url"] + return { + "id": image["photoId"], + "url": url, + "width": image["width"], + "height": image["height"], + "extension": text.ext_from_url(url), + } + + def _extract_video(self, video): + video_id = video["videoId"] + if isinstance(self, WeverseMediaExtractor): + master_id = ( + video.get("uploadInfo", {}).get( + "videoId") or video["infraVideoId"] + ) + best_video = self.get_best_video( + self.api.get_media_video_list(video_id, master_id), + ) + else: + best_video = self.get_best_video( + self.api.get_post_video_list(video_id)) + url = best_video["source"] + return { + "id": video_id, + "url": url, + "width": best_video["encodingOption"]["width"], + "height": best_video["encodingOption"]["height"], + "extension": text.ext_from_url(url), + } + + def _extract_embed(self, embed): + return { + "id": embed["youtubeVideoId"], + "extension": None, + "url": "ytdl:" + embed["videoPath"], + } + + def _extract_post(self, post, files): + attachments = {} + attachments.update(post["attachment"].get("photo", {})) + attachments.update(post["attachment"].get("video", {})) + + # the order of attachments in the api response can differ to the order + # of attachments on the site + attachment_order = list(text.extract_iter(post["body"], 'id="', '"')) + for index, attachment_id in enumerate(attachment_order, 1): + file = { + "num": index, + } + attachment = attachments[attachment_id] + if "photoId" in attachment: + file.update(self._extract_image(attachment)) + else: + file.update(self._extract_video(attachment)) + files.append(file) + + def _extract_moment(self, post, files): + moment = next( + post["extension"][key] + for key in ("moment", "momentW1") + if key in post["extension"] + ) + if not moment: + return + + file = { + "num": 1, + } + if "photo" in moment: + file.update(self._extract_image(moment["photo"])) + else: + if not self.videos: + return + file.update(self._extract_video(moment["video"])) + + files.append(file) + + def _extract_media(self, extension, files): + if "image" in extension: + for index, photo in enumerate(extension["image"]["photos"], 1): + file = self._extract_image(photo) + file["num"] = index + files.append(file) + elif "video" in extension: + if not self.videos: + return + file = self._extract_video(extension["video"]) + files.append(file) + else: + if not self.embeds or not self.videos: + return + file = self._extract_embed(extension["youtube"]) + file["num"] = 1 + files.append(file) + + def get_best_video(self, videos): + return max( + videos, + key=lambda video: video["encodingOption"]["width"] * + video["encodingOption"]["height"], + ) + + def metadata(self, post): + published_at = text.parse_timestamp(post["publishedAt"] / 1000) + data = { + "date": published_at, + "post_url": post.get("shareUrl", self.post_url), + "post_id": post["postId"], + "post_type": post["postType"], + "section_type": post["sectionType"], + } + + if "hideFromArtist" in post: + data["hide_from_artist"] = post["hideFromArtist"] + + if "membershipOnly" in post: + data["membership_only"] = post["membershipOnly"] + + if post.get("tags", []): + data["tags"] = post["tags"] + + if "author" in post: + author = { + "id": post["author"]["memberId"], + "name": post["author"]["profileName"], + "profile_type": post["author"]["profileType"], + } + if "artistOfficialProfile" in post["author"]: + artist_profile = post["author"]["artistOfficialProfile"] + author["name"] = artist_profile["officialName"] + data["author"] = author + + if "community" in post: + community = { + "id": post["community"]["communityId"], + "name": post["community"]["communityName"], + "artist_code": post["community"]["artistCode"], + } + data["community"] = community + + extension = post["extension"] + media_info = extension.get("mediaInfo", {}) + if media_info: + categories = [ + { + "id": category["id"], + "type": category["type"], + "title": category["title"], + } + for category in media_info["categories"] + ] + data.update( + { + "title": media_info["title"], + "media_type": media_info["mediaType"], + "categories": categories, + }, + ) + + moment = next( + (extension[key] + for key in ("moment", "momentW1") if key in extension), + None, + ) + if moment: + expire_at = text.parse_timestamp(moment["expireAt"] / 1000) + data["expire_at"] = expire_at + + return data + + def post(self): + return {} + + def login(self): + if self.cookies_check(self.cookies_names): + return + + username, password = self._get_auth_info() + if username: + self.cookies_update(_login_impl(self, username, password)) + + +class WeversePostExtractor(WeverseExtractor): + """Extractor for a weverse community post""" + + subcategory = "post" + directory_fmt = ( + "{category}", "{community[name]}", "{author[id]}", "{post_id}") + pattern = BASE_PATTERN + r"/(?:artist|fanpost)" + POST_ID_PATTERN + example = "https://weverse.io/abcdef/artist/1-123456789" + + def __init__(self, match): + WeverseExtractor.__init__(self, match) + self.post_id = match.group(2) + + def post(self): + return self.api.get_post(self.post_id) + + +class WeverseMemberExtractor(WeverseExtractor): + """Extractor for all posts from a weverse community member""" + + subcategory = "member" + pattern = BASE_PATTERN + "/profile" + MEMBER_ID_PATTERN + r"$" + example = "https://weverse.io/abcdef/profile/a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5" # noqa E501 + + def __init__(self, match): + WeverseExtractor.__init__(self, match) + self.member_id = match.group(2) + + def items(self): + self.login() + self.api = WeverseAPI(self) + + data = {"_extractor": WeversePostExtractor} + posts = self.api.get_member_posts(self.member_id) + for post in posts: + yield Message.Queue, post["shareUrl"], data + + +class WeverseFeedExtractor(WeverseExtractor): + """Extractor for a weverse community feed""" + + subcategory = "feed" + pattern = BASE_PATTERN + r"/(feed|artist)$" + example = "https://weverse.io/abcdef/feed" + + def __init__(self, match): + WeverseExtractor.__init__(self, match) + self.feed_name = match.group(2) + + def items(self): + self.login() + self.api = WeverseAPI(self) + + data = {"_extractor": WeversePostExtractor} + posts = self.api.get_feed_posts(self.community_keyword, self.feed_name) + for post in posts: + yield Message.Queue, post["shareUrl"], data + + +class WeverseMomentExtractor(WeverseExtractor): + """Extractor for a weverse community artist moment""" + + subcategory = "moment" + pattern = (BASE_PATTERN + "/moment" + + MEMBER_ID_PATTERN + "/post" + + POST_ID_PATTERN) + example = "https://weverse.io/abcdef/moment/a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5/post/1-123456789" # noqa E501 + + def __init__(self, match): + WeverseExtractor.__init__(self, match) + self.post_id = match.group(3) + + def post(self): + return self.api.get_post(self.post_id) + + +class WeverseMomentsExtractor(WeverseExtractor): + """Extractor for all moments from a weverse community artist""" + + subcategory = "moments" + pattern = BASE_PATTERN + "/moment" + MEMBER_ID_PATTERN + r"$" + example = "https://weverse.io/abcdef/moment/a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5" # noqa E501 + + def __init__(self, match): + WeverseExtractor.__init__(self, match) + self.member_id = match.group(2) + + def items(self): + self.login() + self.api = WeverseAPI(self) + + data = {"_extractor": WeverseMomentExtractor} + moments = self.api.get_member_moments(self.member_id) + for moment in moments: + yield Message.Queue, moment["shareUrl"], data + + +class WeverseMediaExtractor(WeverseExtractor): + """Extractor for a weverse community media post""" + + subcategory = "media" + directory_fmt = ("{category}", "{community[name]}", "media", "{post_id}") + pattern = BASE_PATTERN + "/media" + POST_ID_PATTERN + example = "https://weverse.io/abcdef/media/1-123456789" + + def __init__(self, match): + WeverseExtractor.__init__(self, match) + self.post_id = match.group(2) + + def post(self): + return self.api.get_post(self.post_id) + + +class WeverseMediaTabExtractor(WeverseExtractor): + """Extractor for the media tab of a weverse community""" + + subcategory = "media-tab" + pattern = BASE_PATTERN + r"/media(?:/(all|membership|new))?$" + example = "https://weverse.io/abcdef/media" + + def __init__(self, match): + WeverseExtractor.__init__(self, match) + self.tab_name = match.group(2) or "all" + + def items(self): + self.login() + self.api = WeverseAPI(self) + + data = {"_extractor": WeverseMediaExtractor} + if self.tab_name == "new": + get_media = self.api.get_latest_community_media + elif self.tab_name == "membership": + get_media = self.api.get_membership_community_media + else: + get_media = self.api.get_all_community_media + medias = get_media(self.community_keyword) + for media in medias: + yield Message.Queue, media["shareUrl"], data + + +class WeverseMediaCategoryExtractor(WeverseExtractor): + """Extractor for media by category of a weverse community""" + + subcategory = "media-category" + pattern = BASE_PATTERN + r"/media/category/(\d+)" + example = "https://weverse.io/abcdef/media/category/1234" + + def __init__(self, match): + WeverseExtractor.__init__(self, match) + self.media_category = match.group(2) + + def items(self): + self.login() + self.api = WeverseAPI(self) + + data = {"_extractor": WeverseMediaExtractor} + medias = self.api.get_media_by_category_id(self.media_category) + for media in medias: + yield Message.Queue, media["shareUrl"], data + + +class WeverseAPI: + """Interface for the Weverse API""" + + BASE_API_URL = "https://global.apis.naver.com" + WMD_API_URL = BASE_API_URL + "/weverse/wevweb" + VOD_API_URL = BASE_API_URL + "/rmcnmv/rmcnmv" + + APP_ID = "be4d79eb8fc7bd008ee82c8ec4ff6fd4" + SECRET = "1b9cb6378d959b45714bec49971ade22e6e24e42" + + def __init__(self, extractor): + self.extractor = extractor + + cookies = extractor.cookies + token_cookie_name = extractor.cookies_names[0] + cookies_domain = extractor.cookies_domain + self.access_token = cookies.get( + token_cookie_name, domain=cookies_domain) + self.headers = ( + {"Authorization": "Bearer " + self.access_token} + if self.access_token + else None + ) + + def _endpoint_with_params(self, endpoint, params): + params_delimiter = "?" + if "?" in endpoint: + params_delimiter = "&" + return (endpoint + params_delimiter + + urllib.parse.urlencode(query=params)) + + def _message_digest(self, endpoint, params, timestamp): + key = self.SECRET.encode() + url = self._endpoint_with_params(endpoint, params) + message = "{}{}".format(url[:255], timestamp).encode() + hash_digest = hmac.new(key, message, hashlib.sha1).digest() + return binascii.b2a_base64(hash_digest).rstrip().decode() + + def _apply_no_auth(self, endpoint, params): + if not endpoint.endswith("/preview"): + endpoint += "/preview" + params.update({"fieldSet": "postForPreview"}) + return endpoint, params + + def _is_text_only(self, post): + for key in ("attachment", "extension"): + if post.get(key, {}): + return False + if "summary" in post: + s = post["summary"] + if s.get("videoCount", 0) + s.get("photoCount", 0) > 0: + return False + return True + + def get_in_key(self, video_id): + endpoint = "/video/v1.1/vod/{}/inKey".format(video_id) + return self._call_wmd(endpoint, method="POST")["inKey"] + + def get_community_id(self, community_keyword): + endpoint = "/community/v1.0/communityIdUrlPathByUrlPathArtistCode" + params = {"keyword": community_keyword} + return self._call_wmd(endpoint, params)["communityId"] + + def get_post(self, post_id): + endpoint = "/post/v1.0/post-{}".format(post_id) + params = {"fieldSet": "postV1"} + if not self.access_token: + endpoint, params = self._apply_no_auth(endpoint, params) + return self._call_wmd(endpoint, params) + + def get_media_video_list(self, video_id, master_id): + in_key = self.get_in_key(video_id) + url = "{}/vod/play/v2.0/{}".format(self.VOD_API_URL, master_id) + params = {"key": in_key} + res = self._call(url, params=params) + return res["videos"]["list"] + + def get_post_video_list(self, video_id): + endpoint = "/cvideo/v1.0/cvideo-{}/playInfo".format(video_id) + params = {"videoId": video_id} + res = self._call_wmd(endpoint, params=params) + return res["playInfo"]["videos"]["list"] + + def get_member_posts(self, member_id): + endpoint = "/post/v1.0/member-{}/posts".format(member_id) + params = { + "fieldSet": "postsV1", + "filterType": "DEFAULT", + "limit": 20, + "sortType": "LATEST", + } + return self._pagination(endpoint, params) + + def get_feed_posts(self, community_keyword, feed_name): + community_id = self.get_community_id(community_keyword) + endpoint = "/post/v1.0/community-{}/{}TabPosts".format( + community_id, feed_name) + params = { + "fieldSet": "postsV1", + "limit": 20, + "pagingType": "CURSOR", + } + return self._pagination(endpoint, params) + + def get_latest_community_media(self, community_keyword): + community_id = self.get_community_id(community_keyword) + endpoint = "/media/v1.0/community-{}/more".format(community_id) + params = { + "fieldSet": "postsV1", + "filterType": "RECENT", + } + return self._pagination(endpoint, params) + + def get_membership_community_media(self, community_keyword): + community_id = self.get_community_id(community_keyword) + endpoint = "/media/v1.0/community-{}/more".format(community_id) + params = { + "fieldSet": "postsV1", + "filterType": "MEMBERSHIP", + } + return self._pagination(endpoint, params) + + def get_all_community_media(self, community_keyword): + community_id = self.get_community_id(community_keyword) + endpoint = "/media/v1.0/community-{}/searchAllMedia".format( + community_id) + params = { + "fieldSet": "postsV1", + "sortOrder": "DESC", + } + return self._pagination(endpoint, params) + + def get_media_by_category_id(self, category_id): + endpoint = "/media/v1.0/category-{}/mediaPosts".format(category_id) + params = { + "fieldSet": "postsV1", + "sortOrder": "DESC", + } + return self._pagination(endpoint, params) + + def get_member_moments(self, member_id): + endpoint = "/post/v1.0/member-{}/posts".format(member_id) + params = { + "fieldSet": "postsV1", + "filterType": "MOMENT", + "limit": 1, + } + return self._pagination(endpoint, params) + + def _call(self, url, **kwargs): + while True: + try: + return self.extractor.request(url, **kwargs).json() + except exception.HttpError as exc: + if exc.response.status_code == HTTPStatus.UNAUTHORIZED: + raise exception.AuthenticationError() from None + if exc.response.status_code == HTTPStatus.FORBIDDEN: + raise exception.AuthorizationError( + "Post requires membership", + ) from None + if exc.response.status_code == HTTPStatus.NOT_FOUND: + raise exception.NotFoundError( + self.extractor.subcategory) from None + self.extractor.log.debug(exc) + return None + + def _call_wmd(self, endpoint, params=None, **kwargs): + if params is None: + params = {} + params.update( + { + "appId": self.APP_ID, + "language": "en", + "os": "WEB", + "platform": "WEB", + "wpf": "pc", + }, + ) + # the param order is important for the message digest + params = OrderedDict(sorted(params.items())) + timestamp = int(time.time() * 1000) + message_digest = self._message_digest(endpoint, params, timestamp) + params.update( + { + "wmsgpad": timestamp, + "wmd": message_digest, + }, + ) + return self._call( + self.WMD_API_URL + endpoint, + params=params, + headers=self.headers, + **kwargs, + ) + + def _pagination(self, endpoint, params=None): + if not self.access_token: + raise exception.AuthenticationError() + if params is None: + params = {} + while True: + res = self._call_wmd(endpoint, params) + for post in res["data"]: + if not self._is_text_only(post): + yield post + np = res.get("paging", {}).get("nextParams", {}) + if "after" not in np: + return + params["after"] = np["after"] + + +@cache(maxage=365 * 24 * 3600, keyarg=1) +def _login_impl(extr, username, password): + url = "https://accountapi.weverse.io/web/api/v2/auth/token/by-credentials" + data = {"email": username, "password": password} + headers = { + "x-acc-app-secret": "5419526f1c624b38b10787e5c10b2a7a", + "x-acc-app-version": "3.3.3", + "x-acc-language": "en", + "x-acc-service-id": "weverse", + "x-acc-trace-id": str(uuid.uuid4()), + } + extr.log.info("Logging in as %s", username) + res = extr.request(url, method="POST", json=data, headers=headers).json() + if "accessToken" not in res: + extr.log.warning( + "Unable to log in as %s, proceeding without auth", username) + return {cookie.name: cookie.value for cookie in extr.cookies} diff --git a/scripts/supportedsites.py b/scripts/supportedsites.py index 4b9acbac..ecb356f9 100755 --- a/scripts/supportedsites.py +++ b/scripts/supportedsites.py @@ -323,6 +323,13 @@ SUBCATEGORY_MAP = { "home": "", "newvideo": "", }, + "weverse": { + "feed": "Feed Tab, Artist Tab", + "media-category": "Media Categories", + "media-tab": "Media Tabs", + "member": "Member Profiles", + "moments": "", + }, "wikiart": { "artists": "Artist Listings", }, @@ -407,6 +414,7 @@ AUTH_MAP = { "vipergirls" : "Supported", "wallhaven" : _APIKEY_WH, "weasyl" : _APIKEY_WY, + "weverse" : _COOKIES, "zerochan" : "Supported", } diff --git a/test/results/weverse.py b/test/results/weverse.py new file mode 100644 index 00000000..7866f81c --- /dev/null +++ b/test/results/weverse.py @@ -0,0 +1,216 @@ +# -*- 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 weverse + + +IMAGE_URL_PATTERN = r"https://phinf\.wevpstatic\.net/.+\.(?:gif|jpe?g|png|webp)$" +VIDEO_URL_PATTERN = r"https://weverse-rmcnmv\.akamaized\.net/.+\.(?:mp4|webm)(?:\?.+)?$" +COMBINED_URL_PATTERN = "(?i)" + IMAGE_URL_PATTERN + "|" + VIDEO_URL_PATTERN + + +__tests__ = ( +{ + "#url": "https://weverse.io/lesserafim/artist/4-147791342", + "#comment": "post containing both a video and image", + "#category": ("", "weverse", "post"), + "#class": weverse.WeversePostExtractor, + "#pattern": COMBINED_URL_PATTERN, + "#count": 2, + + "date": "dt:2024-01-18 06:08:35", + "post_url": "https://weverse.io/lesserafim/artist/4-147791342", + "post_id": "4-147791342", + "post_type": "NORMAL", + "section_type": "ARTIST", + "author": { + "id": "b60d95bc3b71f4d97b28ac1b971cc641", + "name": "KAZUHA", + "profile_type": "ARTIST", + }, + "community": { + "id": 47, + "name": "LE SSERAFIM", + "artist_code": "LESSERAFIM", + }, +}, + +{ + "#url": "https://weverse.io/lesserafim/artist/4-150863209", + "#comment": "text only", + "#category": ("", "weverse", "post"), + "#class": weverse.WeversePostExtractor, + "#count": 0, +}, + +{ + "#url": "https://weverse.io/dreamcatcher/artist/3-138146100", + "#comment": ("the order of the files returned by the api does not always match the order on the site" + "the id of the second file returned by the api is `2-274423384`" + "the id of the second file displayed on the site is `3-274413871`"), + "#category": ("", "weverse", "post"), + "#class": weverse.WeversePostExtractor, + "#pattern": COMBINED_URL_PATTERN, + "#range": "2", + "#count": 1, + + "id": "3-274413871", + "num": 2, +}, + +{ + "#url": "https://weverse.io/dreamcatcher/fanpost/2-135105553", + "#comment": "fan post", + "#category": ("", "weverse", "post"), + "#class": weverse.WeversePostExtractor, + "#pattern": COMBINED_URL_PATTERN, + "#count": 1, + + "section_type": "FEED", + "author": { + "profile_type": "FAN", + }, +}, + +{ + "#url": "https://weverse.io/dreamcatcher/profile/e89820ec1a72d7255120284ca3aeafa5", + "#category": ("", "weverse", "member"), + "#class": weverse.WeverseMemberExtractor, + "#pattern": weverse.WeversePostExtractor.pattern, + "#auth": True, +}, + +{ + "#url": "https://weverse.io/dreamcatcher/feed", + "#comment": "feed tab (fan posts)" + "each pagination call returns up to 20 items", + "#category": ("", "weverse", "feed"), + "#class": weverse.WeverseFeedExtractor, + "#pattern": weverse.WeversePostExtractor.pattern, + "#auth": True, + "#range": "21", +}, + +{ + "#url": "https://weverse.io/dreamcatcher/artist", + "#comment": "artist tab (artist posts)" + "each pagination call returns up to 20 items", + "#category": ("", "weverse", "feed"), + "#class": weverse.WeverseFeedExtractor, + "#pattern": weverse.WeversePostExtractor.pattern, + "#auth": True, + "#range": "21", +}, + +{ + "#url": "https://weverse.io/dreamcatcher/moment/e89820ec1a72d7255120284ca3aeafa5/post/2-111675163", + "#comment": "moment", + "#category": ("", "weverse", "moment"), + "#class": weverse.WeverseMomentExtractor, + "#pattern": COMBINED_URL_PATTERN, + "#count": 1, + + "width": 1080, + "height": 1920, + "date": "dt:2023-01-09 06:25:41", + "expire_at": "dt:2023-01-10 06:25:41", +}, + +{ + "#url": "https://weverse.io/dreamcatcher/moment/785506b50e7890c3b81491f20728ee82/post/2-101327656", + "#comment": "momentW1", + "#category": ("", "weverse", "moment"), + "#class": weverse.WeverseMomentExtractor, + "#pattern": COMBINED_URL_PATTERN, + "#count": 1, + + "width": 1128, + "height": 1504, + "date": "dt:2022-07-17 00:24:48", + "expire_at": "dt:2022-07-18 00:24:48", +}, + +{ + "#url": "https://weverse.io/dreamcatcher/moment/e89820ec1a72d7255120284ca3aeafa5", + "#comment": "each pagination call returns 1 item", + "#category": ("", "weverse", "moments"), + "#class": weverse.WeverseMomentsExtractor, + "#pattern": weverse.WeverseMomentExtractor.pattern, + "#auth": True, + "#range": "2", +}, + +{ + "#url": "https://weverse.io/lesserafim/media/0-128617470", + "#comment": "image", + "#category": ("", "weverse", "media"), + "#class": weverse.WeverseMediaExtractor, + "#pattern": COMBINED_URL_PATTERN, + "#count": 5, + + "media_type": "IMAGE", + "categories": [ + { + "id": 1091, + "type": "MEDIA", + "title": "PHOTOBOOK", + }, + ], + "community": { + "name": "LE SSERAFIM", + }, +}, + +{ + "#url": "https://weverse.io/lesserafim/media/1-128435266", + "#comment": "video", + "#category": ("", "weverse", "media"), + "#class": weverse.WeverseMediaExtractor, + "#pattern": COMBINED_URL_PATTERN, + "#count": 1, + + "width": 1080, + "height": 1920, + "media_type": "VOD", + "categories": [ + { + "id": 1532, + "type": "MEDIA", + "title": "Perfect Night", + } + ], +}, + +{ + "#url": "https://weverse.io/dreamcatcher/media/1-128875973", + "#comment": "embed", + "#category": ("", "weverse", "media"), + "#class": weverse.WeverseMediaExtractor, + + "post_type": "YOUTUBE", +}, + +{ + "#url": "https://weverse.io/dreamcatcher/media", + "#comment": "each pagination call returns up to 10 items", + "#category": ("", "weverse", "media-tab"), + "#class": weverse.WeverseMediaTabExtractor, + "#pattern": weverse.WeverseMediaExtractor.pattern, + "#auth": True, + "#range": "11", +}, + +{ + "#url": "https://weverse.io/lesserafim/media/category/494", + "#comment": "each pagination call returns up to 10 items", + "#category": ("", "weverse", "media-category"), + "#class": weverse.WeverseMediaCategoryExtractor, + "#pattern": weverse.WeverseMediaExtractor.pattern, + "#auth": True, + "#range": "11", +}, + +)