scratchattach 2.1.14__py3-none-any.whl → 3.0.0b0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scratchattach/__init__.py +14 -6
- scratchattach/__main__.py +93 -0
- {scratchattach-2.1.14.dist-info → scratchattach-3.0.0b0.dist-info}/METADATA +7 -11
- scratchattach-3.0.0b0.dist-info/RECORD +8 -0
- {scratchattach-2.1.14.dist-info → scratchattach-3.0.0b0.dist-info}/WHEEL +1 -1
- scratchattach-3.0.0b0.dist-info/entry_points.txt +2 -0
- scratchattach/cloud/__init__.py +0 -2
- scratchattach/cloud/_base.py +0 -458
- scratchattach/cloud/cloud.py +0 -183
- scratchattach/editor/__init__.py +0 -21
- scratchattach/editor/asset.py +0 -253
- scratchattach/editor/backpack_json.py +0 -117
- scratchattach/editor/base.py +0 -193
- scratchattach/editor/block.py +0 -579
- scratchattach/editor/blockshape.py +0 -357
- scratchattach/editor/build_defaulting.py +0 -51
- scratchattach/editor/code_translation/__init__.py +0 -0
- scratchattach/editor/code_translation/parse.py +0 -177
- scratchattach/editor/comment.py +0 -80
- scratchattach/editor/commons.py +0 -306
- scratchattach/editor/extension.py +0 -50
- scratchattach/editor/field.py +0 -99
- scratchattach/editor/inputs.py +0 -135
- scratchattach/editor/meta.py +0 -114
- scratchattach/editor/monitor.py +0 -183
- scratchattach/editor/mutation.py +0 -324
- scratchattach/editor/pallete.py +0 -90
- scratchattach/editor/prim.py +0 -170
- scratchattach/editor/project.py +0 -279
- scratchattach/editor/sprite.py +0 -599
- scratchattach/editor/twconfig.py +0 -114
- scratchattach/editor/vlb.py +0 -134
- scratchattach/eventhandlers/__init__.py +0 -0
- scratchattach/eventhandlers/_base.py +0 -100
- scratchattach/eventhandlers/cloud_events.py +0 -110
- scratchattach/eventhandlers/cloud_recorder.py +0 -26
- scratchattach/eventhandlers/cloud_requests.py +0 -459
- scratchattach/eventhandlers/cloud_server.py +0 -246
- scratchattach/eventhandlers/cloud_storage.py +0 -136
- scratchattach/eventhandlers/combine.py +0 -30
- scratchattach/eventhandlers/filterbot.py +0 -161
- scratchattach/eventhandlers/message_events.py +0 -42
- scratchattach/other/__init__.py +0 -0
- scratchattach/other/other_apis.py +0 -284
- scratchattach/other/project_json_capabilities.py +0 -475
- scratchattach/site/__init__.py +0 -0
- scratchattach/site/_base.py +0 -66
- scratchattach/site/activity.py +0 -382
- scratchattach/site/alert.py +0 -227
- scratchattach/site/backpack_asset.py +0 -118
- scratchattach/site/browser_cookie3_stub.py +0 -17
- scratchattach/site/browser_cookies.py +0 -61
- scratchattach/site/classroom.py +0 -447
- scratchattach/site/cloud_activity.py +0 -107
- scratchattach/site/comment.py +0 -242
- scratchattach/site/forum.py +0 -432
- scratchattach/site/project.py +0 -825
- scratchattach/site/session.py +0 -1238
- scratchattach/site/studio.py +0 -611
- scratchattach/site/user.py +0 -956
- scratchattach/utils/__init__.py +0 -0
- scratchattach/utils/commons.py +0 -255
- scratchattach/utils/encoder.py +0 -158
- scratchattach/utils/enums.py +0 -236
- scratchattach/utils/exceptions.py +0 -243
- scratchattach/utils/requests.py +0 -93
- scratchattach-2.1.14.dist-info/RECORD +0 -66
- {scratchattach-2.1.14.dist-info → scratchattach-3.0.0b0.dist-info}/licenses/LICENSE +0 -0
- {scratchattach-2.1.14.dist-info → scratchattach-3.0.0b0.dist-info}/top_level.txt +0 -0
scratchattach/site/session.py
DELETED
|
@@ -1,1238 +0,0 @@
|
|
|
1
|
-
"""Session class and login function"""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import base64
|
|
5
|
-
import datetime
|
|
6
|
-
import hashlib
|
|
7
|
-
import json
|
|
8
|
-
import pathlib
|
|
9
|
-
import random
|
|
10
|
-
import re
|
|
11
|
-
import time
|
|
12
|
-
import warnings
|
|
13
|
-
import zlib
|
|
14
|
-
|
|
15
|
-
from dataclasses import dataclass, field
|
|
16
|
-
from typing import Optional, TypeVar, TYPE_CHECKING, overload, Any, Union
|
|
17
|
-
from contextlib import contextmanager
|
|
18
|
-
from threading import local
|
|
19
|
-
|
|
20
|
-
Type = type
|
|
21
|
-
|
|
22
|
-
if TYPE_CHECKING:
|
|
23
|
-
from _typeshed import FileDescriptorOrPath, SupportsRead
|
|
24
|
-
from scratchattach.cloud._base import BaseCloud
|
|
25
|
-
T = TypeVar("T", bound=BaseCloud)
|
|
26
|
-
else:
|
|
27
|
-
T = TypeVar("T")
|
|
28
|
-
|
|
29
|
-
from bs4 import BeautifulSoup, Tag
|
|
30
|
-
from typing_extensions import deprecated
|
|
31
|
-
|
|
32
|
-
from . import activity, classroom, forum, studio, user, project, backpack_asset, alert
|
|
33
|
-
# noinspection PyProtectedMember
|
|
34
|
-
from ._base import BaseSiteComponent
|
|
35
|
-
from scratchattach.cloud import cloud, _base
|
|
36
|
-
from scratchattach.eventhandlers import message_events, filterbot
|
|
37
|
-
from scratchattach.other import project_json_capabilities
|
|
38
|
-
from scratchattach.utils import commons, exceptions
|
|
39
|
-
from scratchattach.utils.commons import headers, empty_project_json, webscrape_count, get_class_sort_mode
|
|
40
|
-
from scratchattach.utils.requests import requests
|
|
41
|
-
from .browser_cookies import Browser, ANY, cookies_from_browser
|
|
42
|
-
|
|
43
|
-
ratelimit_cache: dict[str, list[float]] = {}
|
|
44
|
-
|
|
45
|
-
def enforce_ratelimit(__type: str, name: str, amount: int = 5, duration: int = 60) -> None:
|
|
46
|
-
cache = ratelimit_cache
|
|
47
|
-
cache.setdefault(__type, [])
|
|
48
|
-
uses = cache[__type]
|
|
49
|
-
while uses[-1] < time.time() - duration:
|
|
50
|
-
uses.pop()
|
|
51
|
-
if len(uses) < amount:
|
|
52
|
-
uses.insert(0, time.time())
|
|
53
|
-
return
|
|
54
|
-
raise exceptions.RateLimitedError(
|
|
55
|
-
f"Rate limit for {name} exceeded.\n"
|
|
56
|
-
"This rate limit is enforced by scratchattach, not by the Scratch API.\n"
|
|
57
|
-
"For security reasons, it cannot be turned off.\n\n"
|
|
58
|
-
"Don't spam-create studios or similar, it WILL get you banned."
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
C = TypeVar("C", bound=BaseSiteComponent)
|
|
62
|
-
|
|
63
|
-
@dataclass
|
|
64
|
-
class Session(BaseSiteComponent):
|
|
65
|
-
"""
|
|
66
|
-
Represents a Scratch log in / session. Stores authentication data (session id and xtoken).
|
|
67
|
-
|
|
68
|
-
Attributes:
|
|
69
|
-
id: The session id associated with the login
|
|
70
|
-
username: The username associated with the login
|
|
71
|
-
xtoken: The xtoken associated with the login
|
|
72
|
-
email: The email address associated with the logged in account
|
|
73
|
-
new_scratcher: True if the associated account is a new Scratcher
|
|
74
|
-
mute_status: Information about commenting restrictions of the associated account
|
|
75
|
-
banned: Returns True if the associated account is banned
|
|
76
|
-
"""
|
|
77
|
-
username: str = None
|
|
78
|
-
_user: user.User = field(repr=False, default=None)
|
|
79
|
-
|
|
80
|
-
id: str = field(repr=False, default=None)
|
|
81
|
-
session_string: str | None = field(repr=False, default=None)
|
|
82
|
-
xtoken: str = field(repr=False, default=None)
|
|
83
|
-
email: str = field(repr=False, default=None)
|
|
84
|
-
|
|
85
|
-
new_scratcher: bool = field(repr=False, default=None)
|
|
86
|
-
mute_status: Any = field(repr=False, default=None)
|
|
87
|
-
banned: bool = field(repr=False, default=None)
|
|
88
|
-
is_teacher: bool = field(repr=False, default=None)
|
|
89
|
-
|
|
90
|
-
time_created: datetime.datetime = None
|
|
91
|
-
language: str = field(repr=False, default="en")
|
|
92
|
-
|
|
93
|
-
def __str__(self) -> str:
|
|
94
|
-
return f"<Login for {self.username!r}>"
|
|
95
|
-
|
|
96
|
-
def __post_init__(self):
|
|
97
|
-
# Info on how the .update method has to fetch the data:
|
|
98
|
-
self.update_function = requests.post
|
|
99
|
-
self.update_api = "https://scratch.mit.edu/session"
|
|
100
|
-
|
|
101
|
-
# Set alternative attributes:
|
|
102
|
-
self._username = self.username # backwards compatibility with v1
|
|
103
|
-
|
|
104
|
-
# Base headers and cookies of every session:
|
|
105
|
-
self._headers = dict(headers)
|
|
106
|
-
self._cookies = {
|
|
107
|
-
"scratchsessionsid": self.id,
|
|
108
|
-
"scratchcsrftoken": "a",
|
|
109
|
-
"scratchlanguage": "en",
|
|
110
|
-
"accept": "application/json",
|
|
111
|
-
"Content-Type": "application/json",
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if self.id:
|
|
115
|
-
self._process_session_id()
|
|
116
|
-
|
|
117
|
-
def _update_from_dict(self, data: dict):
|
|
118
|
-
# Note: there are a lot more things you can get from this data dict.
|
|
119
|
-
# Maybe it would be a good idea to also store the dict itself?
|
|
120
|
-
# self.data = data
|
|
121
|
-
|
|
122
|
-
self.xtoken = data['user']['token']
|
|
123
|
-
self._headers["X-Token"] = self.xtoken
|
|
124
|
-
|
|
125
|
-
self.has_outstanding_email_confirmation = data["flags"]["has_outstanding_email_confirmation"]
|
|
126
|
-
|
|
127
|
-
self.email = data["user"]["email"]
|
|
128
|
-
|
|
129
|
-
self.new_scratcher = data["permissions"]["new_scratcher"]
|
|
130
|
-
self.is_teacher = data["permissions"]["educator"]
|
|
131
|
-
|
|
132
|
-
self.mute_status = data["permissions"]["mute_status"]
|
|
133
|
-
|
|
134
|
-
self.username = data["user"]["username"]
|
|
135
|
-
self._username = data["user"]["username"]
|
|
136
|
-
self.banned = data["user"]["banned"]
|
|
137
|
-
|
|
138
|
-
if self.banned:
|
|
139
|
-
warnings.warn(f"Warning: The account {self.username} you logged in to is BANNED. "
|
|
140
|
-
f"Some features may not work properly.")
|
|
141
|
-
if self.has_outstanding_email_confirmation:
|
|
142
|
-
warnings.warn(f"Warning: The account {self.username} you logged is not email confirmed. "
|
|
143
|
-
f"Some features may not work properly.")
|
|
144
|
-
return True
|
|
145
|
-
|
|
146
|
-
def _process_session_id(self):
|
|
147
|
-
assert self.id
|
|
148
|
-
|
|
149
|
-
data, self.time_created = decode_session_id(self.id)
|
|
150
|
-
|
|
151
|
-
self.username = data["username"]
|
|
152
|
-
self._username = self.username
|
|
153
|
-
if self._user:
|
|
154
|
-
self._user.username = self.username
|
|
155
|
-
else:
|
|
156
|
-
self._user = user.User(_session=self, username=self.username)
|
|
157
|
-
|
|
158
|
-
self._user.id = data["_auth_user_id"]
|
|
159
|
-
self.xtoken = data["token"]
|
|
160
|
-
self._headers["X-Token"] = self.xtoken
|
|
161
|
-
|
|
162
|
-
# not saving the login ip because it is a security issue, and is not very helpful
|
|
163
|
-
|
|
164
|
-
self.language = data["_language"]
|
|
165
|
-
# self._cookies["scratchlanguage"] = self.language
|
|
166
|
-
|
|
167
|
-
def connect_linked_user(self) -> user.User:
|
|
168
|
-
"""
|
|
169
|
-
Gets the user associated with the login / session.
|
|
170
|
-
|
|
171
|
-
Warning:
|
|
172
|
-
The returned User object is cached. To ensure its attribute are up to date, you need to run .update() on it.
|
|
173
|
-
|
|
174
|
-
Returns:
|
|
175
|
-
scratchattach.user.User: Object representing the user associated with the session.
|
|
176
|
-
"""
|
|
177
|
-
cached = hasattr(self, "_user")
|
|
178
|
-
if cached:
|
|
179
|
-
cached = self._user is not None
|
|
180
|
-
|
|
181
|
-
if not cached:
|
|
182
|
-
self._user = self.connect_user(self._username)
|
|
183
|
-
return self._user
|
|
184
|
-
|
|
185
|
-
def get_linked_user(self) -> user.User:
|
|
186
|
-
# backwards compatibility with v1
|
|
187
|
-
|
|
188
|
-
# To avoid inconsistencies with "connect" and "get", this function was renamed
|
|
189
|
-
return self.connect_linked_user()
|
|
190
|
-
|
|
191
|
-
def set_country(self, country: str = "Antarctica"):
|
|
192
|
-
"""
|
|
193
|
-
Sets the profile country of the session's associated user
|
|
194
|
-
|
|
195
|
-
Arguments:
|
|
196
|
-
country (str): The country to relocate to
|
|
197
|
-
"""
|
|
198
|
-
requests.post("https://scratch.mit.edu/accounts/settings/",
|
|
199
|
-
data={"country": country},
|
|
200
|
-
headers=self._headers, cookies=self._cookies)
|
|
201
|
-
|
|
202
|
-
def resend_email(self, password: str):
|
|
203
|
-
"""
|
|
204
|
-
Sends a request to resend a confirmation email for this session's account
|
|
205
|
-
|
|
206
|
-
Keyword arguments:
|
|
207
|
-
password (str): Password associated with the session (not stored)
|
|
208
|
-
"""
|
|
209
|
-
requests.post("https://scratch.mit.edu/accounts/email_change/",
|
|
210
|
-
data={"email_address": self.get_new_email_address(),
|
|
211
|
-
"password": password},
|
|
212
|
-
headers=self._headers, cookies=self._cookies)
|
|
213
|
-
|
|
214
|
-
@property
|
|
215
|
-
@deprecated("Use get_new_email_address instead.")
|
|
216
|
-
def new_email_address(self) -> str:
|
|
217
|
-
"""
|
|
218
|
-
Gets the (unconfirmed) email address that this session has requested to transfer to, if any,
|
|
219
|
-
otherwise the current address.
|
|
220
|
-
|
|
221
|
-
Returns:
|
|
222
|
-
str: The email that this session wants to switch to
|
|
223
|
-
"""
|
|
224
|
-
return self.get_new_email_address()
|
|
225
|
-
|
|
226
|
-
def get_new_email_address(self) -> str:
|
|
227
|
-
"""
|
|
228
|
-
Gets the (unconfirmed) email address that this session has requested to transfer to, if any,
|
|
229
|
-
otherwise the current address.
|
|
230
|
-
|
|
231
|
-
Returns:
|
|
232
|
-
str: The email that this session wants to switch to
|
|
233
|
-
"""
|
|
234
|
-
response = requests.get("https://scratch.mit.edu/accounts/email_change/",
|
|
235
|
-
headers=self._headers, cookies=self._cookies)
|
|
236
|
-
|
|
237
|
-
soup = BeautifulSoup(response.content, "html.parser")
|
|
238
|
-
|
|
239
|
-
email = None
|
|
240
|
-
for label_span in soup.find_all("span", {"class": "label"}):
|
|
241
|
-
if not isinstance(label_span, Tag):
|
|
242
|
-
continue
|
|
243
|
-
if not isinstance(label_span.parent, Tag):
|
|
244
|
-
continue
|
|
245
|
-
if label_span.contents[0] == "New Email Address":
|
|
246
|
-
return label_span.parent.contents[-1].text.strip("\n ")
|
|
247
|
-
|
|
248
|
-
elif label_span.contents[0] == "Current Email Address":
|
|
249
|
-
email = label_span.parent.contents[-1].text.strip("\n ")
|
|
250
|
-
assert email is not None
|
|
251
|
-
return email
|
|
252
|
-
|
|
253
|
-
def logout(self):
|
|
254
|
-
"""
|
|
255
|
-
Sends a logout request to scratch. (Might not do anything, might log out this account on other ips/sessions.)
|
|
256
|
-
"""
|
|
257
|
-
requests.post("https://scratch.mit.edu/accounts/logout/",
|
|
258
|
-
headers=self._headers, cookies=self._cookies)
|
|
259
|
-
|
|
260
|
-
def messages(self, *, limit: int = 40, offset: int = 0, date_limit=None, filter_by=None) -> list[activity.Activity]:
|
|
261
|
-
"""
|
|
262
|
-
Returns the messages.
|
|
263
|
-
|
|
264
|
-
Keyword arguments:
|
|
265
|
-
limit, offset, date_limit
|
|
266
|
-
filter_by (str or None): Can either be None (no filter), "comments", "projects", "studios" or "forums"
|
|
267
|
-
|
|
268
|
-
Returns:
|
|
269
|
-
list<scratch.activity.Activity>: List that contains all messages as Activity objects.
|
|
270
|
-
"""
|
|
271
|
-
add_params = ""
|
|
272
|
-
if date_limit is not None:
|
|
273
|
-
add_params += f"&dateLimit={date_limit}"
|
|
274
|
-
if filter_by is not None:
|
|
275
|
-
add_params += f"&filter={filter_by}"
|
|
276
|
-
|
|
277
|
-
data = commons.api_iterative(
|
|
278
|
-
f"https://api.scratch.mit.edu/users/{self._username}/messages",
|
|
279
|
-
limit=limit, offset=offset, _headers=self._headers, cookies=self._cookies, add_params=add_params
|
|
280
|
-
)
|
|
281
|
-
return commons.parse_object_list(data, activity.Activity, self)
|
|
282
|
-
|
|
283
|
-
def admin_messages(self, *, limit=40, offset=0) -> list[dict]:
|
|
284
|
-
"""
|
|
285
|
-
Returns your messages sent by the Scratch team (alerts).
|
|
286
|
-
"""
|
|
287
|
-
return commons.api_iterative(
|
|
288
|
-
f"https://api.scratch.mit.edu/users/{self._username}/messages/admin",
|
|
289
|
-
limit=limit, offset=offset, _headers=self._headers, cookies=self._cookies
|
|
290
|
-
)
|
|
291
|
-
|
|
292
|
-
def classroom_alerts(self, _classroom: Optional[classroom.Classroom | int] = None, mode: str = "Last created",
|
|
293
|
-
page: Optional[int] = None):
|
|
294
|
-
"""
|
|
295
|
-
Load and parse admin alerts, optionally for a specific class, using https://scratch.mit.edu/site-api/classrooms/alerts/
|
|
296
|
-
|
|
297
|
-
Returns:
|
|
298
|
-
list[alert.EducatorAlert]: A list of parsed EducatorAlert objects
|
|
299
|
-
"""
|
|
300
|
-
|
|
301
|
-
if isinstance(_classroom, classroom.Classroom):
|
|
302
|
-
_classroom = _classroom.id
|
|
303
|
-
|
|
304
|
-
if _classroom is None:
|
|
305
|
-
_classroom_str = ''
|
|
306
|
-
else:
|
|
307
|
-
_classroom_str = f"{_classroom}/"
|
|
308
|
-
|
|
309
|
-
ascsort, descsort = get_class_sort_mode(mode)
|
|
310
|
-
|
|
311
|
-
data = requests.get(f"https://scratch.mit.edu/site-api/classrooms/alerts/{_classroom_str}",
|
|
312
|
-
params={"page": page, "ascsort": ascsort, "descsort": descsort},
|
|
313
|
-
headers=self._headers, cookies=self._cookies).json()
|
|
314
|
-
|
|
315
|
-
alerts = [alert.EducatorAlert.from_json(alert_data, self) for alert_data in data]
|
|
316
|
-
|
|
317
|
-
return alerts
|
|
318
|
-
|
|
319
|
-
def clear_messages(self):
|
|
320
|
-
"""
|
|
321
|
-
Clears all messages.
|
|
322
|
-
"""
|
|
323
|
-
return requests.post(
|
|
324
|
-
"https://scratch.mit.edu/site-api/messages/messages-clear/",
|
|
325
|
-
headers=self._headers,
|
|
326
|
-
cookies=self._cookies,
|
|
327
|
-
timeout=10,
|
|
328
|
-
).text
|
|
329
|
-
|
|
330
|
-
def message_count(self) -> int:
|
|
331
|
-
"""
|
|
332
|
-
Returns the message count.
|
|
333
|
-
|
|
334
|
-
Returns:
|
|
335
|
-
int: message count
|
|
336
|
-
"""
|
|
337
|
-
return json.loads(requests.get(
|
|
338
|
-
f"https://scratch.mit.edu/messages/ajax/get-message-count/",
|
|
339
|
-
headers=self._headers,
|
|
340
|
-
cookies=self._cookies,
|
|
341
|
-
timeout=10,
|
|
342
|
-
).text)["msg_count"]
|
|
343
|
-
|
|
344
|
-
# Front-page-related stuff:
|
|
345
|
-
|
|
346
|
-
def feed(self, *, limit=20, offset=0, date_limit=None) -> list[activity.Activity]:
|
|
347
|
-
"""
|
|
348
|
-
Returns the "What's happening" section (frontpage).
|
|
349
|
-
|
|
350
|
-
Returns:
|
|
351
|
-
list<scratch.activity.Activity>: List that contains all "What's happening" entries as Activity objects
|
|
352
|
-
"""
|
|
353
|
-
add_params = ""
|
|
354
|
-
if date_limit is not None:
|
|
355
|
-
add_params = f"&dateLimit={date_limit}"
|
|
356
|
-
data = commons.api_iterative(
|
|
357
|
-
f"https://api.scratch.mit.edu/users/{self._username}/following/users/activity",
|
|
358
|
-
limit=limit, offset=offset, _headers=self._headers, cookies=self._cookies, add_params=add_params
|
|
359
|
-
)
|
|
360
|
-
return commons.parse_object_list(data, activity.Activity, self)
|
|
361
|
-
|
|
362
|
-
def get_feed(self, *, limit=20, offset=0, date_limit=None):
|
|
363
|
-
# for more consistent names, this method was renamed
|
|
364
|
-
return self.feed(limit=limit, offset=offset, date_limit=date_limit) # for backwards compatibility with v1
|
|
365
|
-
|
|
366
|
-
def loved_by_followed_users(self, *, limit=40, offset=0) -> list[project.Project]:
|
|
367
|
-
"""
|
|
368
|
-
Returns the "Projects loved by Scratchers I'm following" section (frontpage).
|
|
369
|
-
|
|
370
|
-
Returns:
|
|
371
|
-
list<scratchattach.project.Project>: List that contains all "Projects loved by Scratchers I'm following"
|
|
372
|
-
entries as Project objects
|
|
373
|
-
"""
|
|
374
|
-
data = commons.api_iterative(
|
|
375
|
-
f"https://api.scratch.mit.edu/users/{self._username}/following/users/loves",
|
|
376
|
-
limit=limit, offset=offset, _headers=self._headers, cookies=self._cookies
|
|
377
|
-
)
|
|
378
|
-
return commons.parse_object_list(data, project.Project, self)
|
|
379
|
-
|
|
380
|
-
"""
|
|
381
|
-
These methods are disabled because it is unclear if there is any case in which the response is not empty.
|
|
382
|
-
def shared_by_followed_users(self, *, limit=40, offset=0) -> list[project.Project]:
|
|
383
|
-
'''
|
|
384
|
-
Returns the "Projects by Scratchers I'm following" section (frontpage).
|
|
385
|
-
This section is only visible to old accounts (according to the Scratch wiki).
|
|
386
|
-
For newer users, this method will always return an empty list.
|
|
387
|
-
|
|
388
|
-
Returns:
|
|
389
|
-
list<scratchattach.project.Project>: List that contains all "Projects loved by Scratchers I'm following"
|
|
390
|
-
entries as Project objects
|
|
391
|
-
'''
|
|
392
|
-
data = commons.api_iterative(
|
|
393
|
-
f"https://api.scratch.mit.edu/users/{self._username}/following/users/projects",
|
|
394
|
-
limit = limit, offset = offset, headers = self._headers, cookies = self._cookies
|
|
395
|
-
)
|
|
396
|
-
return commons.parse_object_list(data, project.Project, self)
|
|
397
|
-
|
|
398
|
-
def in_followed_studios(self, *, limit=40, offset=0) -> list['project.Project']:
|
|
399
|
-
'''
|
|
400
|
-
Returns the "Projects in studios I'm following" section (frontpage).
|
|
401
|
-
This section is only visible to old accounts (according to the Scratch wiki).
|
|
402
|
-
For newer users, this method will always return an empty list.
|
|
403
|
-
|
|
404
|
-
Returns:
|
|
405
|
-
list<scratchattach.project.Project>: List that contains all "Projects loved by Scratchers I'm following"
|
|
406
|
-
entries as Project objects
|
|
407
|
-
'''
|
|
408
|
-
data = commons.api_iterative(
|
|
409
|
-
f"https://api.scratch.mit.edu/users/{self._username}/following/studios/projects",
|
|
410
|
-
limit = limit, offset = offset, headers = self._headers, cookies = self._cookies
|
|
411
|
-
)
|
|
412
|
-
return commons.parse_object_list(data, project.Project, self)"""
|
|
413
|
-
|
|
414
|
-
# -- Project JSON editing capabilities ---
|
|
415
|
-
# These are set to staticmethods right now, but they probably should not be
|
|
416
|
-
@staticmethod
|
|
417
|
-
def connect_empty_project_pb() -> project_json_capabilities.ProjectBody:
|
|
418
|
-
pb = project_json_capabilities.ProjectBody()
|
|
419
|
-
pb.from_json(empty_project_json)
|
|
420
|
-
return pb
|
|
421
|
-
|
|
422
|
-
@staticmethod
|
|
423
|
-
def connect_pb_from_dict(project_json: dict) -> project_json_capabilities.ProjectBody:
|
|
424
|
-
pb = project_json_capabilities.ProjectBody()
|
|
425
|
-
pb.from_json(project_json)
|
|
426
|
-
return pb
|
|
427
|
-
|
|
428
|
-
@staticmethod
|
|
429
|
-
def connect_pb_from_file(path_to_file) -> project_json_capabilities.ProjectBody:
|
|
430
|
-
pb = project_json_capabilities.ProjectBody()
|
|
431
|
-
# noinspection PyProtectedMember
|
|
432
|
-
# _load_sb3_file starts with an underscore
|
|
433
|
-
pb.from_json(project_json_capabilities._load_sb3_file(path_to_file))
|
|
434
|
-
return pb
|
|
435
|
-
|
|
436
|
-
@staticmethod
|
|
437
|
-
def download_asset(asset_id_with_file_ext, *, filename: Optional[str] = None, fp=""):
|
|
438
|
-
if not (fp.endswith("/") or fp.endswith("\\")):
|
|
439
|
-
fp = fp + "/"
|
|
440
|
-
try:
|
|
441
|
-
if filename is None:
|
|
442
|
-
filename = str(asset_id_with_file_ext)
|
|
443
|
-
response = requests.get(
|
|
444
|
-
"https://assets.scratch.mit.edu/" + str(asset_id_with_file_ext),
|
|
445
|
-
timeout=10,
|
|
446
|
-
)
|
|
447
|
-
open(f"{fp}{filename}", "wb").write(response.content)
|
|
448
|
-
except Exception:
|
|
449
|
-
raise (
|
|
450
|
-
exceptions.FetchError(
|
|
451
|
-
"Failed to download asset"
|
|
452
|
-
)
|
|
453
|
-
)
|
|
454
|
-
|
|
455
|
-
def upload_asset(self, asset_content, *, asset_id=None, file_ext=None):
|
|
456
|
-
data = asset_content if isinstance(asset_content, bytes) else open(asset_content, "rb").read()
|
|
457
|
-
|
|
458
|
-
if isinstance(asset_content, str):
|
|
459
|
-
file_ext = pathlib.Path(asset_content).suffix
|
|
460
|
-
file_ext = file_ext.replace(".", "")
|
|
461
|
-
|
|
462
|
-
if asset_id is None:
|
|
463
|
-
asset_id = hashlib.md5(data).hexdigest()
|
|
464
|
-
|
|
465
|
-
requests.post(
|
|
466
|
-
f"https://assets.scratch.mit.edu/{asset_id}.{file_ext}",
|
|
467
|
-
headers=self._headers,
|
|
468
|
-
cookies=self._cookies,
|
|
469
|
-
data=data,
|
|
470
|
-
timeout=10,
|
|
471
|
-
)
|
|
472
|
-
|
|
473
|
-
# --- Search ---
|
|
474
|
-
|
|
475
|
-
def search_projects(self, *, query: str = "", mode: str = "trending", language: str = "en", limit: int = 40,
|
|
476
|
-
offset: int = 0) -> list[project.Project]:
|
|
477
|
-
"""
|
|
478
|
-
Uses the Scratch search to search projects.
|
|
479
|
-
|
|
480
|
-
Keyword arguments:
|
|
481
|
-
query (str): The query that will be searched.
|
|
482
|
-
mode (str): Has to be one of these values: "trending", "popular" or "recent". Defaults to "trending".
|
|
483
|
-
language (str): A language abbreviation, defaults to "en".
|
|
484
|
-
(Depending on the language used on the Scratch website, Scratch displays you different results.)
|
|
485
|
-
limit (int): Max. amount of returned projects.
|
|
486
|
-
offset (int): Offset of the first returned project.
|
|
487
|
-
|
|
488
|
-
Returns:
|
|
489
|
-
list<scratchattach.project.Project>: List that contains the search results.
|
|
490
|
-
"""
|
|
491
|
-
response = commons.api_iterative(
|
|
492
|
-
f"https://api.scratch.mit.edu/search/projects", limit=limit, offset=offset,
|
|
493
|
-
add_params=f"&language={language}&mode={mode}&q={query}")
|
|
494
|
-
return commons.parse_object_list(response, project.Project, self)
|
|
495
|
-
|
|
496
|
-
def explore_projects(self, *, query: str = "*", mode: str = "trending", language: str = "en", limit: int = 40,
|
|
497
|
-
offset: int = 0) -> list[project.Project]:
|
|
498
|
-
"""
|
|
499
|
-
Gets projects from the explore page.
|
|
500
|
-
|
|
501
|
-
Keyword arguments:
|
|
502
|
-
query (str): Specifies the tag of the explore page.
|
|
503
|
-
To get the projects from the "All" tag, set this argument to "*".
|
|
504
|
-
mode (str): Has to be one of these values: "trending", "popular" or "recent".
|
|
505
|
-
Defaults to "trending".
|
|
506
|
-
language (str): A language abbreviation, defaults to "en".
|
|
507
|
-
(Depending on the language used on the Scratch website, Scratch displays you different explore pages.)
|
|
508
|
-
limit (int): Max. amount of returned projects.
|
|
509
|
-
offset (int): Offset of the first returned project.
|
|
510
|
-
|
|
511
|
-
Returns:
|
|
512
|
-
list<scratchattach.project.Project>: List that contains the explore page projects.
|
|
513
|
-
"""
|
|
514
|
-
response = commons.api_iterative(
|
|
515
|
-
f"https://api.scratch.mit.edu/explore/projects", limit=limit, offset=offset,
|
|
516
|
-
add_params=f"&language={language}&mode={mode}&q={query}")
|
|
517
|
-
return commons.parse_object_list(response, project.Project, self)
|
|
518
|
-
|
|
519
|
-
def search_studios(self, *, query: str = "", mode: str = "trending", language: str = "en", limit: int = 40,
|
|
520
|
-
offset: int = 0) -> list[studio.Studio]:
|
|
521
|
-
#if not query:
|
|
522
|
-
# raise ValueError("The query can't be empty for search")
|
|
523
|
-
query = f"&q={query}" if query else ""
|
|
524
|
-
response = commons.api_iterative(
|
|
525
|
-
f"https://api.scratch.mit.edu/search/studios", limit=limit, offset=offset,
|
|
526
|
-
add_params=f"&language={language}&mode={mode}{query}")
|
|
527
|
-
return commons.parse_object_list(response, studio.Studio, self)
|
|
528
|
-
|
|
529
|
-
def explore_studios(self, *, query: str = "", mode: str = "trending", language: str = "en", limit: int = 40,
|
|
530
|
-
offset: int = 0) -> list[studio.Studio]:
|
|
531
|
-
#if not query:
|
|
532
|
-
# raise ValueError("The query can't be empty for explore")
|
|
533
|
-
query = f"&q={query}" if query else ""
|
|
534
|
-
response = commons.api_iterative(
|
|
535
|
-
f"https://api.scratch.mit.edu/explore/studios", limit=limit, offset=offset,
|
|
536
|
-
add_params=f"&language={language}&mode={mode}{query}")
|
|
537
|
-
return commons.parse_object_list(response, studio.Studio, self)
|
|
538
|
-
|
|
539
|
-
# --- Create project API ---
|
|
540
|
-
|
|
541
|
-
def create_project(self, *, title: Optional[str] = None, project_json: dict = empty_project_json,
|
|
542
|
-
parent_id=None) -> project.Project: # not working
|
|
543
|
-
"""
|
|
544
|
-
Creates a project on the Scratch website.
|
|
545
|
-
|
|
546
|
-
Warning:
|
|
547
|
-
Don't spam this method - it WILL get you banned from Scratch.
|
|
548
|
-
To prevent accidental spam, a rate limit (5 projects per minute) is implemented for this function.
|
|
549
|
-
"""
|
|
550
|
-
enforce_ratelimit("create_scratch_project", "creating Scratch projects")
|
|
551
|
-
|
|
552
|
-
if title is None:
|
|
553
|
-
title = f'Untitled-{random.randint(0, 1<<16)}'
|
|
554
|
-
|
|
555
|
-
params = {
|
|
556
|
-
'is_remix': '0' if parent_id is None else "1",
|
|
557
|
-
'original_id': parent_id,
|
|
558
|
-
'title': title,
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
response = requests.post('https://projects.scratch.mit.edu/', params=params, cookies=self._cookies,
|
|
562
|
-
headers=self._headers, json=project_json).json()
|
|
563
|
-
return self.connect_project(response["content-name"])
|
|
564
|
-
|
|
565
|
-
def create_studio(self, *, title: Optional[str] = None, description: Optional[str] = None) -> studio.Studio:
|
|
566
|
-
"""
|
|
567
|
-
Create a studio on the scratch website
|
|
568
|
-
|
|
569
|
-
Warning:
|
|
570
|
-
Don't spam this method - it WILL get you banned from Scratch.
|
|
571
|
-
To prevent accidental spam, a rate limit (5 studios per minute) is implemented for this function.
|
|
572
|
-
"""
|
|
573
|
-
enforce_ratelimit("create_scratch_studio", "creating Scratch studios")
|
|
574
|
-
|
|
575
|
-
if self.new_scratcher:
|
|
576
|
-
raise exceptions.Unauthorized(f"\nNew scratchers (like {self.username}) cannot create studios.")
|
|
577
|
-
|
|
578
|
-
response = requests.post("https://scratch.mit.edu/studios/create/",
|
|
579
|
-
cookies=self._cookies, headers=self._headers)
|
|
580
|
-
|
|
581
|
-
studio_id = webscrape_count(response.json()["redirect"], "/studios/", "/")
|
|
582
|
-
new_studio = self.connect_studio(studio_id)
|
|
583
|
-
|
|
584
|
-
if title is not None:
|
|
585
|
-
new_studio.set_title(title)
|
|
586
|
-
if description is not None:
|
|
587
|
-
new_studio.set_description(description)
|
|
588
|
-
|
|
589
|
-
return new_studio
|
|
590
|
-
|
|
591
|
-
def create_class(self, title: str, desc: str = '') -> classroom.Classroom:
|
|
592
|
-
"""
|
|
593
|
-
Create a class on the scratch website
|
|
594
|
-
|
|
595
|
-
Warning:
|
|
596
|
-
Don't spam this method - it WILL get you banned from Scratch.
|
|
597
|
-
To prevent accidental spam, a rate limit (5 classes per minute) is implemented for this function.
|
|
598
|
-
"""
|
|
599
|
-
enforce_ratelimit("create_scratch_class", "creating Scratch classes")
|
|
600
|
-
|
|
601
|
-
if not self.is_teacher:
|
|
602
|
-
raise exceptions.Unauthorized(f"{self.username} is not a teacher; can't create class")
|
|
603
|
-
|
|
604
|
-
data = requests.post("https://scratch.mit.edu/classes/create_classroom/",
|
|
605
|
-
json={"title": title, "description": desc},
|
|
606
|
-
headers=self._headers, cookies=self._cookies).json()
|
|
607
|
-
|
|
608
|
-
class_id = data[0]["id"]
|
|
609
|
-
return self.connect_classroom(class_id)
|
|
610
|
-
|
|
611
|
-
# --- My stuff page ---
|
|
612
|
-
|
|
613
|
-
def mystuff_projects(self, filter_arg: str = "all", *, page: int = 1, sort_by: str = '', descending: bool = True) \
|
|
614
|
-
-> list[project.Project]:
|
|
615
|
-
"""
|
|
616
|
-
Gets the projects from the "My stuff" page.
|
|
617
|
-
|
|
618
|
-
Args:
|
|
619
|
-
filter_arg (str): Possible values for this parameter are "all", "shared", "unshared" and "trashed"
|
|
620
|
-
|
|
621
|
-
Keyword Arguments:
|
|
622
|
-
page (int): The page of the "My Stuff" projects that should be returned
|
|
623
|
-
sort_by (str): The key the projects should be sorted based on. Possible values for this parameter are "" (then the projects are sorted based on last modified), "view_count", love_count", "remixers_count" (then the projects are sorted based on remix count) and "title" (then the projects are sorted based on title)
|
|
624
|
-
descending (boolean): Determines if the element with the highest key value (the key is specified in the sort_by argument) should be returned first. Defaults to True.
|
|
625
|
-
|
|
626
|
-
Returns:
|
|
627
|
-
list<scratchattach.project.Project>: A list with the projects from the "My Stuff" page, each project is represented by a Project object.
|
|
628
|
-
"""
|
|
629
|
-
if descending:
|
|
630
|
-
ascsort = ""
|
|
631
|
-
descsort = sort_by
|
|
632
|
-
else:
|
|
633
|
-
ascsort = sort_by
|
|
634
|
-
descsort = ""
|
|
635
|
-
try:
|
|
636
|
-
targets = requests.get(
|
|
637
|
-
f"https://scratch.mit.edu/site-api/projects/{filter_arg}/?page={page}&ascsort={ascsort}&descsort={descsort}",
|
|
638
|
-
headers=headers,
|
|
639
|
-
cookies=self._cookies,
|
|
640
|
-
timeout=10,
|
|
641
|
-
).json()
|
|
642
|
-
projects = []
|
|
643
|
-
for target in targets:
|
|
644
|
-
projects.append(project.Project(
|
|
645
|
-
id=target["pk"], _session=self, author_name=self._username,
|
|
646
|
-
comments_allowed=None, instructions=None, notes=None,
|
|
647
|
-
created=target["fields"]["datetime_created"],
|
|
648
|
-
last_modified=target["fields"]["datetime_modified"],
|
|
649
|
-
share_date=target["fields"]["datetime_shared"],
|
|
650
|
-
thumbnail_url="https:" + target["fields"]["thumbnail_url"],
|
|
651
|
-
favorites=target["fields"]["favorite_count"],
|
|
652
|
-
loves=target["fields"]["love_count"],
|
|
653
|
-
remixes=target["fields"]["remixers_count"],
|
|
654
|
-
views=target["fields"]["view_count"],
|
|
655
|
-
title=target["fields"]["title"],
|
|
656
|
-
comment_count=target["fields"]["commenters_count"]
|
|
657
|
-
))
|
|
658
|
-
return projects
|
|
659
|
-
except Exception:
|
|
660
|
-
raise exceptions.FetchError()
|
|
661
|
-
|
|
662
|
-
def mystuff_studios(self, filter_arg: str = "all", *, page: int = 1, sort_by: str = "", descending: bool = True) \
|
|
663
|
-
-> list[studio.Studio]:
|
|
664
|
-
if descending:
|
|
665
|
-
ascsort = ""
|
|
666
|
-
descsort = sort_by
|
|
667
|
-
else:
|
|
668
|
-
ascsort = sort_by
|
|
669
|
-
descsort = ""
|
|
670
|
-
try:
|
|
671
|
-
params: dict[str, Union[str, int]] = {"page": page, "ascsort": ascsort, "descsort": descsort}
|
|
672
|
-
targets = requests.get(
|
|
673
|
-
f"https://scratch.mit.edu/site-api/galleries/{filter_arg}/",
|
|
674
|
-
params=params,
|
|
675
|
-
headers=headers,
|
|
676
|
-
cookies=self._cookies,
|
|
677
|
-
timeout=10
|
|
678
|
-
).json()
|
|
679
|
-
studios = []
|
|
680
|
-
for target in targets:
|
|
681
|
-
studios.append(studio.Studio(
|
|
682
|
-
id=target["pk"], _session=self,
|
|
683
|
-
title=target["fields"]["title"],
|
|
684
|
-
description=None,
|
|
685
|
-
host_id=target["fields"]["owner"]["pk"],
|
|
686
|
-
host_name=target["fields"]["owner"]["username"],
|
|
687
|
-
open_to_all=None, comments_allowed=None,
|
|
688
|
-
image_url="https:" + target["fields"]["thumbnail_url"],
|
|
689
|
-
created=target["fields"]["datetime_created"],
|
|
690
|
-
modified=target["fields"]["datetime_modified"],
|
|
691
|
-
follower_count=None, manager_count=None,
|
|
692
|
-
curator_count=target["fields"]["curators_count"],
|
|
693
|
-
project_count=target["fields"]["projecters_count"]
|
|
694
|
-
))
|
|
695
|
-
return studios
|
|
696
|
-
except Exception:
|
|
697
|
-
raise exceptions.FetchError()
|
|
698
|
-
|
|
699
|
-
def mystuff_classes(self, mode: str = "Last created", page: Optional[int] = None) -> list[classroom.Classroom]:
|
|
700
|
-
if self.is_teacher is None:
|
|
701
|
-
self.update()
|
|
702
|
-
|
|
703
|
-
if not self.is_teacher:
|
|
704
|
-
raise exceptions.Unauthorized(f"{self.username} is not a teacher; can't have classes")
|
|
705
|
-
ascsort, descsort = get_class_sort_mode(mode)
|
|
706
|
-
|
|
707
|
-
classes_data = requests.get("https://scratch.mit.edu/site-api/classrooms/all/",
|
|
708
|
-
params={"page": page, "ascsort": ascsort, "descsort": descsort},
|
|
709
|
-
headers=self._headers, cookies=self._cookies).json()
|
|
710
|
-
classes = []
|
|
711
|
-
for data in classes_data:
|
|
712
|
-
fields = data["fields"]
|
|
713
|
-
educator_pf = fields["educator_profile"]
|
|
714
|
-
classes.append(classroom.Classroom(
|
|
715
|
-
id=data["pk"],
|
|
716
|
-
title=fields["title"],
|
|
717
|
-
classtoken=fields["token"],
|
|
718
|
-
datetime=datetime.datetime.fromisoformat(fields["datetime_created"]),
|
|
719
|
-
author=user.User(
|
|
720
|
-
username=educator_pf["user"]["username"], id=educator_pf["user"]["pk"], _session=self),
|
|
721
|
-
_session=self))
|
|
722
|
-
return classes
|
|
723
|
-
|
|
724
|
-
def mystuff_ended_classes(self, mode: str = "Last created", page: Optional[int] = None) -> list[classroom.Classroom]:
|
|
725
|
-
if not self.is_teacher:
|
|
726
|
-
raise exceptions.Unauthorized(f"{self.username} is not a teacher; can't have (deleted) classes")
|
|
727
|
-
ascsort, descsort = get_class_sort_mode(mode)
|
|
728
|
-
|
|
729
|
-
classes_data = requests.get("https://scratch.mit.edu/site-api/classrooms/closed/",
|
|
730
|
-
params={"page": page, "ascsort": ascsort, "descsort": descsort},
|
|
731
|
-
headers=self._headers, cookies=self._cookies).json()
|
|
732
|
-
classes = []
|
|
733
|
-
for data in classes_data:
|
|
734
|
-
fields = data["fields"]
|
|
735
|
-
educator_pf = fields["educator_profile"]
|
|
736
|
-
classes.append(classroom.Classroom(
|
|
737
|
-
id=data["pk"],
|
|
738
|
-
title=fields["title"],
|
|
739
|
-
classtoken=fields["token"],
|
|
740
|
-
datetime=datetime.datetime.fromisoformat(fields["datetime_created"]),
|
|
741
|
-
author=user.User(
|
|
742
|
-
username=educator_pf["user"]["username"], id=educator_pf["user"]["pk"], _session=self),
|
|
743
|
-
_session=self))
|
|
744
|
-
return classes
|
|
745
|
-
|
|
746
|
-
def backpack(self, limit: int = 20, offset: int = 0) -> list[backpack_asset.BackpackAsset]:
|
|
747
|
-
"""
|
|
748
|
-
Lists the assets that are in the backpack of the user associated with the session.
|
|
749
|
-
|
|
750
|
-
Returns:
|
|
751
|
-
list<backpack_asset.BackpackAsset>: List that contains the backpack items
|
|
752
|
-
"""
|
|
753
|
-
data = commons.api_iterative(
|
|
754
|
-
f"https://backpack.scratch.mit.edu/{self._username}",
|
|
755
|
-
limit=limit, offset=offset, _headers=self._headers
|
|
756
|
-
)
|
|
757
|
-
return commons.parse_object_list(data, backpack_asset.BackpackAsset, self)
|
|
758
|
-
|
|
759
|
-
def delete_from_backpack(self, backpack_asset_id) -> backpack_asset.BackpackAsset:
|
|
760
|
-
"""
|
|
761
|
-
Deletes an asset from the backpack.
|
|
762
|
-
|
|
763
|
-
Args:
|
|
764
|
-
backpack_asset_id: ID of the backpack asset that will be deleted
|
|
765
|
-
"""
|
|
766
|
-
return backpack_asset.BackpackAsset(id=backpack_asset_id, _session=self).delete()
|
|
767
|
-
|
|
768
|
-
def become_scratcher_invite(self) -> dict:
|
|
769
|
-
"""
|
|
770
|
-
If you are a new Scratcher and have been invited for becoming a Scratcher, this API endpoint will provide
|
|
771
|
-
more info on the invite.
|
|
772
|
-
"""
|
|
773
|
-
return requests.get(f"https://api.scratch.mit.edu/users/{self.username}/invites", headers=self._headers,
|
|
774
|
-
cookies=self._cookies).json()
|
|
775
|
-
|
|
776
|
-
# --- Connect classes inheriting from BaseCloud ---
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
@overload
|
|
780
|
-
def connect_cloud(self, project_id, *, cloud_class: type[T]) -> T:
|
|
781
|
-
"""
|
|
782
|
-
Connects to a cloud as logged-in user.
|
|
783
|
-
|
|
784
|
-
Args:
|
|
785
|
-
project_id:
|
|
786
|
-
|
|
787
|
-
Keyword arguments: CloudClass: The class that the returned object should be of. By default, this class is
|
|
788
|
-
scratchattach.cloud.ScratchCloud.
|
|
789
|
-
|
|
790
|
-
Returns: Type[scratchattach.cloud._base.BaseCloud]: An object representing the cloud of a project. Can be of any
|
|
791
|
-
class inheriting from BaseCloud.
|
|
792
|
-
"""
|
|
793
|
-
|
|
794
|
-
@overload
|
|
795
|
-
def connect_cloud(self, project_id) -> cloud.ScratchCloud:
|
|
796
|
-
"""
|
|
797
|
-
Connects to a cloud (by default Scratch's cloud) as logged-in user.
|
|
798
|
-
|
|
799
|
-
Args:
|
|
800
|
-
project_id:
|
|
801
|
-
|
|
802
|
-
Keyword arguments: CloudClass: The class that the returned object should be of. By default, this class is
|
|
803
|
-
scratchattach.cloud.ScratchCloud.
|
|
804
|
-
|
|
805
|
-
Returns: Type[scratchattach.cloud._base.BaseCloud]: An object representing the cloud of a project. Can be of any
|
|
806
|
-
class inheriting from BaseCloud.
|
|
807
|
-
"""
|
|
808
|
-
# noinspection PyPep8Naming
|
|
809
|
-
def connect_cloud(self, project_id, *, cloud_class: Optional[type[_base.BaseCloud]] = None) \
|
|
810
|
-
-> _base.BaseCloud:
|
|
811
|
-
cloud_class = cloud_class or cloud.ScratchCloud
|
|
812
|
-
return cloud_class(project_id=project_id, _session=self)
|
|
813
|
-
|
|
814
|
-
def connect_scratch_cloud(self, project_id) -> cloud.ScratchCloud:
|
|
815
|
-
"""
|
|
816
|
-
Returns:
|
|
817
|
-
scratchattach.cloud.ScratchCloud: An object representing the Scratch cloud of a project.
|
|
818
|
-
"""
|
|
819
|
-
return cloud.ScratchCloud(project_id=project_id, _session=self)
|
|
820
|
-
|
|
821
|
-
def connect_tw_cloud(self, project_id, *, purpose="", contact="",
|
|
822
|
-
cloud_host="wss://clouddata.turbowarp.org") -> cloud.TwCloud:
|
|
823
|
-
"""
|
|
824
|
-
Returns:
|
|
825
|
-
scratchattach.cloud.TwCloud: An object representing the TurboWarp cloud of a project.
|
|
826
|
-
"""
|
|
827
|
-
return cloud.TwCloud(project_id=project_id, purpose=purpose, contact=contact, cloud_host=cloud_host,
|
|
828
|
-
_session=self)
|
|
829
|
-
|
|
830
|
-
# --- Connect classes inheriting from BaseSiteComponent ---
|
|
831
|
-
|
|
832
|
-
# noinspection PyPep8Naming
|
|
833
|
-
# Class is camelcase here
|
|
834
|
-
def _make_linked_object(self, identificator_name, identificator, __class: type[C],
|
|
835
|
-
NotFoundException: type[Exception]) -> C:
|
|
836
|
-
"""
|
|
837
|
-
The Session class doesn't save the login in a ._session attribute, but IS the login ITSELF.
|
|
838
|
-
|
|
839
|
-
Therefore, the _make_linked_object method has to be adjusted
|
|
840
|
-
to get it to work for in the Session class.
|
|
841
|
-
|
|
842
|
-
Class must inherit from BaseSiteComponent
|
|
843
|
-
"""
|
|
844
|
-
# noinspection PyProtectedMember
|
|
845
|
-
# _get_object is protected
|
|
846
|
-
return commons._get_object(identificator_name, identificator, __class, NotFoundException, self)
|
|
847
|
-
|
|
848
|
-
def connect_user(self, username: str) -> user.User:
|
|
849
|
-
"""
|
|
850
|
-
Gets a user using this session, connects the session to the User object to allow authenticated actions
|
|
851
|
-
|
|
852
|
-
Args:
|
|
853
|
-
username (str): Username of the requested user
|
|
854
|
-
|
|
855
|
-
Returns:
|
|
856
|
-
scratchattach.user.User: An object that represents the requested user and allows you to perform actions on the user (like user.follow)
|
|
857
|
-
"""
|
|
858
|
-
return self._make_linked_object("username", username, user.User, exceptions.UserNotFound)
|
|
859
|
-
|
|
860
|
-
@deprecated("Finding usernames by user ids has been fixed.")
|
|
861
|
-
def find_username_from_id(self, user_id: int) -> str:
|
|
862
|
-
"""
|
|
863
|
-
Warning:
|
|
864
|
-
Every time this functions is run, a comment on your profile is posted and deleted. Therefore you shouldn't run this too often.
|
|
865
|
-
|
|
866
|
-
Returns:
|
|
867
|
-
str: The username that corresponds to the user id
|
|
868
|
-
"""
|
|
869
|
-
you = user.User(username=self.username, _session=self)
|
|
870
|
-
try:
|
|
871
|
-
comment = you.post_comment("scratchattach", commentee_id=int(user_id))
|
|
872
|
-
except exceptions.CommentPostFailure:
|
|
873
|
-
raise exceptions.BadRequest(
|
|
874
|
-
"After posting a comment, you need to wait 10 seconds before you can connect users by id again.")
|
|
875
|
-
except exceptions.BadRequest:
|
|
876
|
-
raise exceptions.UserNotFound("Invalid user id")
|
|
877
|
-
except Exception as e:
|
|
878
|
-
raise e
|
|
879
|
-
you.delete_comment(comment_id=comment.id)
|
|
880
|
-
try:
|
|
881
|
-
username = comment.content.split('">@')[1]
|
|
882
|
-
username = username.split("</a>")[0]
|
|
883
|
-
except IndexError:
|
|
884
|
-
raise exceptions.UserNotFound()
|
|
885
|
-
return username
|
|
886
|
-
|
|
887
|
-
@deprecated("Finding usernames by user ids has been fixed.")
|
|
888
|
-
def connect_user_by_id(self, user_id: int) -> user.User:
|
|
889
|
-
"""
|
|
890
|
-
Gets a user using this session, connects the session to the User object to allow authenticated actions
|
|
891
|
-
|
|
892
|
-
This method ...
|
|
893
|
-
1) gets the username by posting a comment with the user_id as commentee_id.
|
|
894
|
-
2) deletes the posted comment.
|
|
895
|
-
3) fetches other information about the user using Scratch's api.scratch.mit.edu/users/username API.
|
|
896
|
-
|
|
897
|
-
Warning:
|
|
898
|
-
Every time this functions is run, a comment on your profile is posted and deleted. Therefore, you shouldn't run this too often.
|
|
899
|
-
|
|
900
|
-
Args:
|
|
901
|
-
user_id (int): User ID of the requested user
|
|
902
|
-
|
|
903
|
-
Returns:
|
|
904
|
-
scratchattach.user.User: An object that represents the requested user and allows you to perform actions on the user (like user.follow)
|
|
905
|
-
"""
|
|
906
|
-
# noinspection PyDeprecation
|
|
907
|
-
return self._make_linked_object("username", self.find_username_from_id(user_id), user.User,
|
|
908
|
-
exceptions.UserNotFound)
|
|
909
|
-
|
|
910
|
-
def connect_project(self, project_id) -> project.Project:
|
|
911
|
-
"""
|
|
912
|
-
Gets a project using this session, connects the session to the Project object to allow authenticated actions
|
|
913
|
-
sess
|
|
914
|
-
Args:
|
|
915
|
-
project_id (int): ID of the requested project
|
|
916
|
-
|
|
917
|
-
Returns:
|
|
918
|
-
scratchattach.project.Project: An object that represents the requested project and allows you to perform actions on the project (like project.love)
|
|
919
|
-
"""
|
|
920
|
-
return self._make_linked_object("id", int(project_id), project.Project, exceptions.ProjectNotFound)
|
|
921
|
-
|
|
922
|
-
def connect_studio(self, studio_id) -> studio.Studio:
|
|
923
|
-
"""
|
|
924
|
-
Gets a studio using this session, connects the session to the Studio object to allow authenticated actions
|
|
925
|
-
|
|
926
|
-
Args:
|
|
927
|
-
studio_id (int): ID of the requested studio
|
|
928
|
-
|
|
929
|
-
Returns:
|
|
930
|
-
scratchattach.studio.Studio: An object that represents the requested studio and allows you to perform actions on the studio (like studio.follow)
|
|
931
|
-
"""
|
|
932
|
-
return self._make_linked_object("id", int(studio_id), studio.Studio, exceptions.StudioNotFound)
|
|
933
|
-
|
|
934
|
-
def connect_classroom(self, class_id) -> classroom.Classroom:
|
|
935
|
-
"""
|
|
936
|
-
Gets a class using this session.
|
|
937
|
-
|
|
938
|
-
Args:
|
|
939
|
-
class_id (str): class id of the requested class
|
|
940
|
-
|
|
941
|
-
Returns:
|
|
942
|
-
scratchattach.classroom.Classroom: An object representing the requested classroom
|
|
943
|
-
"""
|
|
944
|
-
return self._make_linked_object("id", int(class_id), classroom.Classroom, exceptions.ClassroomNotFound)
|
|
945
|
-
|
|
946
|
-
def connect_classroom_from_token(self, class_token) -> classroom.Classroom:
|
|
947
|
-
"""
|
|
948
|
-
Gets a class using this session.
|
|
949
|
-
|
|
950
|
-
Args:
|
|
951
|
-
class_token (str): class token of the requested class
|
|
952
|
-
|
|
953
|
-
Returns:
|
|
954
|
-
scratchattach.classroom.Classroom: An object representing the requested classroom
|
|
955
|
-
"""
|
|
956
|
-
return self._make_linked_object("classtoken", int(class_token), classroom.Classroom,
|
|
957
|
-
exceptions.ClassroomNotFound)
|
|
958
|
-
|
|
959
|
-
def connect_topic(self, topic_id) -> forum.ForumTopic:
|
|
960
|
-
"""
|
|
961
|
-
Gets a forum topic using this session, connects the session to the ForumTopic object to allow authenticated actions
|
|
962
|
-
Data is up-to-date. Data received from Scratch's RSS feed XML API.
|
|
963
|
-
|
|
964
|
-
Args:
|
|
965
|
-
topic_id (int): ID of the requested forum topic (can be found in the browser URL bar)
|
|
966
|
-
|
|
967
|
-
Returns:
|
|
968
|
-
scratchattach.forum.ForumTopic: An object that represents the requested forum topic
|
|
969
|
-
"""
|
|
970
|
-
return self._make_linked_object("id", int(topic_id), forum.ForumTopic, exceptions.ForumContentNotFound)
|
|
971
|
-
|
|
972
|
-
def connect_topic_list(self, category_id, *, page=1):
|
|
973
|
-
|
|
974
|
-
"""
|
|
975
|
-
Gets the topics from a forum category. Data web-scraped from Scratch's forums UI.
|
|
976
|
-
Data is up-to-date.
|
|
977
|
-
|
|
978
|
-
Args:
|
|
979
|
-
category_id (str): ID of the forum category
|
|
980
|
-
|
|
981
|
-
Keyword Arguments:
|
|
982
|
-
page (str): Page of the category topics that should be returned
|
|
983
|
-
|
|
984
|
-
Returns:
|
|
985
|
-
list<scratchattach.forum.ForumTopic>: A list containing the forum topics from the specified category
|
|
986
|
-
"""
|
|
987
|
-
|
|
988
|
-
try:
|
|
989
|
-
response = requests.get(f"https://scratch.mit.edu/discuss/{category_id}/?page={page}",
|
|
990
|
-
headers=self._headers, cookies=self._cookies)
|
|
991
|
-
soup = BeautifulSoup(response.content, 'html.parser')
|
|
992
|
-
except Exception as e:
|
|
993
|
-
raise exceptions.FetchError(str(e))
|
|
994
|
-
|
|
995
|
-
try:
|
|
996
|
-
category_name = soup.find('h4').find("span").get_text()
|
|
997
|
-
except Exception:
|
|
998
|
-
raise exceptions.BadRequest("Invalid category id")
|
|
999
|
-
|
|
1000
|
-
try:
|
|
1001
|
-
topics = soup.find_all('tr')
|
|
1002
|
-
topics.pop(0)
|
|
1003
|
-
return_topics = []
|
|
1004
|
-
|
|
1005
|
-
for topic in topics:
|
|
1006
|
-
title_link = topic.find('a')
|
|
1007
|
-
title = title_link.text.strip()
|
|
1008
|
-
topic_id = title_link['href'].split('/')[-2]
|
|
1009
|
-
|
|
1010
|
-
columns = topic.find_all('td')
|
|
1011
|
-
columns = [column.text for column in columns]
|
|
1012
|
-
if len(columns) == 1:
|
|
1013
|
-
# This is a sticky topic -> Skip it
|
|
1014
|
-
continue
|
|
1015
|
-
|
|
1016
|
-
last_updated = columns[3].split(" ")[0] + " " + columns[3].split(" ")[1]
|
|
1017
|
-
|
|
1018
|
-
return_topics.append(
|
|
1019
|
-
forum.ForumTopic(_session=self, id=int(topic_id), title=title, category_name=category_name,
|
|
1020
|
-
last_updated=last_updated, reply_count=int(columns[1]),
|
|
1021
|
-
view_count=int(columns[2])))
|
|
1022
|
-
return return_topics
|
|
1023
|
-
except Exception as e:
|
|
1024
|
-
raise exceptions.ScrapeError(str(e))
|
|
1025
|
-
|
|
1026
|
-
# --- Connect classes inheriting from BaseEventHandler ---
|
|
1027
|
-
|
|
1028
|
-
def connect_message_events(self, *, update_interval=2) -> message_events.MessageEvents:
|
|
1029
|
-
# shortcut for connect_linked_user().message_events()
|
|
1030
|
-
return message_events.MessageEvents(user.User(username=self.username, _session=self),
|
|
1031
|
-
update_interval=update_interval)
|
|
1032
|
-
|
|
1033
|
-
def connect_filterbot(self, *, log_deletions=True) -> filterbot.Filterbot:
|
|
1034
|
-
return filterbot.Filterbot(user.User(username=self.username, _session=self), log_deletions=log_deletions)
|
|
1035
|
-
|
|
1036
|
-
def get_session_string(self) -> str:
|
|
1037
|
-
assert self.session_string
|
|
1038
|
-
return self.session_string
|
|
1039
|
-
|
|
1040
|
-
def get_headers(self) -> dict[str, str]:
|
|
1041
|
-
return self._headers
|
|
1042
|
-
|
|
1043
|
-
def get_cookies(self) -> dict[str, str]:
|
|
1044
|
-
return self._cookies
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
# ------ #
|
|
1048
|
-
|
|
1049
|
-
def decode_session_id(session_id: str) -> tuple[dict[str, str], datetime.datetime]:
|
|
1050
|
-
"""
|
|
1051
|
-
Extract the JSON data from the main part of a session ID string
|
|
1052
|
-
Session id is in the format:
|
|
1053
|
-
<p1: long base64 string>:<p2: short base64 string>:<p3: medium base64 string>
|
|
1054
|
-
|
|
1055
|
-
p1 contains a base64-zlib compressed JSON string
|
|
1056
|
-
p2 is a base 62 encoded timestamp
|
|
1057
|
-
p3 might be a `synchronous signature` for the first 2 parts (might be useless for us)
|
|
1058
|
-
|
|
1059
|
-
The dict has these attributes:
|
|
1060
|
-
- username
|
|
1061
|
-
- _auth_user_id
|
|
1062
|
-
- testcookie
|
|
1063
|
-
- _auth_user_backend
|
|
1064
|
-
- token
|
|
1065
|
-
- login-ip
|
|
1066
|
-
- _language
|
|
1067
|
-
- django_timezone
|
|
1068
|
-
- _auth_user_hash
|
|
1069
|
-
"""
|
|
1070
|
-
p1, p2, p3 = session_id.split(':')
|
|
1071
|
-
|
|
1072
|
-
return (
|
|
1073
|
-
json.loads(zlib.decompress(base64.urlsafe_b64decode(p1 + "=="))),
|
|
1074
|
-
datetime.datetime.fromtimestamp(commons.b62_decode(p2))
|
|
1075
|
-
)
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
# ------ #
|
|
1079
|
-
|
|
1080
|
-
suppressed_login_warning = local()
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
@contextmanager
|
|
1084
|
-
def suppress_login_warning():
|
|
1085
|
-
"""
|
|
1086
|
-
Suppress the login warning.
|
|
1087
|
-
"""
|
|
1088
|
-
suppressed_login_warning.suppressed = getattr(suppressed_login_warning, "suppressed", 0)
|
|
1089
|
-
try:
|
|
1090
|
-
suppressed_login_warning.suppressed += 1
|
|
1091
|
-
yield
|
|
1092
|
-
finally:
|
|
1093
|
-
suppressed_login_warning.suppressed -= 1
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
def issue_login_warning() -> None:
|
|
1097
|
-
"""
|
|
1098
|
-
Issue a login data warning.
|
|
1099
|
-
"""
|
|
1100
|
-
if getattr(suppressed_login_warning, "suppressed", 0):
|
|
1101
|
-
return
|
|
1102
|
-
warnings.warn(
|
|
1103
|
-
"IMPORTANT: If you included login credentials directly in your code (e.g. session_id, session_string, ...), "
|
|
1104
|
-
"then make sure to EITHER instead load them from environment variables or files OR remember to remove them before "
|
|
1105
|
-
"you share your code with anyone else. If you want to remove this warning, "
|
|
1106
|
-
"use `warnings.filterwarnings('ignore', category=scratchattach.LoginDataWarning)`",
|
|
1107
|
-
exceptions.LoginDataWarning
|
|
1108
|
-
)
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
def login_by_id(session_id: str, *, username: Optional[str] = None, password: Optional[str] = None, xtoken=None) -> Session:
|
|
1112
|
-
"""
|
|
1113
|
-
Creates a session / log in to the Scratch website with the specified session id.
|
|
1114
|
-
Structured similarly to Session._connect_object method.
|
|
1115
|
-
|
|
1116
|
-
Args:
|
|
1117
|
-
session_id (str)
|
|
1118
|
-
|
|
1119
|
-
Keyword arguments:
|
|
1120
|
-
username (str)
|
|
1121
|
-
password (str)
|
|
1122
|
-
xtoken (str)
|
|
1123
|
-
|
|
1124
|
-
Returns:
|
|
1125
|
-
scratchattach.session.Session: An object that represents the created login / session
|
|
1126
|
-
"""
|
|
1127
|
-
# Generate session_string (a scratchattach-specific authentication method)
|
|
1128
|
-
# should this be changed to a @property?
|
|
1129
|
-
issue_login_warning()
|
|
1130
|
-
if password is not None:
|
|
1131
|
-
session_data = dict(id=session_id, username=username, password=password)
|
|
1132
|
-
session_string = base64.b64encode(json.dumps(session_data).encode()).decode()
|
|
1133
|
-
else:
|
|
1134
|
-
session_string = None
|
|
1135
|
-
|
|
1136
|
-
_session = Session(id=session_id, username=username, session_string=session_string)
|
|
1137
|
-
if xtoken is not None:
|
|
1138
|
-
# xtoken is retrievable from session id, so the most we can do is assert equality
|
|
1139
|
-
assert xtoken == _session.xtoken
|
|
1140
|
-
|
|
1141
|
-
return _session
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
def login(username, password, *, timeout=10) -> Session:
|
|
1145
|
-
"""
|
|
1146
|
-
Creates a session / log in to the Scratch website with the specified username and password.
|
|
1147
|
-
|
|
1148
|
-
This method ...
|
|
1149
|
-
1. creates a session id by posting a login request to Scratch's login API. (If this fails, scratchattach.exceptions.LoginFailure is raised)
|
|
1150
|
-
2. fetches the xtoken and other information by posting a request to scratch.mit.edu/session. (If this fails, a warning is displayed)
|
|
1151
|
-
|
|
1152
|
-
Args:
|
|
1153
|
-
username (str)
|
|
1154
|
-
password (str)
|
|
1155
|
-
|
|
1156
|
-
Keyword arguments:
|
|
1157
|
-
timeout (int): Timeout for the request to Scratch's login API (in seconds). Defaults to 10.
|
|
1158
|
-
|
|
1159
|
-
Returns:
|
|
1160
|
-
scratchattach.session.Session: An object that represents the created login / session
|
|
1161
|
-
"""
|
|
1162
|
-
issue_login_warning()
|
|
1163
|
-
|
|
1164
|
-
# Post request to login API:
|
|
1165
|
-
_headers = headers.copy()
|
|
1166
|
-
_headers["Cookie"] = "scratchcsrftoken=a;scratchlanguage=en;"
|
|
1167
|
-
with requests.no_error_handling():
|
|
1168
|
-
request = requests.post(
|
|
1169
|
-
"https://scratch.mit.edu/login/", json={"username": username, "password": password}, headers=_headers,
|
|
1170
|
-
timeout=timeout
|
|
1171
|
-
)
|
|
1172
|
-
try:
|
|
1173
|
-
result = re.search('"(.*)"', request.headers["Set-Cookie"])
|
|
1174
|
-
assert result is not None
|
|
1175
|
-
session_id = str(result.group())
|
|
1176
|
-
except Exception:
|
|
1177
|
-
raise exceptions.LoginFailure(
|
|
1178
|
-
"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 address. In this case, try logging in with your session id: https://github.com/TimMcCool/scratchattach/wiki#logging-in")
|
|
1179
|
-
|
|
1180
|
-
# Create session object:
|
|
1181
|
-
with suppress_login_warning():
|
|
1182
|
-
return login_by_id(session_id, username=username, password=password)
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
def login_by_session_string(session_string: str) -> Session:
|
|
1186
|
-
"""
|
|
1187
|
-
Login using a session string.
|
|
1188
|
-
"""
|
|
1189
|
-
issue_login_warning()
|
|
1190
|
-
session_string = base64.b64decode(session_string).decode() # unobfuscate
|
|
1191
|
-
session_data = json.loads(session_string)
|
|
1192
|
-
try:
|
|
1193
|
-
assert session_data.get("id")
|
|
1194
|
-
with suppress_login_warning():
|
|
1195
|
-
return login_by_id(session_data["id"], username=session_data.get("username"),
|
|
1196
|
-
password=session_data.get("password"))
|
|
1197
|
-
except Exception:
|
|
1198
|
-
pass
|
|
1199
|
-
try:
|
|
1200
|
-
assert session_data.get("session_id")
|
|
1201
|
-
with suppress_login_warning():
|
|
1202
|
-
return login_by_id(session_data["session_id"], username=session_data.get("username"),
|
|
1203
|
-
password=session_data.get("password"))
|
|
1204
|
-
except Exception:
|
|
1205
|
-
pass
|
|
1206
|
-
try:
|
|
1207
|
-
assert session_data.get("username") and session_data.get("password")
|
|
1208
|
-
with suppress_login_warning():
|
|
1209
|
-
return login(username=session_data["username"], password=session_data["password"])
|
|
1210
|
-
except Exception:
|
|
1211
|
-
pass
|
|
1212
|
-
raise ValueError("Couldn't log in.")
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
def login_by_io(file: SupportsRead[str]) -> Session:
|
|
1216
|
-
"""
|
|
1217
|
-
Login using a file object.
|
|
1218
|
-
"""
|
|
1219
|
-
with suppress_login_warning():
|
|
1220
|
-
return login_by_session_string(file.read())
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
def login_by_file(file: FileDescriptorOrPath) -> Session:
|
|
1224
|
-
"""
|
|
1225
|
-
Login using a path to a file.
|
|
1226
|
-
"""
|
|
1227
|
-
with suppress_login_warning(), open(file, encoding="utf-8") as f:
|
|
1228
|
-
return login_by_io(f)
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
def login_from_browser(browser: Browser = ANY):
|
|
1232
|
-
"""
|
|
1233
|
-
Login from a browser
|
|
1234
|
-
"""
|
|
1235
|
-
cookies = cookies_from_browser(browser)
|
|
1236
|
-
if "scratchsessionsid" in cookies:
|
|
1237
|
-
return login_by_id(cookies["scratchsessionsid"])
|
|
1238
|
-
raise ValueError("Not enough data to log in.")
|