scratchattach 2.1.12__py3-none-any.whl → 2.1.14__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scratchattach/cloud/_base.py +12 -8
- scratchattach/cloud/cloud.py +19 -7
- scratchattach/editor/asset.py +59 -5
- scratchattach/editor/base.py +82 -31
- scratchattach/editor/block.py +87 -15
- scratchattach/editor/blockshape.py +8 -4
- scratchattach/editor/build_defaulting.py +6 -2
- scratchattach/editor/code_translation/__init__.py +0 -0
- scratchattach/editor/code_translation/parse.py +177 -0
- scratchattach/editor/comment.py +6 -0
- scratchattach/editor/commons.py +82 -19
- scratchattach/editor/extension.py +10 -3
- scratchattach/editor/field.py +9 -0
- scratchattach/editor/inputs.py +4 -1
- scratchattach/editor/meta.py +11 -3
- scratchattach/editor/monitor.py +46 -38
- scratchattach/editor/mutation.py +11 -4
- scratchattach/editor/pallete.py +24 -25
- scratchattach/editor/prim.py +2 -2
- scratchattach/editor/project.py +9 -3
- scratchattach/editor/sprite.py +19 -6
- scratchattach/editor/twconfig.py +2 -1
- scratchattach/editor/vlb.py +1 -1
- scratchattach/eventhandlers/_base.py +3 -3
- scratchattach/eventhandlers/cloud_events.py +2 -2
- scratchattach/eventhandlers/cloud_requests.py +4 -7
- scratchattach/eventhandlers/cloud_server.py +3 -3
- scratchattach/eventhandlers/combine.py +2 -2
- scratchattach/eventhandlers/message_events.py +1 -1
- scratchattach/other/other_apis.py +4 -4
- scratchattach/other/project_json_capabilities.py +3 -3
- scratchattach/site/_base.py +13 -12
- scratchattach/site/activity.py +11 -43
- scratchattach/site/alert.py +227 -0
- scratchattach/site/backpack_asset.py +2 -2
- scratchattach/site/browser_cookie3_stub.py +17 -0
- scratchattach/site/browser_cookies.py +27 -21
- scratchattach/site/classroom.py +51 -34
- scratchattach/site/cloud_activity.py +4 -4
- scratchattach/site/comment.py +30 -8
- scratchattach/site/forum.py +101 -69
- scratchattach/site/project.py +37 -17
- scratchattach/site/session.py +177 -83
- scratchattach/site/studio.py +4 -4
- scratchattach/site/user.py +184 -62
- scratchattach/utils/commons.py +35 -23
- scratchattach/utils/enums.py +44 -5
- scratchattach/utils/exceptions.py +10 -0
- scratchattach/utils/requests.py +57 -31
- {scratchattach-2.1.12.dist-info → scratchattach-2.1.14.dist-info}/METADATA +9 -3
- scratchattach-2.1.14.dist-info/RECORD +66 -0
- {scratchattach-2.1.12.dist-info → scratchattach-2.1.14.dist-info}/WHEEL +1 -1
- scratchattach/editor/sbuild.py +0 -2837
- scratchattach-2.1.12.dist-info/RECORD +0 -63
- {scratchattach-2.1.12.dist-info → scratchattach-2.1.14.dist-info}/licenses/LICENSE +0 -0
- {scratchattach-2.1.12.dist-info → scratchattach-2.1.14.dist-info}/top_level.txt +0 -0
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
from typing import Optional
|
|
1
|
+
from typing import Optional, TYPE_CHECKING
|
|
2
|
+
from typing_extensions import assert_never
|
|
2
3
|
from http.cookiejar import CookieJar
|
|
3
4
|
from enum import Enum, auto
|
|
4
5
|
browsercookie_err = None
|
|
5
6
|
try:
|
|
6
|
-
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from . import browser_cookie3_stub as browser_cookie3
|
|
9
|
+
else:
|
|
10
|
+
import browser_cookie3
|
|
7
11
|
except Exception as e:
|
|
8
12
|
browsercookie = None
|
|
9
13
|
browsercookie_err = e
|
|
@@ -15,8 +19,8 @@ class Browser(Enum):
|
|
|
15
19
|
EDGE = auto()
|
|
16
20
|
SAFARI = auto()
|
|
17
21
|
CHROMIUM = auto()
|
|
18
|
-
EDGE_DEV = auto()
|
|
19
22
|
VIVALDI = auto()
|
|
23
|
+
EDGE_DEV = auto()
|
|
20
24
|
|
|
21
25
|
|
|
22
26
|
FIREFOX = Browser.FIREFOX
|
|
@@ -24,32 +28,34 @@ CHROME = Browser.CHROME
|
|
|
24
28
|
EDGE = Browser.EDGE
|
|
25
29
|
SAFARI = Browser.SAFARI
|
|
26
30
|
CHROMIUM = Browser.CHROMIUM
|
|
27
|
-
EDGE_DEV = Browser.EDGE_DEV
|
|
28
31
|
VIVALDI = Browser.VIVALDI
|
|
29
32
|
ANY = Browser.ANY
|
|
33
|
+
EDGE_DEV = Browser.EDGE_DEV
|
|
30
34
|
|
|
31
35
|
def cookies_from_browser(browser : Browser = ANY) -> dict[str, str]:
|
|
32
36
|
"""
|
|
33
37
|
Import cookies from browser to login
|
|
34
38
|
"""
|
|
35
|
-
if not
|
|
39
|
+
if not browser_cookie3:
|
|
36
40
|
raise browsercookie_err or ModuleNotFoundError()
|
|
37
41
|
cookies : Optional[CookieJar] = None
|
|
38
|
-
if browser
|
|
39
|
-
cookies =
|
|
40
|
-
elif browser
|
|
41
|
-
cookies =
|
|
42
|
-
elif browser
|
|
43
|
-
cookies =
|
|
44
|
-
elif browser
|
|
45
|
-
cookies =
|
|
46
|
-
elif browser
|
|
47
|
-
cookies =
|
|
48
|
-
elif browser
|
|
49
|
-
cookies =
|
|
50
|
-
elif browser
|
|
51
|
-
cookies =
|
|
52
|
-
elif browser
|
|
53
|
-
|
|
42
|
+
if browser is Browser.ANY:
|
|
43
|
+
cookies = browser_cookie3.load()
|
|
44
|
+
elif browser is Browser.FIREFOX:
|
|
45
|
+
cookies = browser_cookie3.firefox()
|
|
46
|
+
elif browser is Browser.CHROME:
|
|
47
|
+
cookies = browser_cookie3.chrome()
|
|
48
|
+
elif browser is Browser.EDGE:
|
|
49
|
+
cookies = browser_cookie3.edge()
|
|
50
|
+
elif browser is Browser.SAFARI:
|
|
51
|
+
cookies = browser_cookie3.safari()
|
|
52
|
+
elif browser is Browser.CHROMIUM:
|
|
53
|
+
cookies = browser_cookie3.chromium()
|
|
54
|
+
elif browser is Browser.VIVALDI:
|
|
55
|
+
cookies = browser_cookie3.vivaldi()
|
|
56
|
+
elif browser is Browser.EDGE_DEV:
|
|
57
|
+
raise ValueError("EDGE_DEV is not supported anymore.")
|
|
58
|
+
else:
|
|
59
|
+
assert_never(browser)
|
|
54
60
|
assert isinstance(cookies, CookieJar)
|
|
55
61
|
return {cookie.name: cookie.value for cookie in cookies if "scratch.mit.edu" in cookie.domain and cookie.value}
|
scratchattach/site/classroom.py
CHANGED
|
@@ -1,58 +1,67 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import datetime
|
|
4
|
+
import json
|
|
4
5
|
import warnings
|
|
5
|
-
from
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Optional, TYPE_CHECKING, Any, Callable
|
|
6
9
|
|
|
7
10
|
import bs4
|
|
11
|
+
from bs4 import BeautifulSoup
|
|
8
12
|
|
|
9
13
|
if TYPE_CHECKING:
|
|
10
|
-
from
|
|
14
|
+
from scratchattach.site.session import Session
|
|
11
15
|
|
|
12
|
-
from
|
|
16
|
+
from scratchattach.utils.commons import requests
|
|
13
17
|
from . import user, activity
|
|
14
18
|
from ._base import BaseSiteComponent
|
|
15
|
-
from
|
|
16
|
-
from
|
|
17
|
-
|
|
18
|
-
from bs4 import BeautifulSoup
|
|
19
|
+
from scratchattach.utils import exceptions, commons
|
|
20
|
+
from scratchattach.utils.commons import headers
|
|
19
21
|
|
|
20
22
|
|
|
23
|
+
@dataclass
|
|
21
24
|
class Classroom(BaseSiteComponent):
|
|
22
|
-
|
|
25
|
+
title: str = None
|
|
26
|
+
id: int = None
|
|
27
|
+
classtoken: str = None
|
|
28
|
+
|
|
29
|
+
author: user.User = None
|
|
30
|
+
about_class: str = None
|
|
31
|
+
working_on: str = None
|
|
32
|
+
|
|
33
|
+
is_closed: bool = False
|
|
34
|
+
datetime: datetime = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
update_function: Callable = field(repr=False, default=requests.get)
|
|
38
|
+
_session: Optional[Session] = field(repr=False, default=None)
|
|
39
|
+
|
|
40
|
+
def __post_init__(self):
|
|
23
41
|
# Info on how the .update method has to fetch the data:
|
|
24
42
|
# NOTE: THIS DOESN'T WORK WITH CLOSED CLASSES!
|
|
25
|
-
self.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
self.update_API = f"https://api.scratch.mit.edu/classtoken/{entries['classtoken']}"
|
|
43
|
+
if self.id:
|
|
44
|
+
self.update_api = f"https://api.scratch.mit.edu/classrooms/{self.id}"
|
|
45
|
+
elif self.classtoken:
|
|
46
|
+
self.update_api = f"https://api.scratch.mit.edu/classtoken/{self.classtoken}"
|
|
30
47
|
else:
|
|
31
|
-
raise KeyError(f"No class id or token provided!
|
|
32
|
-
|
|
33
|
-
# Set attributes every Project object needs to have:
|
|
34
|
-
self._session: Session = None
|
|
35
|
-
self.id = None
|
|
36
|
-
self.classtoken = None
|
|
37
|
-
|
|
38
|
-
self.__dict__.update(entries)
|
|
48
|
+
raise KeyError(f"No class id or token provided! {self.__dict__ = }")
|
|
39
49
|
|
|
40
50
|
# Headers and cookies:
|
|
41
51
|
if self._session is None:
|
|
42
|
-
self._headers = headers
|
|
52
|
+
self._headers = commons.headers
|
|
43
53
|
self._cookies = {}
|
|
44
54
|
else:
|
|
45
55
|
self._headers = self._session._headers
|
|
46
56
|
self._cookies = self._session._cookies
|
|
47
57
|
|
|
48
58
|
# Headers for operations that require accept and Content-Type fields:
|
|
49
|
-
self._json_headers =
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
self.is_closed = False
|
|
59
|
+
self._json_headers = {**self._headers,
|
|
60
|
+
"accept": "application/json",
|
|
61
|
+
"Content-Type": "application/json"}
|
|
53
62
|
|
|
54
|
-
def
|
|
55
|
-
return f"
|
|
63
|
+
def __str__(self) -> str:
|
|
64
|
+
return f"<Classroom {self.title!r}, id={self.id!r}>"
|
|
56
65
|
|
|
57
66
|
def update(self):
|
|
58
67
|
try:
|
|
@@ -305,7 +314,8 @@ class Classroom(BaseSiteComponent):
|
|
|
305
314
|
warnings.warn(f"{self._session} may not be authenticated to edit {self}")
|
|
306
315
|
raise e
|
|
307
316
|
|
|
308
|
-
def register_student(self, username: str, password: str = '', birth_month: Optional[int] = None,
|
|
317
|
+
def register_student(self, username: str, password: str = '', birth_month: Optional[int] = None,
|
|
318
|
+
birth_year: Optional[int] = None,
|
|
309
319
|
gender: Optional[str] = None, country: Optional[str] = None, is_robot: bool = False) -> None:
|
|
310
320
|
return register_by_token(self.id, self.classtoken, username, password, birth_month, birth_year, gender, country,
|
|
311
321
|
is_robot)
|
|
@@ -346,7 +356,8 @@ class Classroom(BaseSiteComponent):
|
|
|
346
356
|
|
|
347
357
|
return activities
|
|
348
358
|
|
|
349
|
-
def activity(self, student: str = "all", mode: str = "Last created", page: Optional[int] = None) -> list[
|
|
359
|
+
def activity(self, student: str = "all", mode: str = "Last created", page: Optional[int] = None) -> list[
|
|
360
|
+
dict[str, Any]]:
|
|
350
361
|
"""
|
|
351
362
|
Get a list of private activity, only available to the class owner.
|
|
352
363
|
Returns:
|
|
@@ -357,9 +368,15 @@ class Classroom(BaseSiteComponent):
|
|
|
357
368
|
|
|
358
369
|
ascsort, descsort = commons.get_class_sort_mode(mode)
|
|
359
370
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
371
|
+
with requests.no_error_handling():
|
|
372
|
+
try:
|
|
373
|
+
data = requests.get(
|
|
374
|
+
f"https://scratch.mit.edu/site-api/classrooms/activity/{self.id}/{student}/",
|
|
375
|
+
params={"page": page, "ascsort": ascsort, "descsort": descsort},
|
|
376
|
+
headers=self._headers, cookies=self._cookies
|
|
377
|
+
).json()
|
|
378
|
+
except json.JSONDecodeError:
|
|
379
|
+
return []
|
|
363
380
|
|
|
364
381
|
_activity = []
|
|
365
382
|
for activity_json in data:
|
|
@@ -421,7 +438,7 @@ def register_by_token(class_id: int, class_token: str, username: str, password:
|
|
|
421
438
|
"is_robot": is_robot}
|
|
422
439
|
|
|
423
440
|
response = requests.post("https://scratch.mit.edu/classes/register_new_student/",
|
|
424
|
-
data=data, headers=headers, cookies={"scratchcsrftoken": 'a'})
|
|
441
|
+
data=data, headers=commons.headers, cookies={"scratchcsrftoken": 'a'})
|
|
425
442
|
ret = response.json()[0]
|
|
426
443
|
|
|
427
444
|
if "username" in ret:
|
|
@@ -91,8 +91,8 @@ class CloudActivity(BaseSiteComponent):
|
|
|
91
91
|
"""
|
|
92
92
|
if self.username is None:
|
|
93
93
|
return None
|
|
94
|
-
from
|
|
95
|
-
from
|
|
94
|
+
from scratchattach.site import user
|
|
95
|
+
from scratchattach.utils import exceptions
|
|
96
96
|
return self._make_linked_object("username", self.username, user.User, exceptions.UserNotFound)
|
|
97
97
|
|
|
98
98
|
def project(self):
|
|
@@ -101,7 +101,7 @@ class CloudActivity(BaseSiteComponent):
|
|
|
101
101
|
"""
|
|
102
102
|
if self.cloud is None:
|
|
103
103
|
return None
|
|
104
|
-
from
|
|
105
|
-
from
|
|
104
|
+
from scratchattach.site import project
|
|
105
|
+
from scratchattach.utils import exceptions
|
|
106
106
|
return self._make_linked_object("id", self.cloud.project_id, project.Project, exceptions.ProjectNotFound)
|
|
107
107
|
|
scratchattach/site/comment.py
CHANGED
|
@@ -1,17 +1,29 @@
|
|
|
1
1
|
"""Comment class"""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import html
|
|
5
|
+
from typing import Union, Optional, Any
|
|
6
|
+
from typing_extensions import assert_never # importing from typing caused me errors
|
|
7
|
+
from enum import Enum, auto
|
|
8
|
+
|
|
4
9
|
from . import user, project, studio
|
|
5
10
|
from ._base import BaseSiteComponent
|
|
6
|
-
from
|
|
7
|
-
|
|
11
|
+
from scratchattach.utils import exceptions
|
|
8
12
|
|
|
9
13
|
class Comment(BaseSiteComponent):
|
|
10
14
|
"""
|
|
11
15
|
Represents a Scratch comment (on a profile, studio or project)
|
|
12
16
|
"""
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
id: Union[int, str]
|
|
18
|
+
source: str
|
|
19
|
+
source_id: Union[int, str]
|
|
20
|
+
cached_replies: Optional[list[Comment]]
|
|
21
|
+
parent_id: Optional[Union[int, str]]
|
|
22
|
+
cached_parent_comment: Optional[Comment]
|
|
23
|
+
commentee_id: Optional[int]
|
|
24
|
+
content: Any
|
|
25
|
+
|
|
26
|
+
def __str__(self):
|
|
15
27
|
return str(self.content)
|
|
16
28
|
|
|
17
29
|
def __init__(self, **entries):
|
|
@@ -82,6 +94,12 @@ class Comment(BaseSiteComponent):
|
|
|
82
94
|
pass
|
|
83
95
|
return True
|
|
84
96
|
|
|
97
|
+
@property
|
|
98
|
+
def text(self) -> str:
|
|
99
|
+
if self.source == "profile":
|
|
100
|
+
return self.content
|
|
101
|
+
return str(html.unescape(self.content))
|
|
102
|
+
|
|
85
103
|
# Methods for getting related entities
|
|
86
104
|
|
|
87
105
|
def author(self) -> user.User:
|
|
@@ -95,10 +113,12 @@ class Comment(BaseSiteComponent):
|
|
|
95
113
|
"""
|
|
96
114
|
if self.source == "profile":
|
|
97
115
|
return self._make_linked_object("username", self.source_id, user.User, exceptions.UserNotFound)
|
|
98
|
-
|
|
116
|
+
elif self.source == "studio":
|
|
99
117
|
return self._make_linked_object("id", self.source_id, studio.Studio, exceptions.UserNotFound)
|
|
100
|
-
|
|
118
|
+
elif self.source == "project":
|
|
101
119
|
return self._make_linked_object("id", self.source_id, project.Project, exceptions.UserNotFound)
|
|
120
|
+
else:
|
|
121
|
+
raise ValueError("Unknown source.")
|
|
102
122
|
|
|
103
123
|
def parent_comment(self) -> Comment | None:
|
|
104
124
|
if self.parent_id is None:
|
|
@@ -129,8 +149,10 @@ class Comment(BaseSiteComponent):
|
|
|
129
149
|
"""
|
|
130
150
|
if (self.cached_replies is None) or (not use_cache):
|
|
131
151
|
if self.source == "profile":
|
|
132
|
-
|
|
133
|
-
self.id).cached_replies
|
|
152
|
+
_cached_replies = user.User(username=self.source_id, _session=self._session).comment_by_id(
|
|
153
|
+
self.id).cached_replies
|
|
154
|
+
if _cached_replies is not None:
|
|
155
|
+
self.cached_replies = _cached_replies[offset:offset + limit]
|
|
134
156
|
|
|
135
157
|
elif self.source == "project":
|
|
136
158
|
p = project.Project(id=self.source_id, _session=self._session)
|
scratchattach/site/forum.py
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
"""ForumTopic and ForumPost classes"""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from
|
|
5
|
-
from
|
|
6
|
-
from ..utils import exceptions, commons
|
|
7
|
-
from ._base import BaseSiteComponent
|
|
8
|
-
import xml.etree.ElementTree as ET
|
|
9
|
-
from bs4 import BeautifulSoup
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Optional, Any
|
|
10
6
|
from urllib.parse import urlparse, parse_qs
|
|
7
|
+
import xml.etree.ElementTree as ET
|
|
11
8
|
|
|
12
|
-
from
|
|
9
|
+
from bs4 import BeautifulSoup, Tag
|
|
10
|
+
|
|
11
|
+
from . import user
|
|
12
|
+
from . import session as module_session
|
|
13
|
+
from scratchattach.utils.commons import headers
|
|
14
|
+
from scratchattach.utils import exceptions, commons
|
|
15
|
+
from ._base import BaseSiteComponent
|
|
16
|
+
from scratchattach.utils.requests import requests
|
|
13
17
|
|
|
18
|
+
@dataclass
|
|
14
19
|
class ForumTopic(BaseSiteComponent):
|
|
15
20
|
'''
|
|
16
21
|
Represents a Scratch forum topic.
|
|
@@ -33,28 +38,26 @@ class ForumTopic(BaseSiteComponent):
|
|
|
33
38
|
|
|
34
39
|
:.update(): Updates the attributes
|
|
35
40
|
'''
|
|
36
|
-
|
|
37
|
-
|
|
41
|
+
id: int
|
|
42
|
+
title: str
|
|
43
|
+
category_name: str
|
|
44
|
+
last_updated: str
|
|
45
|
+
_session: Optional[module_session.Session] = field(default=None)
|
|
46
|
+
reply_count: Optional[int] = field(default=None)
|
|
47
|
+
view_count: Optional[int] = field(default=None)
|
|
48
|
+
|
|
49
|
+
def __post_init__(self):
|
|
38
50
|
# Info on how the .update method has to fetch the data:
|
|
39
51
|
self.update_function = requests.get
|
|
40
|
-
self.
|
|
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)
|
|
52
|
+
self.update_api = f"https://scratch.mit.edu/discuss/feeds/topic/{self.id}/"
|
|
50
53
|
|
|
51
54
|
# Headers and cookies:
|
|
52
55
|
if self._session is None:
|
|
53
56
|
self._headers = headers
|
|
54
57
|
self._cookies = {}
|
|
55
58
|
else:
|
|
56
|
-
self._headers = self._session.
|
|
57
|
-
self._cookies = self._session.
|
|
59
|
+
self._headers = self._session.get_headers()
|
|
60
|
+
self._cookies = self._session.get_cookies()
|
|
58
61
|
|
|
59
62
|
# Headers for operations that require accept and Content-Type fields:
|
|
60
63
|
self._json_headers = dict(self._headers)
|
|
@@ -65,7 +68,7 @@ class ForumTopic(BaseSiteComponent):
|
|
|
65
68
|
# As there is no JSON API for getting forum topics anymore,
|
|
66
69
|
# the data has to be retrieved from the XML feed.
|
|
67
70
|
response = self.update_function(
|
|
68
|
-
self.
|
|
71
|
+
self.update_api,
|
|
69
72
|
headers = self._headers,
|
|
70
73
|
cookies = self._cookies, timeout=20 # fetching forums can take very long
|
|
71
74
|
)
|
|
@@ -87,17 +90,22 @@ class ForumTopic(BaseSiteComponent):
|
|
|
87
90
|
raise exceptions.ScrapeError(str(e))
|
|
88
91
|
else:
|
|
89
92
|
raise exceptions.ForumContentNotFound
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
))
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def _update_from_dict(self, data):
|
|
97
|
-
self.__dict__.update(data)
|
|
93
|
+
self.title = title
|
|
94
|
+
self.category_name = category_name
|
|
95
|
+
self.last_updated = last_updated
|
|
98
96
|
return True
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def from_id(cls, __id: int, session: module_session.Session, update: bool = False):
|
|
100
|
+
new = cls(id=__id, _session=session, title="", last_updated="", category_name="")
|
|
101
|
+
if update:
|
|
102
|
+
new.update()
|
|
103
|
+
return new
|
|
104
|
+
|
|
105
|
+
def _update_from_dict(self, data: dict[str, Any]):
|
|
106
|
+
self.__dict__.update(data)
|
|
99
107
|
|
|
100
|
-
def posts(self, *, page=1, order="oldest"):
|
|
108
|
+
def posts(self, *, page=1, order="oldest") -> list[ForumPost]:
|
|
101
109
|
"""
|
|
102
110
|
Args:
|
|
103
111
|
page (int): The page of the forum topic that should be returned. First page is at index 1.
|
|
@@ -117,10 +125,11 @@ class ForumTopic(BaseSiteComponent):
|
|
|
117
125
|
raise exceptions.FetchError(str(e))
|
|
118
126
|
try:
|
|
119
127
|
soup = BeautifulSoup(response.content, 'html.parser')
|
|
120
|
-
|
|
121
|
-
|
|
128
|
+
soup_elm = soup.find("div", class_="djangobb")
|
|
129
|
+
assert isinstance(soup_elm, Tag)
|
|
122
130
|
try:
|
|
123
|
-
pagination_div =
|
|
131
|
+
pagination_div = soup_elm.find('div', class_='pagination')
|
|
132
|
+
assert isinstance(pagination_div, Tag)
|
|
124
133
|
num_pages = int(pagination_div.find_all('a', class_='page')[-1].text)
|
|
125
134
|
except Exception:
|
|
126
135
|
num_pages = 1
|
|
@@ -128,8 +137,9 @@ class ForumTopic(BaseSiteComponent):
|
|
|
128
137
|
try:
|
|
129
138
|
# get topic category:
|
|
130
139
|
topic_category = ""
|
|
131
|
-
breadcrumb_ul =
|
|
140
|
+
breadcrumb_ul = soup_elm.find_all('ul')[1] # Find the second ul element
|
|
132
141
|
if breadcrumb_ul:
|
|
142
|
+
assert isinstance(breadcrumb_ul, Tag)
|
|
133
143
|
link = breadcrumb_ul.find_all('a')[1] # Get the right anchor tag
|
|
134
144
|
topic_category = link.text.strip() # Extract and strip text content
|
|
135
145
|
except Exception as e:
|
|
@@ -139,12 +149,14 @@ class ForumTopic(BaseSiteComponent):
|
|
|
139
149
|
# get corresponding posts:
|
|
140
150
|
post_htmls = soup.find_all('div', class_='blockpost')
|
|
141
151
|
for raw_post in post_htmls:
|
|
142
|
-
|
|
143
|
-
|
|
152
|
+
if not isinstance(raw_post, Tag):
|
|
153
|
+
continue
|
|
154
|
+
post = ForumPost(id=int(str(raw_post['id']).replace("p", "")), topic_id=self.id, _session=self._session, topic_category=topic_category, topic_num_pages=num_pages)
|
|
155
|
+
post.update_from_html(raw_post)
|
|
144
156
|
|
|
145
157
|
posts.append(post)
|
|
146
158
|
except Exception as e:
|
|
147
|
-
raise exceptions.ScrapeError(
|
|
159
|
+
raise exceptions.ScrapeError() from e
|
|
148
160
|
|
|
149
161
|
return posts
|
|
150
162
|
|
|
@@ -157,7 +169,7 @@ class ForumTopic(BaseSiteComponent):
|
|
|
157
169
|
if len(posts) > 0:
|
|
158
170
|
return posts[0]
|
|
159
171
|
|
|
160
|
-
|
|
172
|
+
@dataclass
|
|
161
173
|
class ForumPost(BaseSiteComponent):
|
|
162
174
|
'''
|
|
163
175
|
Represents a Scratch forum post.
|
|
@@ -190,34 +202,39 @@ class ForumPost(BaseSiteComponent):
|
|
|
190
202
|
|
|
191
203
|
:.update(): Updates the attributes
|
|
192
204
|
'''
|
|
193
|
-
|
|
194
|
-
|
|
205
|
+
id: int = field(default=0)
|
|
206
|
+
topic_id: int = field(default=0)
|
|
207
|
+
topic_name: str = field(default="")
|
|
208
|
+
topic_category: str = field(default="")
|
|
209
|
+
topic_num_pages: int = field(default=0)
|
|
210
|
+
author_name: str = field(default="")
|
|
211
|
+
author_avatar_url: str = field(default="")
|
|
212
|
+
posted: str = field(default="")
|
|
213
|
+
deleted: bool = field(default=False)
|
|
214
|
+
html_content: str = field(default="")
|
|
215
|
+
content: str = field(default="")
|
|
216
|
+
post_index: int = field(default=0)
|
|
217
|
+
_session: Optional[module_session.Session] = field(default=None)
|
|
218
|
+
def __post_init__(self):
|
|
195
219
|
|
|
196
220
|
# A forum post can't be updated the usual way as there is no API anymore
|
|
197
|
-
self.
|
|
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)
|
|
221
|
+
self.update_api = ""
|
|
208
222
|
|
|
209
223
|
# Headers and cookies:
|
|
210
224
|
if self._session is None:
|
|
211
225
|
self._headers = headers
|
|
212
226
|
self._cookies = {}
|
|
213
227
|
else:
|
|
214
|
-
self._headers = self._session.
|
|
215
|
-
self._cookies = self._session.
|
|
228
|
+
self._headers = self._session.get_headers()
|
|
229
|
+
self._cookies = self._session.get_cookies()
|
|
216
230
|
|
|
217
231
|
# Headers for operations that require accept and Content-Type fields:
|
|
218
232
|
self._json_headers = dict(self._headers)
|
|
219
233
|
self._json_headers["accept"] = "application/json"
|
|
220
234
|
self._json_headers["Content-Type"] = "application/json"
|
|
235
|
+
|
|
236
|
+
def update_function(self, *args, **kwargs):
|
|
237
|
+
raise TypeError("Forum posts cannot be updated like this")
|
|
221
238
|
|
|
222
239
|
def update(self):
|
|
223
240
|
"""
|
|
@@ -225,32 +242,47 @@ class ForumPost(BaseSiteComponent):
|
|
|
225
242
|
As there is no API for retrieving a single post anymore, this requires reloading the forum page.
|
|
226
243
|
"""
|
|
227
244
|
page = 1
|
|
228
|
-
posts = ForumTopic(
|
|
245
|
+
posts = ForumTopic.from_id(self.topic_id, session=self._session).posts(page=1)
|
|
229
246
|
while posts != []:
|
|
230
247
|
matching = list(filter(lambda x : int(x.id) == int(self.id), posts))
|
|
231
248
|
if len(matching) > 0:
|
|
232
249
|
this = matching[0]
|
|
233
250
|
break
|
|
234
251
|
page += 1
|
|
235
|
-
posts = ForumTopic(
|
|
252
|
+
posts = ForumTopic.from_id(self.topic_id, session=self._session).posts(page=page)
|
|
236
253
|
else:
|
|
237
254
|
return False
|
|
238
|
-
|
|
239
|
-
return self._update_from_dict(this.__dict__)
|
|
255
|
+
self._update_from_dict(vars(this))
|
|
240
256
|
|
|
241
|
-
def _update_from_dict(self, data):
|
|
257
|
+
def _update_from_dict(self, data: dict[str, Any]):
|
|
242
258
|
self.__dict__.update(data)
|
|
243
259
|
return True
|
|
244
|
-
|
|
245
|
-
def
|
|
246
|
-
self.
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
260
|
+
|
|
261
|
+
def update_from_html(self, soup_html: Tag):
|
|
262
|
+
return self._update_from_html(soup_html)
|
|
263
|
+
|
|
264
|
+
def _update_from_html(self, soup_html: Tag):
|
|
265
|
+
post_index_elm = soup_html.find('span', class_='conr')
|
|
266
|
+
assert isinstance(post_index_elm, Tag)
|
|
267
|
+
id_attr = soup_html['id']
|
|
268
|
+
assert isinstance(id_attr, str)
|
|
269
|
+
posted_elm = soup_html.find('a', href=True)
|
|
270
|
+
assert isinstance(posted_elm, Tag)
|
|
271
|
+
content_elm = soup_html.find('div', class_='post_body_html')
|
|
272
|
+
assert isinstance(content_elm, Tag)
|
|
273
|
+
author_name_elm = soup_html.select_one('dl dt a')
|
|
274
|
+
assert isinstance(author_name_elm, Tag)
|
|
275
|
+
topic_name_elm = soup_html.find('h3')
|
|
276
|
+
assert isinstance(topic_name_elm, Tag)
|
|
277
|
+
|
|
278
|
+
self.post_index = int(post_index_elm.text.strip('#'))
|
|
279
|
+
self.id = int(id_attr.replace("p", ""))
|
|
280
|
+
self.posted = posted_elm.text.strip()
|
|
281
|
+
self.content = content_elm.text.strip()
|
|
250
282
|
self.html_content = str(soup_html.find('div', class_='post_body_html'))
|
|
251
|
-
self.author_name =
|
|
252
|
-
self.author_avatar_url =
|
|
253
|
-
self.topic_name =
|
|
283
|
+
self.author_name = author_name_elm.text.strip()
|
|
284
|
+
self.author_avatar_url = str(author_name_elm['href'])
|
|
285
|
+
self.topic_name = topic_name_elm.text.strip()
|
|
254
286
|
return True
|
|
255
287
|
|
|
256
288
|
def topic(self):
|
|
@@ -270,7 +302,7 @@ class ForumPost(BaseSiteComponent):
|
|
|
270
302
|
"""
|
|
271
303
|
return self._make_linked_object("username", self.author_name, user.User, exceptions.UserNotFound)
|
|
272
304
|
|
|
273
|
-
def edit(self, new_content):
|
|
305
|
+
def edit(self, new_content: str):
|
|
274
306
|
"""
|
|
275
307
|
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
308
|
|