scratchattach 2.1.15b0__py3-none-any.whl → 3.0.0b1__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.
- cli/__about__.py +1 -0
- cli/__init__.py +26 -0
- cli/cmd/__init__.py +4 -0
- cli/cmd/group.py +127 -0
- cli/cmd/login.py +60 -0
- cli/cmd/profile.py +7 -0
- cli/cmd/sessions.py +5 -0
- cli/context.py +142 -0
- cli/db.py +66 -0
- cli/namespace.py +14 -0
- {scratchattach/cloud → cloud}/_base.py +112 -87
- {scratchattach/cloud → cloud}/cloud.py +16 -16
- {scratchattach/editor → editor}/__init__.py +2 -1
- {scratchattach/editor → editor}/asset.py +26 -14
- {scratchattach/editor → editor}/backpack_json.py +3 -5
- {scratchattach/editor → editor}/base.py +2 -4
- {scratchattach/editor → editor}/block.py +27 -22
- {scratchattach/editor → editor}/blockshape.py +1 -1
- {scratchattach/editor → editor}/build_defaulting.py +2 -2
- editor/commons.py +145 -0
- {scratchattach/editor → editor}/field.py +1 -1
- {scratchattach/editor → editor}/inputs.py +6 -3
- {scratchattach/editor → editor}/meta.py +10 -7
- {scratchattach/editor → editor}/monitor.py +10 -8
- {scratchattach/editor → editor}/mutation.py +68 -11
- {scratchattach/editor → editor}/pallete.py +1 -3
- {scratchattach/editor → editor}/prim.py +4 -0
- {scratchattach/editor → editor}/project.py +118 -16
- {scratchattach/editor → editor}/sprite.py +25 -15
- {scratchattach/editor → editor}/vlb.py +2 -2
- {scratchattach/eventhandlers → eventhandlers}/_base.py +1 -0
- {scratchattach/eventhandlers → eventhandlers}/cloud_events.py +26 -6
- {scratchattach/eventhandlers → eventhandlers}/cloud_recorder.py +4 -4
- {scratchattach/eventhandlers → eventhandlers}/cloud_requests.py +139 -54
- {scratchattach/eventhandlers → eventhandlers}/cloud_server.py +6 -3
- {scratchattach/eventhandlers → eventhandlers}/cloud_storage.py +1 -2
- eventhandlers/filterbot.py +163 -0
- other/other_apis.py +598 -0
- {scratchattach-2.1.15b0.dist-info → scratchattach-3.0.0b1.dist-info}/METADATA +7 -11
- scratchattach-3.0.0b1.dist-info/RECORD +79 -0
- {scratchattach-2.1.15b0.dist-info → scratchattach-3.0.0b1.dist-info}/WHEEL +1 -1
- scratchattach-3.0.0b1.dist-info/entry_points.txt +2 -0
- scratchattach-3.0.0b1.dist-info/top_level.txt +7 -0
- {scratchattach/site → site}/_base.py +32 -5
- site/activity.py +426 -0
- {scratchattach/site → site}/alert.py +4 -5
- {scratchattach/site → site}/backpack_asset.py +2 -1
- {scratchattach/site → site}/classroom.py +80 -73
- {scratchattach/site → site}/cloud_activity.py +43 -29
- {scratchattach/site → site}/comment.py +86 -100
- {scratchattach/site → site}/forum.py +8 -4
- site/placeholder.py +132 -0
- {scratchattach/site → site}/project.py +228 -122
- {scratchattach/site → site}/session.py +156 -71
- {scratchattach/site → site}/studio.py +139 -46
- site/typed_dicts.py +151 -0
- {scratchattach/site → site}/user.py +511 -215
- {scratchattach/utils → utils}/commons.py +12 -4
- {scratchattach/utils → utils}/encoder.py +7 -4
- {scratchattach/utils → utils}/enums.py +1 -0
- {scratchattach/utils → utils}/exceptions.py +36 -2
- utils/optional_async.py +154 -0
- utils/requests.py +306 -0
- scratchattach/__init__.py +0 -29
- scratchattach/editor/commons.py +0 -273
- scratchattach/eventhandlers/filterbot.py +0 -161
- scratchattach/other/other_apis.py +0 -284
- scratchattach/site/activity.py +0 -382
- scratchattach/utils/requests.py +0 -93
- scratchattach-2.1.15b0.dist-info/RECORD +0 -66
- scratchattach-2.1.15b0.dist-info/top_level.txt +0 -1
- {scratchattach/cloud → cloud}/__init__.py +0 -0
- {scratchattach/editor → editor}/code_translation/__init__.py +0 -0
- {scratchattach/editor → editor}/code_translation/parse.py +0 -0
- {scratchattach/editor → editor}/comment.py +0 -0
- {scratchattach/editor → editor}/extension.py +0 -0
- {scratchattach/editor → editor}/twconfig.py +0 -0
- {scratchattach/eventhandlers → eventhandlers}/__init__.py +0 -0
- {scratchattach/eventhandlers → eventhandlers}/combine.py +0 -0
- {scratchattach/eventhandlers → eventhandlers}/message_events.py +0 -0
- {scratchattach/other → other}/__init__.py +0 -0
- {scratchattach/other → other}/project_json_capabilities.py +0 -0
- {scratchattach-2.1.15b0.dist-info → scratchattach-3.0.0b1.dist-info}/licenses/LICENSE +0 -0
- {scratchattach/site → site}/__init__.py +0 -0
- {scratchattach/site → site}/browser_cookie3_stub.py +0 -0
- {scratchattach/site → site}/browser_cookies.py +0 -0
- {scratchattach/utils → utils}/__init__.py +0 -0
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
"""User class"""
|
|
2
|
+
|
|
2
3
|
from __future__ import annotations
|
|
3
4
|
|
|
4
5
|
import json
|
|
5
6
|
import random
|
|
6
7
|
import re
|
|
7
8
|
import string
|
|
9
|
+
import warnings
|
|
10
|
+
from typing import Union, cast, Optional, TypedDict
|
|
11
|
+
from dataclasses import dataclass, field
|
|
8
12
|
from datetime import datetime, timezone
|
|
9
13
|
from enum import Enum
|
|
10
|
-
import warnings
|
|
11
14
|
|
|
12
15
|
from typing_extensions import deprecated
|
|
13
16
|
from bs4 import BeautifulSoup, Tag
|
|
@@ -26,29 +29,62 @@ from . import forum
|
|
|
26
29
|
from . import comment
|
|
27
30
|
from . import activity
|
|
28
31
|
from . import classroom
|
|
32
|
+
from . import typed_dicts
|
|
33
|
+
from . import session
|
|
34
|
+
|
|
29
35
|
|
|
30
36
|
class Rank(Enum):
|
|
31
37
|
"""
|
|
32
38
|
Possible ranks in scratch
|
|
33
39
|
"""
|
|
40
|
+
|
|
34
41
|
NEW_SCRATCHER = 0
|
|
35
42
|
SCRATCHER = 1
|
|
36
43
|
SCRATCH_TEAM = 2
|
|
37
44
|
|
|
45
|
+
|
|
46
|
+
class _OcularStatusMeta(TypedDict):
|
|
47
|
+
updated: str
|
|
48
|
+
updatedBy: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _OcularStatus(TypedDict):
|
|
52
|
+
_id: str
|
|
53
|
+
name: str
|
|
54
|
+
status: str
|
|
55
|
+
color: str
|
|
56
|
+
meta: _OcularStatusMeta
|
|
57
|
+
|
|
58
|
+
|
|
38
59
|
class Verificator:
|
|
39
60
|
|
|
40
61
|
def __init__(self, user: User, project_id: int):
|
|
41
|
-
self.project = user._make_linked_object(
|
|
62
|
+
self.project = user._make_linked_object(
|
|
63
|
+
"id", project_id, project.Project, exceptions.ProjectNotFound
|
|
64
|
+
)
|
|
42
65
|
self.projecturl = self.project.url
|
|
43
|
-
self.code =
|
|
66
|
+
self.code = "".join(random.choices(string.ascii_letters + string.digits, k=8))
|
|
44
67
|
self.username = user.username
|
|
45
68
|
|
|
46
69
|
def check(self) -> bool:
|
|
47
|
-
return bool(
|
|
70
|
+
return bool(
|
|
71
|
+
list(
|
|
72
|
+
filter(
|
|
73
|
+
lambda x: x.author_name == self.username
|
|
74
|
+
and (
|
|
75
|
+
x.content == self.code
|
|
76
|
+
or x.content.startswith(self.code)
|
|
77
|
+
or x.content.endswith(self.code)
|
|
78
|
+
),
|
|
79
|
+
self.project.comments(),
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
)
|
|
48
83
|
|
|
49
|
-
class User(BaseSiteComponent):
|
|
50
84
|
|
|
51
|
-
|
|
85
|
+
@dataclass
|
|
86
|
+
class User(BaseSiteComponent[typed_dicts.UserDict]):
|
|
87
|
+
"""
|
|
52
88
|
Represents a Scratch user.
|
|
53
89
|
|
|
54
90
|
Attributes:
|
|
@@ -68,76 +104,150 @@ class User(BaseSiteComponent):
|
|
|
68
104
|
:.scratchteam: Retuns True if the user is in the Scratch team
|
|
69
105
|
|
|
70
106
|
:.update(): Updates the attributes
|
|
71
|
-
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
username: str = field(kw_only=True, default="")
|
|
110
|
+
join_date: str = field(kw_only=True, default="")
|
|
111
|
+
about_me: str = field(kw_only=True, default="")
|
|
112
|
+
wiwo: str = field(kw_only=True, default="")
|
|
113
|
+
country: str = field(kw_only=True, default="")
|
|
114
|
+
icon_url: str = field(kw_only=True, default="")
|
|
115
|
+
id: int = field(kw_only=True, default=0)
|
|
116
|
+
scratchteam: bool = field(kw_only=True, repr=False, default=False)
|
|
117
|
+
is_member: bool = field(kw_only=True, repr=False, default=False)
|
|
118
|
+
has_ears: bool = field(kw_only=True, repr=False, default=False)
|
|
119
|
+
_classroom: tuple[bool, Optional[classroom.Classroom]] = field(
|
|
120
|
+
init=False, default=(False, None)
|
|
121
|
+
)
|
|
122
|
+
_headers: dict[str, str] = field(init=False, default_factory=headers.copy)
|
|
123
|
+
_cookies: dict[str, str] = field(init=False, default_factory=dict)
|
|
124
|
+
_json_headers: dict[str, str] = field(init=False, default_factory=dict)
|
|
125
|
+
_session: Optional[session.Session] = field(kw_only=True, default=None)
|
|
72
126
|
|
|
73
127
|
def __str__(self):
|
|
74
|
-
return
|
|
128
|
+
return f"-U {self.username}"
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def status(self) -> str:
|
|
132
|
+
return self.wiwo
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def bio(self) -> str:
|
|
136
|
+
return self.about_me
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def icon(self) -> bytes:
|
|
140
|
+
with requests.no_error_handling():
|
|
141
|
+
return requests.get(self.icon_url).content
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def name(self) -> str:
|
|
145
|
+
return self.username
|
|
75
146
|
|
|
76
|
-
def
|
|
147
|
+
def __post_init__(self):
|
|
77
148
|
|
|
78
149
|
# Info on how the .update method has to fetch the data:
|
|
79
150
|
self.update_function = requests.get
|
|
80
|
-
self.update_api = f"https://api.scratch.mit.edu/users/{
|
|
81
|
-
|
|
82
|
-
# Set attributes every User object needs to have:
|
|
83
|
-
self._session = None
|
|
84
|
-
self.id = None
|
|
85
|
-
self.username = None
|
|
86
|
-
self.name = None
|
|
151
|
+
self.update_api = f"https://api.scratch.mit.edu/users/{self.username}"
|
|
87
152
|
|
|
88
153
|
# cache value for classroom getter method (using @property)
|
|
89
154
|
# first value is whether the cache has actually been set (because it can be None), second is the value itself
|
|
90
|
-
self._classroom
|
|
91
|
-
|
|
92
|
-
# Update attributes from entries dict:
|
|
93
|
-
entries.setdefault("name", entries.get("username"))
|
|
94
|
-
self.__dict__.update(entries)
|
|
95
|
-
|
|
96
|
-
# Set alternative attributes:
|
|
97
|
-
if hasattr(self, "bio"):
|
|
98
|
-
self.about_me = self.bio
|
|
99
|
-
if hasattr(self, "status"):
|
|
100
|
-
self.wiwo = self.status
|
|
101
|
-
if hasattr(self, "name"):
|
|
102
|
-
self.username = self.name
|
|
155
|
+
# self._classroom
|
|
103
156
|
|
|
104
157
|
# Headers and cookies:
|
|
105
|
-
if self._session is None:
|
|
106
|
-
self._headers
|
|
107
|
-
self._cookies =
|
|
108
|
-
else:
|
|
109
|
-
self._headers :dict = self._session._headers
|
|
110
|
-
self._cookies = self._session._cookies
|
|
158
|
+
if self._session is not None:
|
|
159
|
+
self._headers = self._session.get_headers()
|
|
160
|
+
self._cookies = self._session.get_cookies()
|
|
111
161
|
|
|
112
162
|
# Headers for operations that require accept and Content-Type fields:
|
|
113
163
|
self._json_headers = dict(self._headers)
|
|
114
164
|
self._json_headers["accept"] = "application/json"
|
|
115
165
|
self._json_headers["Content-Type"] = "application/json"
|
|
116
166
|
|
|
117
|
-
def _update_from_dict(self, data):
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
except KeyError: pass
|
|
167
|
+
def _update_from_dict(self, data: Union[dict, typed_dicts.UserDict]):
|
|
168
|
+
data = cast(typed_dicts.UserDict, data)
|
|
169
|
+
|
|
170
|
+
self.id = data.get("id", self.id)
|
|
171
|
+
self.username = data.get("username", self.username)
|
|
172
|
+
self.scratchteam = data.get("scratchteam", self.scratchteam)
|
|
173
|
+
if history := data.get("history"):
|
|
174
|
+
self.join_date = history["joined"]
|
|
175
|
+
|
|
176
|
+
if profile := data.get("profile"):
|
|
177
|
+
self.about_me = profile["bio"]
|
|
178
|
+
self.wiwo = profile["status"]
|
|
179
|
+
self.country = profile["country"]
|
|
180
|
+
self.icon_url = profile["images"]["90x90"]
|
|
181
|
+
self.is_member = bool(profile.get("membership_label", False))
|
|
182
|
+
self.has_ears = bool(profile.get("membership_avatar_badge", False))
|
|
134
183
|
return True
|
|
135
184
|
|
|
136
185
|
def _assert_permission(self):
|
|
137
186
|
self._assert_auth()
|
|
138
|
-
if self._session.
|
|
187
|
+
if self._session.username != self.username:
|
|
139
188
|
raise exceptions.Unauthorized(
|
|
140
|
-
"You need to be authenticated as the profile owner to do this."
|
|
189
|
+
"You need to be authenticated as the profile owner to do this."
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def url(self):
|
|
194
|
+
return f"https://scratch.mit.edu/users/{self.username}"
|
|
195
|
+
|
|
196
|
+
def __rich__(self):
|
|
197
|
+
from rich.panel import Panel
|
|
198
|
+
from rich.table import Table
|
|
199
|
+
from rich import box
|
|
200
|
+
from rich.markup import escape
|
|
201
|
+
|
|
202
|
+
featured_data = self.featured_data() or {}
|
|
203
|
+
ocular_data = self.ocular_status()
|
|
204
|
+
ocular = "No ocular status"
|
|
205
|
+
|
|
206
|
+
if status := ocular_data.get("status"):
|
|
207
|
+
color_str = ""
|
|
208
|
+
color_data = ocular_data.get("color")
|
|
209
|
+
if color_data is not None:
|
|
210
|
+
color_str = f"[{color_data}] ⬤ [/]"
|
|
211
|
+
|
|
212
|
+
ocular = f"[i]{escape(status)}[/]{color_str}"
|
|
213
|
+
|
|
214
|
+
_classroom = self.classroom
|
|
215
|
+
url = f"[link={self.url}]{escape(self.username)}[/]"
|
|
216
|
+
|
|
217
|
+
info = Table(box=box.SIMPLE)
|
|
218
|
+
info.add_column(url, overflow="fold")
|
|
219
|
+
info.add_column(f"#{self.id}", overflow="fold")
|
|
220
|
+
|
|
221
|
+
info.add_row("Joined", escape(self.join_date))
|
|
222
|
+
info.add_row("Country", escape(self.country))
|
|
223
|
+
info.add_row("Messages", str(self.message_count()))
|
|
224
|
+
info.add_row(
|
|
225
|
+
"Class", str(_classroom.title if _classroom is not None else "None")
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
desc = Table("Profile", ocular, box=box.SIMPLE)
|
|
229
|
+
desc.add_row("About me", escape(self.about_me))
|
|
230
|
+
desc.add_row("Wiwo", escape(self.wiwo))
|
|
231
|
+
desc.add_row(
|
|
232
|
+
escape(featured_data.get("label", "Featured Project")),
|
|
233
|
+
escape(str(self.connect_featured_project())),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
ret = Table.grid(expand=True)
|
|
237
|
+
|
|
238
|
+
ret.add_column(ratio=1)
|
|
239
|
+
ret.add_column(ratio=3)
|
|
240
|
+
ret.add_row(Panel(info, title=url), Panel(desc, title="Description"))
|
|
241
|
+
|
|
242
|
+
return ret
|
|
243
|
+
|
|
244
|
+
def connect_featured_project(self) -> Optional[project.Project]:
|
|
245
|
+
data = self.featured_data() or {}
|
|
246
|
+
if pid := data.get("id"):
|
|
247
|
+
return self._session.connect_project(int(pid))
|
|
248
|
+
if projs := self.projects(limit=1):
|
|
249
|
+
return projs[0]
|
|
250
|
+
return None
|
|
141
251
|
|
|
142
252
|
@property
|
|
143
253
|
def classroom(self) -> classroom.Classroom | None:
|
|
@@ -151,48 +261,58 @@ class User(BaseSiteComponent):
|
|
|
151
261
|
soup = BeautifulSoup(resp.text, "html.parser")
|
|
152
262
|
|
|
153
263
|
details = soup.find("p", {"class": "profile-details"})
|
|
264
|
+
if details is None:
|
|
265
|
+
# No details, e.g. if the user is banned
|
|
266
|
+
return None
|
|
267
|
+
|
|
154
268
|
assert isinstance(details, Tag)
|
|
155
269
|
|
|
156
|
-
class_name, class_id, is_closed = None,
|
|
270
|
+
class_name, class_id, is_closed = None, None, False
|
|
157
271
|
for a in details.find_all("a"):
|
|
158
272
|
if not isinstance(a, Tag):
|
|
159
273
|
continue
|
|
160
274
|
href = str(a.get("href"))
|
|
161
275
|
if re.match(r"/classes/\d*/", href):
|
|
162
|
-
class_name = a.text.strip()[len("Student of: "):]
|
|
163
|
-
is_closed =
|
|
276
|
+
class_name = a.text.strip()[len("Student of: ") :]
|
|
277
|
+
is_closed = bool(
|
|
278
|
+
re.search(r"\n *\(ended\)", class_name)
|
|
279
|
+
) # as this has a \n, we can be sure
|
|
164
280
|
if is_closed:
|
|
165
|
-
class_name = class_name
|
|
281
|
+
class_name = re.sub(r"\n *\(ended\)", "", class_name).strip()
|
|
166
282
|
|
|
167
|
-
class_id = int(href.split(
|
|
283
|
+
class_id = int(href.split("/")[2])
|
|
168
284
|
break
|
|
169
285
|
|
|
170
286
|
if class_name:
|
|
171
287
|
self._classroom = True, classroom.Classroom(
|
|
172
288
|
_session=self._session,
|
|
173
|
-
id=class_id,
|
|
289
|
+
id=class_id or 0,
|
|
174
290
|
title=class_name,
|
|
175
|
-
is_closed=is_closed
|
|
291
|
+
is_closed=is_closed,
|
|
176
292
|
)
|
|
177
293
|
else:
|
|
178
294
|
self._classroom = True, None
|
|
179
295
|
|
|
180
296
|
return self._classroom[1]
|
|
181
297
|
|
|
182
|
-
def does_exist(self):
|
|
298
|
+
def does_exist(self) -> Optional[bool]:
|
|
183
299
|
"""
|
|
184
300
|
Returns:
|
|
185
301
|
boolean : True if the user exists, False if the user is deleted, None if an error occured
|
|
186
302
|
"""
|
|
187
303
|
with requests.no_error_handling():
|
|
188
|
-
status_code = requests.get(
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
304
|
+
status_code = requests.get(
|
|
305
|
+
f"https://scratch.mit.edu/users/{self.username}/"
|
|
306
|
+
).status_code
|
|
307
|
+
if status_code == 200:
|
|
308
|
+
return True
|
|
309
|
+
elif status_code == 404:
|
|
310
|
+
return False
|
|
311
|
+
|
|
312
|
+
return None
|
|
193
313
|
|
|
194
314
|
# Will maybe be deprecated later, but for now still has its own purpose.
|
|
195
|
-
|
|
315
|
+
# @deprecated("This function is partially deprecated. Use user.rank() instead.")
|
|
196
316
|
def is_new_scratcher(self):
|
|
197
317
|
"""
|
|
198
318
|
Returns:
|
|
@@ -200,8 +320,10 @@ class User(BaseSiteComponent):
|
|
|
200
320
|
"""
|
|
201
321
|
try:
|
|
202
322
|
with requests.no_error_handling():
|
|
203
|
-
res = requests.get(
|
|
204
|
-
|
|
323
|
+
res = requests.get(
|
|
324
|
+
f"https://scratch.mit.edu/users/{self.username}/"
|
|
325
|
+
).text
|
|
326
|
+
group = res[res.rindex('<span class="group">') :][:70]
|
|
205
327
|
return "new scratcher" in group.lower()
|
|
206
328
|
|
|
207
329
|
except Exception as e:
|
|
@@ -209,8 +331,14 @@ class User(BaseSiteComponent):
|
|
|
209
331
|
return None
|
|
210
332
|
|
|
211
333
|
def message_count(self):
|
|
212
|
-
|
|
213
|
-
|
|
334
|
+
return json.loads(
|
|
335
|
+
requests.get(
|
|
336
|
+
f"https://api.scratch.mit.edu/users/{self.username}/messages/count/?cachebust={random.randint(0,10000)}",
|
|
337
|
+
headers={
|
|
338
|
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.3c6 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36",
|
|
339
|
+
},
|
|
340
|
+
).text
|
|
341
|
+
)["count"]
|
|
214
342
|
|
|
215
343
|
def featured_data(self):
|
|
216
344
|
"""
|
|
@@ -218,35 +346,69 @@ class User(BaseSiteComponent):
|
|
|
218
346
|
dict: Gets info on the user's featured project and featured label (like "Featured project", "My favorite things", etc.)
|
|
219
347
|
"""
|
|
220
348
|
try:
|
|
221
|
-
response =
|
|
349
|
+
response = requests.get(
|
|
350
|
+
f"https://scratch.mit.edu/site-api/users/all/{self.username}/"
|
|
351
|
+
).json()
|
|
222
352
|
return {
|
|
223
|
-
"label":response["featured_project_label_name"],
|
|
224
|
-
"project":
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
353
|
+
"label": response["featured_project_label_name"],
|
|
354
|
+
"project": dict(
|
|
355
|
+
id=str(response["featured_project_data"]["id"]),
|
|
356
|
+
author=response["featured_project_data"]["creator"],
|
|
357
|
+
thumbnail_url="https://"
|
|
358
|
+
+ response["featured_project_data"]["thumbnail_url"][2:],
|
|
359
|
+
title=response["featured_project_data"]["title"],
|
|
360
|
+
),
|
|
361
|
+
}
|
|
232
362
|
except Exception:
|
|
233
363
|
return None
|
|
234
364
|
|
|
365
|
+
def unfollowers(self) -> list[User]:
|
|
366
|
+
"""
|
|
367
|
+
Get all unfollowers by comparing API response and HTML response.
|
|
368
|
+
NOTE: This method can take a long time to run.
|
|
369
|
+
|
|
370
|
+
Based on https://juegostrower.github.io/unfollowers/
|
|
371
|
+
"""
|
|
372
|
+
follower_count = self.follower_count()
|
|
373
|
+
|
|
374
|
+
# regular followers
|
|
375
|
+
usernames = []
|
|
376
|
+
for i in range(1, 2 + follower_count // 60):
|
|
377
|
+
with requests.no_error_handling():
|
|
378
|
+
resp = requests.get(
|
|
379
|
+
f"https://scratch.mit.edu/users/{self.username}/followers/",
|
|
380
|
+
params={"page": i},
|
|
381
|
+
)
|
|
382
|
+
soup = BeautifulSoup(resp.text, "html.parser")
|
|
383
|
+
usernames.extend(span.text.strip() for span in soup.select("span.title"))
|
|
384
|
+
|
|
385
|
+
# api response contains all-time followers, including deleted and unfollowed
|
|
386
|
+
unfollowers = []
|
|
387
|
+
for offset in range(0, follower_count, 40):
|
|
388
|
+
unfollowers.extend(
|
|
389
|
+
user
|
|
390
|
+
for user in self.followers(offset=offset, limit=40)
|
|
391
|
+
if user.username not in usernames
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
return unfollowers
|
|
395
|
+
|
|
396
|
+
def unfollower_usernames(self) -> list[str]:
|
|
397
|
+
return [user.username for user in self.unfollowers()]
|
|
398
|
+
|
|
235
399
|
def follower_count(self):
|
|
236
|
-
# follower count
|
|
237
400
|
with requests.no_error_handling():
|
|
238
401
|
text = requests.get(
|
|
239
402
|
f"https://scratch.mit.edu/users/{self.username}/followers/",
|
|
240
|
-
headers
|
|
403
|
+
headers=self._headers,
|
|
241
404
|
).text
|
|
242
405
|
return commons.webscrape_count(text, "Followers (", ")")
|
|
243
406
|
|
|
244
407
|
def following_count(self):
|
|
245
|
-
# following count
|
|
246
408
|
with requests.no_error_handling():
|
|
247
409
|
text = requests.get(
|
|
248
410
|
f"https://scratch.mit.edu/users/{self.username}/following/",
|
|
249
|
-
headers
|
|
411
|
+
headers=self._headers,
|
|
250
412
|
).text
|
|
251
413
|
return commons.webscrape_count(text, "Following (", ")")
|
|
252
414
|
|
|
@@ -256,7 +418,10 @@ class User(BaseSiteComponent):
|
|
|
256
418
|
list<scratchattach.user.User>: The user's followers as list of scratchattach.user.User objects
|
|
257
419
|
"""
|
|
258
420
|
response = commons.api_iterative(
|
|
259
|
-
f"https://api.scratch.mit.edu/users/{self.username}/followers/",
|
|
421
|
+
f"https://api.scratch.mit.edu/users/{self.username}/followers/",
|
|
422
|
+
limit=limit,
|
|
423
|
+
offset=offset,
|
|
424
|
+
)
|
|
260
425
|
return commons.parse_object_list(response, User, self._session, "username")
|
|
261
426
|
|
|
262
427
|
def follower_names(self, *, limit=40, offset=0):
|
|
@@ -272,7 +437,10 @@ class User(BaseSiteComponent):
|
|
|
272
437
|
list<scratchattach.user.User>: The users that the user is following as list of scratchattach.user.User objects
|
|
273
438
|
"""
|
|
274
439
|
response = commons.api_iterative(
|
|
275
|
-
f"https://api.scratch.mit.edu/users/{self.username}/following/",
|
|
440
|
+
f"https://api.scratch.mit.edu/users/{self.username}/following/",
|
|
441
|
+
limit=limit,
|
|
442
|
+
offset=offset,
|
|
443
|
+
)
|
|
276
444
|
return commons.parse_object_list(response, User, self._session, "username")
|
|
277
445
|
|
|
278
446
|
def following_names(self, *, limit=40, offset=0):
|
|
@@ -330,7 +498,7 @@ class User(BaseSiteComponent):
|
|
|
330
498
|
with requests.no_error_handling():
|
|
331
499
|
text = requests.get(
|
|
332
500
|
f"https://scratch.mit.edu/users/{self.username}/projects/",
|
|
333
|
-
headers
|
|
501
|
+
headers=self._headers,
|
|
334
502
|
).text
|
|
335
503
|
return commons.webscrape_count(text, "Shared Projects (", ")")
|
|
336
504
|
|
|
@@ -338,7 +506,7 @@ class User(BaseSiteComponent):
|
|
|
338
506
|
with requests.no_error_handling():
|
|
339
507
|
text = requests.get(
|
|
340
508
|
f"https://scratch.mit.edu/users/{self.username}/studios/",
|
|
341
|
-
headers
|
|
509
|
+
headers=self._headers,
|
|
342
510
|
).text
|
|
343
511
|
return commons.webscrape_count(text, "Studios I Curate (", ")")
|
|
344
512
|
|
|
@@ -346,16 +514,19 @@ class User(BaseSiteComponent):
|
|
|
346
514
|
with requests.no_error_handling():
|
|
347
515
|
text = requests.get(
|
|
348
516
|
f"https://scratch.mit.edu/users/{self.username}/studios_following/",
|
|
349
|
-
headers
|
|
517
|
+
headers=self._headers,
|
|
350
518
|
).text
|
|
351
519
|
return commons.webscrape_count(text, "Studios I Follow (", ")")
|
|
352
520
|
|
|
353
|
-
def studios(self, *, limit=40, offset=0):
|
|
521
|
+
def studios(self, *, limit=40, offset=0) -> list[studio.Studio]:
|
|
354
522
|
_studios = commons.api_iterative(
|
|
355
|
-
f"https://api.scratch.mit.edu/users/{self.username}/studios/curate",
|
|
523
|
+
f"https://api.scratch.mit.edu/users/{self.username}/studios/curate",
|
|
524
|
+
limit=limit,
|
|
525
|
+
offset=offset,
|
|
526
|
+
)
|
|
356
527
|
studios = []
|
|
357
528
|
for studio_dict in _studios:
|
|
358
|
-
_studio = studio.Studio(_session
|
|
529
|
+
_studio = studio.Studio(_session=self._session, id=studio_dict["id"])
|
|
359
530
|
_studio._update_from_dict(studio_dict)
|
|
360
531
|
studios.append(_studio)
|
|
361
532
|
return studios
|
|
@@ -366,12 +537,18 @@ class User(BaseSiteComponent):
|
|
|
366
537
|
list<projects.projects.Project>: The user's shared projects
|
|
367
538
|
"""
|
|
368
539
|
_projects = commons.api_iterative(
|
|
369
|
-
f"https://api.scratch.mit.edu/users/{self.username}/projects/",
|
|
540
|
+
f"https://api.scratch.mit.edu/users/{self.username}/projects/",
|
|
541
|
+
limit=limit,
|
|
542
|
+
offset=offset,
|
|
543
|
+
_headers=self._headers,
|
|
544
|
+
)
|
|
370
545
|
for p in _projects:
|
|
371
|
-
p["author"] = {"username":self.username}
|
|
546
|
+
p["author"] = {"username": self.username}
|
|
372
547
|
return commons.parse_object_list(_projects, project.Project, self._session)
|
|
373
548
|
|
|
374
|
-
def loves(
|
|
549
|
+
def loves(
|
|
550
|
+
self, *, limit=40, offset=0, get_full_project: bool = False
|
|
551
|
+
) -> list[project.Project]:
|
|
375
552
|
"""
|
|
376
553
|
Returns:
|
|
377
554
|
list<projects.projects.Project>: The user's loved projects
|
|
@@ -391,8 +568,7 @@ class User(BaseSiteComponent):
|
|
|
391
568
|
# So the page number for this is 1 + (offset + limit - 1) // 40
|
|
392
569
|
|
|
393
570
|
# But this is a range so we have to add another 1 for the second argument
|
|
394
|
-
pages = range(1 + offset // 40,
|
|
395
|
-
2 + (offset + limit - 1) // 40)
|
|
571
|
+
pages = range(1 + offset // 40, 2 + (offset + limit - 1) // 40)
|
|
396
572
|
_projects = []
|
|
397
573
|
|
|
398
574
|
for page in pages:
|
|
@@ -400,13 +576,13 @@ class User(BaseSiteComponent):
|
|
|
400
576
|
first_idx = (page - 1) * 40
|
|
401
577
|
|
|
402
578
|
with requests.no_error_handling():
|
|
403
|
-
page_content = requests.get(
|
|
404
|
-
|
|
579
|
+
page_content = requests.get(
|
|
580
|
+
f"https://scratch.mit.edu/projects/all/{self.username}/loves/"
|
|
581
|
+
f"?page={page}",
|
|
582
|
+
headers=self._headers,
|
|
583
|
+
).content
|
|
405
584
|
|
|
406
|
-
soup = BeautifulSoup(
|
|
407
|
-
page_content,
|
|
408
|
-
"html.parser"
|
|
409
|
-
)
|
|
585
|
+
soup = BeautifulSoup(page_content, "html.parser")
|
|
410
586
|
|
|
411
587
|
# We need to check if we are out of bounds
|
|
412
588
|
# If we are, we can jump out early
|
|
@@ -424,7 +600,8 @@ class User(BaseSiteComponent):
|
|
|
424
600
|
|
|
425
601
|
# Each project element is a list item with the class name 'project thumb item' so we can just use that
|
|
426
602
|
for i, project_element in enumerate(
|
|
427
|
-
|
|
603
|
+
soup.find_all("li", {"class": "project thumb item"})
|
|
604
|
+
):
|
|
428
605
|
# Remember we only want certain projects:
|
|
429
606
|
# The current project idx = first_idx + i
|
|
430
607
|
# We want to start at {offset} and end at {offset + limit}
|
|
@@ -451,26 +628,27 @@ class User(BaseSiteComponent):
|
|
|
451
628
|
assert isinstance(first_anchor, Tag)
|
|
452
629
|
assert isinstance(second_anchor, Tag)
|
|
453
630
|
assert isinstance(third_anchor, Tag)
|
|
454
|
-
project_id = commons.webscrape_count(
|
|
455
|
-
|
|
631
|
+
project_id = commons.webscrape_count(
|
|
632
|
+
first_anchor.attrs["href"], "/projects/", "/"
|
|
633
|
+
)
|
|
456
634
|
title = second_anchor.contents[0]
|
|
457
635
|
author = third_anchor.contents[0]
|
|
458
636
|
|
|
459
637
|
# Instantiating a project with the properties that we know
|
|
460
638
|
# This may cause issues (see below)
|
|
461
|
-
_project = project.Project(
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
639
|
+
_project = project.Project(
|
|
640
|
+
id=project_id,
|
|
641
|
+
_session=self._session,
|
|
642
|
+
title=title,
|
|
643
|
+
author_name=author,
|
|
644
|
+
url=f"https://scratch.mit.edu/projects/{project_id}/",
|
|
645
|
+
)
|
|
466
646
|
if get_full_project:
|
|
467
647
|
# Put this under an if statement since making api requests for every single
|
|
468
648
|
# project will cause the function to take a lot longer
|
|
469
649
|
_project.update()
|
|
470
650
|
|
|
471
|
-
_projects.append(
|
|
472
|
-
_project
|
|
473
|
-
)
|
|
651
|
+
_projects.append(_project)
|
|
474
652
|
|
|
475
653
|
return _projects
|
|
476
654
|
|
|
@@ -478,7 +656,7 @@ class User(BaseSiteComponent):
|
|
|
478
656
|
with requests.no_error_handling():
|
|
479
657
|
text = requests.get(
|
|
480
658
|
f"https://scratch.mit.edu/projects/all/{self.username}/loves/",
|
|
481
|
-
headers=self._headers
|
|
659
|
+
headers=self._headers,
|
|
482
660
|
).text
|
|
483
661
|
|
|
484
662
|
# If there are no loved projects, then Scratch doesn't actually display the number - so we have to catch this
|
|
@@ -496,25 +674,47 @@ class User(BaseSiteComponent):
|
|
|
496
674
|
list<projects.projects.Project>: The user's favorite projects
|
|
497
675
|
"""
|
|
498
676
|
_projects = commons.api_iterative(
|
|
499
|
-
f"https://api.scratch.mit.edu/users/{self.username}/favorites/",
|
|
677
|
+
f"https://api.scratch.mit.edu/users/{self.username}/favorites/",
|
|
678
|
+
limit=limit,
|
|
679
|
+
offset=offset,
|
|
680
|
+
_headers=self._headers,
|
|
681
|
+
)
|
|
500
682
|
return commons.parse_object_list(_projects, project.Project, self._session)
|
|
501
683
|
|
|
502
684
|
def favorites_count(self):
|
|
503
685
|
with requests.no_error_handling():
|
|
504
686
|
text = requests.get(
|
|
505
687
|
f"https://scratch.mit.edu/users/{self.username}/favorites/",
|
|
506
|
-
headers=self._headers
|
|
688
|
+
headers=self._headers,
|
|
507
689
|
).text
|
|
508
690
|
return commons.webscrape_count(text, "Favorites (", ")")
|
|
509
691
|
|
|
692
|
+
def has_badge(self) -> bool:
|
|
693
|
+
"""
|
|
694
|
+
Returns:
|
|
695
|
+
bool: Whether the user has a scratch membership badge on their profile (located next to the follow button)
|
|
696
|
+
"""
|
|
697
|
+
with requests.no_error_handling():
|
|
698
|
+
resp = requests.get(self.url)
|
|
699
|
+
soup = BeautifulSoup(resp.text, "html.parser")
|
|
700
|
+
head = soup.find("div", {"class": "box-head"})
|
|
701
|
+
if not head:
|
|
702
|
+
return False
|
|
703
|
+
for child in head.children:
|
|
704
|
+
if child.name == "img":
|
|
705
|
+
if "membership-badge.svg" in child["src"]:
|
|
706
|
+
return True
|
|
707
|
+
return False
|
|
708
|
+
|
|
510
709
|
def toggle_commenting(self):
|
|
511
710
|
"""
|
|
512
711
|
You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
|
|
513
712
|
"""
|
|
514
713
|
self._assert_permission()
|
|
515
|
-
requests.post(
|
|
516
|
-
|
|
517
|
-
|
|
714
|
+
requests.post(
|
|
715
|
+
f"https://scratch.mit.edu/site-api/comments/user/{self.username}/toggle-comments/",
|
|
716
|
+
headers=headers,
|
|
717
|
+
cookies=self._cookies,
|
|
518
718
|
)
|
|
519
719
|
|
|
520
720
|
def viewed_projects(self, limit=24, offset=0):
|
|
@@ -526,7 +726,11 @@ class User(BaseSiteComponent):
|
|
|
526
726
|
"""
|
|
527
727
|
self._assert_permission()
|
|
528
728
|
_projects = commons.api_iterative(
|
|
529
|
-
f"https://api.scratch.mit.edu/users/{self.username}/projects/recentlyviewed",
|
|
729
|
+
f"https://api.scratch.mit.edu/users/{self.username}/projects/recentlyviewed",
|
|
730
|
+
limit=limit,
|
|
731
|
+
offset=offset,
|
|
732
|
+
_headers=self._headers,
|
|
733
|
+
)
|
|
530
734
|
return commons.parse_object_list(_projects, project.Project, self._session)
|
|
531
735
|
|
|
532
736
|
def set_pfp(self, image: bytes):
|
|
@@ -539,7 +743,8 @@ class User(BaseSiteComponent):
|
|
|
539
743
|
f"https://scratch.mit.edu/site-api/users/all/{self.username}/",
|
|
540
744
|
headers=self._headers,
|
|
541
745
|
cookies=self._cookies,
|
|
542
|
-
files={"file": image}
|
|
746
|
+
files={"file": image},
|
|
747
|
+
)
|
|
543
748
|
|
|
544
749
|
def set_bio(self, text):
|
|
545
750
|
"""
|
|
@@ -551,7 +756,8 @@ class User(BaseSiteComponent):
|
|
|
551
756
|
f"https://scratch.mit.edu/site-api/users/all/{self.username}/",
|
|
552
757
|
headers=self._json_headers,
|
|
553
758
|
cookies=self._cookies,
|
|
554
|
-
json={"bio": text}
|
|
759
|
+
json={"bio": text},
|
|
760
|
+
)
|
|
555
761
|
|
|
556
762
|
def set_wiwo(self, text):
|
|
557
763
|
"""
|
|
@@ -563,7 +769,8 @@ class User(BaseSiteComponent):
|
|
|
563
769
|
f"https://scratch.mit.edu/site-api/users/all/{self.username}/",
|
|
564
770
|
headers=self._json_headers,
|
|
565
771
|
cookies=self._cookies,
|
|
566
|
-
json={"status": text}
|
|
772
|
+
json={"status": text},
|
|
773
|
+
)
|
|
567
774
|
|
|
568
775
|
def set_featured(self, project_id, *, label=""):
|
|
569
776
|
"""
|
|
@@ -580,7 +787,7 @@ class User(BaseSiteComponent):
|
|
|
580
787
|
f"https://scratch.mit.edu/site-api/users/all/{self.username}/",
|
|
581
788
|
headers=self._json_headers,
|
|
582
789
|
cookies=self._cookies,
|
|
583
|
-
json={"featured_project": int(project_id), "featured_project_label": label}
|
|
790
|
+
json={"featured_project": int(project_id), "featured_project_label": label},
|
|
584
791
|
)
|
|
585
792
|
|
|
586
793
|
def set_forum_signature(self, text):
|
|
@@ -589,38 +796,43 @@ class User(BaseSiteComponent):
|
|
|
589
796
|
"""
|
|
590
797
|
self._assert_permission()
|
|
591
798
|
headers = {
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
799
|
+
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
|
800
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
801
|
+
"origin": "https://scratch.mit.edu",
|
|
802
|
+
"referer": "https://scratch.mit.edu/discuss/settings/TimMcCool/",
|
|
803
|
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
|
597
804
|
}
|
|
598
805
|
data = {
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
806
|
+
"csrfmiddlewaretoken": "a",
|
|
807
|
+
"signature": text,
|
|
808
|
+
"update": "",
|
|
602
809
|
}
|
|
603
|
-
response = requests.post(
|
|
810
|
+
response = requests.post(
|
|
811
|
+
f"https://scratch.mit.edu/discuss/settings/{self.username}/",
|
|
812
|
+
cookies=self._cookies,
|
|
813
|
+
headers=headers,
|
|
814
|
+
data=data,
|
|
815
|
+
)
|
|
604
816
|
|
|
605
817
|
def post_comment(self, content, *, parent_id="", commentee_id=""):
|
|
606
818
|
"""
|
|
607
819
|
Posts a comment on the user's profile. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
|
|
608
820
|
|
|
609
821
|
Args:
|
|
610
|
-
content: Content of the comment that should be posted
|
|
822
|
+
:param content: Content of the comment that should be posted
|
|
611
823
|
|
|
612
824
|
Keyword Arguments:
|
|
613
|
-
|
|
614
|
-
|
|
825
|
+
:param commentee_id: ID of the comment you want to reply to. If you don't want to mention a user, don't put the argument.
|
|
826
|
+
:param parent_id: ID of the user that will be mentioned in your comment and will receive a message about your comment. If you don't want to mention a user, don't put the argument.
|
|
615
827
|
|
|
616
828
|
Returns:
|
|
617
829
|
scratchattach.comment.Comment: An object representing the created comment.
|
|
618
830
|
"""
|
|
619
831
|
self._assert_auth()
|
|
620
832
|
data = {
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
833
|
+
"commentee_id": commentee_id,
|
|
834
|
+
"content": str(content),
|
|
835
|
+
"parent_id": parent_id,
|
|
624
836
|
}
|
|
625
837
|
r = requests.post(
|
|
626
838
|
f"https://scratch.mit.edu/site-api/comments/user/{self.username}/add/",
|
|
@@ -634,30 +846,55 @@ class User(BaseSiteComponent):
|
|
|
634
846
|
else:
|
|
635
847
|
raise exceptions.CommentPostFailure(r.text)
|
|
636
848
|
|
|
849
|
+
text = r.text
|
|
637
850
|
try:
|
|
638
|
-
text = r.text
|
|
639
851
|
data = {
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
852
|
+
"id": text.split('<div id="comments-')[1].split('" class="comment')[0],
|
|
853
|
+
"author": {
|
|
854
|
+
"username": text.split('" data-comment-user="')[1].split(
|
|
855
|
+
'"><img class'
|
|
856
|
+
)[0]
|
|
857
|
+
},
|
|
858
|
+
"content": text.split('<div class="content">')[1]
|
|
859
|
+
.split("</div>")[0]
|
|
860
|
+
.strip(),
|
|
861
|
+
"reply_count": 0,
|
|
862
|
+
"cached_replies": [],
|
|
645
863
|
}
|
|
646
|
-
_comment = comment.Comment(
|
|
864
|
+
_comment = comment.Comment(
|
|
865
|
+
source=comment.CommentSource.USER_PROFILE,
|
|
866
|
+
parent_id=None if parent_id == "" else parent_id,
|
|
867
|
+
commentee_id=commentee_id,
|
|
868
|
+
source_id=self.username,
|
|
869
|
+
id=data["id"],
|
|
870
|
+
_session=self._session,
|
|
871
|
+
datetime=datetime.now(),
|
|
872
|
+
)
|
|
647
873
|
_comment._update_from_dict(data)
|
|
648
874
|
return _comment
|
|
649
|
-
except Exception:
|
|
875
|
+
except Exception as e:
|
|
650
876
|
if '{"error": "isFlood"}' in text:
|
|
651
|
-
raise(
|
|
652
|
-
|
|
877
|
+
raise (
|
|
878
|
+
exceptions.CommentPostFailure(
|
|
879
|
+
"You are being rate-limited for running this operation too often. Implement a cooldown of about 10 seconds."
|
|
880
|
+
)
|
|
881
|
+
) from e
|
|
653
882
|
elif '<script id="error-data" type="application/json">' in text:
|
|
654
|
-
raw_error_data = text.split(
|
|
883
|
+
raw_error_data = text.split(
|
|
884
|
+
'<script id="error-data" type="application/json">'
|
|
885
|
+
)[1].split("</script>")[0]
|
|
655
886
|
error_data = json.loads(raw_error_data)
|
|
656
|
-
expires = error_data[
|
|
887
|
+
expires = error_data["mute_status"]["muteExpiresAt"]
|
|
657
888
|
expires = datetime.fromtimestamp(expires, timezone.utc)
|
|
658
|
-
raise(
|
|
889
|
+
raise (
|
|
890
|
+
exceptions.CommentPostFailure(
|
|
891
|
+
f"You have been muted. Mute expires on {expires}"
|
|
892
|
+
)
|
|
893
|
+
) from e
|
|
659
894
|
else:
|
|
660
|
-
raise(
|
|
895
|
+
raise (
|
|
896
|
+
exceptions.FetchError(f"Couldn't parse API response: {r.text!r}")
|
|
897
|
+
) from e
|
|
661
898
|
|
|
662
899
|
def reply_comment(self, content, *, parent_id, commentee_id=""):
|
|
663
900
|
"""
|
|
@@ -669,13 +906,15 @@ class User(BaseSiteComponent):
|
|
|
669
906
|
Therefore, parent_id should be the comment id of a top level comment.
|
|
670
907
|
|
|
671
908
|
Args:
|
|
672
|
-
content: Content of the comment that should be posted
|
|
909
|
+
:param content: Content of the comment that should be posted
|
|
673
910
|
|
|
674
911
|
Keyword Arguments:
|
|
675
|
-
parent_id: ID of the comment you want to reply to
|
|
676
|
-
commentee_id: ID of the user that will be mentioned in your comment and will receive a message about your comment. If you don't want to mention a user, don't put the argument.
|
|
912
|
+
:param parent_id: ID of the comment you want to reply to
|
|
913
|
+
:param commentee_id: ID of the user that will be mentioned in your comment and will receive a message about your comment. If you don't want to mention a user, don't put the argument.
|
|
677
914
|
"""
|
|
678
|
-
return self.post_comment(
|
|
915
|
+
return self.post_comment(
|
|
916
|
+
content, parent_id=parent_id, commentee_id=commentee_id
|
|
917
|
+
)
|
|
679
918
|
|
|
680
919
|
def activity(self, *, limit=1000):
|
|
681
920
|
"""
|
|
@@ -683,27 +922,32 @@ class User(BaseSiteComponent):
|
|
|
683
922
|
list<scratchattach.Activity>: The user's activity data as parsed list of scratchattach.activity.Activity objects
|
|
684
923
|
"""
|
|
685
924
|
with requests.no_error_handling():
|
|
686
|
-
soup = BeautifulSoup(
|
|
925
|
+
soup = BeautifulSoup(
|
|
926
|
+
requests.get(
|
|
927
|
+
f"https://scratch.mit.edu/messages/ajax/user-activity/?user={self.username}&max={limit}"
|
|
928
|
+
).text,
|
|
929
|
+
"html.parser",
|
|
930
|
+
)
|
|
687
931
|
|
|
688
932
|
activities = []
|
|
689
933
|
source = soup.find_all("li")
|
|
690
934
|
|
|
691
935
|
for data in source:
|
|
692
|
-
_activity = activity.Activity(_session
|
|
936
|
+
_activity = activity.Activity(_session=self._session, raw=data)
|
|
693
937
|
_activity._update_from_html(data)
|
|
694
938
|
activities.append(_activity)
|
|
695
939
|
|
|
696
940
|
return activities
|
|
697
941
|
|
|
698
|
-
|
|
699
942
|
def activity_html(self, *, limit=1000):
|
|
700
943
|
"""
|
|
701
944
|
Returns:
|
|
702
945
|
str: The raw user activity HTML data
|
|
703
946
|
"""
|
|
704
947
|
with requests.no_error_handling():
|
|
705
|
-
return requests.get(
|
|
706
|
-
|
|
948
|
+
return requests.get(
|
|
949
|
+
f"https://scratch.mit.edu/messages/ajax/user-activity/?user={self.username}&max={limit}"
|
|
950
|
+
).text
|
|
707
951
|
|
|
708
952
|
def follow(self):
|
|
709
953
|
"""
|
|
@@ -712,8 +956,8 @@ class User(BaseSiteComponent):
|
|
|
712
956
|
self._assert_auth()
|
|
713
957
|
requests.put(
|
|
714
958
|
f"https://scratch.mit.edu/site-api/users/followers/{self.username}/add/?usernames={self._session._username}",
|
|
715
|
-
headers
|
|
716
|
-
cookies
|
|
959
|
+
headers=headers,
|
|
960
|
+
cookies=self._cookies,
|
|
717
961
|
)
|
|
718
962
|
|
|
719
963
|
def unfollow(self):
|
|
@@ -723,8 +967,8 @@ class User(BaseSiteComponent):
|
|
|
723
967
|
self._assert_auth()
|
|
724
968
|
requests.put(
|
|
725
969
|
f"https://scratch.mit.edu/site-api/users/followers/{self.username}/remove/?usernames={self._session._username}",
|
|
726
|
-
headers
|
|
727
|
-
cookies
|
|
970
|
+
headers=headers,
|
|
971
|
+
cookies=self._cookies,
|
|
728
972
|
)
|
|
729
973
|
|
|
730
974
|
def delete_comment(self, *, comment_id):
|
|
@@ -735,12 +979,13 @@ class User(BaseSiteComponent):
|
|
|
735
979
|
comment_id: The id of the comment that should be deleted
|
|
736
980
|
"""
|
|
737
981
|
self._assert_permission()
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
982
|
+
with requests.no_error_handling():
|
|
983
|
+
return requests.post(
|
|
984
|
+
f"https://scratch.mit.edu/site-api/comments/user/{self.username}/del/",
|
|
985
|
+
headers=headers,
|
|
986
|
+
cookies=self._cookies,
|
|
987
|
+
data=json.dumps({"id": str(comment_id)}),
|
|
988
|
+
)
|
|
744
989
|
|
|
745
990
|
def report_comment(self, *, comment_id):
|
|
746
991
|
"""
|
|
@@ -752,12 +997,12 @@ class User(BaseSiteComponent):
|
|
|
752
997
|
self._assert_auth()
|
|
753
998
|
return requests.post(
|
|
754
999
|
f"https://scratch.mit.edu/site-api/comments/user/{self.username}/rep/",
|
|
755
|
-
headers
|
|
756
|
-
cookies
|
|
757
|
-
data
|
|
1000
|
+
headers=headers,
|
|
1001
|
+
cookies=self._cookies,
|
|
1002
|
+
data=json.dumps({"id": str(comment_id)}),
|
|
758
1003
|
)
|
|
759
1004
|
|
|
760
|
-
def comments(self, *, page=1, limit=None):
|
|
1005
|
+
def comments(self, *, page=1, limit=None) -> list[comment.Comment]:
|
|
761
1006
|
"""
|
|
762
1007
|
Returns the comments posted on the user's profile (with replies).
|
|
763
1008
|
|
|
@@ -779,21 +1024,25 @@ class User(BaseSiteComponent):
|
|
|
779
1024
|
_comments = soup.find_all("li", {"class": "top-level-reply"})
|
|
780
1025
|
|
|
781
1026
|
if len(_comments) == 0:
|
|
782
|
-
return
|
|
1027
|
+
return []
|
|
783
1028
|
|
|
784
1029
|
for entity in _comments:
|
|
785
|
-
comment_id = entity.find("div", {"class": "comment"})[
|
|
786
|
-
user = entity.find("a", {"id": "comment-user"})[
|
|
1030
|
+
comment_id = entity.find("div", {"class": "comment"})["data-comment-id"]
|
|
1031
|
+
user = entity.find("a", {"id": "comment-user"})["data-comment-user"]
|
|
787
1032
|
content = str(entity.find("div", {"class": "content"}).text).strip()
|
|
788
|
-
time = entity.find("span", {"class": "time"})[
|
|
1033
|
+
time = entity.find("span", {"class": "time"})["title"]
|
|
789
1034
|
|
|
790
1035
|
main_comment = {
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
1036
|
+
"id": comment_id,
|
|
1037
|
+
"author": {"username": user},
|
|
1038
|
+
"content": content,
|
|
1039
|
+
"datetime_created": time,
|
|
795
1040
|
}
|
|
796
|
-
_comment = comment.Comment(
|
|
1041
|
+
_comment = comment.Comment(
|
|
1042
|
+
source=comment.CommentSource.USER_PROFILE,
|
|
1043
|
+
source_id=self.username,
|
|
1044
|
+
_session=self._session,
|
|
1045
|
+
)
|
|
797
1046
|
_comment._update_from_dict(main_comment)
|
|
798
1047
|
|
|
799
1048
|
ALL_REPLIES = []
|
|
@@ -803,20 +1052,31 @@ class User(BaseSiteComponent):
|
|
|
803
1052
|
else:
|
|
804
1053
|
hasReplies = False
|
|
805
1054
|
for reply in replies:
|
|
806
|
-
r_comment_id = reply.find("div", {"class": "comment"})[
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
1055
|
+
r_comment_id = reply.find("div", {"class": "comment"})[
|
|
1056
|
+
"data-comment-id"
|
|
1057
|
+
]
|
|
1058
|
+
r_user = reply.find("a", {"id": "comment-user"})["data-comment-user"]
|
|
1059
|
+
r_content = (
|
|
1060
|
+
str(reply.find("div", {"class": "content"}).text)
|
|
1061
|
+
.strip()
|
|
1062
|
+
.replace("\n", "")
|
|
1063
|
+
.replace(" ", " ")
|
|
1064
|
+
)
|
|
1065
|
+
r_time = reply.find("span", {"class": "time"})["title"]
|
|
811
1066
|
reply_data = {
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
"parent_id"
|
|
817
|
-
"cached_parent_comment"
|
|
1067
|
+
"id": r_comment_id,
|
|
1068
|
+
"author": {"username": r_user},
|
|
1069
|
+
"content": r_content,
|
|
1070
|
+
"datetime_created": r_time,
|
|
1071
|
+
"parent_id": comment_id,
|
|
1072
|
+
"cached_parent_comment": _comment,
|
|
818
1073
|
}
|
|
819
|
-
_r_comment = comment.Comment(
|
|
1074
|
+
_r_comment = comment.Comment(
|
|
1075
|
+
source=comment.CommentSource.USER_PROFILE,
|
|
1076
|
+
source_id=self.username,
|
|
1077
|
+
_session=self._session,
|
|
1078
|
+
cached_parent_comment=_comment,
|
|
1079
|
+
)
|
|
820
1080
|
_r_comment._update_from_dict(reply_data)
|
|
821
1081
|
ALL_REPLIES.append(_r_comment)
|
|
822
1082
|
|
|
@@ -840,9 +1100,14 @@ class User(BaseSiteComponent):
|
|
|
840
1100
|
page = 1
|
|
841
1101
|
page_content = self.comments(page=page)
|
|
842
1102
|
while page_content != []:
|
|
843
|
-
results = list(filter(lambda x
|
|
1103
|
+
results = list(filter(lambda x: str(x.id) == str(comment_id), page_content))
|
|
844
1104
|
if results == []:
|
|
845
|
-
results = list(
|
|
1105
|
+
results = list(
|
|
1106
|
+
filter(
|
|
1107
|
+
lambda x: str(x.id) == str(comment_id),
|
|
1108
|
+
[item for x in page_content for item in x.cached_replies],
|
|
1109
|
+
)
|
|
1110
|
+
)
|
|
846
1111
|
if results != []:
|
|
847
1112
|
return results[0]
|
|
848
1113
|
else:
|
|
@@ -854,6 +1119,7 @@ class User(BaseSiteComponent):
|
|
|
854
1119
|
def message_events(self):
|
|
855
1120
|
return message_events.MessageEvents(self)
|
|
856
1121
|
|
|
1122
|
+
@deprecated("This method is deprecated because ScratchDB is down indefinitely.")
|
|
857
1123
|
def stats(self):
|
|
858
1124
|
"""
|
|
859
1125
|
Gets information about the user's stats. Fetched from ScratchDB.
|
|
@@ -864,16 +1130,25 @@ class User(BaseSiteComponent):
|
|
|
864
1130
|
Returns:
|
|
865
1131
|
dict: A dict containing the user's stats. If the stats aren't available, all values will be -1.
|
|
866
1132
|
"""
|
|
867
|
-
print("Warning: ScratchDB is down indefinitely, therefore this method is deprecated.")
|
|
868
1133
|
try:
|
|
869
|
-
stats= requests.get(
|
|
1134
|
+
stats = requests.get(
|
|
870
1135
|
f"https://scratchdb.lefty.one/v3/user/info/{self.username}"
|
|
871
1136
|
).json()["statistics"]
|
|
872
1137
|
stats.pop("ranks")
|
|
873
1138
|
except Exception:
|
|
874
|
-
stats = {
|
|
1139
|
+
stats = {
|
|
1140
|
+
"loves": -1,
|
|
1141
|
+
"favorites": -1,
|
|
1142
|
+
"comments": -1,
|
|
1143
|
+
"views": -1,
|
|
1144
|
+
"followers": -1,
|
|
1145
|
+
"following": -1,
|
|
1146
|
+
}
|
|
875
1147
|
return stats
|
|
876
1148
|
|
|
1149
|
+
@deprecated(
|
|
1150
|
+
"Warning: ScratchDB is down indefinitely, therefore this method is deprecated."
|
|
1151
|
+
)
|
|
877
1152
|
def ranks(self):
|
|
878
1153
|
"""
|
|
879
1154
|
Gets information about the user's ranks. Fetched from ScratchDB.
|
|
@@ -884,22 +1159,38 @@ class User(BaseSiteComponent):
|
|
|
884
1159
|
Returns:
|
|
885
1160
|
dict: A dict containing the user's ranks. If the ranks aren't available, all values will be -1.
|
|
886
1161
|
"""
|
|
887
|
-
print("Warning: ScratchDB is down indefinitely, therefore this method is deprecated.")
|
|
888
1162
|
try:
|
|
889
1163
|
return requests.get(
|
|
890
1164
|
f"https://scratchdb.lefty.one/v3/user/info/{self.username}"
|
|
891
1165
|
).json()["statistics"]["ranks"]
|
|
892
1166
|
except Exception:
|
|
893
|
-
return {
|
|
1167
|
+
return {
|
|
1168
|
+
"country": {
|
|
1169
|
+
"loves": 0,
|
|
1170
|
+
"favorites": 0,
|
|
1171
|
+
"comments": 0,
|
|
1172
|
+
"views": 0,
|
|
1173
|
+
"followers": 0,
|
|
1174
|
+
"following": 0,
|
|
1175
|
+
},
|
|
1176
|
+
"loves": 0,
|
|
1177
|
+
"favorites": 0,
|
|
1178
|
+
"comments": 0,
|
|
1179
|
+
"views": 0,
|
|
1180
|
+
"followers": 0,
|
|
1181
|
+
"following": 0,
|
|
1182
|
+
}
|
|
894
1183
|
|
|
895
|
-
def ocular_status(self):
|
|
1184
|
+
def ocular_status(self) -> _OcularStatus:
|
|
896
1185
|
"""
|
|
897
1186
|
Gets information about the user's ocular status. Ocular is a website developed by jeffalo: https://ocular.jeffalo.net/
|
|
898
1187
|
|
|
899
1188
|
Returns:
|
|
900
1189
|
dict
|
|
901
1190
|
"""
|
|
902
|
-
return requests.get(
|
|
1191
|
+
return requests.get(
|
|
1192
|
+
f"https://my-ocular.jeffalo.net/api/user/{self.username}"
|
|
1193
|
+
).json()
|
|
903
1194
|
|
|
904
1195
|
def verify_identity(self, *, verification_project_id=395330233):
|
|
905
1196
|
"""
|
|
@@ -917,26 +1208,25 @@ class User(BaseSiteComponent):
|
|
|
917
1208
|
v = Verificator(self, verification_project_id)
|
|
918
1209
|
return v
|
|
919
1210
|
|
|
920
|
-
def rank(self):
|
|
1211
|
+
def rank(self) -> Rank:
|
|
921
1212
|
"""
|
|
922
1213
|
Finds the rank of the user.
|
|
1214
|
+
Returns a member of the Rank enum: either Rank.NEW_SCRATCHER, Rank.SCRATCHER, or Rank.SCRATCH_TEAM.
|
|
923
1215
|
May replace user.scratchteam and user.is_new_scratcher in the future.
|
|
924
1216
|
"""
|
|
925
1217
|
|
|
926
1218
|
if self.is_new_scratcher():
|
|
927
1219
|
return Rank.NEW_SCRATCHER
|
|
928
|
-
|
|
929
|
-
|
|
1220
|
+
|
|
930
1221
|
if not self.scratchteam:
|
|
931
1222
|
return Rank.SCRATCHER
|
|
932
|
-
# Is Scratcher
|
|
933
1223
|
|
|
934
1224
|
return Rank.SCRATCH_TEAM
|
|
935
|
-
# Is Scratch Team member
|
|
936
1225
|
|
|
937
1226
|
|
|
938
1227
|
# ------ #
|
|
939
1228
|
|
|
1229
|
+
|
|
940
1230
|
def get_user(username) -> User:
|
|
941
1231
|
"""
|
|
942
1232
|
Gets a user without logging in.
|
|
@@ -952,5 +1242,11 @@ def get_user(username) -> User:
|
|
|
952
1242
|
|
|
953
1243
|
If you want to use these, get the user with :meth:`scratchattach.session.Session.connect_user` instead.
|
|
954
1244
|
"""
|
|
955
|
-
|
|
1245
|
+
warnings.warn(
|
|
1246
|
+
"Warning: For methods that require authentication, use session.connect_user instead of get_user.\n"
|
|
1247
|
+
"To ignore this warning, use warnings.filterwarnings('ignore', category=scratchattach.UserAuthenticationWarning).\n"
|
|
1248
|
+
"To ignore all warnings of the type GetAuthenticationWarning, which includes this warning, use "
|
|
1249
|
+
"`warnings.filterwarnings('ignore', category=scratchattach.GetAuthenticationWarning)`.",
|
|
1250
|
+
exceptions.UserAuthenticationWarning,
|
|
1251
|
+
)
|
|
956
1252
|
return commons._get_object("username", username, User, exceptions.UserNotFound)
|