diff --git a/scratchattach/__init__.py b/scratchattach/__init__.py index 3e96d7b4..0ec563e1 100644 --- a/scratchattach/__init__.py +++ b/scratchattach/__init__.py @@ -10,6 +10,7 @@ from .other.other_apis import * from .other.project_json_capabilities import ProjectBody, get_empty_project_pb, get_pb_from_dict, read_sb3_file, download_asset from .utils.encoder import Encoding +from .utils.enums import Languages, TTSVoices from .site.activity import Activity from .site.backpack_asset import BackpackAsset diff --git a/scratchattach/other/other_apis.py b/scratchattach/other/other_apis.py index df8d4235..c9519b87 100644 --- a/scratchattach/other/other_apis.py +++ b/scratchattach/other/other_apis.py @@ -1,38 +1,51 @@ """Other Scratch API-related functions""" +import json + from ..utils import commons +from ..utils.exceptions import BadRequest, InvalidLanguage, InvalidTTSGender from ..utils.requests import Requests as requests -import json +from ..utils.enums import Languages, Language, TTSVoices, TTSVoice + # --- Front page --- def get_news(*, limit=10, offset=0): - return commons.api_iterative("https://api.scratch.mit.edu/news", limit = limit, offset = offset) + return commons.api_iterative("https://api.scratch.mit.edu/news", limit=limit, offset=offset) + def featured_data(): return requests.get("https://api.scratch.mit.edu/proxy/featured").json() + def featured_projects(): return featured_data()["community_featured_projects"] + def featured_studios(): return featured_data()["community_featured_studios"] + def top_loved(): return featured_data()["community_most_loved_projects"] + def top_remixed(): return featured_data()["community_most_remixed_projects"] + def newest_projects(): return featured_data()["community_newest_projects"] + def curated_projects(): return featured_data()["curator_top_projects"] + def design_studio_projects(): return featured_data()["scratch_design_studio"] + # --- Statistics --- def total_site_stats(): @@ -40,14 +53,17 @@ def total_site_stats(): data.pop("_TS") return data + def monthly_site_traffic(): data = requests.get("https://scratch.mit.edu/statistics/data/monthly-ga/").json() data.pop("_TS") return data + def country_counts(): return requests.get("https://scratch.mit.edu/statistics/data/monthly/").json()["country_distribution"] + def age_distribution(): data = requests.get("https://scratch.mit.edu/statistics/data/monthly/").json()["age_distribution_data"][0]["values"] return_data = {} @@ -55,18 +71,23 @@ def age_distribution(): return_data[value["x"]] = value["y"] return return_data + def monthly_comment_activity(): return requests.get("https://scratch.mit.edu/statistics/data/monthly/").json()["comment_data"] + def monthly_project_shares(): return requests.get("https://scratch.mit.edu/statistics/data/monthly/").json()["project_data"] + def monthly_active_users(): return requests.get("https://scratch.mit.edu/statistics/data/monthly/").json()["active_user_data"] + def monthly_activity_trends(): return requests.get("https://scratch.mit.edu/statistics/data/monthly/").json()["activity_data"] + # --- CSRF Token Generation API --- def get_csrf_token(): @@ -80,32 +101,41 @@ def get_csrf_token(): "https://scratch.mit.edu/csrf_token/" ).headers["set-cookie"].split(";")[3][len(" Path=/, scratchcsrftoken="):] + # --- Various other api.scratch.mit.edu API endpoints --- def get_health(): return requests.get("https://api.scratch.mit.edu/health").json() + def get_total_project_count() -> int: return requests.get("https://api.scratch.mit.edu/projects/count/all").json()["count"] + def check_username(username): return requests.get(f"https://api.scratch.mit.edu/accounts/checkusername/{username}").json()["msg"] + def check_password(password): - return requests.post("https://api.scratch.mit.edu/accounts/checkpassword/", json={"password":password}).json()["msg"] + return requests.post("https://api.scratch.mit.edu/accounts/checkpassword/", json={"password": password}).json()[ + "msg"] + # --- April fools endpoints --- def aprilfools_get_counter() -> int: return requests.get("https://api.scratch.mit.edu/surprise").json()["surprise"] + def aprilfools_increment_counter() -> int: return requests.post("https://api.scratch.mit.edu/surprise").json()["surprise"] + # --- Resources --- def get_resource_urls(): return requests.get("https://resources.scratch.mit.edu/localized-urls.json").json() + # --- Misc --- # I'm not sure what to label this as def scratch_team_members() -> dict: @@ -115,3 +145,68 @@ def scratch_team_members() -> dict: text = text.split("\"}]')")[0] + "\"}]" return json.loads(text) + + +def translate(language: str | Languages, text: str = "hello"): + if isinstance(language, str): + lang = Languages.find_by_attrs(language.lower(), ["code", "tts_locale", "name"], str.lower) + elif isinstance(language, Languages): + lang = language.value + else: + lang = language + + if not isinstance(lang, Language): + raise InvalidLanguage(f"{language} is not a language") + + if lang.code is None: + raise InvalidLanguage(f"{lang} is not a valid translate language") + + response_json = requests.get( + f"https://translate-service.scratch.mit.edu/translate?language={lang.code}&text={text}").json() + + if "result" in response_json: + return response_json["result"] + else: + raise BadRequest(f"Language '{language}' does not seem to be valid.\nResponse: {response_json}") + + +def text2speech(text: str = "hello", voice_name: str = "female", language: str = "en-US"): + """ + Sends a request to Scratch's TTS synthesis service. + Returns: + - The TTS audio (mp3) as bytes + - The playback rate (e.g. for giant it would be 0.84) + """ + if isinstance(voice_name, str): + voice = TTSVoices.find_by_attrs(voice_name.lower(), ["name", "gender"], str.lower) + elif isinstance(voice_name, TTSVoices): + voice = voice_name.value + else: + voice = voice_name + + if not isinstance(voice, TTSVoice): + raise InvalidTTSGender(f"TTS Gender {voice_name} is not supported.") + + # If it's kitten, make sure to change everything to just meows + if voice.name == "kitten": + text = '' + for word in text.split(' '): + if word.strip() != '': + text += "meow " + + if isinstance(language, str): + lang = Languages.find_by_attrs(language.lower(), ["code", "tts_locale", "name"], str.lower) + elif isinstance(language, Languages): + lang = language.value + else: + lang = language + + if not isinstance(lang, Language): + raise InvalidLanguage(f"Language '{language}' is not a language") + + if lang.tts_locale is None: + raise InvalidLanguage(f"Language '{language}' is not a valid TTS language") + + response = requests.get(f"https://synthesis-service.scratch.mit.edu/synth" + f"?locale={lang.tts_locale}&gender={voice.gender}&text={text}") + return response.content, voice.playback_rate diff --git a/scratchattach/utils/enums.py b/scratchattach/utils/enums.py new file mode 100644 index 00000000..0fbc0ffc --- /dev/null +++ b/scratchattach/utils/enums.py @@ -0,0 +1,190 @@ +""" +List of supported languages of scratch's translate and text2speech extensions. +Adapted from https://translate-service.scratch.mit.edu/supported?language=en +""" + +from enum import Enum +from dataclasses import dataclass + +from typing import Callable, Iterable + + +@dataclass(init=True, repr=True) +class Language: + name: str = None + code: str = None + locales: list[str] = None + tts_locale: str = None + single_gender: bool = None + + +class _EnumWrapper(Enum): + @classmethod + def find(cls, value, by: str, apply_func: Callable = None): + """ + Finds the enum item with the given attribute that is equal to the given value. + the apply_func will be applied to the attribute of each language object before comparison. + + i.e. Languages.find("ukranian", "name", str.lower) will return the Ukrainian language dataclass object + (even though Ukrainian was spelt lowercase, since str.lower will convert the "Ukrainian" string to lowercase) + """ + if apply_func is None: + def apply_func(x): + return x + + for item in cls: + item_obj = item.value + + try: + if apply_func(getattr(item_obj, by)) == value: + return item_obj + except TypeError: + pass + + @classmethod + def all_of(cls, attr_name: str, apply_func: Callable = None) -> Iterable: + """ + Returns the list of each listed enum item's specified attribute by "attr_name" + + i.e. Languages.all_of("name") will return a list of names: + ["Albanian", "Amharic", ...] + + The apply_func function will be applied to every list item, + i.e. Languages.all_of("name", str.lower) will return the same except in lowercase: + ["albanian", "amharic", ...] + """ + if apply_func is None: + def apply_func(x): + return x + + for item in cls: + item_obj = item.value + attr = getattr(item_obj, attr_name) + try: + yield apply_func(attr) + + except TypeError: + yield attr + + @classmethod + def find_by_attrs(cls, value, bys: list[str], apply_func: Callable = None) -> list: + """ + Calls the EnumWrapper.by function multiple times until a match is found, using the provided 'by' attribute names + """ + for by in bys: + ret = cls.find(value, by, apply_func) + if ret is not None: + return ret + + +class Languages(_EnumWrapper): + Albanian = Language('Albanian', 'sq', None, None, None) + Amharic = Language('Amharic', 'am', None, None, None) + Arabic = Language('Arabic', 'ar', ['ar'], 'arb', True) + Armenian = Language('Armenian', 'hy', None, None, None) + Azerbaijani = Language('Azerbaijani', 'az', None, None, None) + Basque = Language('Basque', 'eu', None, None, None) + Belarusian = Language('Belarusian', 'be', None, None, None) + Bulgarian = Language('Bulgarian', 'bg', None, None, None) + Catalan = Language('Catalan', 'ca', None, None, None) + Chinese_Traditional = Language('Chinese (Traditional)', 'zh-tw', ['zh-cn', 'zh-tw'], 'cmn-CN', True) + Croatian = Language('Croatian', 'hr', None, None, None) + Czech = Language('Czech', 'cs', None, None, None) + Danish = Language('Danish', 'da', ['da'], 'da-DK', False) + Dutch = Language('Dutch', 'nl', ['nl'], 'nl-NL', False) + English = Language('English', 'en', ['en'], 'en-US', False) + Esperanto = Language('Esperanto', 'eo', None, None, None) + Estonian = Language('Estonian', 'et', None, None, None) + Finnish = Language('Finnish', 'fi', None, None, None) + French = Language('French', 'fr', ['fr'], 'fr-FR', False) + Galician = Language('Galician', 'gl', None, None, None) + German = Language('German', 'de', ['de'], 'de-DE', False) + Greek = Language('Greek', 'el', None, None, None) + Haitian_Creole = Language('Haitian Creole', 'ht', None, None, None) + Hindi = Language('Hindi', 'hi', ['hi'], 'hi-IN', True) + Hungarian = Language('Hungarian', 'hu', None, None, None) + Icelandic = Language('Icelandic', 'is', ['is'], 'is-IS', False) + Indonesian = Language('Indonesian', 'id', None, None, None) + Irish = Language('Irish', 'ga', None, None, None) + Italian = Language('Italian', 'it', ['it'], 'it-IT', False) + Japanese = Language('Japanese', 'ja', ['ja', 'ja-hira'], 'ja-JP', False) + Kannada = Language('Kannada', 'kn', None, None, None) + Korean = Language('Korean', 'ko', ['ko'], 'ko-KR', True) + Kurdish_Kurmanji = Language('Kurdish (Kurmanji)', 'ku', None, None, None) + Latin = Language('Latin', 'la', None, None, None) + Latvian = Language('Latvian', 'lv', None, None, None) + Lithuanian = Language('Lithuanian', 'lt', None, None, None) + Macedonian = Language('Macedonian', 'mk', None, None, None) + Malay = Language('Malay', 'ms', None, None, None) + Malayalam = Language('Malayalam', 'ml', None, None, None) + Maltese = Language('Maltese', 'mt', None, None, None) + Maori = Language('Maori', 'mi', None, None, None) + Marathi = Language('Marathi', 'mr', None, None, None) + Mongolian = Language('Mongolian', 'mn', None, None, None) + Myanmar_Burmese = Language('Myanmar (Burmese)', 'my', None, None, None) + Persian = Language('Persian', 'fa', None, None, None) + Polish = Language('Polish', 'pl', ['pl'], 'pl-PL', False) + Portuguese = Language('Portuguese', 'pt', ['pt'], 'pt-PT', False) + Romanian = Language('Romanian', 'ro', ['ro'], 'ro-RO', True) + Russian = Language('Russian', 'ru', ['ru'], 'ru-RU', False) + Scots_Gaelic = Language('Scots Gaelic', 'gd', None, None, None) + Serbian = Language('Serbian', 'sr', None, None, None) + Slovak = Language('Slovak', 'sk', None, None, None) + Slovenian = Language('Slovenian', 'sl', None, None, None) + Spanish = Language('Spanish', 'es', None, None, None) + Swedish = Language('Swedish', 'sv', ['sv'], 'sv-SE', True) + Telugu = Language('Telugu', 'te', None, None, None) + Thai = Language('Thai', 'th', None, None, None) + Turkish = Language('Turkish', 'tr', ['tr'], 'tr-TR', True) + Ukrainian = Language('Ukrainian', 'uk', None, None, None) + Uzbek = Language('Uzbek', 'uz', None, None, None) + Vietnamese = Language('Vietnamese', 'vi', None, None, None) + Welsh = Language('Welsh', 'cy', ['cy'], 'cy-GB', True) + Zulu = Language('Zulu', 'zu', None, None, None) + Hebrew = Language('Hebrew', 'he', None, None, None) + Chinese_Simplified = Language('Chinese (Simplified)', 'zh-cn', ['zh-cn', 'zh-tw'], 'cmn-CN', True) + Mandarin = Chinese_Simplified + + nb_NO = Language(None, None, ['nb', 'nn'], 'nb-NO', True) + pt_BR = Language(None, None, ['pt-br'], 'pt-BR', False) + Brazilian = pt_BR + es_ES = Language(None, None, ['es'], 'es-ES', False) + es_US = Language(None, None, ['es-419'], 'es-US', False) + + @classmethod + def find(cls, value, by: str = "name", apply_func: Callable = None) -> Language: + return super().find(value, by, apply_func) + + @classmethod + def all_of(cls, attr_name: str = "name", apply_func: Callable = None) -> list: + return super().all_of(attr_name, apply_func) + + +@dataclass(init=True, repr=True) +class TTSVoice: + name: str + gender: str + playback_rate: float | int = 1 + + +class TTSVoices(_EnumWrapper): + alto = TTSVoice("alto", "female") + # female is functionally equal to alto + female = TTSVoice("female", "female") + + tenor = TTSVoice("tenor", "male") + # male is functionally equal to tenor + male = TTSVoice("male", "male") + + squeak = TTSVoice("squeak", "female", 1.19) + giant = TTSVoice("giant", "male", .84) + kitten = TTSVoice("kitten", "female", 1.41) + + @classmethod + def find(cls, value, by: str = "name", apply_func: Callable = None) -> TTSVoice: + return super().find(value, by, apply_func) + + @classmethod + def all_of(cls, attr_name: str = "name", apply_func: Callable = None) -> Iterable: + return super().all_of(attr_name, apply_func) + diff --git a/scratchattach/utils/exceptions.py b/scratchattach/utils/exceptions.py index 10b756e8..82e1514a 100644 --- a/scratchattach/utils/exceptions.py +++ b/scratchattach/utils/exceptions.py @@ -18,7 +18,6 @@ class Unauthenticated(Exception): def __init__(self, message=""): self.message = "No login / session connected.\n\nThe object on which the method was called was created using scratchattach.get_xyz()\nUse session.connect_xyz() instead (xyz is a placeholder for user / project / cloud / ...).\n\nMore information: https://scratchattach.readthedocs.io/en/latest/scratchattach.html#scratchattach.utils.exceptions.Unauthenticated" super().__init__(self.message) - pass class Unauthorized(Exception): @@ -32,7 +31,6 @@ def __init__(self, message=""): self.message = "The user corresponding to the connected login / session is not allowed to perform this action." super().__init__(self.message) - pass class XTokenError(Exception): """ @@ -43,6 +41,7 @@ class XTokenError(Exception): pass + # Not found errors: class UserNotFound(Exception): @@ -60,6 +59,7 @@ class ProjectNotFound(Exception): pass + class ClassroomNotFound(Exception): """ Raised when a non-existent Classroom is requested. @@ -75,15 +75,32 @@ class StudioNotFound(Exception): pass + class ForumContentNotFound(Exception): """ Raised when a non-existent forum topic / post is requested. """ pass + class CommentNotFound(Exception): pass + +# Invalid inputs +class InvalidLanguage(Exception): + """ + Raised when an invalid language/language code/language object is provided, for TTS or Translate + """ + pass + + +class InvalidTTSGender(Exception): + """ + Raised when an invalid TTS gender is provided. + """ + pass + # API errors: class LoginFailure(Exception): @@ -95,6 +112,7 @@ class LoginFailure(Exception): pass + class FetchError(Exception): """ Raised when getting information from the Scratch API fails. This can have various reasons. Make sure all provided arguments are valid. @@ -102,6 +120,7 @@ class FetchError(Exception): pass + class BadRequest(Exception): """ Raised when the Scratch API responds with a "Bad Request" error message. This can have various reasons. Make sure all provided arguments are valid. @@ -117,6 +136,7 @@ class Response429(Exception): pass + class CommentPostFailure(Exception): """ Raised when a comment fails to post. This can have various reasons. @@ -124,12 +144,14 @@ class CommentPostFailure(Exception): pass + class APIError(Exception): """ For API errors that can't be classified into one of the above errors """ pass + class ScrapeError(Exception): """ Raised when something goes wrong while web-scraping a page with bs4. @@ -137,9 +159,10 @@ class ScrapeError(Exception): pass + # Cloud / encoding errors: -class ConnectionError(Exception): +class CloudConnectionError(Exception): """ Raised when connecting to Scratch's cloud server fails. This can have various reasons. """ @@ -172,12 +195,12 @@ class RequestNotFound(Exception): pass + # Websocket server errors: class WebsocketServerError(Exception): - """ Raised when the self-hosted cloud websocket server fails to start. """ - pass \ No newline at end of file + pass