scratchattach 2.1.9__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.9.dist-info → scratchattach-2.1.10a0.dist-info}/LICENSE +21 -21
  55. {scratchattach-2.1.9.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.9.dist-info → scratchattach-2.1.10a0.dist-info}/WHEEL +1 -1
  58. scratchattach-2.1.9.dist-info/RECORD +0 -40
  59. {scratchattach-2.1.9.dist-info → scratchattach-2.1.10a0.dist-info}/top_level.txt +0 -0
@@ -1,399 +1,400 @@
1
- """ForumTopic and ForumPost classes"""
2
-
3
- from . import user
4
- from ..utils.commons import headers
5
- from ..utils import exceptions, commons
6
- from ._base import BaseSiteComponent
7
- import xml.etree.ElementTree as ET
8
- from bs4 import BeautifulSoup
9
- from urllib.parse import urlparse, parse_qs
10
-
11
- from ..utils.requests import Requests as requests
12
-
13
- class ForumTopic(BaseSiteComponent):
14
- '''
15
- Represents a Scratch forum topic.
16
-
17
- Attributes:
18
-
19
- :.id:
20
-
21
- :.title:
22
-
23
- :.category_name:
24
-
25
- :.last_updated:
26
-
27
- Attributes only available if the object was created using scratchattach.get_topic_list or scratchattach.Session.connect_topic_list:
28
-
29
- :.reply_count:
30
-
31
- :.view_count:
32
-
33
- :.update(): Updates the attributes
34
- '''
35
- def __init__(self, **entries):
36
-
37
- # Info on how the .update method has to fetch the data:
38
- self.update_function = requests.get
39
- self.update_API = f"https://scratch.mit.edu/discuss/feeds/topic/{entries['id']}/"
40
-
41
- # Set attributes every Project object needs to have:
42
- self._session = None
43
- self.id = 0
44
- self.reply_count = None
45
- self.view_count = None
46
-
47
- # Update attributes from entries dict:
48
- self.__dict__.update(entries)
49
-
50
- # Headers and cookies:
51
- if self._session is None:
52
- self._headers = headers
53
- self._cookies = {}
54
- else:
55
- self._headers = self._session._headers
56
- self._cookies = self._session._cookies
57
-
58
- # Headers for operations that require accept and Content-Type fields:
59
- self._json_headers = dict(self._headers)
60
- self._json_headers["accept"] = "application/json"
61
- self._json_headers["Content-Type"] = "application/json"
62
-
63
- def update(self):
64
- # As there is no JSON API for getting forum topics anymore,
65
- # the data has to be retrieved from the XML feed.
66
- response = self.update_function(
67
- self.update_API,
68
- headers = self._headers,
69
- cookies = self._cookies, timeout=20 # fetching forums can take very long
70
- )
71
- # Check for 429 error:
72
- if "429" in str(response):
73
- return "429"
74
-
75
- # Parse XML response
76
- if response.status_code == 200:
77
- try:
78
- root = ET.fromstring(response.text)
79
- namespace = {'atom': 'http://www.w3.org/2005/Atom'}
80
-
81
- title = root.findtext('atom:title', namespaces=namespace).replace("Latest posts on ","")
82
- category_name = root.findall('.//atom:entry', namespaces=namespace)[0].findtext('.//atom:title', namespaces=namespace).split(" :: ")[1]
83
- last_updated = root.findtext('atom:updated', namespaces=namespace)
84
-
85
- except Exception as e:
86
- raise exceptions.ScrapeError(str(e))
87
- else:
88
- raise exceptions.ForumContentNotFound
89
-
90
- return self._update_from_dict(dict(
91
- title = title, category_name = category_name, last_updated = last_updated
92
- ))
93
-
94
-
95
- def _update_from_dict(self, data):
96
- self.__dict__.update(data)
97
- return True
98
-
99
- def posts(self, *, page=1, order="oldest"):
100
- """
101
- Args:
102
- page (int): The page of the forum topic that should be returned. First page is at index 1.
103
-
104
- Returns:
105
- list<scratchattach.forum.ForumPost>: A list containing the posts from the specified page of the forum topic
106
- """
107
- if order != "oldest":
108
- print("Warning: All post orders except for 'oldest' are deprecated and no longer work") # For backwards compatibility
109
-
110
- posts = []
111
-
112
- try:
113
- url = f"https://scratch.mit.edu/discuss/topic/{self.id}/?page={page}"
114
- response = requests.get(url, headers=headers, cookies=self._cookies)
115
- except Exception as e:
116
- raise exceptions.FetchError(str(e))
117
- try:
118
- soup = BeautifulSoup(response.content, 'html.parser')
119
- soup = soup.find("div", class_="djangobb")
120
-
121
- try:
122
- pagination_div = soup.find('div', class_='pagination')
123
- num_pages = int(pagination_div.find_all('a', class_='page')[-1].text)
124
- except Exception:
125
- num_pages = 1
126
-
127
- try:
128
- # get topic category:
129
- topic_category = ""
130
- breadcrumb_ul = soup.find_all('ul')[1] # Find the second ul element
131
- if breadcrumb_ul:
132
- link = breadcrumb_ul.find_all('a')[1] # Get the right anchor tag
133
- topic_category = link.text.strip() # Extract and strip text content
134
- except Exception as e:
135
- print(f"Warning: Couldn't scrape topic category for topic {self.id} - {e}")
136
- topic_category = ""
137
-
138
- # get corresponding posts:
139
- post_htmls = soup.find_all('div', class_='blockpost')
140
- for raw_post in post_htmls:
141
- post = ForumPost(id=int(raw_post['id'].replace("p", "")), topic_id=self.id, _session=self._session, topic_category=topic_category, topic_num_pages=num_pages)
142
- post._update_from_html(raw_post)
143
-
144
- posts.append(post)
145
- except Exception as e:
146
- raise exceptions.ScrapeError(str(e))
147
-
148
- return posts
149
-
150
- def first_post(self):
151
- """
152
- Returns:
153
- scratchattach.forum.ForumPost: An object representing the first topic post
154
- """
155
- posts = self.posts(page=1)
156
- if len(posts) > 0:
157
- return posts[0]
158
-
159
-
160
- class ForumPost(BaseSiteComponent):
161
- '''
162
- Represents a Scratch forum post.
163
-
164
- Attributes:
165
-
166
- :.id:
167
-
168
- :.author_name: The name of the user who created this post
169
-
170
- :.author_avatar_url:
171
-
172
- :.posted: The date the post was made
173
-
174
- :.topic_id: The id of the topic this post is in
175
-
176
- :.topic_name: The name of the topic the post is in
177
-
178
- :.topic_category: The name of the category the post topic is in
179
-
180
- :.topic_num_pages: The number of pages the post topic has
181
-
182
- :.deleted: Whether the post was deleted (always False because deleted posts can't be retrieved anymore)
183
-
184
- :.html_content: Returns the content as HTML
185
-
186
- :.content: Returns the content as text
187
-
188
- :.post_index: The index that the post has in the topic
189
-
190
- :.update(): Updates the attributes
191
- '''
192
-
193
- def __init__(self, **entries):
194
-
195
- # A forum post can't be updated the usual way as there is no API anymore
196
- self.update_function = None
197
- self.update_API = None
198
-
199
- # Set attributes every Project object needs to have:
200
- self._session = None
201
- self.id = 0
202
- self.topic_id = 0
203
- self.deleted = False
204
-
205
- # Update attributes from entries dict:
206
- self.__dict__.update(entries)
207
-
208
- # Headers and cookies:
209
- if self._session is None:
210
- self._headers = headers
211
- self._cookies = {}
212
- else:
213
- self._headers = self._session._headers
214
- self._cookies = self._session._cookies
215
-
216
- # Headers for operations that require accept and Content-Type fields:
217
- self._json_headers = dict(self._headers)
218
- self._json_headers["accept"] = "application/json"
219
- self._json_headers["Content-Type"] = "application/json"
220
-
221
- def update(self):
222
- """
223
- Updates the attributes of the ForumPost object.
224
- As there is no API for retrieving a single post anymore, this requires reloading the forum page.
225
- """
226
- page = 1
227
- posts = ForumTopic(id=self.topic_id, _session=self._session).posts(page=1)
228
- while posts != []:
229
- matching = list(filter(lambda x : int(x.id) == int(self.id), posts))
230
- if len(matching) > 0:
231
- this = matching[0]
232
- break
233
- page += 1
234
- posts = ForumTopic(id=self.topic_id, _session=self._session).posts(page=page)
235
- else:
236
- return False
237
-
238
- return self._update_from_dict(this.__dict__)
239
-
240
- def _update_from_dict(self, data):
241
- self.__dict__.update(data)
242
- return True
243
-
244
- def _update_from_html(self, soup_html):
245
- self.post_index = int(soup_html.find('span', class_='conr').text.strip('#'))
246
- self.id = int(soup_html['id'].replace("p", ""))
247
- self.posted = soup_html.find('a', href=True).text.strip()
248
- self.content = soup_html.find('div', class_='post_body_html').text.strip()
249
- self.html_content = str(soup_html.find('div', class_='post_body_html'))
250
- self.author_name = soup_html.find('dl').find('dt').find('a').text.strip()
251
- self.author_avatar_url = soup_html.find('dl').find('dt').find('a')['href']
252
- self.topic_name = soup_html.find('h3').text.strip()
253
- return True
254
-
255
- def topic(self):
256
- """
257
- Returns:
258
- scratchattach.forum.ForumTopic: An object representing the forum topic this post is in.
259
- """
260
- return self._make_linked_object("id", self.topic_id, ForumTopic, exceptions.ForumContentNotFound)
261
-
262
- def ocular_reactions(self):
263
- return requests.get(f"https://my-ocular.jeffalo.net/api/reactions/{self.id}", timeout=10).json()
264
-
265
- def author(self):
266
- """
267
- Returns:
268
- scratchattach.user.User: An object representing the user who created this forum post.
269
- """
270
- return self._make_linked_object("username", self.author_name, user.User, exceptions.UserNotFound)
271
-
272
- def edit(self, new_content):
273
- """
274
- Changes the content of the forum post. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_post` or through another method that requires authentication. You must own the forum post.
275
-
276
- Args:
277
- new_content (str): The text that the forum post will be set to.
278
- """
279
-
280
- self._assert_auth()
281
-
282
- cookies = dict(self._cookies)
283
- cookies["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.9"
284
- cookies["Content-Type"] = "application/x-www-form-urlencoded"
285
-
286
- r = requests.post(
287
- f"https://scratch.mit.edu/discuss/post/{self.id}/edit/",
288
- headers = {
289
- "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.9",
290
- "accept-language": "de,en;q=0.9",
291
- "cache-control": "max-age=0",
292
- "content-type": "application/x-www-form-urlencoded",
293
- "sec-ch-ua": "\" Not A;Brand\";v=\"99\", \"Chromium\";v=\"101\", \"Google Chrome\";v=\"101\"",
294
- "sec-ch-ua-mobile": "?0",
295
- "sec-ch-ua-platform": "\"Windows\"",
296
- "sec-fetch-dest": "document",
297
- "sec-fetch-mode": "navigate",
298
- "sec-fetch-site": "same-origin",
299
- "sec-fetch-user": "?1",
300
- "upgrade-insecure-requests": "1",
301
- "Referer": f"https://scratch.mit.edu/discuss/post/{self.id}/edit/",
302
- "x-csrftoken": "a"
303
- },
304
- cookies = cookies,
305
- json = f"csrfmiddlewaretoken=a&body={new_content}&",
306
- timeout = 10,
307
- )
308
-
309
-
310
- def get_topic(topic_id) -> ForumTopic:
311
-
312
- """
313
- Gets a forum topic without logging in. Data received from Scratch's RSS feed XML API.
314
-
315
- Args:
316
- topic_id (int): ID of the requested forum topic
317
-
318
- Returns:
319
- scratchattach.forum.ForumTopic: An object representing the requested forum topic
320
-
321
- Warning:
322
- Scratch's API uses very heavy caching for logged out users, therefore the returned data will not be up to date.
323
-
324
- Any methods that require authentication will not work on the returned object.
325
-
326
- If you need up-to-date data or want to use methods that require authentication, create the object with :meth:`scratchattach.session.Session.connect_topic` instead.
327
- """
328
- return commons._get_object("id", topic_id, ForumTopic, exceptions.ForumContentNotFound)
329
-
330
-
331
- def get_topic_list(category_id, *, page=1):
332
-
333
- """
334
- Gets the topics from a forum category without logging in. Data web-scraped from Scratch's forums UI.
335
-
336
- Args:
337
- category_id (str): ID of the forum category
338
-
339
- Keyword Arguments:
340
- page (str): Page of the category topics that should be returned
341
-
342
- Returns:
343
- list<scratchattach.forum.ForumTopic>: A list containing the forum topics from the specified category
344
-
345
- Warning:
346
- Scratch's API uses very heavy caching for logged out users, therefore the returned data will not be up to date.
347
-
348
- Any methods that require authentication will not work on the returned objects.
349
-
350
- If you need up-to-date data or want to use methods that require authentication, get the forum topics with :meth:`scratchattach.session.Session.connect_topic_list` instead.
351
- """
352
-
353
- try:
354
- response = requests.get(f"https://scratch.mit.edu/discuss/{category_id}/?page={page}")
355
- soup = BeautifulSoup(response.content, 'html.parser')
356
- except Exception as e:
357
- raise exceptions.FetchError(str(e))
358
-
359
- try:
360
- category_name = soup.find('h4').find("span").get_text()
361
- except Exception as e:
362
- raise exceptions.BadRequest("Invalid category id")
363
-
364
- try:
365
- topics = soup.find_all('tr')
366
- topics.pop(0)
367
- return_topics = []
368
-
369
- for topic in topics:
370
- title_link = topic.find('a')
371
- title = title_link.text.strip()
372
- topic_id = title_link['href'].split('/')[-2]
373
-
374
- columns = topic.find_all('td')
375
- columns = [column.text for column in columns]
376
- if len(columns) == 1:
377
- # This is a sticky topic -> Skip it
378
- continue
379
-
380
- last_updated = columns[3].split(" ")[0] + " " + columns[3].split(" ")[1]
381
-
382
- return_topics.append(ForumTopic(id=int(topic_id), title=title, category_name=category_name, last_updated=last_updated, reply_count=int(columns[1]), view_count=int(columns[2])))
383
- return return_topics
384
- except Exception as e:
385
- raise exceptions.ScrapeError(str(e))
386
-
387
-
388
- def youtube_link_to_scratch(link: str):
389
- """
390
- Converts a YouTube url (in multiple formats) like https://youtu.be/1JTgg4WVAX8?si=fIEskaEaOIRZyTAz
391
- to a link like https://scratch.mit.edu/discuss/youtube/1JTgg4WVAX8
392
- """
393
- url_parse = urlparse(link)
394
- query_parse = parse_qs(url_parse.query)
395
- if 'v' in query_parse:
396
- video_id = query_parse['v'][0]
397
- else:
398
- video_id = url_parse.path.split('/')[-1]
399
- return f"https://scratch.mit.edu/discuss/youtube/{video_id}"
1
+ """ForumTopic and ForumPost classes"""
2
+ from __future__ import annotations
3
+
4
+ from . import user
5
+ from ..utils.commons import headers
6
+ from ..utils import exceptions, commons
7
+ from ._base import BaseSiteComponent
8
+ import xml.etree.ElementTree as ET
9
+ from bs4 import BeautifulSoup
10
+ from urllib.parse import urlparse, parse_qs
11
+
12
+ from ..utils.requests import Requests as requests
13
+
14
+ class ForumTopic(BaseSiteComponent):
15
+ '''
16
+ Represents a Scratch forum topic.
17
+
18
+ Attributes:
19
+
20
+ :.id:
21
+
22
+ :.title:
23
+
24
+ :.category_name:
25
+
26
+ :.last_updated:
27
+
28
+ Attributes only available if the object was created using scratchattach.get_topic_list or scratchattach.Session.connect_topic_list:
29
+
30
+ :.reply_count:
31
+
32
+ :.view_count:
33
+
34
+ :.update(): Updates the attributes
35
+ '''
36
+
37
+ def __init__(self, **entries):
38
+ # Info on how the .update method has to fetch the data:
39
+ self.update_function = requests.get
40
+ self.update_API = f"https://scratch.mit.edu/discuss/feeds/topic/{entries['id']}/"
41
+
42
+ # Set attributes every Project object needs to have:
43
+ self._session = None
44
+ self.id = 0
45
+ self.reply_count = None
46
+ self.view_count = None
47
+
48
+ # Update attributes from entries dict:
49
+ self.__dict__.update(entries)
50
+
51
+ # Headers and cookies:
52
+ if self._session is None:
53
+ self._headers = headers
54
+ self._cookies = {}
55
+ else:
56
+ self._headers = self._session._headers
57
+ self._cookies = self._session._cookies
58
+
59
+ # Headers for operations that require accept and Content-Type fields:
60
+ self._json_headers = dict(self._headers)
61
+ self._json_headers["accept"] = "application/json"
62
+ self._json_headers["Content-Type"] = "application/json"
63
+
64
+ def update(self):
65
+ # As there is no JSON API for getting forum topics anymore,
66
+ # the data has to be retrieved from the XML feed.
67
+ response = self.update_function(
68
+ self.update_API,
69
+ headers = self._headers,
70
+ cookies = self._cookies, timeout=20 # fetching forums can take very long
71
+ )
72
+ # Check for 429 error:
73
+ if "429" in str(response):
74
+ return "429"
75
+
76
+ # Parse XML response
77
+ if response.status_code == 200:
78
+ try:
79
+ root = ET.fromstring(response.text)
80
+ namespace = {'atom': 'http://www.w3.org/2005/Atom'}
81
+
82
+ title = root.findtext('atom:title', namespaces=namespace).replace("Latest posts on ","")
83
+ category_name = root.findall('.//atom:entry', namespaces=namespace)[0].findtext('.//atom:title', namespaces=namespace).split(" :: ")[1]
84
+ last_updated = root.findtext('atom:updated', namespaces=namespace)
85
+
86
+ except Exception as e:
87
+ raise exceptions.ScrapeError(str(e))
88
+ else:
89
+ raise exceptions.ForumContentNotFound
90
+
91
+ return self._update_from_dict(dict(
92
+ title = title, category_name = category_name, last_updated = last_updated
93
+ ))
94
+
95
+
96
+ def _update_from_dict(self, data):
97
+ self.__dict__.update(data)
98
+ return True
99
+
100
+ def posts(self, *, page=1, order="oldest"):
101
+ """
102
+ Args:
103
+ page (int): The page of the forum topic that should be returned. First page is at index 1.
104
+
105
+ Returns:
106
+ list<scratchattach.forum.ForumPost>: A list containing the posts from the specified page of the forum topic
107
+ """
108
+ if order != "oldest":
109
+ print("Warning: All post orders except for 'oldest' are deprecated and no longer work") # For backwards compatibility
110
+
111
+ posts = []
112
+
113
+ try:
114
+ url = f"https://scratch.mit.edu/discuss/topic/{self.id}/?page={page}"
115
+ response = requests.get(url, headers=headers, cookies=self._cookies)
116
+ except Exception as e:
117
+ raise exceptions.FetchError(str(e))
118
+ try:
119
+ soup = BeautifulSoup(response.content, 'html.parser')
120
+ soup = soup.find("div", class_="djangobb")
121
+
122
+ try:
123
+ pagination_div = soup.find('div', class_='pagination')
124
+ num_pages = int(pagination_div.find_all('a', class_='page')[-1].text)
125
+ except Exception:
126
+ num_pages = 1
127
+
128
+ try:
129
+ # get topic category:
130
+ topic_category = ""
131
+ breadcrumb_ul = soup.find_all('ul')[1] # Find the second ul element
132
+ if breadcrumb_ul:
133
+ link = breadcrumb_ul.find_all('a')[1] # Get the right anchor tag
134
+ topic_category = link.text.strip() # Extract and strip text content
135
+ except Exception as e:
136
+ print(f"Warning: Couldn't scrape topic category for topic {self.id} - {e}")
137
+ topic_category = ""
138
+
139
+ # get corresponding posts:
140
+ post_htmls = soup.find_all('div', class_='blockpost')
141
+ for raw_post in post_htmls:
142
+ post = ForumPost(id=int(raw_post['id'].replace("p", "")), topic_id=self.id, _session=self._session, topic_category=topic_category, topic_num_pages=num_pages)
143
+ post._update_from_html(raw_post)
144
+
145
+ posts.append(post)
146
+ except Exception as e:
147
+ raise exceptions.ScrapeError(str(e))
148
+
149
+ return posts
150
+
151
+ def first_post(self):
152
+ """
153
+ Returns:
154
+ scratchattach.forum.ForumPost: An object representing the first topic post
155
+ """
156
+ posts = self.posts(page=1)
157
+ if len(posts) > 0:
158
+ return posts[0]
159
+
160
+
161
+ class ForumPost(BaseSiteComponent):
162
+ '''
163
+ Represents a Scratch forum post.
164
+
165
+ Attributes:
166
+
167
+ :.id:
168
+
169
+ :.author_name: The name of the user who created this post
170
+
171
+ :.author_avatar_url:
172
+
173
+ :.posted: The date the post was made
174
+
175
+ :.topic_id: The id of the topic this post is in
176
+
177
+ :.topic_name: The name of the topic the post is in
178
+
179
+ :.topic_category: The name of the category the post topic is in
180
+
181
+ :.topic_num_pages: The number of pages the post topic has
182
+
183
+ :.deleted: Whether the post was deleted (always False because deleted posts can't be retrieved anymore)
184
+
185
+ :.html_content: Returns the content as HTML
186
+
187
+ :.content: Returns the content as text
188
+
189
+ :.post_index: The index that the post has in the topic
190
+
191
+ :.update(): Updates the attributes
192
+ '''
193
+
194
+ def __init__(self, **entries):
195
+
196
+ # A forum post can't be updated the usual way as there is no API anymore
197
+ self.update_function = None
198
+ self.update_API = None
199
+
200
+ # Set attributes every Project object needs to have:
201
+ self._session = None
202
+ self.id = 0
203
+ self.topic_id = 0
204
+ self.deleted = False
205
+
206
+ # Update attributes from entries dict:
207
+ self.__dict__.update(entries)
208
+
209
+ # Headers and cookies:
210
+ if self._session is None:
211
+ self._headers = headers
212
+ self._cookies = {}
213
+ else:
214
+ self._headers = self._session._headers
215
+ self._cookies = self._session._cookies
216
+
217
+ # Headers for operations that require accept and Content-Type fields:
218
+ self._json_headers = dict(self._headers)
219
+ self._json_headers["accept"] = "application/json"
220
+ self._json_headers["Content-Type"] = "application/json"
221
+
222
+ def update(self):
223
+ """
224
+ Updates the attributes of the ForumPost object.
225
+ As there is no API for retrieving a single post anymore, this requires reloading the forum page.
226
+ """
227
+ page = 1
228
+ posts = ForumTopic(id=self.topic_id, _session=self._session).posts(page=1)
229
+ while posts != []:
230
+ matching = list(filter(lambda x : int(x.id) == int(self.id), posts))
231
+ if len(matching) > 0:
232
+ this = matching[0]
233
+ break
234
+ page += 1
235
+ posts = ForumTopic(id=self.topic_id, _session=self._session).posts(page=page)
236
+ else:
237
+ return False
238
+
239
+ return self._update_from_dict(this.__dict__)
240
+
241
+ def _update_from_dict(self, data):
242
+ self.__dict__.update(data)
243
+ return True
244
+
245
+ def _update_from_html(self, soup_html):
246
+ self.post_index = int(soup_html.find('span', class_='conr').text.strip('#'))
247
+ self.id = int(soup_html['id'].replace("p", ""))
248
+ self.posted = soup_html.find('a', href=True).text.strip()
249
+ self.content = soup_html.find('div', class_='post_body_html').text.strip()
250
+ self.html_content = str(soup_html.find('div', class_='post_body_html'))
251
+ self.author_name = soup_html.find('dl').find('dt').find('a').text.strip()
252
+ self.author_avatar_url = soup_html.find('dl').find('dt').find('a')['href']
253
+ self.topic_name = soup_html.find('h3').text.strip()
254
+ return True
255
+
256
+ def topic(self):
257
+ """
258
+ Returns:
259
+ scratchattach.forum.ForumTopic: An object representing the forum topic this post is in.
260
+ """
261
+ return self._make_linked_object("id", self.topic_id, ForumTopic, exceptions.ForumContentNotFound)
262
+
263
+ def ocular_reactions(self):
264
+ return requests.get(f"https://my-ocular.jeffalo.net/api/reactions/{self.id}", timeout=10).json()
265
+
266
+ def author(self):
267
+ """
268
+ Returns:
269
+ scratchattach.user.User: An object representing the user who created this forum post.
270
+ """
271
+ return self._make_linked_object("username", self.author_name, user.User, exceptions.UserNotFound)
272
+
273
+ def edit(self, new_content):
274
+ """
275
+ Changes the content of the forum post. You can only use this function if this object was created using :meth:`scratchattach.session.Session.connect_post` or through another method that requires authentication. You must own the forum post.
276
+
277
+ Args:
278
+ new_content (str): The text that the forum post will be set to.
279
+ """
280
+
281
+ self._assert_auth()
282
+
283
+ cookies = dict(self._cookies)
284
+ cookies["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.9"
285
+ cookies["Content-Type"] = "application/x-www-form-urlencoded"
286
+
287
+ r = requests.post(
288
+ f"https://scratch.mit.edu/discuss/post/{self.id}/edit/",
289
+ headers = {
290
+ "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.9",
291
+ "accept-language": "de,en;q=0.9",
292
+ "cache-control": "max-age=0",
293
+ "content-type": "application/x-www-form-urlencoded",
294
+ "sec-ch-ua": "\" Not A;Brand\";v=\"99\", \"Chromium\";v=\"101\", \"Google Chrome\";v=\"101\"",
295
+ "sec-ch-ua-mobile": "?0",
296
+ "sec-ch-ua-platform": "\"Windows\"",
297
+ "sec-fetch-dest": "document",
298
+ "sec-fetch-mode": "navigate",
299
+ "sec-fetch-site": "same-origin",
300
+ "sec-fetch-user": "?1",
301
+ "upgrade-insecure-requests": "1",
302
+ "Referer": f"https://scratch.mit.edu/discuss/post/{self.id}/edit/",
303
+ "x-csrftoken": "a"
304
+ },
305
+ cookies = cookies,
306
+ json = f"csrfmiddlewaretoken=a&body={new_content}&",
307
+ timeout = 10,
308
+ )
309
+
310
+
311
+ def get_topic(topic_id) -> ForumTopic:
312
+
313
+ """
314
+ Gets a forum topic without logging in. Data received from Scratch's RSS feed XML API.
315
+
316
+ Args:
317
+ topic_id (int): ID of the requested forum topic
318
+
319
+ Returns:
320
+ scratchattach.forum.ForumTopic: An object representing the requested forum topic
321
+
322
+ Warning:
323
+ Scratch's API uses very heavy caching for logged out users, therefore the returned data will not be up to date.
324
+
325
+ Any methods that require authentication will not work on the returned object.
326
+
327
+ If you need up-to-date data or want to use methods that require authentication, create the object with :meth:`scratchattach.session.Session.connect_topic` instead.
328
+ """
329
+ return commons._get_object("id", topic_id, ForumTopic, exceptions.ForumContentNotFound)
330
+
331
+
332
+ def get_topic_list(category_id, *, page=1):
333
+
334
+ """
335
+ Gets the topics from a forum category without logging in. Data web-scraped from Scratch's forums UI.
336
+
337
+ Args:
338
+ category_id (str): ID of the forum category
339
+
340
+ Keyword Arguments:
341
+ page (str): Page of the category topics that should be returned
342
+
343
+ Returns:
344
+ list<scratchattach.forum.ForumTopic>: A list containing the forum topics from the specified category
345
+
346
+ Warning:
347
+ Scratch's API uses very heavy caching for logged out users, therefore the returned data will not be up to date.
348
+
349
+ Any methods that require authentication will not work on the returned objects.
350
+
351
+ If you need up-to-date data or want to use methods that require authentication, get the forum topics with :meth:`scratchattach.session.Session.connect_topic_list` instead.
352
+ """
353
+
354
+ try:
355
+ response = requests.get(f"https://scratch.mit.edu/discuss/{category_id}/?page={page}")
356
+ soup = BeautifulSoup(response.content, 'html.parser')
357
+ except Exception as e:
358
+ raise exceptions.FetchError(str(e))
359
+
360
+ try:
361
+ category_name = soup.find('h4').find("span").get_text()
362
+ except Exception as e:
363
+ raise exceptions.BadRequest("Invalid category id")
364
+
365
+ try:
366
+ topics = soup.find_all('tr')
367
+ topics.pop(0)
368
+ return_topics = []
369
+
370
+ for topic in topics:
371
+ title_link = topic.find('a')
372
+ title = title_link.text.strip()
373
+ topic_id = title_link['href'].split('/')[-2]
374
+
375
+ columns = topic.find_all('td')
376
+ columns = [column.text for column in columns]
377
+ if len(columns) == 1:
378
+ # This is a sticky topic -> Skip it
379
+ continue
380
+
381
+ last_updated = columns[3].split(" ")[0] + " " + columns[3].split(" ")[1]
382
+
383
+ return_topics.append(ForumTopic(id=int(topic_id), title=title, category_name=category_name, last_updated=last_updated, reply_count=int(columns[1]), view_count=int(columns[2])))
384
+ return return_topics
385
+ except Exception as e:
386
+ raise exceptions.ScrapeError(str(e))
387
+
388
+
389
+ def youtube_link_to_scratch(link: str):
390
+ """
391
+ Converts a YouTube url (in multiple formats) like https://youtu.be/1JTgg4WVAX8?si=fIEskaEaOIRZyTAz
392
+ to a link like https://scratch.mit.edu/discuss/youtube/1JTgg4WVAX8
393
+ """
394
+ url_parse = urlparse(link)
395
+ query_parse = parse_qs(url_parse.query)
396
+ if 'v' in query_parse:
397
+ video_id = query_parse['v'][0]
398
+ else:
399
+ video_id = url_parse.path.split('/')[-1]
400
+ return f"https://scratch.mit.edu/discuss/youtube/{video_id}"