Skip to content
2 changes: 1 addition & 1 deletion scratchattach/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from .site.backpack_asset import BackpackAsset
from .site.comment import Comment
from .site.cloud_activity import CloudActivity
from .site.forum import ForumPost, ForumTopic, get_topic, get_topic_list
from .site.forum import ForumPost, ForumTopic, get_topic, get_topic_list, youtube_link_to_scratch
from .site.project import Project, get_project, search_projects, explore_projects
from .site.session import Session, login, login_by_id, login_by_session_string
from .site.studio import Studio, get_studio, search_studios, explore_studios
Expand Down
14 changes: 14 additions & 0 deletions scratchattach/other/other_apis.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,17 @@ def aprilfools_get_counter() -> int:

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:
# Unfortunately, the only place to find this is a js file, not a json file, which is annoying
text = requests.get("https://scratch.mit.edu/js/credits.bundle.js").text
text = "[{\"userName\"" + text.split("JSON.parse('[{\"userName\"")[1]
text = text.split("\"}]')")[0] + "\"}]"

return json.loads(text)
14 changes: 14 additions & 0 deletions scratchattach/site/forum.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from ._base import BaseSiteComponent
import xml.etree.ElementTree as ET
from bs4 import BeautifulSoup
from urllib.parse import urlparse, parse_qs

from ..utils.requests import Requests as requests

Expand Down Expand Up @@ -383,3 +384,16 @@ def get_topic_list(category_id, *, page=1):
except Exception as e:
raise exceptions.ScrapeError(str(e))


def youtube_link_to_scratch(link: str):
"""
Converts a YouTube url (in multiple formats) like https://youtu.be/1JTgg4WVAX8?si=fIEskaEaOIRZyTAz
to a link like https://scratch.mit.edu/discuss/youtube/1JTgg4WVAX8
"""
url_parse = urlparse(link)
query_parse = parse_qs(url_parse.query)
if 'v' in query_parse:
video_id = query_parse['v'][0]
else:
video_id = url_parse.path.split('/')[-1]
return f"https://scratch.mit.edu/discuss/youtube/{video_id}"
8 changes: 8 additions & 0 deletions scratchattach/site/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ def _update_from_dict(self, data):
return False
return True

@property
def embed_url(self):
"""
Returns:
the url of the embed of the project
"""
return f"{self.url}/embed"

def remixes(self, *, limit=40, offset=0):
"""
Returns:
Expand Down
104 changes: 100 additions & 4 deletions scratchattach/site/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,28 @@ def __init__(self, **entries):
}

def _update_from_dict(self, data):
# Note: there are a lot more things you can get from this data dict.
# Maybe it would be a good idea to also store the dict itself?
# self.data = data

self.xtoken = data['user']['token']
self._headers["X-Token"] = self.xtoken

self.has_outstanding_email_confirmation = data["flags"]["has_outstanding_email_confirmation"]

self.email = data["user"]["email"]

self.new_scratcher = data["permissions"]["new_scratcher"]
self.mute_status = data["permissions"]["mute_status"]

self.username = data["user"]["username"]
self._username = data["user"]["username"]
self.banned = data["user"]["banned"]

if self.banned:
warnings.warn(f"Warning: The account {self._username} you logged in to is BANNED. Some features may not work properly.")
if self.has_outstanding_email_confirmation:
warnings.warn(f"Warning: The account {self._username} you logged is not email confirmed. Some features may not work properly.")
return True

def connect_linked_user(self):
Expand All @@ -115,6 +127,90 @@ def get_linked_user(self):
# backwards compatibility with v1
return self.connect_linked_user() # To avoid inconsistencies with "connect" and "get", this function was renamed

def set_country(self, country: str="Antarctica"):
requests.post("https://scratch.mit.edu/accounts/settings/",
data={"country": country},
headers=self._headers, cookies=self._cookies)

def change_password(self, old_password: str, new_password: str = None):
if new_password is None or new_password == old_password:
return
requests.post("https://scratch.mit.edu/accounts/password_change/",
data={"old_password": old_password,
"new_password1": new_password,
"new_password2": new_password},
headers=self._headers, cookies=self._cookies)

def resend_email(self, password: str):
"""
Sends a request to resend a confirmation email for this session's account

Keyword arguments:
password (str): Password associated with the session (not stored)
"""
requests.post("https://scratch.mit.edu/accounts/email_change/",
data={"email_address": self.new_email_address,
"password": password},
headers=self._headers, cookies=self._cookies)

def change_email(self, new_email: str, password: str):
"""
Sends a request to change the email of this session

Keyword arguments:
new_email (str): The email you want to switch to
password (str): Password associated with the session (not stored)
"""
requests.post("https://scratch.mit.edu/accounts/email_change/",
data={"email_address": new_email,
"password": password},
headers=self._headers, cookies=self._cookies)

@property
def new_email_address(self) -> str | None:
"""
Gets the (unconfirmed) email address that this session has requested to transfer to, if any, otherwise the current address.

Returns:
str: The email that this session wants to switch to
"""
response = requests.get("https://scratch.mit.edu/accounts/email_change/",
headers=self._headers, cookies=self._cookies)

soup = BeautifulSoup(response.content, "html.parser")

email = None
for label_span in soup.find_all("span", {"class": "label"}):
if label_span.contents[0] == "New Email Address":
return label_span.parent.contents[-1].text.strip("\n ")
elif label_span.contents[0] == "Current Email Address":
email = label_span.parent.contents[-1].text.strip("\n ")

return email

def delete_account(self, *, password: str, delete_projects: bool = False):
"""
!!! Dangerous !!!
Sends a request to delete the account that is associated with this session.
You can cancel the deletion simply by logging back in (including using sa.login(username, password))

Keyword arguments:
password (str): The password associated with the account
delete_projects (bool): Whether to delete all the projects as well
"""
requests.post("https://scratch.mit.edu/accounts/settings/delete_account/",
data={
"delete_state": "delbyusrwproj" if delete_projects else "delbyusr",
"password": password
}, headers=self._headers, cookies=self._cookies)

def logout(self):
"""
Sends a logout request to scratch. Might not do anything, might log out this account on other ips/sessions? I am not sure
"""
requests.post("https://scratch.mit.edu/accounts/logout/",
headers=self._headers, cookies=self._cookies)

def messages(self, *, limit=40, offset=0, date_limit=None, filter_by=None):
'''
Returns the messages.
Expand Down Expand Up @@ -255,7 +351,7 @@ def connect_pb_from_file(path_to_file):
pb = project_json_capabilities.ProjectBody()
pb.from_json(project_json_capabilities._load_sb3_file(path_to_file))
return pb

def download_asset(asset_id_with_file_ext, *, filename=None, dir=""):
if not (dir.endswith("/") or dir.endswith("\\")):
dir = dir+"/"
Expand Down Expand Up @@ -461,7 +557,7 @@ def mystuff_studios(self, filter_arg="all", *, page=1, sort_by="", descending=Tr
except Exception:
raise(exceptions.FetchError)


def backpack(self,limit=20, offset=0):
'''
Lists the assets that are in the backpack of the user associated with the session.
Expand All @@ -474,7 +570,7 @@ def backpack(self,limit=20, offset=0):
limit = limit, offset = offset, headers = self._headers
)
return commons.parse_object_list(data, backpack_asset.BackpackAsset, self)

def delete_from_backpack(self, backpack_asset_id):
'''
Deletes an asset from the backpack.
Expand Down Expand Up @@ -786,7 +882,7 @@ def login(username, password, *, timeout=10) -> Session:
except Exception:
raise exceptions.LoginFailure(
"Either the provided authentication data is wrong or your network is banned from Scratch.\n\nIf you're using an online IDE (like replit.com) Scratch possibly banned its IP adress. In this case, try logging in with your session id: https://github.com/TimMcCool/scratchattach/wiki#logging-in")

# Create session object:
return login_by_id(session_id, username=username, password=password)

Expand Down