scratchattach 2.1.8__py3-none-any.whl → 2.1.10a0__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.
Files changed (59) hide show
  1. scratchattach/__init__.py +28 -25
  2. scratchattach/cloud/__init__.py +2 -0
  3. scratchattach/cloud/_base.py +454 -282
  4. scratchattach/cloud/cloud.py +171 -168
  5. scratchattach/editor/__init__.py +21 -0
  6. scratchattach/editor/asset.py +199 -0
  7. scratchattach/editor/backpack_json.py +117 -0
  8. scratchattach/editor/base.py +142 -0
  9. scratchattach/editor/block.py +507 -0
  10. scratchattach/editor/blockshape.py +353 -0
  11. scratchattach/editor/build_defaulting.py +47 -0
  12. scratchattach/editor/comment.py +74 -0
  13. scratchattach/editor/commons.py +243 -0
  14. scratchattach/editor/extension.py +43 -0
  15. scratchattach/editor/field.py +90 -0
  16. scratchattach/editor/inputs.py +132 -0
  17. scratchattach/editor/meta.py +106 -0
  18. scratchattach/editor/monitor.py +175 -0
  19. scratchattach/editor/mutation.py +317 -0
  20. scratchattach/editor/pallete.py +91 -0
  21. scratchattach/editor/prim.py +170 -0
  22. scratchattach/editor/project.py +273 -0
  23. scratchattach/editor/sbuild.py +2837 -0
  24. scratchattach/editor/sprite.py +586 -0
  25. scratchattach/editor/twconfig.py +113 -0
  26. scratchattach/editor/vlb.py +134 -0
  27. scratchattach/eventhandlers/_base.py +99 -92
  28. scratchattach/eventhandlers/cloud_events.py +110 -103
  29. scratchattach/eventhandlers/cloud_recorder.py +26 -21
  30. scratchattach/eventhandlers/cloud_requests.py +460 -452
  31. scratchattach/eventhandlers/cloud_server.py +246 -244
  32. scratchattach/eventhandlers/cloud_storage.py +135 -134
  33. scratchattach/eventhandlers/combine.py +29 -27
  34. scratchattach/eventhandlers/filterbot.py +160 -159
  35. scratchattach/eventhandlers/message_events.py +41 -40
  36. scratchattach/other/other_apis.py +284 -212
  37. scratchattach/other/project_json_capabilities.py +475 -546
  38. scratchattach/site/_base.py +64 -46
  39. scratchattach/site/activity.py +414 -122
  40. scratchattach/site/backpack_asset.py +118 -84
  41. scratchattach/site/classroom.py +430 -142
  42. scratchattach/site/cloud_activity.py +107 -103
  43. scratchattach/site/comment.py +220 -190
  44. scratchattach/site/forum.py +400 -399
  45. scratchattach/site/project.py +806 -787
  46. scratchattach/site/session.py +1134 -867
  47. scratchattach/site/studio.py +611 -609
  48. scratchattach/site/user.py +835 -837
  49. scratchattach/utils/commons.py +243 -148
  50. scratchattach/utils/encoder.py +157 -156
  51. scratchattach/utils/enums.py +197 -190
  52. scratchattach/utils/exceptions.py +233 -206
  53. scratchattach/utils/requests.py +67 -59
  54. {scratchattach-2.1.8.dist-info → scratchattach-2.1.10a0.dist-info}/LICENSE +21 -21
  55. {scratchattach-2.1.8.dist-info → scratchattach-2.1.10a0.dist-info}/METADATA +154 -146
  56. scratchattach-2.1.10a0.dist-info/RECORD +62 -0
  57. {scratchattach-2.1.8.dist-info → scratchattach-2.1.10a0.dist-info}/WHEEL +1 -1
  58. scratchattach-2.1.8.dist-info/RECORD +0 -40
  59. {scratchattach-2.1.8.dist-info → scratchattach-2.1.10a0.dist-info}/top_level.txt +0 -0
@@ -1,837 +1,835 @@
1
- """Session class and login function"""
2
-
3
- import json
4
- import random
5
- import string
6
-
7
- from ..eventhandlers import message_events
8
- from . import project
9
- from ..utils import exceptions
10
- from . import studio
11
- from . import forum
12
- from bs4 import BeautifulSoup
13
- from ._base import BaseSiteComponent
14
- from ..utils.commons import headers
15
- from ..utils import commons
16
- from . import comment
17
- from . import activity
18
-
19
- from ..utils.requests import Requests as requests
20
-
21
- class User(BaseSiteComponent):
22
-
23
- '''
24
- Represents a Scratch user.
25
-
26
- Attributes:
27
-
28
- :.join_date:
29
-
30
- :.about_me:
31
-
32
- :.wiwo: Returns the user's 'What I'm working on' section
33
-
34
- :.country: Returns the country from the user profile
35
-
36
- :.icon_url: Returns the link to the user's pfp (90x90)
37
-
38
- :.id: Returns the id of the user
39
-
40
- :.scratchteam: Retuns True if the user is in the Scratch team
41
-
42
- :.update(): Updates the attributes
43
- '''
44
-
45
- def __str__(self):
46
- return str(self.username)
47
-
48
- def __init__(self, **entries):
49
-
50
- # Info on how the .update method has to fetch the data:
51
- self.update_function = requests.get
52
- self.update_API = f"https://api.scratch.mit.edu/users/{entries['username']}"
53
-
54
- # Set attributes every User object needs to have:
55
- self._session = None
56
- self.id = None
57
- self.username = None
58
- self.name = None
59
-
60
- # Update attributes from entries dict:
61
- entries.setdefault("name", entries.get("username"))
62
- self.__dict__.update(entries)
63
-
64
- # Set alternative attributes:
65
- if hasattr(self, "bio"):
66
- self.about_me = self.bio
67
- if hasattr(self, "status"):
68
- self.wiwo = self.status
69
- if hasattr(self, "name"):
70
- self.username = self.name
71
-
72
- # Headers and cookies:
73
- if self._session is None:
74
- self._headers = headers
75
- self._cookies = {}
76
- else:
77
- self._headers = self._session._headers
78
- self._cookies = self._session._cookies
79
-
80
- # Headers for operations that require accept and Content-Type fields:
81
- self._json_headers = dict(self._headers)
82
- self._json_headers["accept"] = "application/json"
83
- self._json_headers["Content-Type"] = "application/json"
84
-
85
- def _update_from_dict(self, data):
86
- try: self.id = data["id"]
87
- except KeyError: pass
88
- try: self.username = data["username"]
89
- except KeyError: pass
90
- try: self.scratchteam = data["scratchteam"]
91
- except KeyError: pass
92
- try: self.join_date = data["history"]["joined"]
93
- except KeyError: pass
94
- try: self.about_me = data["profile"]["bio"]
95
- except KeyError: pass
96
- try: self.wiwo = data["profile"]["status"]
97
- except KeyError: pass
98
- try: self.country = data["profile"]["country"]
99
- except KeyError: pass
100
- try: self.icon_url = data["profile"]["images"]["90x90"]
101
- except KeyError: pass
102
- return True
103
-
104
- def _assert_permission(self):
105
- self._assert_auth()
106
- if self._session._username != self.username:
107
- raise exceptions.Unauthorized(
108
- "You need to be authenticated as the profile owner to do this.")
109
-
110
-
111
- def does_exist(self):
112
- """
113
- Returns:
114
- boolean : True if the user exists, False if the user is deleted, None if an error occured
115
- """
116
- status_code = requests.get(f"https://scratch.mit.edu/users/{self.username}/").status_code
117
- if status_code == 200:
118
- return True
119
- elif status_code == 404:
120
- return False
121
-
122
- def is_new_scratcher(self):
123
- """
124
- Returns:
125
- boolean : True if the user has the New Scratcher status, else False
126
- """
127
- try:
128
- res = requests.get(f"https://scratch.mit.edu/users/{self.username}/").text
129
- group=res[res.rindex('<span class="group">'):][:70]
130
- return "new scratcher" in group.lower()
131
- except Exception:
132
- return None
133
-
134
- def message_count(self):
135
-
136
- return json.loads(requests.get(f"https://api.scratch.mit.edu/users/{self.username}/messages/count/?cachebust={random.randint(0,10000)}", headers = {'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',}).text)["count"]
137
-
138
- def featured_data(self):
139
- """
140
- Returns:
141
- dict: Gets info on the user's featured project and featured label (like "Featured project", "My favorite things", etc.)
142
- """
143
- try:
144
- response = json.loads(requests.get(f"https://scratch.mit.edu/site-api/users/all/{self.username}/").text)
145
- return {
146
- "label":response["featured_project_label_name"],
147
- "project":
148
- dict(
149
- id=str(response["featured_project_data"]["id"]),
150
- author=response["featured_project_data"]["creator"],
151
- thumbnail_url="https://"+response["featured_project_data"]["thumbnail_url"][2:],
152
- title=response["featured_project_data"]["title"]
153
- )
154
- }
155
- except Exception:
156
- return None
157
-
158
- def follower_count(self):
159
- # follower count
160
- text = requests.get(
161
- f"https://scratch.mit.edu/users/{self.username}/followers/",
162
- headers = self._headers
163
- ).text
164
- return commons.webscrape_count(text, "Followers (", ")")
165
-
166
- def following_count(self):
167
- # following count
168
- text = requests.get(
169
- f"https://scratch.mit.edu/users/{self.username}/following/",
170
- headers = self._headers
171
- ).text
172
- return commons.webscrape_count(text, "Following (", ")")
173
-
174
- def followers(self, *, limit=40, offset=0):
175
- """
176
- Returns:
177
- list<scratchattach.user.User>: The user's followers as list of scratchattach.user.User objects
178
- """
179
- response = commons.api_iterative(
180
- f"https://api.scratch.mit.edu/users/{self.username}/followers/", limit=limit, offset=offset)
181
- return commons.parse_object_list(response, User, self._session, "username")
182
-
183
- def follower_names(self, *, limit=40, offset=0):
184
- """
185
- Returns:
186
- list<str>: The usernames of the user's followers
187
- """
188
- return [i.name for i in self.followers(limit=limit, offset=offset)]
189
-
190
- def following(self, *, limit=40, offset=0):
191
- """
192
- Returns:
193
- list<scratchattach.user.User>: The users that the user is following as list of scratchattach.user.User objects
194
- """
195
- response = commons.api_iterative(
196
- f"https://api.scratch.mit.edu/users/{self.username}/following/", limit=limit, offset=offset)
197
- return commons.parse_object_list(response, User, self._session, "username")
198
-
199
- def following_names(self, *, limit=40, offset=0):
200
- """
201
- Returns:
202
- list<str>: The usernames of the users the user is following
203
- """
204
- return [i.name for i in self.following(limit=limit, offset=offset)]
205
-
206
- def is_following(self, user):
207
- """
208
- Returns:
209
- boolean: Whether the user is following the user provided as argument
210
- """
211
- offset = 0
212
- following = False
213
-
214
- while True:
215
- try:
216
- following_names = self.following_names(limit=20, offset=offset)
217
- if user in following_names:
218
- following = True
219
- break
220
- if following_names == []:
221
- break
222
- offset += 20
223
- except Exception:
224
- print("Warning: API error when performing following check")
225
- return following
226
- return following
227
-
228
- def is_followed_by(self, user):
229
- """
230
- Returns:
231
- boolean: Whether the user is followed by the user provided as argument
232
- """
233
- return User(username=user).is_following(self.username)
234
-
235
- def project_count(self):
236
- text = requests.get(
237
- f"https://scratch.mit.edu/users/{self.username}/projects/",
238
- headers = self._headers
239
- ).text
240
- return commons.webscrape_count(text, "Shared Projects (", ")")
241
-
242
- def studio_count(self):
243
- text = requests.get(
244
- f"https://scratch.mit.edu/users/{self.username}/studios/",
245
- headers = self._headers
246
- ).text
247
- return commons.webscrape_count(text, "Studios I Curate (", ")")
248
-
249
- def studios_following_count(self):
250
- text = requests.get(
251
- f"https://scratch.mit.edu/users/{self.username}/studios/",
252
- headers = self._headers
253
- ).text
254
- return commons.webscrape_count(text, "Studios I Follow (", ")")
255
-
256
- def studios(self, *, limit=40, offset=0):
257
- _studios = commons.api_iterative(
258
- f"https://api.scratch.mit.edu/users/{self.username}/studios/curate", limit=limit, offset=offset)
259
- studios = []
260
- for studio_dict in _studios:
261
- _studio = studio.Studio(_session = self._session, id = studio_dict["id"])
262
- _studio._update_from_dict(studio_dict)
263
- studios.append(_studio)
264
- return studios
265
-
266
- def projects(self, *, limit=40, offset=0):
267
- """
268
- Returns:
269
- list<projects.projects.Project>: The user's shared projects
270
- """
271
- _projects = commons.api_iterative(
272
- f"https://api.scratch.mit.edu/users/{self.username}/projects/", limit=limit, offset=offset, headers = self._headers)
273
- for p in _projects:
274
- p["author"] = {"username":self.username}
275
- return commons.parse_object_list(_projects, project.Project, self._session)
276
-
277
- def loves(self, *, limit=40, offset=0, get_full_project: bool = False) -> list[project.Project]:
278
- """
279
- Returns:
280
- list<projects.projects.Project>: The user's loved projects
281
- """
282
- # We need to use beautifulsoup webscraping so we cant use the api_iterative function
283
- if offset < 0:
284
- raise exceptions.BadRequest("offset parameter must be >= 0")
285
- if limit < 0:
286
- raise exceptions.BadRequest("limit parameter must be >= 0")
287
-
288
- # There are 40 projects on display per page
289
- # So the first page you need to view is 1 + offset // 40
290
- # (You have to add one because the first page is idx 1 instead of 0)
291
-
292
- # The final project to view is at idx offset + limit - 1
293
- # (You have to -1 because the index starts at 0)
294
- # So the page number for this is 1 + (offset + limit - 1) // 40
295
-
296
- # But this is a range so we have to add another 1 for the second argument
297
- pages = range(1 + offset // 40,
298
- 2 + (offset + limit - 1) // 40)
299
- _projects = []
300
-
301
- for page in pages:
302
- # The index of the first project on page #n is just (n-1) * 40
303
- first_idx = (page - 1) * 40
304
-
305
- page_content = requests.get(f"https://scratch.mit.edu/projects/all/{self.username}/loves/"
306
- f"?page={page}", headers=self._headers).content
307
-
308
- soup = BeautifulSoup(
309
- page_content,
310
- "html.parser"
311
- )
312
-
313
- # We need to check if we are out of bounds
314
- # If we are, we can jump out early
315
- # This is detectable if Scratch gives you a '404'
316
-
317
- # We can't just detect if the 404 text is within the whole of the page content
318
- # because it would break if someone made a project with that name
319
-
320
- # This page only uses <h1> tags for the 404 text, so we can just use a soup for those
321
- h1_tag = soup.find("h1")
322
- if h1_tag is not None:
323
- # Just to confirm that it's a 404, in case I am wrong. It can't hurt
324
- if "Whoops! Our server is Scratch'ing its head" in h1_tag.text:
325
- break
326
-
327
- # Each project element is a list item with the class name 'project thumb item' so we can just use that
328
- for i, project_element in enumerate(
329
- soup.find_all("li", {"class": "project thumb item"})):
330
- # Remember we only want certain projects:
331
- # The current project idx = first_idx + i
332
- # We want to start at {offset} and end at {offset + limit}
333
-
334
- # So the offset <= current project idx <= offset + limit
335
- if offset <= first_idx + i <= offset + limit:
336
- # Each of these elements provides:
337
- # A project id
338
- # A thumbnail link (no need to webscrape this)
339
- # A title
340
- # An Author (called an owner for some reason)
341
-
342
- project_anchors = project_element.find_all("a")
343
- # Each list item has three <a> tags, the first two linking the project
344
- # 1st contains <img> tag
345
- # 2nd contains project title
346
- # 3rd links to the author & contains their username
347
-
348
- # This function is pretty handy!
349
- # I'll use it for an id from a string like: /projects/1070616180/
350
- project_id = commons.webscrape_count(project_anchors[0].attrs["href"],
351
- "/projects/", "/")
352
- title = project_anchors[1].contents[0]
353
- author = project_anchors[2].contents[0]
354
-
355
- # Instantiating a project with the properties that we know
356
- # This may cause issues (see below)
357
- _project = project.Project(id=project_id,
358
- _session=self._session,
359
- title=title,
360
- author_name=author,
361
- url=f"https://scratch.mit.edu/projects/{project_id}/")
362
- if get_full_project:
363
- # Put this under an if statement since making api requests for every single
364
- # project will cause the function to take a lot longer
365
- _project.update()
366
-
367
- _projects.append(
368
- _project
369
- )
370
-
371
- return _projects
372
-
373
- def loves_count(self):
374
- text = requests.get(
375
- f"https://scratch.mit.edu/projects/all/{self.username}/loves/",
376
- headers=self._headers
377
- ).text
378
-
379
- # If there are no loved projects, then Scratch doesn't actually display the number - so we have to catch this
380
- soup = BeautifulSoup(text, "html.parser")
381
-
382
- if not soup.find("li", {"class": "project thumb item"}):
383
- # There are no projects, so there are no projects loved
384
- return 0
385
-
386
- return commons.webscrape_count(text, "&raquo;\n\n (", ")")
387
-
388
- def favorites(self, *, limit=40, offset=0):
389
- """
390
- Returns:
391
- list<projects.projects.Project>: The user's favorite projects
392
- """
393
- _projects = commons.api_iterative(
394
- f"https://api.scratch.mit.edu/users/{self.username}/favorites/", limit=limit, offset=offset, headers = self._headers)
395
- return commons.parse_object_list(_projects, project.Project, self._session)
396
-
397
- def favorites_count(self):
398
- text = requests.get(
399
- f"https://scratch.mit.edu/users/{self.username}/favorites/",
400
- headers = self._headers
401
- ).text
402
- return commons.webscrape_count(text, "Favorites (", ")")
403
-
404
- def toggle_commenting(self):
405
- """
406
- You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
407
- """
408
- self._assert_permission()
409
- requests.post(f"https://scratch.mit.edu/site-api/comments/user/{self.username}/toggle-comments/",
410
- headers = headers,
411
- cookies = self._cookies
412
- )
413
-
414
- def viewed_projects(self, limit=24, offset=0):
415
- """
416
- Returns:
417
- list<projects.projects.Project>: The user's recently viewed projects
418
-
419
- You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
420
- """
421
- self._assert_permission()
422
- _projects = commons.api_iterative(
423
- f"https://api.scratch.mit.edu/users/{self.username}/projects/recentlyviewed", limit=limit, offset=offset, headers = self._headers)
424
- return commons.parse_object_list(_projects, project.Project, self._session)
425
-
426
- def set_bio(self, text):
427
- """
428
- Sets the user's "About me" section. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
429
- """
430
- self._assert_permission()
431
- requests.put(
432
- f"https://scratch.mit.edu/site-api/users/all/{self.username}/",
433
- headers = self._json_headers,
434
- cookies = self._cookies,
435
- data = json.dumps(dict(
436
- comments_allowed = True,
437
- id = self.username,
438
- bio = text,
439
- thumbnail_url = self.icon_url,
440
- userId = self.id,
441
- username = self.username
442
- ))
443
- )
444
-
445
- def set_wiwo(self, text):
446
- """
447
- Sets the user's "What I'm working on" section. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
448
- """
449
- self._assert_permission()
450
- requests.put(
451
- f"https://scratch.mit.edu/site-api/users/all/{self.username}/",
452
- headers = self._json_headers,
453
- cookies = self._cookies,
454
- data = json.dumps(dict(
455
- comments_allowed = True,
456
- id = self.username,
457
- status = text,
458
- thumbnail_url = self.icon_url,
459
- userId = self.id,
460
- username = self.username
461
- ))
462
- )
463
-
464
- def set_featured(self, project_id, *, label=""):
465
- """
466
- Sets the user's featured project. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
467
-
468
- Args:
469
- project_id: Project id of the project that should be set as featured
470
-
471
- Keyword Args:
472
- label: The label that should appear above the featured project on the user's profile (Like "Featured project", "Featured tutorial", "My favorite things", etc.)
473
- """
474
- self._assert_permission()
475
- requests.put(
476
- f"https://scratch.mit.edu/site-api/users/all/{self.username}/",
477
- headers = self._json_headers,
478
- cookies = self._cookies,
479
- data = json.dumps({"featured_project":int(project_id),"featured_project_label":label})
480
- )
481
-
482
- def set_forum_signature(self, text):
483
- """
484
- Sets the user's discuss forum signature. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
485
- """
486
- self._assert_permission()
487
- headers = {
488
- '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',
489
- 'content-type': 'application/x-www-form-urlencoded',
490
- 'origin': 'https://scratch.mit.edu',
491
- 'referer': 'https://scratch.mit.edu/discuss/settings/TimMcCool/',
492
- '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',
493
- }
494
- data = {
495
- 'csrfmiddlewaretoken': 'a',
496
- 'signature': text,
497
- 'update': '',
498
- }
499
- response = requests.post(f'https://scratch.mit.edu/discuss/settings/{self.username}/', cookies=self._cookies, headers=headers, data=data)
500
-
501
- def post_comment(self, content, *, parent_id="", commentee_id=""):
502
- """
503
- 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`
504
-
505
- Args:
506
- content: Content of the comment that should be posted
507
-
508
- Keyword Arguments:
509
- parent_id: ID of the comment you want to reply to. If you don't want to mention a user, don't put the argument.
510
- 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.
511
-
512
- Returns:
513
- scratchattach.comment.Comment: An object representing the created comment.
514
- """
515
- self._assert_auth()
516
- data = {
517
- "commentee_id": commentee_id,
518
- "content": str(content),
519
- "parent_id": parent_id,
520
- }
521
- r = requests.post(
522
- f"https://scratch.mit.edu/site-api/comments/user/{self.username}/add/",
523
- headers = headers,
524
- cookies = self._cookies,
525
- data=json.dumps(data),
526
- )
527
- if r.status_code != 200:
528
- if "Looks like we are having issues with our servers!" in r.text:
529
- raise exceptions.BadRequest("Invalid arguments passed")
530
- else:
531
- raise exceptions.CommentPostFailure(r.text)
532
-
533
- try:
534
- text = r.text
535
- data = {
536
- 'id': text.split('<div id="comments-')[1].split('" class="comment')[0],
537
- 'author': {"username":text.split('" data-comment-user="')[1].split('"><img class')[0]},
538
- 'content': text.split('<div class="content">')[1].split('"</div>')[0],
539
- 'reply_count': 0,
540
- 'cached_replies': []
541
- }
542
- _comment = comment.Comment(source="profile", parent_id=None if parent_id=="" else parent_id, commentee_id=commentee_id, source_id=self.username, id=data["id"], _session = self._session)
543
- _comment._update_from_dict(data)
544
- return _comment
545
- except Exception:
546
- if '{"error": "isFlood"}' in text:
547
- raise(exceptions.CommentPostFailure(
548
- "You are being rate-limited for running this operation too often. Implement a cooldown of about 10 seconds."))
549
- else:
550
- raise(exceptions.FetchError("Couldn't parse API response"))
551
-
552
- def reply_comment(self, content, *, parent_id, commentee_id=""):
553
- """
554
- Replies to a comment given by its id
555
-
556
- Warning:
557
- Only replies to top-level comments are shown on the Scratch website. Replies to replies are actually replies to the corresponding top-level comment in the API.
558
-
559
- Therefore, parent_id should be the comment id of a top level comment.
560
-
561
- Args:
562
- content: Content of the comment that should be posted
563
-
564
- Keyword Arguments:
565
- parent_id: ID of the comment you want to reply to
566
- 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.
567
- """
568
- return self.post_comment(content, parent_id=parent_id, commentee_id=commentee_id)
569
-
570
- def activity(self, *, limit=1000):
571
- """
572
- Returns:
573
- list<scratchattach.Activity>: The user's activity data as parsed list of scratchattach.activity.Activity objects
574
- """
575
- soup = BeautifulSoup(requests.get(f"https://scratch.mit.edu/messages/ajax/user-activity/?user={self.username}&max={limit}").text, 'html.parser')
576
-
577
- activities = []
578
- source = soup.find_all("li")
579
-
580
- for data in source:
581
- _activity = activity.Activity(_session = self._session, raw=data)
582
- _activity._update_from_html(data)
583
- activities.append(_activity)
584
-
585
- return activities
586
-
587
-
588
- def activity_html(self, *, limit=1000):
589
- """
590
- Returns:
591
- str: The raw user activity HTML data
592
- """
593
- return requests.get(f"https://scratch.mit.edu/messages/ajax/user-activity/?user={self.username}&max={limit}").text
594
-
595
-
596
- def follow(self):
597
- """
598
- Follows the user represented by the User object. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
599
- """
600
- self._assert_auth()
601
- requests.put(
602
- f"https://scratch.mit.edu/site-api/users/followers/{self.username}/add/?usernames={self._session._username}",
603
- headers = headers,
604
- cookies = self._cookies,
605
- )
606
-
607
- def unfollow(self):
608
- """
609
- Unfollows the user represented by the User object. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
610
- """
611
- self._assert_auth()
612
- requests.put(
613
- f"https://scratch.mit.edu/site-api/users/followers/{self.username}/remove/?usernames={self._session._username}",
614
- headers = headers,
615
- cookies = self._cookies,
616
- )
617
-
618
- def delete_comment(self, *, comment_id):
619
- """
620
- Deletes a comment by its ID. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
621
-
622
- Args:
623
- comment_id: The id of the comment that should be deleted
624
- """
625
- self._assert_permission()
626
- return requests.post(
627
- f"https://scratch.mit.edu/site-api/comments/user/{self.username}/del/",
628
- headers = headers,
629
- cookies = self._cookies,
630
- data = json.dumps({"id":str(comment_id)})
631
- )
632
-
633
- def report_comment(self, *, comment_id):
634
- """
635
- Reports a comment by its ID to the Scratch team. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
636
-
637
- Args:
638
- comment_id: The id of the comment that should be reported
639
- """
640
- self._assert_permission()
641
- return requests.post(
642
- f"https://scratch.mit.edu/site-api/comments/user/{self.username}/rep/",
643
- headers = headers,
644
- cookies = self._cookies,
645
- data = json.dumps({"id":str(comment_id)})
646
- )
647
-
648
- def comments(self, *, page=1, limit=None):
649
- """
650
- Returns the comments posted on the user's profile (with replies).
651
-
652
- Keyword Arguments:
653
- page: The page of the comments that should be returned.
654
- limit: Max. amount of returned comments.
655
-
656
- Returns:
657
- list<scratchattach.comment.Comment>: A list containing the requested comments as Comment objects.
658
- """
659
- URL = f"https://scratch.mit.edu/site-api/comments/user/{self.username}/?page={page}"
660
- DATA = []
661
-
662
- page_contents = requests.get(URL).content
663
-
664
- soup = BeautifulSoup(page_contents, "html.parser")
665
-
666
- _comments = soup.find_all("li", {"class": "top-level-reply"})
667
-
668
- if len(_comments) == 0:
669
- return None
670
-
671
- for entity in _comments:
672
- comment_id = entity.find("div", {"class": "comment"})['data-comment-id']
673
- user = entity.find("a", {"id": "comment-user"})['data-comment-user']
674
- content = str(entity.find("div", {"class": "content"}).text).strip()
675
- time = entity.find("span", {"class": "time"})['title']
676
-
677
- main_comment = {
678
- 'id': comment_id,
679
- 'author': {"username":user},
680
- 'content': content,
681
- 'datetime_created': time,
682
- }
683
- _comment = comment.Comment(source="profile", source_id=self.username, _session = self._session)
684
- _comment._update_from_dict(main_comment)
685
-
686
- ALL_REPLIES = []
687
- replies = entity.find_all("li", {"class": "reply"})
688
- if len(replies) > 0:
689
- hasReplies = True
690
- else:
691
- hasReplies = False
692
- for reply in replies:
693
- r_comment_id = reply.find("div", {"class": "comment"})['data-comment-id']
694
- r_user = reply.find("a", {"id": "comment-user"})['data-comment-user']
695
- r_content = str(reply.find("div", {"class": "content"}).text).strip().replace("\n", "").replace(
696
- " ", " ")
697
- r_time = reply.find("span", {"class": "time"})['title']
698
- reply_data = {
699
- 'id': r_comment_id,
700
- 'author':{'username': r_user},
701
- 'content': r_content,
702
- 'datetime_created': r_time,
703
- "parent_id" : comment_id,
704
- "cached_parent_comment" : _comment,
705
- }
706
- _r_comment = comment.Comment(source="profile", source_id=self.username, _session = self._session, cached_parent_comment=_comment)
707
- _r_comment._update_from_dict(reply_data)
708
- ALL_REPLIES.append(_r_comment)
709
-
710
- _comment.reply_count = len(ALL_REPLIES)
711
- _comment.cached_replies = list(ALL_REPLIES)
712
-
713
- DATA.append(_comment)
714
- return DATA
715
-
716
- def comment_by_id(self, comment_id):
717
- """
718
- Gets a comment on this user's profile by id.
719
-
720
- Warning:
721
- For comments very far down on the user's profile, this method will take a while to find the comment. Very old comment are deleted from Scratch's database and may not appear.
722
-
723
- Returns:
724
- scratchattach.comments.Comment: The request comment.
725
- """
726
-
727
- page = 1
728
- page_content = self.comments(page=page)
729
- while page_content != []:
730
- results = list(filter(lambda x : str(x.id) == str(comment_id), page_content))
731
- if results == []:
732
- results = list(filter(lambda x : str(x.id) == str(comment_id), [item for x in page_content for item in x.cached_replies]))
733
- if results != []:
734
- return results[0]
735
- else:
736
- return results[0]
737
- page += 1
738
- page_content = self.comments(page=page)
739
- raise exceptions.CommentNotFound()
740
-
741
- def message_events(self):
742
- return message_events.MessageEvents(self)
743
-
744
- def stats(self):
745
- """
746
- Gets information about the user's stats. Fetched from ScratchDB.
747
-
748
- Warning:
749
- ScratchDB is down indefinitely, therefore this method is deprecated.
750
-
751
- Returns:
752
- dict: A dict containing the user's stats. If the stats aren't available, all values will be -1.
753
- """
754
- print("Warning: ScratchDB is down indefinitely, therefore this method is deprecated.")
755
- try:
756
- stats= requests.get(
757
- f"https://scratchdb.lefty.one/v3/user/info/{self.username}"
758
- ).json()["statistics"]
759
- stats.pop("ranks")
760
- except Exception:
761
- stats = {"loves":-1,"favorites":-1,"comments":-1,"views":-1,"followers":-1,"following":-1}
762
- return stats
763
-
764
- def ranks(self):
765
- """
766
- Gets information about the user's ranks. Fetched from ScratchDB.
767
-
768
- Warning:
769
- ScratchDB is down indefinitely, therefore this method is deprecated.
770
-
771
- Returns:
772
- dict: A dict containing the user's ranks. If the ranks aren't available, all values will be -1.
773
- """
774
- print("Warning: ScratchDB is down indefinitely, therefore this method is deprecated.")
775
- try:
776
- return requests.get(
777
- f"https://scratchdb.lefty.one/v3/user/info/{self.username}"
778
- ).json()["statistics"]["ranks"]
779
- except Exception:
780
- return {"country":{"loves":0,"favorites":0,"comments":0,"views":0,"followers":0,"following":0},"loves":0,"favorites":0,"comments":0,"views":0,"followers":0,"following":0}
781
-
782
- def ocular_status(self):
783
- """
784
- Gets information about the user's ocular status. Ocular is a website developed by jeffalo: https://ocular.jeffalo.net/
785
-
786
- Returns:
787
- dict
788
- """
789
- return requests.get(f"https://my-ocular.jeffalo.net/api/user/{self.username}").json()
790
-
791
- def verify_identity(self, *, verification_project_id=395330233):
792
- """
793
- Can be used in applications to verify a user's identity.
794
-
795
- This function returns a Verifactor object. Attributs of this object:
796
- :.projecturl: The link to the project where the user has to go to verify
797
- :.project: The project where the user has to go to verify as scratchattach.Project object
798
- :.code: The code that the user has to comment
799
-
800
- To check if the user verified successfully, call the .check() function on the returned object.
801
- It will return True if the user commented the code.
802
- """
803
-
804
- class Verificator:
805
-
806
- def __init__(self, user):
807
- self.project = user._make_linked_object("id", verification_project_id, project.Project, exceptions.ProjectNotFound)
808
- self.projecturl = self.project.url
809
- self.code = ''.join(random.choices(string.ascii_letters + string.digits, k=130))
810
- self.username = user.username
811
-
812
- def check(self):
813
- return list(filter(lambda x : x.author_name == self.username, self.project.comments())) != []
814
-
815
- v = Verificator(self)
816
- print(f"{self.username} has to go to {v.projecturl} and comment {v.code} to verify their identity")
817
- return Verificator(self)
818
-
819
- # ------ #
820
-
821
- def get_user(username) -> User:
822
- """
823
- Gets a user without logging in.
824
-
825
- Args:
826
- username (str): Username of the requested user
827
-
828
- Returns:
829
- scratchattach.user.User: An object representing the requested user
830
-
831
- Warning:
832
- Any methods that require authentication (like user.follow) will not work on the returned object.
833
-
834
- If you want to use these, get the user with :meth:`scratchattach.session.Session.connect_user` instead.
835
- """
836
- print("Warning: For methods that require authentication, use session.connect_user instead of get_user")
837
- return commons._get_object("username", username, User, exceptions.UserNotFound)
1
+ """User class"""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import random
6
+ import string
7
+
8
+ from ..eventhandlers import message_events
9
+ from . import project
10
+ from ..utils import exceptions
11
+ from . import studio
12
+ from . import forum
13
+ from bs4 import BeautifulSoup
14
+ from ._base import BaseSiteComponent
15
+ from ..utils.commons import headers
16
+ from ..utils import commons
17
+ from . import comment
18
+ from . import activity
19
+
20
+ from ..utils.requests import Requests as requests
21
+
22
+ class User(BaseSiteComponent):
23
+
24
+ '''
25
+ Represents a Scratch user.
26
+
27
+ Attributes:
28
+
29
+ :.join_date:
30
+
31
+ :.about_me:
32
+
33
+ :.wiwo: Returns the user's 'What I'm working on' section
34
+
35
+ :.country: Returns the country from the user profile
36
+
37
+ :.icon_url: Returns the link to the user's pfp (90x90)
38
+
39
+ :.id: Returns the id of the user
40
+
41
+ :.scratchteam: Retuns True if the user is in the Scratch team
42
+
43
+ :.update(): Updates the attributes
44
+ '''
45
+
46
+ def __str__(self):
47
+ return str(self.username)
48
+
49
+ def __init__(self, **entries):
50
+
51
+ # Info on how the .update method has to fetch the data:
52
+ self.update_function = requests.get
53
+ self.update_API = f"https://api.scratch.mit.edu/users/{entries['username']}"
54
+
55
+ # Set attributes every User object needs to have:
56
+ self._session = None
57
+ self.id = None
58
+ self.username = None
59
+ self.name = None
60
+
61
+ # Update attributes from entries dict:
62
+ entries.setdefault("name", entries.get("username"))
63
+ self.__dict__.update(entries)
64
+
65
+ # Set alternative attributes:
66
+ if hasattr(self, "bio"):
67
+ self.about_me = self.bio
68
+ if hasattr(self, "status"):
69
+ self.wiwo = self.status
70
+ if hasattr(self, "name"):
71
+ self.username = self.name
72
+
73
+ # Headers and cookies:
74
+ if self._session is None:
75
+ self._headers :dict = headers
76
+ self._cookies = {}
77
+ else:
78
+ self._headers :dict = self._session._headers
79
+ self._cookies = self._session._cookies
80
+
81
+ # Headers for operations that require accept and Content-Type fields:
82
+ self._json_headers = dict(self._headers)
83
+ self._json_headers["accept"] = "application/json"
84
+ self._json_headers["Content-Type"] = "application/json"
85
+
86
+ def _update_from_dict(self, data):
87
+ try: self.id = data["id"]
88
+ except KeyError: pass
89
+ try: self.username = data["username"]
90
+ except KeyError: pass
91
+ try: self.scratchteam = data["scratchteam"]
92
+ except KeyError: pass
93
+ try: self.join_date = data["history"]["joined"]
94
+ except KeyError: pass
95
+ try: self.about_me = data["profile"]["bio"]
96
+ except KeyError: pass
97
+ try: self.wiwo = data["profile"]["status"]
98
+ except KeyError: pass
99
+ try: self.country = data["profile"]["country"]
100
+ except KeyError: pass
101
+ try: self.icon_url = data["profile"]["images"]["90x90"]
102
+ except KeyError: pass
103
+ return True
104
+
105
+ def _assert_permission(self):
106
+ self._assert_auth()
107
+ if self._session._username != self.username:
108
+ raise exceptions.Unauthorized(
109
+ "You need to be authenticated as the profile owner to do this.")
110
+
111
+ def does_exist(self):
112
+ """
113
+ Returns:
114
+ boolean : True if the user exists, False if the user is deleted, None if an error occured
115
+ """
116
+ status_code = requests.get(f"https://scratch.mit.edu/users/{self.username}/").status_code
117
+ if status_code == 200:
118
+ return True
119
+ elif status_code == 404:
120
+ return False
121
+
122
+ def is_new_scratcher(self):
123
+ """
124
+ Returns:
125
+ boolean : True if the user has the New Scratcher status, else False
126
+ """
127
+ try:
128
+ res = requests.get(f"https://scratch.mit.edu/users/{self.username}/").text
129
+ group=res[res.rindex('<span class="group">'):][:70]
130
+ return "new scratcher" in group.lower()
131
+ except Exception:
132
+ return None
133
+
134
+ def message_count(self):
135
+
136
+ return json.loads(requests.get(f"https://api.scratch.mit.edu/users/{self.username}/messages/count/?cachebust={random.randint(0,10000)}", headers = {'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',}).text)["count"]
137
+
138
+ def featured_data(self):
139
+ """
140
+ Returns:
141
+ dict: Gets info on the user's featured project and featured label (like "Featured project", "My favorite things", etc.)
142
+ """
143
+ try:
144
+ response = json.loads(requests.get(f"https://scratch.mit.edu/site-api/users/all/{self.username}/").text)
145
+ return {
146
+ "label":response["featured_project_label_name"],
147
+ "project":
148
+ dict(
149
+ id=str(response["featured_project_data"]["id"]),
150
+ author=response["featured_project_data"]["creator"],
151
+ thumbnail_url="https://"+response["featured_project_data"]["thumbnail_url"][2:],
152
+ title=response["featured_project_data"]["title"]
153
+ )
154
+ }
155
+ except Exception:
156
+ return None
157
+
158
+ def follower_count(self):
159
+ # follower count
160
+ text = requests.get(
161
+ f"https://scratch.mit.edu/users/{self.username}/followers/",
162
+ headers = self._headers
163
+ ).text
164
+ return commons.webscrape_count(text, "Followers (", ")")
165
+
166
+ def following_count(self):
167
+ # following count
168
+ text = requests.get(
169
+ f"https://scratch.mit.edu/users/{self.username}/following/",
170
+ headers = self._headers
171
+ ).text
172
+ return commons.webscrape_count(text, "Following (", ")")
173
+
174
+ def followers(self, *, limit=40, offset=0):
175
+ """
176
+ Returns:
177
+ list<scratchattach.user.User>: The user's followers as list of scratchattach.user.User objects
178
+ """
179
+ response = commons.api_iterative(
180
+ f"https://api.scratch.mit.edu/users/{self.username}/followers/", limit=limit, offset=offset)
181
+ return commons.parse_object_list(response, User, self._session, "username")
182
+
183
+ def follower_names(self, *, limit=40, offset=0):
184
+ """
185
+ Returns:
186
+ list<str>: The usernames of the user's followers
187
+ """
188
+ return [i.name for i in self.followers(limit=limit, offset=offset)]
189
+
190
+ def following(self, *, limit=40, offset=0):
191
+ """
192
+ Returns:
193
+ list<scratchattach.user.User>: The users that the user is following as list of scratchattach.user.User objects
194
+ """
195
+ response = commons.api_iterative(
196
+ f"https://api.scratch.mit.edu/users/{self.username}/following/", limit=limit, offset=offset)
197
+ return commons.parse_object_list(response, User, self._session, "username")
198
+
199
+ def following_names(self, *, limit=40, offset=0):
200
+ """
201
+ Returns:
202
+ list<str>: The usernames of the users the user is following
203
+ """
204
+ return [i.name for i in self.following(limit=limit, offset=offset)]
205
+
206
+ def is_following(self, user):
207
+ """
208
+ Returns:
209
+ boolean: Whether the user is following the user provided as argument
210
+ """
211
+ offset = 0
212
+ following = False
213
+
214
+ while True:
215
+ try:
216
+ following_names = self.following_names(limit=20, offset=offset)
217
+ if user in following_names:
218
+ following = True
219
+ break
220
+ if following_names == []:
221
+ break
222
+ offset += 20
223
+ except Exception:
224
+ print("Warning: API error when performing following check")
225
+ return following
226
+ return following
227
+
228
+ def is_followed_by(self, user):
229
+ """
230
+ Returns:
231
+ boolean: Whether the user is followed by the user provided as argument
232
+ """
233
+ return User(username=user).is_following(self.username)
234
+
235
+ def project_count(self):
236
+ text = requests.get(
237
+ f"https://scratch.mit.edu/users/{self.username}/projects/",
238
+ headers = self._headers
239
+ ).text
240
+ return commons.webscrape_count(text, "Shared Projects (", ")")
241
+
242
+ def studio_count(self):
243
+ text = requests.get(
244
+ f"https://scratch.mit.edu/users/{self.username}/studios/",
245
+ headers = self._headers
246
+ ).text
247
+ return commons.webscrape_count(text, "Studios I Curate (", ")")
248
+
249
+ def studios_following_count(self):
250
+ text = requests.get(
251
+ f"https://scratch.mit.edu/users/{self.username}/studios/",
252
+ headers = self._headers
253
+ ).text
254
+ return commons.webscrape_count(text, "Studios I Follow (", ")")
255
+
256
+ def studios(self, *, limit=40, offset=0):
257
+ _studios = commons.api_iterative(
258
+ f"https://api.scratch.mit.edu/users/{self.username}/studios/curate", limit=limit, offset=offset)
259
+ studios = []
260
+ for studio_dict in _studios:
261
+ _studio = studio.Studio(_session = self._session, id = studio_dict["id"])
262
+ _studio._update_from_dict(studio_dict)
263
+ studios.append(_studio)
264
+ return studios
265
+
266
+ def projects(self, *, limit=40, offset=0) -> list[project.Project]:
267
+ """
268
+ Returns:
269
+ list<projects.projects.Project>: The user's shared projects
270
+ """
271
+ _projects = commons.api_iterative(
272
+ f"https://api.scratch.mit.edu/users/{self.username}/projects/", limit=limit, offset=offset, _headers= self._headers)
273
+ for p in _projects:
274
+ p["author"] = {"username":self.username}
275
+ return commons.parse_object_list(_projects, project.Project, self._session)
276
+
277
+ def loves(self, *, limit=40, offset=0, get_full_project: bool = False) -> list[project.Project]:
278
+ """
279
+ Returns:
280
+ list<projects.projects.Project>: The user's loved projects
281
+ """
282
+ # We need to use beautifulsoup webscraping so we cant use the api_iterative function
283
+ if offset < 0:
284
+ raise exceptions.BadRequest("offset parameter must be >= 0")
285
+ if limit < 0:
286
+ raise exceptions.BadRequest("limit parameter must be >= 0")
287
+
288
+ # There are 40 projects on display per page
289
+ # So the first page you need to view is 1 + offset // 40
290
+ # (You have to add one because the first page is idx 1 instead of 0)
291
+
292
+ # The final project to view is at idx offset + limit - 1
293
+ # (You have to -1 because the index starts at 0)
294
+ # So the page number for this is 1 + (offset + limit - 1) // 40
295
+
296
+ # But this is a range so we have to add another 1 for the second argument
297
+ pages = range(1 + offset // 40,
298
+ 2 + (offset + limit - 1) // 40)
299
+ _projects = []
300
+
301
+ for page in pages:
302
+ # The index of the first project on page #n is just (n-1) * 40
303
+ first_idx = (page - 1) * 40
304
+
305
+ page_content = requests.get(f"https://scratch.mit.edu/projects/all/{self.username}/loves/"
306
+ f"?page={page}", headers=self._headers).content
307
+
308
+ soup = BeautifulSoup(
309
+ page_content,
310
+ "html.parser"
311
+ )
312
+
313
+ # We need to check if we are out of bounds
314
+ # If we are, we can jump out early
315
+ # This is detectable if Scratch gives you a '404'
316
+
317
+ # We can't just detect if the 404 text is within the whole of the page content
318
+ # because it would break if someone made a project with that name
319
+
320
+ # This page only uses <h1> tags for the 404 text, so we can just use a soup for those
321
+ h1_tag = soup.find("h1")
322
+ if h1_tag is not None:
323
+ # Just to confirm that it's a 404, in case I am wrong. It can't hurt
324
+ if "Whoops! Our server is Scratch'ing its head" in h1_tag.text:
325
+ break
326
+
327
+ # Each project element is a list item with the class name 'project thumb item' so we can just use that
328
+ for i, project_element in enumerate(
329
+ soup.find_all("li", {"class": "project thumb item"})):
330
+ # Remember we only want certain projects:
331
+ # The current project idx = first_idx + i
332
+ # We want to start at {offset} and end at {offset + limit}
333
+
334
+ # So the offset <= current project idx <= offset + limit
335
+ if offset <= first_idx + i <= offset + limit:
336
+ # Each of these elements provides:
337
+ # A project id
338
+ # A thumbnail link (no need to webscrape this)
339
+ # A title
340
+ # An Author (called an owner for some reason)
341
+
342
+ project_anchors = project_element.find_all("a")
343
+ # Each list item has three <a> tags, the first two linking the project
344
+ # 1st contains <img> tag
345
+ # 2nd contains project title
346
+ # 3rd links to the author & contains their username
347
+
348
+ # This function is pretty handy!
349
+ # I'll use it for an id from a string like: /projects/1070616180/
350
+ project_id = commons.webscrape_count(project_anchors[0].attrs["href"],
351
+ "/projects/", "/")
352
+ title = project_anchors[1].contents[0]
353
+ author = project_anchors[2].contents[0]
354
+
355
+ # Instantiating a project with the properties that we know
356
+ # This may cause issues (see below)
357
+ _project = project.Project(id=project_id,
358
+ _session=self._session,
359
+ title=title,
360
+ author_name=author,
361
+ url=f"https://scratch.mit.edu/projects/{project_id}/")
362
+ if get_full_project:
363
+ # Put this under an if statement since making api requests for every single
364
+ # project will cause the function to take a lot longer
365
+ _project.update()
366
+
367
+ _projects.append(
368
+ _project
369
+ )
370
+
371
+ return _projects
372
+
373
+ def loves_count(self):
374
+ text = requests.get(
375
+ f"https://scratch.mit.edu/projects/all/{self.username}/loves/",
376
+ headers=self._headers
377
+ ).text
378
+
379
+ # If there are no loved projects, then Scratch doesn't actually display the number - so we have to catch this
380
+ soup = BeautifulSoup(text, "html.parser")
381
+
382
+ if not soup.find("li", {"class": "project thumb item"}):
383
+ # There are no projects, so there are no projects loved
384
+ return 0
385
+
386
+ return commons.webscrape_count(text, "&raquo;\n\n (", ")")
387
+
388
+ def favorites(self, *, limit=40, offset=0):
389
+ """
390
+ Returns:
391
+ list<projects.projects.Project>: The user's favorite projects
392
+ """
393
+ _projects = commons.api_iterative(
394
+ f"https://api.scratch.mit.edu/users/{self.username}/favorites/", limit=limit, offset=offset, _headers= self._headers)
395
+ return commons.parse_object_list(_projects, project.Project, self._session)
396
+
397
+ def favorites_count(self):
398
+ text = requests.get(
399
+ f"https://scratch.mit.edu/users/{self.username}/favorites/",
400
+ headers = self._headers
401
+ ).text
402
+ return commons.webscrape_count(text, "Favorites (", ")")
403
+
404
+ def toggle_commenting(self):
405
+ """
406
+ You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
407
+ """
408
+ self._assert_permission()
409
+ requests.post(f"https://scratch.mit.edu/site-api/comments/user/{self.username}/toggle-comments/",
410
+ headers = headers,
411
+ cookies = self._cookies
412
+ )
413
+
414
+ def viewed_projects(self, limit=24, offset=0):
415
+ """
416
+ Returns:
417
+ list<projects.projects.Project>: The user's recently viewed projects
418
+
419
+ You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
420
+ """
421
+ self._assert_permission()
422
+ _projects = commons.api_iterative(
423
+ f"https://api.scratch.mit.edu/users/{self.username}/projects/recentlyviewed", limit=limit, offset=offset, _headers= self._headers)
424
+ return commons.parse_object_list(_projects, project.Project, self._session)
425
+
426
+ def set_pfp(self, image: bytes):
427
+ """
428
+ Sets the user's profile picture. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
429
+ """
430
+ # Teachers can set pfp! - Should update this method to check for that
431
+ # self._assert_permission()
432
+ requests.post(
433
+ f"https://scratch.mit.edu/site-api/users/all/{self.username}/",
434
+ headers=self._headers,
435
+ cookies=self._cookies,
436
+ files={"file": image})
437
+
438
+ def set_bio(self, text):
439
+ """
440
+ Sets the user's "About me" section. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
441
+ """
442
+ # Teachers can set bio! - Should update this method to check for that
443
+ # self._assert_permission()
444
+ requests.put(
445
+ f"https://scratch.mit.edu/site-api/users/all/{self.username}/",
446
+ headers=self._json_headers,
447
+ cookies=self._cookies,
448
+ json={"bio": text})
449
+
450
+ def set_wiwo(self, text):
451
+ """
452
+ Sets the user's "What I'm working on" section. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
453
+ """
454
+ # Teachers can also change your wiwo
455
+ # self._assert_permission()
456
+ requests.put(
457
+ f"https://scratch.mit.edu/site-api/users/all/{self.username}/",
458
+ headers=self._json_headers,
459
+ cookies=self._cookies,
460
+ json={"status": text})
461
+
462
+ def set_featured(self, project_id, *, label=""):
463
+ """
464
+ Sets the user's featured project. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
465
+
466
+ Args:
467
+ project_id: Project id of the project that should be set as featured
468
+
469
+ Keyword Args:
470
+ label: The label that should appear above the featured project on the user's profile (Like "Featured project", "Featured tutorial", "My favorite things", etc.)
471
+ """
472
+ self._assert_permission()
473
+ requests.put(
474
+ f"https://scratch.mit.edu/site-api/users/all/{self.username}/",
475
+ headers=self._json_headers,
476
+ cookies=self._cookies,
477
+ json={"featured_project": int(project_id), "featured_project_label": label}
478
+ )
479
+
480
+ def set_forum_signature(self, text):
481
+ """
482
+ Sets the user's discuss forum signature. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
483
+ """
484
+ self._assert_permission()
485
+ headers = {
486
+ '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',
487
+ 'content-type': 'application/x-www-form-urlencoded',
488
+ 'origin': 'https://scratch.mit.edu',
489
+ 'referer': 'https://scratch.mit.edu/discuss/settings/TimMcCool/',
490
+ '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',
491
+ }
492
+ data = {
493
+ 'csrfmiddlewaretoken': 'a',
494
+ 'signature': text,
495
+ 'update': '',
496
+ }
497
+ response = requests.post(f'https://scratch.mit.edu/discuss/settings/{self.username}/', cookies=self._cookies, headers=headers, data=data)
498
+
499
+ def post_comment(self, content, *, parent_id="", commentee_id=""):
500
+ """
501
+ 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`
502
+
503
+ Args:
504
+ content: Content of the comment that should be posted
505
+
506
+ Keyword Arguments:
507
+ parent_id: ID of the comment you want to reply to. If you don't want to mention a user, don't put the argument.
508
+ 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.
509
+
510
+ Returns:
511
+ scratchattach.comment.Comment: An object representing the created comment.
512
+ """
513
+ self._assert_auth()
514
+ data = {
515
+ "commentee_id": commentee_id,
516
+ "content": str(content),
517
+ "parent_id": parent_id,
518
+ }
519
+ r = requests.post(
520
+ f"https://scratch.mit.edu/site-api/comments/user/{self.username}/add/",
521
+ headers=headers,
522
+ cookies=self._cookies,
523
+ data=json.dumps(data),
524
+ )
525
+ if r.status_code != 200:
526
+ if "Looks like we are having issues with our servers!" in r.text:
527
+ raise exceptions.BadRequest("Invalid arguments passed")
528
+ else:
529
+ raise exceptions.CommentPostFailure(r.text)
530
+
531
+ try:
532
+ text = r.text
533
+ data = {
534
+ 'id': text.split('<div id="comments-')[1].split('" class="comment')[0],
535
+ 'author': {"username": text.split('" data-comment-user="')[1].split('"><img class')[0]},
536
+ 'content': text.split('<div class="content">')[1].split('"</div>')[0],
537
+ 'reply_count': 0,
538
+ 'cached_replies': []
539
+ }
540
+ _comment = comment.Comment(source="profile", parent_id=None if parent_id=="" else parent_id, commentee_id=commentee_id, source_id=self.username, id=data["id"], _session = self._session)
541
+ _comment._update_from_dict(data)
542
+ return _comment
543
+ except Exception:
544
+ if '{"error": "isFlood"}' in text:
545
+ raise(exceptions.CommentPostFailure(
546
+ "You are being rate-limited for running this operation too often. Implement a cooldown of about 10 seconds."))
547
+ else:
548
+ raise(exceptions.FetchError(f"Couldn't parse API response: {r.text!r}"))
549
+
550
+ def reply_comment(self, content, *, parent_id, commentee_id=""):
551
+ """
552
+ Replies to a comment given by its id
553
+
554
+ Warning:
555
+ Only replies to top-level comments are shown on the Scratch website. Replies to replies are actually replies to the corresponding top-level comment in the API.
556
+
557
+ Therefore, parent_id should be the comment id of a top level comment.
558
+
559
+ Args:
560
+ content: Content of the comment that should be posted
561
+
562
+ Keyword Arguments:
563
+ parent_id: ID of the comment you want to reply to
564
+ 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.
565
+ """
566
+ return self.post_comment(content, parent_id=parent_id, commentee_id=commentee_id)
567
+
568
+ def activity(self, *, limit=1000):
569
+ """
570
+ Returns:
571
+ list<scratchattach.Activity>: The user's activity data as parsed list of scratchattach.activity.Activity objects
572
+ """
573
+ soup = BeautifulSoup(requests.get(f"https://scratch.mit.edu/messages/ajax/user-activity/?user={self.username}&max={limit}").text, 'html.parser')
574
+
575
+ activities = []
576
+ source = soup.find_all("li")
577
+
578
+ for data in source:
579
+ _activity = activity.Activity(_session = self._session, raw=data)
580
+ _activity._update_from_html(data)
581
+ activities.append(_activity)
582
+
583
+ return activities
584
+
585
+
586
+ def activity_html(self, *, limit=1000):
587
+ """
588
+ Returns:
589
+ str: The raw user activity HTML data
590
+ """
591
+ return requests.get(f"https://scratch.mit.edu/messages/ajax/user-activity/?user={self.username}&max={limit}").text
592
+
593
+
594
+ def follow(self):
595
+ """
596
+ Follows the user represented by the User object. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
597
+ """
598
+ self._assert_auth()
599
+ requests.put(
600
+ f"https://scratch.mit.edu/site-api/users/followers/{self.username}/add/?usernames={self._session._username}",
601
+ headers = headers,
602
+ cookies = self._cookies,
603
+ )
604
+
605
+ def unfollow(self):
606
+ """
607
+ Unfollows the user represented by the User object. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
608
+ """
609
+ self._assert_auth()
610
+ requests.put(
611
+ f"https://scratch.mit.edu/site-api/users/followers/{self.username}/remove/?usernames={self._session._username}",
612
+ headers = headers,
613
+ cookies = self._cookies,
614
+ )
615
+
616
+ def delete_comment(self, *, comment_id):
617
+ """
618
+ Deletes a comment by its ID. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
619
+
620
+ Args:
621
+ comment_id: The id of the comment that should be deleted
622
+ """
623
+ self._assert_permission()
624
+ return requests.post(
625
+ f"https://scratch.mit.edu/site-api/comments/user/{self.username}/del/",
626
+ headers = headers,
627
+ cookies = self._cookies,
628
+ data = json.dumps({"id":str(comment_id)})
629
+ )
630
+
631
+ def report_comment(self, *, comment_id):
632
+ """
633
+ Reports a comment by its ID to the Scratch team. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_user`
634
+
635
+ Args:
636
+ comment_id: The id of the comment that should be reported
637
+ """
638
+ self._assert_auth()
639
+ return requests.post(
640
+ f"https://scratch.mit.edu/site-api/comments/user/{self.username}/rep/",
641
+ headers = headers,
642
+ cookies = self._cookies,
643
+ data = json.dumps({"id":str(comment_id)})
644
+ )
645
+
646
+ def comments(self, *, page=1, limit=None):
647
+ """
648
+ Returns the comments posted on the user's profile (with replies).
649
+
650
+ Keyword Arguments:
651
+ page: The page of the comments that should be returned.
652
+ limit: Max. amount of returned comments.
653
+
654
+ Returns:
655
+ list<scratchattach.comment.Comment>: A list containing the requested comments as Comment objects.
656
+ """
657
+ URL = f"https://scratch.mit.edu/site-api/comments/user/{self.username}/?page={page}"
658
+ DATA = []
659
+
660
+ page_contents = requests.get(URL).content
661
+
662
+ soup = BeautifulSoup(page_contents, "html.parser")
663
+
664
+ _comments = soup.find_all("li", {"class": "top-level-reply"})
665
+
666
+ if len(_comments) == 0:
667
+ return None
668
+
669
+ for entity in _comments:
670
+ comment_id = entity.find("div", {"class": "comment"})['data-comment-id']
671
+ user = entity.find("a", {"id": "comment-user"})['data-comment-user']
672
+ content = str(entity.find("div", {"class": "content"}).text).strip()
673
+ time = entity.find("span", {"class": "time"})['title']
674
+
675
+ main_comment = {
676
+ 'id': comment_id,
677
+ 'author': {"username":user},
678
+ 'content': content,
679
+ 'datetime_created': time,
680
+ }
681
+ _comment = comment.Comment(source="profile", source_id=self.username, _session = self._session)
682
+ _comment._update_from_dict(main_comment)
683
+
684
+ ALL_REPLIES = []
685
+ replies = entity.find_all("li", {"class": "reply"})
686
+ if len(replies) > 0:
687
+ hasReplies = True
688
+ else:
689
+ hasReplies = False
690
+ for reply in replies:
691
+ r_comment_id = reply.find("div", {"class": "comment"})['data-comment-id']
692
+ r_user = reply.find("a", {"id": "comment-user"})['data-comment-user']
693
+ r_content = str(reply.find("div", {"class": "content"}).text).strip().replace("\n", "").replace(
694
+ " ", " ")
695
+ r_time = reply.find("span", {"class": "time"})['title']
696
+ reply_data = {
697
+ 'id': r_comment_id,
698
+ 'author':{'username': r_user},
699
+ 'content': r_content,
700
+ 'datetime_created': r_time,
701
+ "parent_id" : comment_id,
702
+ "cached_parent_comment" : _comment,
703
+ }
704
+ _r_comment = comment.Comment(source="profile", source_id=self.username, _session = self._session, cached_parent_comment=_comment)
705
+ _r_comment._update_from_dict(reply_data)
706
+ ALL_REPLIES.append(_r_comment)
707
+
708
+ _comment.reply_count = len(ALL_REPLIES)
709
+ _comment.cached_replies = list(ALL_REPLIES)
710
+
711
+ DATA.append(_comment)
712
+ return DATA
713
+
714
+ def comment_by_id(self, comment_id) -> comment.Comment:
715
+ """
716
+ Gets a comment on this user's profile by id.
717
+
718
+ Warning:
719
+ For comments very far down on the user's profile, this method will take a while to find the comment. Very old comment are deleted from Scratch's database and may not appear.
720
+
721
+ Returns:
722
+ scratchattach.comments.Comment: The request comment.
723
+ """
724
+
725
+ page = 1
726
+ page_content = self.comments(page=page)
727
+ while page_content != []:
728
+ results = list(filter(lambda x : str(x.id) == str(comment_id), page_content))
729
+ if results == []:
730
+ results = list(filter(lambda x : str(x.id) == str(comment_id), [item for x in page_content for item in x.cached_replies]))
731
+ if results != []:
732
+ return results[0]
733
+ else:
734
+ return results[0]
735
+ page += 1
736
+ page_content = self.comments(page=page)
737
+ raise exceptions.CommentNotFound()
738
+
739
+ def message_events(self):
740
+ return message_events.MessageEvents(self)
741
+
742
+ def stats(self):
743
+ """
744
+ Gets information about the user's stats. Fetched from ScratchDB.
745
+
746
+ Warning:
747
+ ScratchDB is down indefinitely, therefore this method is deprecated.
748
+
749
+ Returns:
750
+ dict: A dict containing the user's stats. If the stats aren't available, all values will be -1.
751
+ """
752
+ print("Warning: ScratchDB is down indefinitely, therefore this method is deprecated.")
753
+ try:
754
+ stats= requests.get(
755
+ f"https://scratchdb.lefty.one/v3/user/info/{self.username}"
756
+ ).json()["statistics"]
757
+ stats.pop("ranks")
758
+ except Exception:
759
+ stats = {"loves":-1,"favorites":-1,"comments":-1,"views":-1,"followers":-1,"following":-1}
760
+ return stats
761
+
762
+ def ranks(self):
763
+ """
764
+ Gets information about the user's ranks. Fetched from ScratchDB.
765
+
766
+ Warning:
767
+ ScratchDB is down indefinitely, therefore this method is deprecated.
768
+
769
+ Returns:
770
+ dict: A dict containing the user's ranks. If the ranks aren't available, all values will be -1.
771
+ """
772
+ print("Warning: ScratchDB is down indefinitely, therefore this method is deprecated.")
773
+ try:
774
+ return requests.get(
775
+ f"https://scratchdb.lefty.one/v3/user/info/{self.username}"
776
+ ).json()["statistics"]["ranks"]
777
+ except Exception:
778
+ return {"country":{"loves":0,"favorites":0,"comments":0,"views":0,"followers":0,"following":0},"loves":0,"favorites":0,"comments":0,"views":0,"followers":0,"following":0}
779
+
780
+ def ocular_status(self):
781
+ """
782
+ Gets information about the user's ocular status. Ocular is a website developed by jeffalo: https://ocular.jeffalo.net/
783
+
784
+ Returns:
785
+ dict
786
+ """
787
+ return requests.get(f"https://my-ocular.jeffalo.net/api/user/{self.username}").json()
788
+
789
+ def verify_identity(self, *, verification_project_id=395330233):
790
+ """
791
+ Can be used in applications to verify a user's identity.
792
+
793
+ This function returns a Verifactor object. Attributs of this object:
794
+ :.projecturl: The link to the project where the user has to go to verify
795
+ :.project: The project where the user has to go to verify as scratchattach.Project object
796
+ :.code: The code that the user has to comment
797
+
798
+ To check if the user verified successfully, call the .check() function on the returned object.
799
+ It will return True if the user commented the code.
800
+ """
801
+
802
+ class Verificator:
803
+
804
+ def __init__(self, user):
805
+ self.project = user._make_linked_object("id", verification_project_id, project.Project, exceptions.ProjectNotFound)
806
+ self.projecturl = self.project.url
807
+ self.code = ''.join(random.choices(string.ascii_letters + string.digits, k=130))
808
+ self.username = user.username
809
+
810
+ def check(self):
811
+ return list(filter(lambda x : x.author_name == self.username, self.project.comments())) != []
812
+
813
+ v = Verificator(self)
814
+ print(f"{self.username} has to go to {v.projecturl} and comment {v.code} to verify their identity")
815
+ return Verificator(self)
816
+
817
+ # ------ #
818
+
819
+ def get_user(username) -> User:
820
+ """
821
+ Gets a user without logging in.
822
+
823
+ Args:
824
+ username (str): Username of the requested user
825
+
826
+ Returns:
827
+ scratchattach.user.User: An object representing the requested user
828
+
829
+ Warning:
830
+ Any methods that require authentication (like user.follow) will not work on the returned object.
831
+
832
+ If you want to use these, get the user with :meth:`scratchattach.session.Session.connect_user` instead.
833
+ """
834
+ print("Warning: For methods that require authentication, use session.connect_user instead of get_user")
835
+ return commons._get_object("username", username, User, exceptions.UserNotFound)