├── .gitignore
├── setup.cfg
├── scratchclient
├── __init__.py
├── Activity.py
├── News.py
├── Message.py
├── ScratchExceptions.py
├── Backpack.py
├── util.py
├── Incomplete.py
├── UserProfile.py
├── User.py
├── Comment.py
├── Forums.py
├── __main__.py
├── Websocket.py
├── Studio.py
├── Project.py
└── CloudConnection.py
├── docs
├── assets
│ ├── session-id.png
│ └── lightbulb.svg
├── reference
│ ├── CloudVariable.md
│ ├── IncompleteStudio.md
│ ├── ForumPost.md
│ ├── News.md
│ ├── IncompleteUser.md
│ ├── BackpackItem.md
│ ├── ProfileComment.md
│ ├── IncompleteProject.md
│ ├── RemixtreeProject.md
│ ├── ForumSession.md
│ ├── ProjectComment.md
│ ├── StudioComment.md
│ ├── UserProfile.md
│ ├── ScrapingSession.md
│ ├── Activity.md
│ ├── Message.md
│ ├── CloudConnection.md
│ ├── AsyncCloudConnection.md
│ ├── User.md
│ ├── Studio.md
│ └── Project.md
├── stylesheets
│ └── extra.css
├── examples
│ ├── basic-usage.md
│ ├── simultaneous-connections.md
│ └── stats-viewer.md
├── replit.md
└── index.md
├── .github
└── workflows
│ └── build-docs.yml
├── LICENSE.txt
├── setup.py
├── README.md
├── mkdocs.yml
└── test
└── test.py
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__/
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [metadata]
2 | description-file = README.md
--------------------------------------------------------------------------------
/scratchclient/__init__.py:
--------------------------------------------------------------------------------
1 | from .ScratchSession import ScratchSession
2 |
--------------------------------------------------------------------------------
/docs/assets/session-id.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CubeyTheCube/scratchclient/HEAD/docs/assets/session-id.png
--------------------------------------------------------------------------------
/scratchclient/Activity.py:
--------------------------------------------------------------------------------
1 | class Activity:
2 | def __init__(self, data):
3 | data["type"] = data.pop("type")
4 | data["created_timestamp"] = data.pop("datetime_created")
5 | data["actor"] = data.pop("actor_username")
6 | self.__dict__.update(data)
7 |
--------------------------------------------------------------------------------
/docs/reference/CloudVariable.md:
--------------------------------------------------------------------------------
1 | # **CloudVariable**
2 |
3 | ## Properties
4 |
5 | ###`#!python name : str` { #name data-toc-label="name" }
6 |
7 | The name of the cloud variable.
8 |
9 | ###`#!python value : str` { #value data-toc-label="value" }
10 |
11 | The value of the cloud variable.
12 |
--------------------------------------------------------------------------------
/scratchclient/News.py:
--------------------------------------------------------------------------------
1 | class News:
2 | def __init__(self, data):
3 | self.id = data["id"]
4 | self.timestamp = data["stamp"]
5 | self.title = data["headline"]
6 | self.description = data["copy"]
7 | self.image_URL = data["image"]
8 | self.src = data["url"]
9 |
--------------------------------------------------------------------------------
/scratchclient/Message.py:
--------------------------------------------------------------------------------
1 | class Message:
2 | def __init__(self, data):
3 | data["type"] = data.pop("type")
4 | data["created_timestamp"] = data.pop("datetime_created")
5 | data["actor"] = data.pop("actor_username")
6 | data["actor_id"] = data.pop("actor_id")
7 | self.__dict__.update(data)
8 |
--------------------------------------------------------------------------------
/scratchclient/ScratchExceptions.py:
--------------------------------------------------------------------------------
1 | class ScratchExceptions(Exception):
2 | pass
3 |
4 |
5 | class InvalidCredentialsException(ScratchExceptions):
6 | pass
7 |
8 |
9 | class UnauthorizedException(ScratchExceptions):
10 | pass
11 |
12 |
13 | class RejectedException(ScratchExceptions):
14 | pass
15 |
16 |
17 | class CloudVariableException(ScratchExceptions):
18 | pass
19 |
--------------------------------------------------------------------------------
/docs/reference/IncompleteStudio.md:
--------------------------------------------------------------------------------
1 | # **IncompleteStudio**
2 |
3 | A class that represents a studio with less data than a [Studio](../Studio) object.
4 |
5 | ## Properties
6 |
7 | ###`#!python id : int` { #id data-toc-label="id" }
8 |
9 | The ID of the studio.
10 |
11 | ###`#!python title : str` { #title data-toc-label="title" }
12 |
13 | The title of the studio.
14 |
15 | ###`#!python thumbnail_URL : str` { #thumbnail_URL data-toc-label="thumbnail_URL" }
16 |
17 | The URL of the studio's thumbnail.
18 |
--------------------------------------------------------------------------------
/docs/assets/lightbulb.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/scratchclient/Backpack.py:
--------------------------------------------------------------------------------
1 | import requests
2 |
3 |
4 | class BackpackItem:
5 | def __init__(self, data, client):
6 | self.body_URL = f"https://backpack.scratch.mit.edu/{data['body']}"
7 | self.thumbnail_URL = data["thumbnail"]
8 | self.id = data["id"]
9 | self.mime = data["mime"]
10 | self.name = data["name"]
11 | self.type = data["type"]
12 |
13 | self._client = client
14 |
15 | def delete(self):
16 | self._client._ensure_logged_in()
17 |
18 | requests.delete(
19 | f"https://backpack.scratch.mit.edu/{self._client.username}/{self.id}",
20 | headers=self._client._headers,
21 | )
22 |
--------------------------------------------------------------------------------
/scratchclient/util.py:
--------------------------------------------------------------------------------
1 | import requests
2 |
3 | # Helper function to get lists of data from the API
4 | def get_data_list(all, limit, offset, url, callback, params="", headers={}):
5 | if all:
6 | data = []
7 | offset = 0
8 | while True:
9 | res = requests.get(
10 | f"{url}/?limit=40&offset={offset}{params}", headers=headers
11 | ).json()
12 | data += res
13 | if len(res) != 40:
14 | break
15 | offset += 40
16 | return [callback(item) for item in data]
17 | else:
18 | data = requests.get(
19 | f"{url}/?limit={limit}&offset={offset}{params}", headers=headers
20 | ).json()
21 | return [callback(item) for item in data]
22 |
--------------------------------------------------------------------------------
/docs/reference/ForumPost.md:
--------------------------------------------------------------------------------
1 | # **ForumPost**
2 |
3 | ## Properties
4 |
5 | ###`#!python id : int` { #id data-toc-label="id" }
6 |
7 | The ID of the forum post.
8 |
9 | ###`#!python title : str` { #title data-toc-label="title" }
10 |
11 | The title of the forum post.
12 |
13 | ###`#!python link : str` { #link data-toc-label="link" }
14 |
15 | A link to the forum post.
16 |
17 | ###`#!python published : str` { #published data-toc-label="published" }
18 |
19 | An [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) timestamp representing the date the post was created.
20 |
21 | ###`#!python author : str` { #author data-toc-label="author" }
22 |
23 | The username of the author of the forum post.
24 |
25 | ###`#!python content : str` { #content data-toc-label="content" }
26 |
27 | The content of the forum post.
28 |
--------------------------------------------------------------------------------
/docs/reference/News.md:
--------------------------------------------------------------------------------
1 | # **News**
2 |
3 | ## Properties
4 |
5 | ###`#!python id : int` { #id data-toc-label="id" }
6 |
7 | The ID of the news item.
8 |
9 | ###`#!python title : str` { #title data-toc-label="title" }
10 |
11 | The title of the news item.
12 |
13 | ###`#!python image_URL : str` { #image_URL data-toc-label="image_URL" }
14 |
15 | The URL of the image next to the news item.
16 |
17 | ###`#!python timestamp : str` { #timestamp data-toc-label="timestamp" }
18 |
19 | An [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) timestamp representing the date the news item was created.
20 |
21 | ###`#!python description : str` { #description data-toc-label="description" }
22 |
23 | The description of the news item.
24 |
25 | ###`#!python src : str` { #src data-toc-label="src" }
26 |
27 | The URL the news item links to.
28 |
--------------------------------------------------------------------------------
/docs/stylesheets/extra.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --md-admonition-icon--fun-fact: url('../assets/lightbulb.svg')
3 | }
4 |
5 | .md-typeset .admonition.fun-fact,
6 | .md-typeset details.fun-fact {
7 | border-color: #FFCC00;
8 | }
9 |
10 | .fun-fact-country-parent li:nth-child(5) {
11 | list-style: none;
12 | }
13 |
14 | .md-typeset .fun-fact > .admonition-title,
15 | .md-typeset .fun-fact > summary {
16 | background-color: #FFCC0019;
17 | padding-bottom: 10px;
18 | }
19 |
20 | .md-typeset h2 {
21 | font-size: 2em;
22 | }
23 |
24 | .md-typeset h3 {
25 | font-size: 1.5625em;
26 | }
27 |
28 | .md-typeset .fun-fact > .admonition-title::before,
29 | .md-typeset .fun-fact > summary::before {
30 | background-color: #FFCC00;
31 | -webkit-mask-image: var(--md-admonition-icon--fun-fact);
32 | mask-image: var(--md-admonition-icon--fun-fact);
33 | }
--------------------------------------------------------------------------------
/.github/workflows/build-docs.yml:
--------------------------------------------------------------------------------
1 | name: Documentation
2 | on: [ push, workflow_dispatch ]
3 |
4 | jobs:
5 | build:
6 | name: Build
7 | runs-on: ubuntu-latest
8 |
9 | steps:
10 | - name: Checkout
11 | uses: actions/checkout@v2.3.4
12 |
13 | - name: Setup Python
14 | uses: actions/setup-python@v2.2.2
15 | with:
16 | python-version: 3.x
17 |
18 | - name: Install Dependencies
19 | run: |
20 | pip install mkdocs-material
21 |
22 | - name: Build Documentation
23 | run: mkdocs build --site-dir dist
24 |
25 | - name: Deploy to GitHub Pages
26 | uses: peaceiris/actions-gh-pages@v3
27 | with:
28 | github_token: ${{ secrets.GITHUB_TOKEN }}
29 | publish_dir: ./dist
30 |
--------------------------------------------------------------------------------
/docs/examples/basic-usage.md:
--------------------------------------------------------------------------------
1 | # Basic Usage
2 |
3 | ## Get Started
4 | ```python
5 | from scratchclient import ScratchSession
6 |
7 | session = ScratchSession("ceebee", "--uwu--")
8 |
9 | # post comments
10 | session.get_user("Paddle2See").post_comment("OwO")
11 |
12 | # lots of other stuff
13 | print(session.get_project(450216269).get_comments()[0].content)
14 | print(session.get_studio(29251822).description)
15 | ```
16 |
17 | ## Cloud Connection:
18 | ```python
19 | from scratchclient import ScratchSession
20 |
21 | session = ScratchSession("griffpatch", "SecurePassword7")
22 |
23 | connection = session.create_cloud_connection(450216269)
24 |
25 | connection.set_cloud_variable("variable name", 5000)
26 |
27 | @connection.on("set")
28 | def on_set(variable):
29 | print(variable.name, variable.value)
30 |
31 | print(connection.get_cloud_variable("other variable"))
32 | ```
33 |
--------------------------------------------------------------------------------
/docs/reference/IncompleteUser.md:
--------------------------------------------------------------------------------
1 | # **IncompleteUser**
2 |
3 | A class that represents a user with less data than a [User](../User) object.
4 |
5 | ## Properties
6 |
7 | ###`#!python username : str` { #username data-toc-label="username" }
8 |
9 | The username of the user.
10 |
11 | ###`#!python id : int` { #id data-toc-label="id" }
12 |
13 | The user ID of the user.
14 |
15 | ###`#!python scratchteam : bool` { #scratchteam data-toc-label="scratchteam" }
16 |
17 | The boolean value representing whether the user is a member of the Scratch Team or not.
18 |
19 | ###`#!python joined_timestamp : str` { #joined_timestamp data-toc-label="joined_timestamp" }
20 |
21 | An [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) timestamp representing the date the user joined Scratch.
22 |
23 | ###`#!python avatar_URL : str` { #avatar_URL data-toc-label="avatar_URL" }
24 |
25 | The URL of the user's avatar (profile picture).
26 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | MIT License
2 | Copyright (c) 2021 CubeyTheCube
3 |
4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
5 |
6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
7 |
8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/docs/examples/simultaneous-connections.md:
--------------------------------------------------------------------------------
1 | # Simultaneous Connections
2 |
3 | This shows how to use scratchclient's asynchronous features to have two simulataneous cloud connections to different projects.
4 |
5 | ```python title="message_passer.py"
6 | # Passes messages between two projects, a pretty simple concept
7 | # Both projects have variables called "Request" and "Received"
8 |
9 | import asyncio
10 | from scratchclient import ScratchSession
11 |
12 | session = ScratchSession("griffpatch", "hunter2")
13 |
14 | # These would be replaced with your actual project IDs
15 | connections = [
16 | session.create_cloud_connection(1239123091, is_async=True),
17 | session.create_cloud_connection(1285894890, is_async=True)
18 | ]
19 |
20 | for i, connection in enumerate(connections):
21 | @connection.on("set")
22 | async def on_set(variable):
23 | if variable.name == "Request":
24 | other_connection = connections[1 - i]
25 | await other_connection.set_cloud_variable("Received", variable.value)
26 |
27 | coroutines = [connection.connect() for connection in connections]
28 | asyncio.run(asyncio.gather(*coroutines))
29 | ```
30 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import setuptools
2 | from distutils.core import setup
3 |
4 | with open("README.md", "r", encoding="utf-8") as fh:
5 | long_description = fh.read()
6 |
7 | setup(
8 | name="scratchclient",
9 | packages=["scratchclient"],
10 | version="1.0.1",
11 | license="MIT",
12 | description="A scratch API wrapper for Python.",
13 | long_description=long_description,
14 | long_description_content_type="text/markdown",
15 | author="CubeyTheCube",
16 | author_email="turtles142857@gmail.com",
17 | url="https://github.com/CubeyTheCube/scratchclient",
18 | download_url="https://github.com/CubeyTheCube/scratchclient/archive/v_10.1.tar.gz",
19 | keywords=["scratch", "api"],
20 | install_requires=["requests"],
21 | extras_require={"fast": ["numpy", "wsaccel"]},
22 | classifiers=[
23 | "Development Status :: 4 - Beta",
24 | "Intended Audience :: Developers",
25 | "License :: OSI Approved :: MIT License",
26 | "Operating System :: OS Independent",
27 | "Programming Language :: Python :: 3",
28 | "Programming Language :: Python :: 3.6",
29 | "Programming Language :: Python :: 3.7",
30 | ],
31 | )
32 |
--------------------------------------------------------------------------------
/docs/replit.md:
--------------------------------------------------------------------------------
1 | # Usage on Replit
2 |
3 | Scratch blocks most requests from the Replit, so you must work around it. To log into Scratch, instead of using your password, you can use your token and session ID.
4 |
5 | You can obtain your session ID by [opening your browser developer tools](https://developer.mozilla.org/en-US/docs/Learn/Common_questions/What_are_browser_developer_tools), going to Application > Storage (or just Storage), then finding "scratchsessionsid" and copying the cookie value.
6 |
7 | 
8 |
9 | You can obtain your token by running this in your browser console:
10 | ```js
11 | alert(
12 | document.getElementById('app')._reactRootContainer._internalRoot
13 | .current.child.pendingProps.store.getState()
14 | .session.session.user.token
15 | );
16 | ```
17 |
18 | Then copying the value that flashes on your screen.
19 |
20 | Then, to log in to scratchclient, use this code:
21 | ```python
22 | from scratchclient import ScratchSession
23 |
24 | session = ScratchSession("username", session_id="session ID here", token="token here")
25 | ```
26 |
27 | However, a lot of functionality still might not work. Sites like [Glitch](https://glitch.com/) could serve your purpose in that case- or you can just host it on your own computer.
28 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # scratchclient
2 | A scratch API wrapper for python.
3 |
4 | ## Installation
5 |
6 | Go to your terminal (not your python shell) and execute this command:
7 | ```bash
8 | pip install scratchclient
9 | ```
10 |
11 | If this didn't work for whatever reason, open your python shell and run the following:
12 | ```python
13 | import os; os.system("pip install scratchclient")
14 | ```
15 |
16 | ## Example Usage
17 |
18 | ### Basic Usage
19 | ```python
20 | from scratchclient import ScratchSession
21 |
22 | session = ScratchSession("ceebee", "--uwu--")
23 |
24 | # post comments
25 | session.get_user("Paddle2See").post_comment("OwO")
26 |
27 | # lots of other stuff
28 | print(session.get_project(450216269).get_comments()[0].content)
29 | print(session.get_studio(29251822).description)
30 | ```
31 | ### Cloud Connection
32 | ```python
33 | from scratchclient import ScratchSession
34 |
35 | session = ScratchSession("griffpatch", "SecurePassword7")
36 |
37 | connection = session.create_cloud_connection(450216269)
38 |
39 | connection.set_cloud_variable("variable name", 5000)
40 |
41 | @connection.on("set")
42 | def on_set(variable):
43 | print(variable.name, variable.value)
44 |
45 | print(connection.get_cloud_variable("other variable"))
46 | ```
47 |
48 | Documentation is available at .
49 |
50 | All bugs should be reported to the [github repository](https://github.com/CubeyTheCube/scratchclient/issues). If you need help or guideance, check out the [forum topic](https://scratch.mit.edu/discuss/topic/506810).
51 |
--------------------------------------------------------------------------------
/docs/reference/BackpackItem.md:
--------------------------------------------------------------------------------
1 | # **BackpackItem**
2 |
3 | ## Properties
4 |
5 | ###`#!python id : str` { #id data-toc-label="id" }
6 |
7 | The ID of the backpack item (a UUID).
8 |
9 | ###`#!python name : str` { #name data-toc-label="name" }
10 |
11 | The name of the item.
12 |
13 | ###`#!python body_URL : str` { #body_URL data-toc-label="body_URL" }
14 |
15 | The URL of the content of the item.
16 |
17 | ###`#!python thumbnail_URL : str` { #thumbnail_URL data-toc-label="thumbnail_URL" }
18 |
19 | The URL of the thumbnail of the item.
20 |
21 | ###`#!python mime : Literal["application/zip"] | Literal["application/json"] | Literal["audio/x-wav"] | Literal["audio/mp3"] | Literal["image/svg+xml"] | Literal["image/png"]` { #mime data-toc-label="mime" }
22 |
23 | The MIME type of the item.
24 |
25 | ###`#!python type : Literal["script"] | Literal["costume"] | Literal["sound"] | Literal["sprite"]` { #type data-toc-label="type" }
26 |
27 | The type of item that the item is.
28 |
29 | ## Methods
30 |
31 | ###`#!python delete()` { #delete data-toc-label="delete" }
32 |
33 | Deletes the item.
34 |
35 | **Example:**
36 |
37 | ```python
38 | import base64
39 | from PIL import Image
40 | from io import BytesIO
41 |
42 | costume_file = open("furry.png", "rb")
43 | body = base64.b64encode(costume_file.read())
44 |
45 | image = Image.open("furry.png")
46 | with BytesIO() as f:
47 | image.save(f, format="JPEG")
48 | thumbnail = base64.b64encode(f.getvalue())
49 | item = session.add_to_backpack("costume", body, "image/png", "furry", thumbnail)
50 | item.delete()
51 | ```
52 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | # Scratchclient Documentation
2 |
3 | This is the documentation for scratchclient.
4 |
5 | ## Installation
6 |
7 | Go to your terminal (not your python shell) and execute this command:
8 | ```bash
9 | pip install scratchclient
10 | ```
11 |
12 | If this didn't work for whatever reason, open your python shell and run the following:
13 | ```python
14 | import os; os.system("pip install scratchclient")
15 | ```
16 |
17 | scratchclient requires Python 3.7; however, it will work for almost all use cases on Python 3.6.
18 |
19 | ## Get Started
20 | ```python
21 | from scratchclient import ScratchSession
22 |
23 | session = ScratchSession("ceebee", "--uwu--")
24 |
25 | # post comments
26 | session.get_user("Paddle2See").post_comment("OwO")
27 |
28 | # lots of other stuff
29 | print(session.get_project(450216269).get_comments()[0].content)
30 | print(session.get_studio(29251822).description)
31 | ```
32 |
33 | ## Cloud Connection
34 | ```python
35 | from scratchclient import ScratchSession
36 |
37 | session = ScratchSession("griffpatch", "SecurePassword7")
38 |
39 | connection = session.create_cloud_connection(450216269)
40 |
41 | connection.set_cloud_variable("variable name", 5000)
42 |
43 | @connection.on("set")
44 | def on_set(variable):
45 | print(variable.name, variable.value)
46 |
47 | print(connection.get_cloud_variable("other variable"))
48 | ```
49 |
50 | See the examples for more code samples.
51 |
52 | ## CLI
53 |
54 | scratchclient has a command line interface for retrieving Scratch website data from the command line. Use `python3 -m scratchclient help` to get started.
--------------------------------------------------------------------------------
/scratchclient/Incomplete.py:
--------------------------------------------------------------------------------
1 | class IncompleteUser:
2 | def __init__(self, data):
3 | # Scratch API sometimes doesn't even return the username
4 | self.username = data["username"] if "username" in data else None
5 | self.id = data["id"]
6 | self.scratchteam = data["scratchteam"]
7 | self.joined_timestamp = data["history"]["joined"]
8 | self.avatar_URL = data["profile"]["images"]["90x90"]
9 |
10 |
11 | class IncompleteProject:
12 | def __init__(self, data):
13 | self.title = data.pop("title")
14 |
15 | self.author = data.pop("creator" if "creator" in data else "username")
16 | self.id = data.pop("id")
17 | self.thumbnail_URL = data.pop(
18 | "thumbnail_url" if "thumbnail_url" in data else "image"
19 | )
20 |
21 | self.__dict__.update(data)
22 |
23 |
24 | class RemixtreeProject:
25 | def __init__(self, data):
26 | self.id = data["id"]
27 | self.author = data["username"]
28 | self.moderation_status = data["moderation_status"]
29 | self.title = data["title"]
30 |
31 | self.created_timestamp = data["datetime_created"]["$date"]
32 | self.last_modified_timestamp = data["mtime"]["$date"]
33 | self.shared_timestamp = (
34 | data["datetime_shared"]["$date"] if data["datetime_shared"] else None
35 | )
36 |
37 | self.love_count = data["love_count"]
38 | self.favorite_count = data["favorite_count"]
39 |
40 | self.visible = data["visibility"] == "visible"
41 | self.is_published = data["is_published"]
42 |
43 | self.parent_id = int(data["parent_id"]) if data["parent_id"] else None
44 | self.children = [int(child) for child in data["children"]]
45 |
46 |
47 | class IncompleteStudio:
48 | def __init__(self, data):
49 | self.title = data["title"]
50 |
51 | self.id = data["id"]
52 | self.thumbnail_URL = data["thumbnail_url"]
53 |
--------------------------------------------------------------------------------
/mkdocs.yml:
--------------------------------------------------------------------------------
1 | site_name: scratchclient docs
2 | site_url: https://CubeyTheCube.github.io/scratchclient/
3 | repo_url: https://github.com/CubeyTheCube/scratchclient
4 | repo_name: CubeyTheCube/scratchclient
5 | theme:
6 | name: material
7 | palette:
8 | primary: light blue
9 | accent: amber
10 | features:
11 | - navigation.instant
12 | icon:
13 | repo: fontawesome/brands/git-alt
14 |
15 | nav:
16 | - Home: 'index.md'
17 | - Usage on Replit: 'replit.md'
18 | - 'Examples':
19 | - 'Basic Usage': 'examples/basic-usage.md'
20 | - 'Stats Viewer': 'examples/stats-viewer.md'
21 | - 'Simultaneous Connections': 'examples/simultaneous-connections.md'
22 | - 'API Reference':
23 | - ScratchSession: 'reference/ScratchSession.md'
24 | - User: 'reference/User.md'
25 | - Project: 'reference/Project.md'
26 | - Studio: 'reference/Studio.md'
27 | - UserProfile: 'reference/UserProfile.md'
28 | - IncompleteUser: 'reference/IncompleteUser.md'
29 | - IncompleteProject: 'reference/IncompleteProject.md'
30 | - IncompleteStudio: 'reference/IncompleteStudio.md'
31 | - RemixtreeProject: 'reference/RemixtreeProject.md'
32 | - ProfileComment: 'reference/ProfileComment.md'
33 | - ProjectComment: 'reference/ProjectComment.md'
34 | - StudioComment: 'reference/StudioComment.md'
35 | - News: 'reference/News.md'
36 | - Message: 'reference/Message.md'
37 | - Activity: 'reference/Activity.md'
38 | - ForumSession: 'reference/ForumSession.md'
39 | - ForumPost: 'reference/ForumPost.md'
40 | - ScrapingSession: 'reference/ScrapingSession.md'
41 | - BackpackItem: 'reference/BackpackItem.md'
42 | - CloudConnection: 'reference/CloudConnection.md'
43 | - AsyncCloudConnection: 'reference/AsyncCloudConnection.md'
44 | - CloudVariable: 'reference/CloudVariable.md'
45 |
46 | markdown_extensions:
47 | - pymdownx.highlight
48 | - pymdownx.superfences
49 | - pymdownx.inlinehilite
50 | - toc:
51 | permalink: True
52 | - admonition
53 | - attr_list
54 |
55 | extra_css:
56 | - stylesheets/extra.css
57 |
58 | plugins:
59 | - search
60 |
--------------------------------------------------------------------------------
/docs/reference/ProfileComment.md:
--------------------------------------------------------------------------------
1 | # **ProfileComment**
2 |
3 | ## Properties
4 |
5 | ###`#!python id : int` { #id data-toc-label="id" }
6 |
7 | The ID of the comment.
8 |
9 | ###`#!python parent_id : int | None` { #parent_id data-toc-label="parent_id" }
10 |
11 | If the comment is a reply, this is the ID of its parent comment. Otherwise, it is `#!python None`.
12 |
13 | ###`#!python commentee_id : int | None` { #commentee_id data-toc-label="commentee_id" }
14 |
15 | If the comment is a reply, this is the user ID of the author of the parent comment. Otherwise, it is `#!python None`.
16 |
17 | ###`#!python content : str` { #content data-toc-label="content" }
18 |
19 | The content of the comment.
20 |
21 | ###`#!python replies : list[ProfileComment]` { #replies data-toc-label="replies" }
22 |
23 | A list of the replies to the comment, as an array of [ProfileComment](../ProfileComment) objects.
24 |
25 | ###`#!python author : str` { #author data-toc-label="author" }
26 |
27 | The username of the author of the comment.
28 |
29 | ###`#!python author_id : int` { #author_id data-toc-label="author_id" }
30 |
31 | The user ID of the author of the comment.
32 |
33 | ###`#!python created_timestamp : str` { #created_timestamp data-toc-label="created_timestamp" }
34 |
35 | An [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) timestamp representing the date the comment was created.
36 |
37 | ###`#!python last_modified_timestamp : str` { #last_modified_timestamp data-toc-label="last_modified_timestamp" }
38 |
39 | An [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) timestamp representing the date the comment was last modified.
40 |
41 | !!! note
42 | I have no idea what the hell this means.
43 |
44 | ###`#!python visible : bool` { #visible data-toc-label="visible" }
45 |
46 | A boolean value representing whether the comment has been deleted or not.
47 |
48 | ###`#!python user : str` { #user data-toc-label="user" }
49 |
50 | The username of the user whose profile the comment is on.
51 |
52 | ## Methods
53 |
54 | ###`#!python delete()` { #delete data-toc-label="delete" }
55 |
56 | Deletes the comment. You must be logged in and the owner of the profile the comment is on for this to not throw an error.
57 |
58 | ###`#!python report()` { #report data-toc-label="report" }
59 |
60 | Reports the comment. You must be logged in for this to not throw an error.
61 |
62 | ###`#!python reply(content)` { #reply data-toc-label="reply"
63 | }
64 |
65 | Replies to the comment. You must be logged in for this to not throw an error.
66 |
67 | **PARAMETERS**
68 |
69 | - **content** (`#!python str`) - The content of your reply.
70 |
71 | **Example:**
72 |
73 | ```python
74 | comment = session.scraping.get_profile_comments("griffpatch")[0]
75 | comment.reply("Go away")
76 | ```
77 |
--------------------------------------------------------------------------------
/docs/examples/stats-viewer.md:
--------------------------------------------------------------------------------
1 | # Stats Viewer
2 |
3 | This is the server code for a "stats viewer" project.
4 |
5 | ```python title="stats_viewer.py"
6 | # This stats viewer can retrieve a user's follower and following count
7 | # This assumes that the project has four cloud variables: "Follower Count Request",
8 | # "Follower Count Response", "Following Count Request",
9 | # and "Following Count Response". The "Follower Count Request" and
10 | # "Following Count Request" variables will be set
11 | # by people using the project, and will contain the username of the user
12 | # requesting the data, a delimiter, and the username of the user for which
13 | # they want the statistics. The "Follower Count Response" and "Following Count Response"
14 | # variables will be set by the server (this program) and will contain the username
15 | # of the user who sent the request, a delimiter, and the number of followers.
16 |
17 |
18 | from scratchclient import ScratchSession
19 |
20 | session = ScratchSession("griffpatch", "badpassword")
21 |
22 | character_set = " abcdefghijklmnopqrstuvwxyz1234567890-_"
23 |
24 | def decode_request(request):
25 | # An example request would be something like 02150200101505
26 | # If you decode this using the character set, it would become "Bob", then a space, then "Joe"
27 | # Bob is the user who sent the request and Joe is the user that which they want to know the follower count of
28 | decoded = ""
29 | for i in range(0, len(request), 2):
30 | # This loops through the request, two characters at a time
31 | decoded += character_set[int(request[i: i+2])]
32 |
33 | # Split it into the requester and the requested username
34 | return request.split(" ")
35 |
36 | def encode_response(username, count):
37 | # An example response would be something like 021502001000
38 | # Everything until the first instead of 00 will be decoded
39 | # and the decoded value is "Bob". After that is the actual
40 | # follower count
41 | response = ""
42 | for char in username:
43 | # Add a 0 to the beginning of the number if there isn't any
44 | response += str(character_set.index(char)).zfill(2)
45 |
46 | response += f"00{count}"
47 | return response
48 |
49 |
50 | # You would replace the number with your actual project ID
51 | connection = session.create_cloud_connection(1032938129)
52 |
53 | # This means that the `on_set` function will run every time someone else changes a cloud variable.
54 | @connection.on("set")
55 | def on_set(variable):
56 | if variable.name == "Follower Count Request" or variable.name == "Following Count Request":
57 | requester_username, requested_username = decode_request(variable.value)
58 | count = session.scraping.get_follower_count(requested_username)
59 | if variable.name == "Follower Count Request"
60 | else session.scraping.get_following_count(requested_username)
61 |
62 | # We need to encode the requester username so the client
63 | # knows which response is theirs and not someone else's
64 | response = encode_response(requester_username, count)
65 |
66 | # The response variable name is the same name with "Request"
67 | # replaced with "Response"
68 | response_variable_name = variable.name.replace("Request", "Response")
69 | connection.set_cloud_variable(response_variable_name, response)
70 | ```
71 |
--------------------------------------------------------------------------------
/scratchclient/UserProfile.py:
--------------------------------------------------------------------------------
1 | import requests
2 | import json
3 | import pathlib
4 |
5 | from .Project import Project
6 | from .Incomplete import IncompleteProject, RemixtreeProject
7 |
8 | class UserProfile:
9 | def __init__(self, data, user):
10 | self.user = user
11 | self._client = user._client
12 | self.username = user.username
13 | self.id = data["id"]
14 | self.avatar_URL = data["images"]["90x90"]
15 | self.bio = data["bio"]
16 | self.status = data["status"]
17 | self.country = data["country"]
18 |
19 | def set_bio(self, content):
20 | self._client._ensure_logged_in()
21 |
22 | if self.username != self._client.username:
23 | raise UnauthorizedException("You are not allowed to do that")
24 |
25 | data = {"bio": content}
26 |
27 | requests.put(
28 | f"https://scratch.mit.edu/site-api/users/all/{self.username}/",
29 | data=json.dumps(data),
30 | headers=self.user._headers,
31 | )
32 |
33 | self.bio = content
34 |
35 | def set_status(self, content):
36 | self._client._ensure_logged_in()
37 |
38 | if self.username != self._client.username:
39 | raise UnauthorizedException("You are not allowed to do that")
40 |
41 | data = {"status": content}
42 |
43 | requests.put(
44 | f"https://scratch.mit.edu/site-api/users/all/{self.username}/",
45 | data=json.dumps(data),
46 | headers=self.user._headers,
47 | )
48 |
49 | self.status = content
50 |
51 | def set_avatar(self, filename):
52 | self._client._ensure_logged_in()
53 |
54 | if self.username != self._client.username:
55 | raise UnauthorizedException("You are not allowed to do that")
56 |
57 | files = {
58 | "file": (
59 | filename,
60 | open(filename, "rb"),
61 | f"image/{pathlib.Path(filename).suffix}",
62 | ),
63 | }
64 |
65 | requests.post(
66 | f"https://scratch.mit.edu/site-api/users/all/{self.username}/",
67 | files=files,
68 | headers=self.user._headers,
69 | )
70 |
71 | def get_featured_project(self):
72 | data = requests.get(
73 | f"https://scratch.mit.edu/site-api/users/all/{self.username}/",
74 | ).json()
75 |
76 | return IncompleteProject(data["featured_project_data"])
77 |
78 | def set_featured_project(self, label, project):
79 | self._client._ensure_logged_in()
80 |
81 | if self.username != self._client.username:
82 | raise UnauthorizedException("You are not allowed to do that")
83 |
84 | label_num = (
85 | {
86 | "featured_project": "",
87 | "featured_tutorial": 0,
88 | "work_in_progress": 1,
89 | "remix_this": 2,
90 | "my_favorite_things": 3,
91 | "why_i_scratch": 4,
92 | }
93 | )[label]
94 | project_id = project.id if isinstance(project, (Project, IncompleteProject, RemixtreeProject)) else project
95 | data = {"featured_project": project_id, "featured_project_label": label_num}
96 |
97 | requests.put(
98 | f"https://scratch.mit.edu/site-api/users/all/{self.username}/",
99 | data=json.dumps(data),
100 | headers=self._headers,
101 | )
102 |
--------------------------------------------------------------------------------
/docs/reference/IncompleteProject.md:
--------------------------------------------------------------------------------
1 | # **IncompleteProject**
2 |
3 | A class that represents a project with less data than a [Project](../Project) object.
4 |
5 | ## Properties
6 |
7 | ###`#!python title : str` { #title data-toc-label="title" }
8 |
9 | The title of the project.
10 |
11 | ###`#!python id : int` { #id data-toc-label="id" }
12 |
13 | The project ID of the project.
14 |
15 | ###`#!python author : str` { #author data-toc-label="author" }
16 |
17 | The username of the project's creator.
18 |
19 | ###`#!python thumbnail_URL : str` { #thumbnail_URL data-toc-label="thumbnail_URL" }
20 |
21 | The URL of the project's thumbnail.
22 |
23 | ---
24 |
25 | An `#!python IncompleteProject` might have other attributes depending on where it came from:
26 |
27 | ###`#!python type : Literal["project"]` { #type data-toc-label="type" }
28 |
29 | This is a string that is always `#!python "project"`. It only appears when returned from a call to [`ScratchSession.get_front_page`](../ScratchSession#get_front_page).
30 |
31 | ###`#!python love_count : int` { #love_count data-toc-label="love_count" }
32 |
33 | The number of loves the project has. It only appears when returned from a call to [`ScratchSession.get_front_page`](../ScratchSession#get_front_page).
34 |
35 | ###`#!python remixers_count : int` { #remixers_count data-toc-label="remixers_count" }
36 |
37 | The number of remixes the project has. It only appears in the `#!python "top_remixed"` and `#!python scratch_design_studio` items of the dictionary returned from a call to [`ScratchSession.get_front_page`](../ScratchSession#get_front_page).
38 |
39 | ###`#!python curator_name : str` { #curator_name data-toc-label="curator_name" }
40 |
41 | The username of Scratch's current Front Page Curator. It only appears in the `#!python "curated"` item when returned from a call to [`ScratchSession.get_front_page`](../ScratchSession#get_front_page).
42 |
43 | ###`#!python gallery_id : int` { #gallery_id data-toc-label="gallery_id" }
44 |
45 | The ID of Scratch's current Scratch Design Studio. It only appears in the `#!python "scratch_design_studio"` item when returned from a call to [`ScratchSession.get_front_page`](../ScratchSession#get_front_page).
46 |
47 | ###`#!python gallery_title : str` { #gallery_title data-toc-label="gallery_title" }
48 |
49 | The title of Scratch's current Scratch Design Studio. It only appears in the `#!python "scratch_design_studio"` item when returned from a call to [`ScratchSession.get_front_page`](../ScratchSession#get_front_page).
50 |
51 | ###`#!python creator_id : int` { #creator_id data-toc-label="creator_id" }
52 |
53 | The user ID of the project's creator. It only appears when returned from a call to [`Studio.get_projects`](../Studio#get_projects).
54 |
55 | ###`#!python avatar : dict` { #avatar data-toc-label="avatar" }
56 |
57 | A dictionary containing different images with the author's avatar (profile picture). Contains the items `#!python "90x90"`, `#!python "60x60"`, `#!python "55x55"`, `#!python "50x50"`, and `#!python "32x32"`, either corresponding to a URL to a different size of the avatar. It only appears when returned from a call to [`Studio.get_projects`](../Studio#get_projects).
58 |
59 | ###`#!python actor_id : int` { #actor_id data-toc-label="actor_id" }
60 |
61 | The user ID of the user who added the project to the studio. It only appears when returned from a call to [`Studio.get_projects`](../Studio#get_projects).
62 |
63 | ###`#!python datetime_modified : str` { #datetime_modified data-toc-label="datetime_modified" }
64 |
65 | An [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) timestamp representing the date the project was last modified. It only appears when returned from a call to [`UserProfile.get_featured_project`](../UserProfile#get_featured_project).
66 |
--------------------------------------------------------------------------------
/docs/reference/RemixtreeProject.md:
--------------------------------------------------------------------------------
1 | # **RemixtreeProject**
2 |
3 | A class that represents the project data that is used on Scratch's remix tree page.
4 |
5 | ## Properties
6 |
7 | ###`#!python title : str` { #title data-toc-label="title" }
8 |
9 | The title of the project.
10 |
11 | ###`#!python id : int` { #id data-toc-label="id" }
12 |
13 | The project ID of the project.
14 |
15 | ###`#!python author : str` { #author data-toc-label="author" }
16 |
17 | The username of the project's creator.
18 |
19 | An `#!python IncompleteProject` might have other attributes depending on where it came from:
20 |
21 | ###`#!python moderation_status : str` { #moderation_status data-toc-label="moderation_status" }
22 |
23 | The moderation status of the project. This is either `#!python "notreviewed"` or `#!python "notsafe"`. If it is `#!python "notsafe"` (NSFE), this means the project can't show up in search results, the front page, or the trending page.
24 |
25 | **Example:**
26 |
27 | ```python
28 | def is_nsfe(project_id):
29 | remixtree = session.get_project(project_id).get_remixtree()
30 | try:
31 | remixtree_project = next(project for project in remixtree if project.id == project_id)
32 | except StopIteration:
33 | # It's unknown since the project has no remix tree
34 | return False
35 |
36 | return remixtree_project.moderation_status == "notsafe"
37 |
38 | print(is_nsfe(414601586))
39 | # True
40 | ```
41 |
42 | !!! fun-fact
43 |
44 | Although you can easily determine whether a project is NSFE using this, you are not allowed to mention how to do this or say that a project is NSFE on Scratch. It's weird that they still include this in an API response, though. Just think of it as a little Easter Egg in Scratch's API.
45 |
46 | ###`#!python visible : bool` { #visible data-toc-label="visible" }
47 |
48 | A boolean value representing whether the project has been deleted or not.
49 |
50 | ###`#!python is_published : bool` { #is_published data-toc-label="is_published" }
51 |
52 | A boolean value representing whether the project has been shared or not.
53 |
54 | ###`#!python love_count : int` { #love_count data-toc-label="love_count" }
55 |
56 | The number of loves the project has.
57 |
58 | ###`#!python favorite_count : int` { #favorite_count data-toc-label="favorite_count" }
59 |
60 | The number of favorites the project has.
61 |
62 | ###`#!python created_timestamp : int` { #created_timestamp data-toc-label="created_timestamp" }
63 |
64 | A [Unix timestamp](https://en.wikipedia.org/wiki/Unix_time) representing the date the project was created.
65 |
66 | **Example:**
67 |
68 | ```python
69 | import datetime
70 |
71 | def unix_to_readable(unix):
72 | timezone = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo
73 |
74 | date = datetime.datetime.fromtimestamp(unix)
75 | date.astimezone(timezone)
76 |
77 | return date.strftime("%Y-%m-%d %I:%M %p")
78 |
79 | project_104 = next(project for project in
80 | session.get_project(104).get_remix_tree() if project.id == 104)
81 | print(unix_to_readable(project_104.created_timestamp))
82 | # 2007-03-05 10:47 AM
83 | ```
84 |
85 | ###`#!python last_modified_timestamp : int` { #last_modified_timestamp data-toc-label="last_modified_timestamp" }
86 |
87 | A [Unix timestamp](https://en.wikipedia.org/wiki/Unix_time) timestamp representing the date the project was most recently modified.
88 |
89 | ###`#!python shared_timestamp : int | None` { #shared_timestamp data-toc-label="shared_timestamp" }
90 |
91 | A [Unix timestamp](https://en.wikipedia.org/wiki/Unix_time) timestamp representing the date the project was shared.
92 |
93 | ###`#!python parent_id : int | None` { #parent_id data-toc-label="parent_id" }
94 |
95 | If the project is a remix, this is the ID of the project's parent project. Otherwise, it's `#!python None`.
96 |
97 | ###`#!python children : list[int]` { #children data-toc-label="children" }
98 |
99 | A list of the project IDs of the project's remixes.
100 |
101 | !!! note
102 |
103 | This can be used to determine the project's remixes much more quickly than [`Project.get_remixes`](../Project#get_remixes).
104 |
--------------------------------------------------------------------------------
/docs/reference/ForumSession.md:
--------------------------------------------------------------------------------
1 | # **ForumSession**
2 |
3 | ## Methods
4 |
5 | ###`#!python create_topic(category_id, title, body)` { #create_topic data-toc-label="create_topic" }
6 |
7 | Creates a forum topic. You must be logged in for this to not throw an error.
8 |
9 | **PARAMETERS**
10 |
11 | - **category_id** (`#!python int | str`) - The ID of the forum category you want to post in. For example, the ID of the "Suggestions" category is `#!python 31`.
12 | - **title** (`#!python str`) - The title of the original post in the topic.
13 | - **body** (`#!python str`) - The body of the original post in the topic.
14 |
15 | **Example:**
16 |
17 | ```python
18 | session.forums.create_topic(1, "Add like button to comments", "Title.\nSupporters:\n\nNobody yet!")
19 | ```
20 |
21 | ###`#!python post(topic_id, content)` { #post data-toc-label="post" }
22 |
23 | Posts a forum post on the specified topic.
24 |
25 | **PARAMETERS**
26 |
27 | - **topic_id** (`#!python int | str`) - The ID of the topic you want to post on.
28 | - **content** (`#!python str`) - The content of the post.
29 |
30 | **Example:**
31 |
32 | ```python
33 | session.forums.post(506810, "This sucks")
34 | ```
35 |
36 | ###`#!python edit_post(post_id, content)` { #edit_post data-toc-label="edit_post" }
37 |
38 | Edits the forum post with the specified ID.
39 |
40 | **PARAMETERS**
41 |
42 | - **post_id** (`#!python int | str`) - The ID of the post you want to edit.
43 | - **content** (`#!python str`) - The new content of the post.
44 |
45 | ###`#!python report_post(post_id, reason)` { #report_post data-toc-label="report_post" }
46 |
47 | Reports the forum post with the specified ID.
48 |
49 | **PARAMETERS**
50 |
51 | - **post_id** (`#!python int | str`) - The ID of the post you want to report.
52 | - **reason** (`#!python str`) - The reason you want to report the post.
53 |
54 | ###`#!python get_post_source(post_id)` { #get_post_source data-toc-label="get_post_source" }
55 |
56 | Gets the BBCode source of the forum post with the specified ID.
57 |
58 | **PARAMETERS**
59 |
60 | - **post_id** (`#!python int | str`) - The ID of the post.
61 |
62 | **RETURNS** - `#!python str`
63 |
64 | ###`#!python follow_topic(topic_id)` { #follow_topic data-toc-label="follow_topic" }
65 |
66 | Follows the forum topic with the specified ID.
67 |
68 | **PARAMETERS**
69 |
70 | - **topic** (`#!python int | str`) - The ID of the topic you want to follow.
71 |
72 | ###`#!python unfollow_topic(topic_id)` { #unfollow_topic data-toc-label="unfollow_topic" }
73 |
74 | Unfollows the forum topic with the specified ID.
75 |
76 | **PARAMETERS**
77 |
78 | - **topic** (`#!python int | str`) - The ID of the topic you want to unfollow.
79 |
80 | ###`#!python change_signature(signature)` { #change_signature data-toc-label="change_signature" }
81 |
82 | Changes your forum signature to a new signature.
83 |
84 | **PARAMETERS**
85 |
86 | - **signature** (`#!python str`) - The signature you want to change your signature to.
87 |
88 | ###`#!python get_latest_topic_posts(topic_id)` { #get_latest_topic_posts data-toc-label="get_latest_topic_posts" }
89 |
90 | Gets the latest posts on the specified forum topic. Returns an array of [ForumPost](../ForumPost) objects.
91 |
92 | **PARAMETERS**
93 |
94 | - **topic_id** (`#!python int | str`) - The ID of the topic you want to get the latest posts on.
95 |
96 | **RETURNS** - `#!python list[ForumPost]`
97 |
98 | **Example:**
99 |
100 | ```python
101 | print(session.forums.get_latest_topic_posts(506810)[0].content)
102 | # scratchclient sucks
103 | ```
104 |
105 | ###`#!python get_latest_category_posts(category_id)` { #get_latest_category_posts data-toc-label="get_latest_category_posts" }
106 |
107 | Gets the latest posts on the specified forum category. Returns an array of [ForumPost](../ForumPost) objects.
108 |
109 | **PARAMETERS**
110 |
111 | - **topic_id** (`#!python int | str`) - The ID of the category you want to get the latest posts on. For example, the ID of the "Suggestions" forum category is `#!python 1`.
112 |
113 | **RETURNS** - `#!python list[ForumPost]`
114 |
115 | **Example:**
116 |
117 | ```python
118 | print(session.forums.get_latest_category_posts(31)[0].content)
119 | # scratchclient sucks
120 | ```
121 |
--------------------------------------------------------------------------------
/docs/reference/ProjectComment.md:
--------------------------------------------------------------------------------
1 | # **ProjectComment**
2 |
3 | ## Properties
4 |
5 | ###`#!python id : int` { #id data-toc-label="id" }
6 |
7 | The ID of the comment.
8 |
9 | ###`#!python parent_id : int | None` { #parent_id data-toc-label="parent_id" }
10 |
11 | If the comment is a reply, this is the ID of its parent comment. Otherwise, it is `#!python None`.
12 |
13 | ###`#!python commentee_id : int | None` { #commentee_id data-toc-label="commentee_id" }
14 |
15 | If the comment is a reply, this is the user ID of the author of the parent comment. Otherwise, it is `#!python None`.
16 |
17 | ###`#!python content : str` { #content data-toc-label="content" }
18 |
19 | The content of the comment.
20 |
21 | ###`#!python reply_count : int` { #reply_count data-toc-label="reply_count" }
22 |
23 | The number of replies the comment has. If the comment is a reply, this is simply `#!python 0`.
24 |
25 | ###`#!python author : str` { #author data-toc-label="author" }
26 |
27 | The username of the author of the comment.
28 |
29 | ###`#!python author_id : int` { #author_id data-toc-label="author_id" }
30 |
31 | The user ID of the author of the comment.
32 |
33 | ###`#!python created_timestamp : str` { #created_timestamp data-toc-label="created_timestamp" }
34 |
35 | An [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) timestamp representing the date the comment was created.
36 |
37 | **Example:**
38 |
39 | ```python
40 | import datetime
41 |
42 | def iso_to_readable(iso):
43 | timezone = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo
44 |
45 | date = datetime.datetime.fromisoformat(iso.replace("Z", "+00:00"))
46 | date.astimezone(timezone)
47 |
48 | return date.strftime("%Y-%m-%d %I:%M %p")
49 |
50 | print(session.get_project(104).get_comments()[0].created_timestamp)
51 | # 2022-08-04 10:47 AM
52 | ```
53 |
54 | ###`#!python last_modified_timestamp : str` { #last_modified_timestamp data-toc-label="last_modified_timestamp" }
55 |
56 | An [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) timestamp representing the date the comment was last modified.
57 |
58 | !!! note
59 | I have no idea what the hell this means.
60 |
61 | ###`#!python visible : bool` { #visible data-toc-label="visible" }
62 |
63 | A boolean value representing whether the comment has been deleted or not.
64 |
65 | ###`#!python project : Project` { #project data-toc-label="project" }
66 |
67 | The project that the comment is on, as a [Project](../Project) object.
68 |
69 | ## Methods
70 |
71 | ###`#!python delete()` { #delete data-toc-label="delete" }
72 |
73 | Deletes the comment. You must be logged in and the owner of the project that the comment is on for this to not throw an error.
74 |
75 | **Example:**
76 |
77 | ```python
78 | project = session.get_project(193293231031)
79 | for comment in project.get_comments(all=True):
80 | if "bad" in comment.content:
81 | comment.delete()
82 | ```
83 |
84 | ###`#!python report()` { #report data-toc-label="report" }
85 |
86 | Reports the comment. You must be logged in for this to not throw an error.
87 |
88 | ###`#!python reply(content)` { #reply data-toc-label="reply"
89 | }
90 |
91 | Replies to the comment. You must be logged in for this to not throw an error. Returns the reply once it is posted as a [ProjectComment](../ProjectComment).
92 |
93 | **PARAMETERS**
94 |
95 | - **content** (`#!python str`) - The content of your reply.
96 |
97 | **RETURNS** - `#!python ProjectComment`
98 |
99 | **Example:**
100 |
101 | ```python
102 | comment = session.get_project(104).get_comments()[0]
103 | comment.reply("Go away")
104 | ```
105 |
106 | ###`#!python get_replies(all=False, limit=20, offset=0)` { #get_replies data-toc-label="get_replies" }
107 |
108 | Gets a list of replies to the comment. Returns an array of [ProjectComment](../ProjectComment) objects.
109 |
110 | **PARAMETERS**
111 |
112 | - **all** (`#!python Optional[bool]`) - Whether to retrieve every single reply or just `#!python limit` replies.
113 | - **limit** (`#!python Optional[int]`) - How many replies to retrieve if `#!python all` is `#!python False`.
114 | - **offset** (`#!python Optional[int]`) - The offset of the replies from the newest ones - i.e. an offset of 20 would give you the next 20 replies after the first 20.
115 |
116 | **RETURNS** - `#!python list[ProjectComment]`
117 |
--------------------------------------------------------------------------------
/docs/reference/StudioComment.md:
--------------------------------------------------------------------------------
1 | # **StudioComment**
2 |
3 | ## Properties
4 |
5 | ###`#!python id : int` { #id data-toc-label="id" }
6 |
7 | The ID of the comment.
8 |
9 | ###`#!python parent_id : int | None` { #parent_id data-toc-label="parent_id" }
10 |
11 | If the comment is a reply, this is the ID of its parent comment. Otherwise, it is `#!python None`.
12 |
13 | ###`#!python commentee_id : int | None` { #commentee_id data-toc-label="commentee_id" }
14 |
15 | If the comment is a reply, this is the user ID of the author of the parent comment. Otherwise, it is `#!python None`.
16 |
17 | ###`#!python content : str` { #content data-toc-label="content" }
18 |
19 | The content of the comment.
20 |
21 | ###`#!python reply_count : int` { #reply_count data-toc-label="reply_count" }
22 |
23 | The number of replies the comment has. If the comment is a reply, this is simply `#!python 0`.
24 |
25 | ###`#!python author : str` { #author data-toc-label="author" }
26 |
27 | The username of the author of the comment.
28 |
29 | ###`#!python author_id : int` { #author_id data-toc-label="author_id" }
30 |
31 | The user ID of the author of the comment.
32 |
33 | ###`#!python created_timestamp : str` { #created_timestamp data-toc-label="created_timestamp" }
34 |
35 | An [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) timestamp representing the date the comment was created.
36 |
37 | **Example:**
38 |
39 | ```python
40 | import datetime
41 |
42 | def iso_to_readable(iso):
43 | timezone = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo
44 |
45 | date = datetime.datetime.fromisoformat(iso.replace("Z", "+00:00"))
46 | date.astimezone(timezone)
47 |
48 | return date.strftime("%Y-%m-%d %I:%M %p")
49 |
50 | print(session.get_studio(14).get_comments()[0].created_timestamp)
51 | # 2022-08-04 10:47 AM
52 | ```
53 |
54 | ###`#!python last_modified_timestamp : str` { #last_modified_timestamp data-toc-label="last_modified_timestamp" }
55 |
56 | An [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) timestamp representing the date the comment was last modified.
57 |
58 | !!! note
59 | I have no idea what the hell this means.
60 |
61 | ###`#!python visible : bool` { #visible data-toc-label="visible" }
62 |
63 | A boolean value representing whether the comment has been deleted or not.
64 |
65 | ###`#!python studio : Studio` { #studio data-toc-label="studio" }
66 |
67 | The studio that the comment is on, as a [Studio](../Studio) object.
68 |
69 | ## Methods
70 |
71 | ###`#!python delete()` { #delete data-toc-label="delete" }
72 |
73 | Deletes the comment. You must be logged in, the author of the comment, and a manager of the studio that the comment is on for this to not throw an error.
74 |
75 | **Example:**
76 |
77 | ```python
78 | studio = session.get_studio(193293231031)
79 | for comment in studio.get_comments(all=True):
80 | if "scratch" in comment.content:
81 | comment.delete()
82 | ```
83 |
84 | ###`#!python report()` { #report data-toc-label="report" }
85 |
86 | Reports the comment. You must be logged in for this to not throw an error.
87 |
88 | ###`#!python reply(content)` { #reply data-toc-label="reply"
89 | }
90 |
91 | Replies to the comment. You must be logged in for this to not throw an error. Returns the reply once it is posted as a [StudioComment](../StudioComment).
92 |
93 | **PARAMETERS**
94 |
95 | - **content** (`#!python str`) - The content of your reply.
96 |
97 | **RETURNS** - `#!python StudioComment`
98 |
99 | **Example:**
100 |
101 | ```python
102 | comment = session.get_studio(14).get_comments()[0]
103 | comment.reply("Go away")
104 | ```
105 |
106 | ###`#!python get_replies(all=False, limit=20, offset=0)` { #get_replies data-toc-label="get_replies" }
107 |
108 | Gets a list of replies to the comment. Returns an array of [StudioComment](../StudioComment) objects.
109 |
110 | **PARAMETERS**
111 |
112 | - **all** (`#!python Optional[bool]`) - Whether to retrieve every single reply or just `#!python limit` replies.
113 | - **limit** (`#!python Optional[int]`) - How many replies to retrieve if `#!python all` is `#!python False`.
114 | - **offset** (`#!python Optional[int]`) - The offset of the replies from the newest ones - i.e. an offset of 20 would give you the next 20 replies after the first 20.
115 |
116 | **RETURNS** - `#!python list[StudioComment]`
117 |
--------------------------------------------------------------------------------
/docs/reference/UserProfile.md:
--------------------------------------------------------------------------------
1 | # **UserProfile**
2 |
3 | ## Properties
4 |
5 | ###`#!python user : User` { #user data-toc-label="user" }
6 |
7 | A [User](../User) object representing the user whose profile it is.
8 |
9 | **Example:**
10 |
11 | ```python
12 | profile = session.get_user("griffpatch").profile
13 | print(profile.user.id)
14 | # 1882674
15 | ```
16 |
17 | ###`#!python username : str` { #username data-toc-label="username" }
18 |
19 | The username of the owner of the profile.
20 |
21 | ###`#!python id : int` { #id data-toc-label="id" }
22 |
23 | The user's profile ID. This is not the same as their user ID.
24 |
25 | **Example:**
26 |
27 | ```python
28 | print(session.get_user("griffpatch").profile.id)
29 | # 1267661
30 | ```
31 |
32 | ###`#!python avatar_URL : str` { #avatar_URL data-toc-label="avatar_URL" }
33 |
34 | The URL of the user's avatar (profile picture).
35 |
36 | ###`#!python bio : str` { #bio data-toc-label="bio" }
37 |
38 | The user's bio (the "About Me" section of their profile).
39 |
40 | ###`#!python status : str` { #status data-toc-label="status" }
41 |
42 | The user's status (the "What I'm Working On" section of their profile).
43 |
44 | ###`#!python country : str` { #country data-toc-label="country" }
45 |
46 | The user's country (location).
47 |
48 | ```python
49 | print(session.get_user("griffpatch").profile.country)
50 | # United Kingdom
51 | ```
52 |
53 | ## Methods
54 |
55 | ###`#!python set_bio(content)` { #set_bio data-toc-label="set_bio" }
56 |
57 | Sets the bio ("About Me" section) of the user's profile to the specified content. You must be logged in and the owner of the profile for this to not throw an error.
58 |
59 | **PARAMETERS**
60 |
61 | - **content** (`#!python str`) - The content that you want to set the bio to.
62 |
63 | **Example:**
64 |
65 | ```python
66 | profile = session.user.profile
67 |
68 | profile.set_bio("I love Scratch :D")
69 | print(profile.bio)
70 | # I love Scratch :D
71 | ```
72 |
73 | ###`#!python set_status(content)` { #set_status data-toc-label="set_status" }
74 |
75 | Sets the status ("What I'm Working On" section) of the user's profile to the specified content. You must be logged in and the owner of the profile for this to not throw an error.
76 |
77 | **PARAMETERS**
78 |
79 | - **content** (`#!python str`) - The content that you want to set the status to.
80 |
81 | ###`#!python set_avatar(filename)` { #set_avatar data-toc-label="set_avatar" }
82 |
83 | Sets the user's avatar (profile picture) to the file with the specified `#!python filename`. You must be logged in and the owner of the profile for this to not throw an error.
84 |
85 | **PARAMETERS**
86 |
87 | - **filename** (`#!python str`) - The path to a file containing the avatar image. Note that this must be a file, not binary data; if you wish to use binary data, you could try writing the data to a temporary file, then deleting it afterwards.
88 |
89 | ###`#!python get_featured_project()` { #get_featured_project data-toc-label="get_featured_project" }
90 |
91 | Retrieves the featured project of the user. Returns an [IncompleteProject](../IncompleteProject) object representing the project.
92 |
93 | **RETURNS** - `#!python IncompleteProject`
94 |
95 | **Example:**
96 |
97 | ```python
98 | print(session.get_user("griffpatch").get_featured_project().id)
99 | # 10128407
100 | ```
101 |
102 | ###`#!python set_featured_project(label, project)` { #set_featured_project data-toc-label="set_featured_project" }
103 |
104 | Sets the user's featured project on their profile. You must be logged in and the owner of the profile for this to not throw an error.
105 |
106 | **PARAMETERS**
107 |
108 | - **label** (`#!python str`) - The label to go above the featured project. Must be one of the following strings:
109 | - `#!python "featured_project"` - Representing "Featured Project".
110 | - `#!python "featured_tutorial"` - Representing "Featured Tutorial".
111 | - `#!python "work_in_progress"` - Representing "Work In Progress".
112 | - `#!python "remix_this"` - Representing "Remix This".
113 | - `#!python "my_favorite_things"` - Representing "My Favorite Things".
114 | - `#!python "why_i_scratch"` - Representing "Why I Scratch".
115 | - **project** (`#!python int | Project | IncompleteProject | RemixtreeProject`) - The project to be set as the featured project. This must either be an `#!python int` representing the project's ID or a corresponding project object.
116 |
117 | **Example:**
118 |
119 | ```python
120 | session.user.profile.set_featured_project("why_i_scratch", 321079301972)
121 | print(session.user.profile.get_featured_project())
122 | # furry art compilation
123 | ```
124 |
--------------------------------------------------------------------------------
/docs/reference/ScrapingSession.md:
--------------------------------------------------------------------------------
1 | # **ScrapingSession**
2 |
3 | Used to scrape data that isn't provided cleanly by Scratch's website.
4 |
5 | ## Methods
6 |
7 | ###`#!python get_follower_count(user)` { #get_follower_count data-toc-label="get_follower_count" }
8 |
9 | Gets the follower count of a user.
10 |
11 | **PARAMETERS**
12 |
13 | - **user** (`#!python User | IncompleteUser | str`) - The username of the user you want to retrieve the follower count of, or a corresponding object representing them.
14 |
15 | **RETURNS** - `#!python int`
16 |
17 | ###`#!python get_following_count(user)` { #get_following_count data-toc-label="get_following_count" }
18 |
19 | Gets the number of users that a user is following.
20 |
21 | **PARAMETERS**
22 |
23 | - **user** (`#!python User | IncompleteUser | str`) - The username of the user you want to retrieve the following count of, or a corresponding object representing them.
24 |
25 | **RETURNS** - `#!python int`
26 |
27 | ###`#!python get_favorited_count(user)` { #get_favorited_count data-toc-label="get_favorited_count" }
28 |
29 | Gets the number of projects that a user has favorited.
30 |
31 | **PARAMETERS**
32 |
33 | - **user** (`#!python User | IncompleteUser | str`) - The username of the user you want to retrieve the favorite count of, or a corresponding object representing them.
34 |
35 | **RETURNS** - `#!python int`
36 |
37 | ###`#!python get_followed_studios_count(user)` { #get_followed_studios_count data-toc-label="get_followed_studios_count" }
38 |
39 | Gets the number of studios that a user has followed.
40 |
41 | **PARAMETERS**
42 |
43 | - **user** (`#!python User | IncompleteUser | str`) - The username of the user you want to retrieve the followed studios count of, or a corresponding object representing them.
44 |
45 | **RETURNS** - `#!python int`
46 |
47 | ###`#!python get_curated_studios_count(user)` { #get_curated_studios_count data-toc-label="get_curated_studios_count" }
48 |
49 | Gets the number of studios that a user curates.
50 |
51 | **PARAMETERS**
52 |
53 | - **user** (`#!python User | IncompleteUser | str`) - The username of the user you want to retrieve the curated studios count of, or a corresponding object representing them.
54 |
55 | **RETURNS** - `#!python int`
56 |
57 | ###`#!python get_shared_projects_count(user)` { #get_shared_projects_count data-toc-label="get_shared_projects_count" }
58 |
59 | Gets the number of projects that a user has shared.
60 |
61 | **PARAMETERS**
62 |
63 | - **user** (`#!python User | IncompleteUser | str`) - The username of the user you want to retrieve the shared project count of, or a corresponding object representing them.
64 |
65 | **RETURNS** - `#!python int`
66 |
67 | ###`#!python get_user_activity(user, max=100000)` { #get_user_activity data-toc-label="get_user_activity" }
68 |
69 | Retrieves a user's activity as an array of [Activity](../Activity) objects.
70 |
71 | **PARAMETERS**
72 |
73 | - **user** (`#!python User | IncompleteUser | str`) - The username of the user you want to retrieve the activity of, or a corresponding object representing them.
74 | - **max** (`#!python int`) - The maximum amount of items you want to retrieve. Note that there is no harm in making this absurdly large, since user activity from before a year ago is not available.
75 |
76 | **RETURNS** - `#!python list[Activity]`
77 |
78 | **Example:**
79 |
80 | ```python
81 | print(session.scraping.get_user_activity("griffpatch", max=1)[0].actor)
82 | # griffpatch
83 | ```
84 |
85 | ###`#!python get_profile_comments(user, all=False, page=1)` { #get_profile_comments data-toc-label="get_profile_comments" }
86 |
87 | Gets a list of comments on a user's profile as an array of [ProfileComment](../ProfileComment) objects.
88 |
89 | **PARAMETERS**
90 |
91 | - **user** (`#!python User | IncompleteUser | str`) - The username of the user you want to retrieve the profile comments of, or a corresponding object representing them.
92 | - **all** (`#!python bool`) - Whether to retrieve all of the user's comments or just one page of them.
93 | - **page** (`#!python page`) - If `#!python all` is `#!python False`, this is the page of profile comments to retrieve.
94 |
95 | **RETURNS** - `#!python list[ProfileComment]`
96 |
97 | **Example:**
98 |
99 | ```python
100 | print(session.scraping.get_profile_comments("griffpatch")[0].content)
101 | # Follow me please
102 | ```
103 |
104 | ###`#!python get_signature(post_id, as_html=False)` { #get_signature data-toc-label="get_signature" }
105 |
106 | Gets the signature at the bottom of a forum post with the specified ID.
107 |
108 | **PARAMETERS**
109 |
110 | - **post_id** (`#!python int`) - The ID of the post you want to retrieve the signature from.
111 | - **as_html** (`#!python bool`) - Whether you want the response in HTML or in BBCode. By default, the response is converted to BBCode.
112 |
113 | **RETURNS** - `#!python str`
114 |
115 | **Example:**
116 |
117 | ```python
118 | print(session.scraping.get_signature(5154718))
119 | # I use scratch.
120 | # GF: I'll dump you. BF: hex dump or binary dump?
121 | # ...
122 | ```
123 |
--------------------------------------------------------------------------------
/docs/reference/Activity.md:
--------------------------------------------------------------------------------
1 | # **Activity**
2 |
3 | ## Properties
4 |
5 | ###`#!python type : Literal["followuser"] | Literal["followstudio"] | Literal["loveproject"], Literal["favoriteproject"] | Literal["remixproject"] | Literal["becomecurator"] | Literal["becomeownerstudio"] | Literal["shareproject"] | Literal["addprojecttostudio"] | Literal["removeprojectstudio"] | Literal["updatestudio"] | Literal["removecuratorstudio"] | Literal["becomehoststudio"]` { #type data-toc-label="type" }
6 |
7 | The type of activity that the activity is. This can be any of the following:
8 |
9 | - `#!python "followuser"` - Occurs when the actor follows someone.
10 | - `#!python "followstudio"` - Occurs when the actor follows a studio.
11 | - `#!python "loveproject"` - Occurs when the actor loves a project.
12 | - `#!python "favoriteproject"` - Occurs when the actor favorites a project.
13 | - `#!python "remixproject"` - Occurs when the actor remixes a project.
14 | - `#!python "becomecurator"` - Occurs when someone becomes a curator of a studio.
15 | - `#!python "becomeownerstudio"` - Occurs when someone is promoted to manager of a studio.
16 | - `#!python "becomehoststudio"` - Occurs when someone becomes the host of a studio.
17 | - `#!python "shareproject"` - Occurs when the actor shares a project.
18 | - `#!python "addprojectotstudio"` - Occurs when someone adds a project to a studio.
19 | - `#!python "removeprojectstudio"` - Occurs when someone removes a project from a studio.
20 | - `#!python "updatestudio"` - Occurs when someone updates the title, thumbnail, or description of a studio.
21 | - `#!python "removecuratorstudio"` - Occurs when a curator is removed from a studio.
22 |
23 | ###`#!python actor : str` { #actor data-toc-label="actor" }
24 |
25 | The username of the person who caused the actvity (I.E. the person who loved a project or updated the title of a studio).
26 |
27 | ###`#!python created_timestamp : str` { #created_timestamp data-toc-label="created_timestamp" }
28 |
29 | An [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) timestamp representing the date the activity was created.
30 |
31 | ---
32 |
33 | A `#!python Activity` might have other attributes depending on its `#!python type` and where it came from.
34 |
35 | ###`#!python actor_id : int` { #actor_id data-toc-label="actor_id" }
36 |
37 | Appears everywhere except from calls to [ScrapingSession.get_user_activity](../ScrapingSession#get_user_activity). This is the user ID of the actor who caused the activity.
38 |
39 | ###`#!python id : str` { #id data-toc-label="id" }
40 |
41 | The ID of the activity. Appears only from calls to [Studio.get_activity](../Studio#get_activity). This is the activity `#!python type` followed by a hyphen `-` and some numbers.
42 |
43 | ###`#!python followed_username : str` { #followed_username data-toc-label="followed_username" }
44 |
45 | Appears when the `#!python type` is `#!python "followuser"`. This is the username of the user who has been followed.
46 |
47 | ###`#!python project_id : int` { #project_id data-toc-label="project_id" }
48 |
49 | Appears when the `#!python type` is either `#!python "loveproject"`, `#!python "favoriteproject"` or `#!python "remixproject"`. This is the ID of the project that was loved, favorited, or remixed.
50 |
51 | ###`#!python title : str` { #title data-toc-label="title" }
52 |
53 | Appears when the `#!python type` is either `#!python "followstudio"`, `#!python "loveproject"`, `#!python "remixproject"`, `#!python "becomecurator"`, or `#!python "shareproject"`. If the activity was related to a studio, this is the title of the studio it involved. Otherwise, this is the title of the project it involved.
54 |
55 | ###`#!python project_title : str` { #project_title data-toc-label="project_title" }
56 |
57 | Appears when the `#!python type` is `#!python "favoriteproject"`, `#!python "addprojecttostudio"`, or `#!python "removeprojectfromstudio"`. This is the title of the project that the activity involves.
58 |
59 | ###`#!python parent_id : int` { #parent_id data-toc-label="parent_id" }
60 |
61 | Appears when the `#!python type` is `#!python "remixproject"`. This is the ID of the parent project that has been remixed.
62 |
63 | ###`#!python parent_title : str` { #parent_title data-toc-label="parent_title" }
64 |
65 | Appears when the `#!python type` is `#!python "remixproject"`. This is the title of the parent project that has been remixed.
66 |
67 | ###`#!python recipient_username : str` { #recipient_username data-toc-label="recipient_username" }
68 |
69 | Appears when the `#!python type` is `#!python "becomeownerstudio"` or `#!python "becomehoststudio"`. This is the username of the user who has become manager or host of the studio.
70 |
71 | ###`#!python username : str` { #username data-toc-label="username" }
72 |
73 | Appears when the `#!python type` is `#!python "becomecurator"` or `#!python "removecuratorstudio"`. This is the username of the person who added or removed the curator.
74 |
75 | ###`#!python gallery_id : int` { #gallery_id data-toc-label="gallery_id" }
76 |
77 | Appears when the `#!python type` is `#!python "followstudio"`, `#!python "becomecurator"`, or `#!python "becomeownerstudio"`. This is the ID of the studio where the action occurred.
78 |
79 | ###`#!python gallery_title : str` { #gallery_title data-toc-label="gallery_title" }
80 |
81 | Appears when the `#!python type` is `#!python "becomeownerstudio"`. This is the title of the studio where the action occurred.
82 |
--------------------------------------------------------------------------------
/scratchclient/User.py:
--------------------------------------------------------------------------------
1 | import requests
2 | import json
3 |
4 | from .ScratchExceptions import UnauthorizedException
5 | from .UserProfile import *
6 | from .Comment import ProjectComment
7 | from .util import get_data_list
8 |
9 |
10 | class User:
11 | def __init__(self, data, client):
12 | global Project
13 | global Studio
14 | from .Project import Project
15 | from .Studio import Studio
16 |
17 | self._client = client
18 |
19 | self.id = data["id"]
20 | self.username = data["username"]
21 | self.joined_timestamp = data["history"]["joined"]
22 | self.scratchteam = data["scratchteam"]
23 | self.profile = UserProfile(data["profile"], self)
24 | self._headers = {
25 | "x-csrftoken": self._client.csrf_token,
26 | "X-Token": self._client.token,
27 | "x-requested-with": "XMLHttpRequest",
28 | "Cookie": f"scratchcsrftoken={self._client.csrf_token};scratchlanguage=en;scratchsessionsid={self._client.session_id};",
29 | "referer": f"https://scratch.mit.edu/users/{self.username}/",
30 | }
31 |
32 | def get_projects(self, all=False, limit=20, offset=0):
33 | return get_data_list(
34 | all,
35 | limit,
36 | offset,
37 | f"https://api.scratch.mit.edu/users/{self.username}/projects",
38 | lambda project: Project(
39 | {**project, "author": {**project["author"], "username": self.username}},
40 | self._client,
41 | ),
42 | )
43 |
44 | def get_curating(self, all=False, limit=20, offset=0):
45 | return get_data_list(
46 | all,
47 | limit,
48 | offset,
49 | f"https://api.scratch.mit.edu/users/{self.username}/studios/curate",
50 | lambda studio: Studio(studio, self._client),
51 | )
52 |
53 | def get_favorites(self, all=False, limit=20, offset=0):
54 | return get_data_list(
55 | all,
56 | limit,
57 | offset,
58 | f"https://api.scratch.mit.edu/users/{self.username}/favorites",
59 | lambda project: Project(project, self._client),
60 | )
61 |
62 | def get_followers(self, all=False, limit=20, offset=0):
63 | return get_data_list(
64 | all,
65 | limit,
66 | offset,
67 | f"https://api.scratch.mit.edu/users/{self.username}/followers",
68 | lambda follower: User(follower, self._client),
69 | )
70 |
71 | def get_following(self, all=False, limit=20, offset=0):
72 | return get_data_list(
73 | all,
74 | limit,
75 | offset,
76 | f"https://api.scratch.mit.edu/users/{self.username}/following",
77 | lambda follower: User(follower, self._client),
78 | )
79 |
80 | def get_message_count(self):
81 | return requests.get(
82 | f"https://api.scratch.mit.edu/users/{self.username}/messages/count/",
83 | headers={
84 | "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.101 Safari/537.36",
85 | },
86 | ).json()["count"]
87 |
88 | def post_comment(self, content, parent_id="", commentee_id=""):
89 | self._client._ensure_logged_in()
90 |
91 | data = {
92 | "commentee_id": commentee_id,
93 | "content": content,
94 | "parent_id": parent_id,
95 | }
96 | response = requests.post(
97 | f"https://scratch.mit.edu/site-api/comments/user/{self.username}/add",
98 | headers=self._headers,
99 | data=json.dumps(data),
100 | )
101 |
102 | if response.status_code == 403:
103 | raise UnauthorizedException("You are not allowed to do that")
104 |
105 | def delete_comment(self, comment_id):
106 | return ProjectComment._comment_action(
107 | None, "del", comment_id, self.username, self._client
108 | )
109 |
110 | def report_comment(self, comment_id):
111 | return ProjectComment._comment_action(
112 | None, "rep", comment_id, self.username, self._client
113 | )
114 |
115 | def report(self, field):
116 | self._client._ensure_logged_in()
117 |
118 | data = {"selected_field": field}
119 | requests.post(
120 | f"https://scratch.mit.edu/site-api/users/all/{self.username}/report",
121 | headers=self._headers,
122 | data=json.dumps(data),
123 | )
124 |
125 | def toggle_commenting(self):
126 | self._client._ensure_logged_in()
127 |
128 | if self.username != self._client.username:
129 | raise UnauthorizedException("You are not allowed to do that")
130 |
131 | requests.post(
132 | f"https://scratch.mit.edu/site-api/comments/user/{self.username}/toggle-comments/",
133 | headers=self._headers,
134 | )
135 |
136 | def follow(self):
137 | self._client._ensure_logged_in()
138 |
139 | return requests.put(
140 | f"https://scratch.mit.edu/site-api/users/followers/{self.username}/add/?usernames={self._client.username}",
141 | headers=self._headers,
142 | ).json()
143 |
144 | def unfollow(self):
145 | self._client._ensure_logged_in()
146 |
147 | return requests.put(
148 | f"https://scratch.mit.edu/site-api/users/followers/{self.username}/remove/?usernames={self._client.username}",
149 | headers=self._headers,
150 | ).json()
151 |
--------------------------------------------------------------------------------
/scratchclient/Comment.py:
--------------------------------------------------------------------------------
1 | import requests
2 |
3 | from .ScratchExceptions import *
4 | from .util import get_data_list
5 |
6 |
7 | class ProjectComment:
8 | def __init__(self, project, data, client):
9 | self.id = data["id"]
10 | self.parent_id = data["parent_id"]
11 | self.commentee_id = data["commentee_id"]
12 | self.content = data["content"]
13 | self.reply_count = data["reply_count"]
14 |
15 | self.author = data["author"]["username"]
16 | self.author_id = data["author"]["id"]
17 |
18 | self.created_timestamp = data["datetime_created"]
19 | self.last_modified_timestamp = data["datetime_modified"]
20 |
21 | self.visible = data["visibility"] == "visible"
22 |
23 | self.project = project
24 | self._client = client
25 |
26 | def delete(self):
27 | self._client._ensure_logged_in()
28 |
29 | if self._client.username != self.project.author.username:
30 | raise UnauthorizedException("You are not allowed to do that")
31 |
32 | requests.delete(
33 | f"https://api.scratch.mit.edu/proxy/comments/project/{self.project.id}/comment/{self.id}",
34 | headers=self.project._headers,
35 | )
36 |
37 | def report(self):
38 | self._client._ensure_logged_in()
39 |
40 | requests.post(
41 | f"https://api.scratch.mit.edu/proxy/comments/project/{self.project.id}/comment/{self.id}",
42 | headers=self.project._headers,
43 | )
44 |
45 | def reply(self, content):
46 | self._client._ensure_logged_in()
47 |
48 | if not self.project.comments_allowed:
49 | raise UnauthorizedException("Comments are closed on this project")
50 |
51 | return self.project.post_comment(content, self.id, self.author_id)
52 |
53 | def get_replies(self):
54 | return get_data_list(
55 | all,
56 | limit,
57 | offset,
58 | f"https://api.scratch.mit.edu/projects/{self.project_id}/comments/{self.id}/replies",
59 | lambda reply: ProjectComment(self.project, reply, self._client),
60 | )
61 |
62 |
63 | class StudioComment:
64 | def __init__(self, studio, data, client):
65 | self.id = data["id"]
66 | self.parent_id = data["parent_id"]
67 | self.commentee_id = data["commentee_id"]
68 | self.content = data["content"]
69 | self.reply_count = data["reply_count"]
70 |
71 | self.author = data["author"]["username"]
72 | self.author_id = data["author"]["id"]
73 |
74 | self.created_timestamp = data["datetime_created"]
75 | self.last_modified_timestamp = data["datetime_modified"]
76 |
77 | self.visible = data["visibility"] == "visible"
78 |
79 | self.studio = studio
80 | self._client = client
81 |
82 | def delete(self):
83 | self._client._ensure_logged_in()
84 |
85 | response = requests.delete(
86 | f"https://api.scratch.mit.edu/proxy/comments/studio/{self.studio.id}/comment/{self.id}",
87 | headers=self.project._headers,
88 | )
89 |
90 | if response.status_code == 403:
91 | raise UnauthorizedException("You are not allowed to do that")
92 |
93 | def report(self):
94 | self._client._ensure_logged_in()
95 |
96 | requests.post(
97 | f"https://api.scratch.mit.edu/proxy/comments/studio/{self.studio.id}/comment/{self.id}",
98 | headers=self.project._headers,
99 | )
100 |
101 | def reply(self, content):
102 | self._client._ensure_logged_in()
103 |
104 | if not self.studio.comments_allowed:
105 | raise UnauthorizedException("Comments are closed on this studio")
106 |
107 | return self.studio.post_comment(content, self.id, self.author_id)
108 |
109 | def get_replies(self, all=False, limit=20, offset=0):
110 | return get_data_list(
111 | all,
112 | limit,
113 | offset,
114 | f"https://api.scratch.mit.edu/studios/{self.studio_id}/comments/{self.id}/replies",
115 | lambda reply: StudioComment(self.studio, reply, self._client),
116 | )
117 |
118 |
119 | class ProfileComment:
120 | def __init__(self, data, client, user):
121 | self.id = data["id"]
122 | self.parent_id = data["parent_id"]
123 | self.commentee_id = data["commentee_id"]
124 | self.content = data["content"]
125 | self.replies = data["replies"]
126 |
127 | self.author = data["author"]["username"]
128 | self.author_id = data["author"]["id"]
129 |
130 | self.created_timestamp = data["datetime_created"]
131 | self.last_modified_timestamp = data["datetime_modified"]
132 |
133 | self.visible = data["visibility"] == "visible"
134 |
135 | self.user = user
136 | self._client = client
137 |
138 | def _comment_action(self, action, comment_id, user, client):
139 | client._ensure_logged_in()
140 |
141 | data = {
142 | "id": comment_id,
143 | }
144 |
145 | response = requests.post(
146 | f"https://scratch.mit.edu/site-api/comments/user/{user}/{action}/",
147 | headers=client._headers,
148 | data=json.dumps(data),
149 | )
150 |
151 | if response.status_code == 403:
152 | raise UnauthorizedException("You are not allowed to do that")
153 |
154 | def delete(self):
155 | self._comment_action("del", self.id, self.user, self._client)
156 |
157 | def report(self):
158 | self._comment_action("rep", self.id, self.user, self._client)
159 |
160 | def reply(self, content):
161 | self._client._ensure_logged_in()
162 |
163 | if not self.studio.comments_allowed:
164 | raise UnauthorizedException("Comments are closed on this profile")
165 |
166 | self._client.get_user(self.user).post_comment(
167 | content, self.id, self.author_id
168 | )
169 |
--------------------------------------------------------------------------------
/docs/reference/Message.md:
--------------------------------------------------------------------------------
1 | # **Message**
2 |
3 | ## Properties
4 |
5 | ###`#!python type : Literal["followuser"] | Literal["loveproject"] | Literal["favoriteproject"] | Literal["addcomment"] | Literal["curatorinvite"] | Literal["remixproject"] | Literal["studioactivity"] | Literal["forumpost"] | Literal["becomeownerstudio"] | Literal["becomehoststudio"] | Literal["userjoin"]` { #type data-toc-label="type" }
6 |
7 | The type of message that the message is. This can be any of the following:
8 |
9 | - `#!python "followuser"` - Received when someone follows you.
10 | - `#!python "loveproject"` - Received when someone loves one of your projects.
11 | - `#!python "favoriteproject"` - Received when someone favorites one of your projects.
12 | - `#!python "addcomment"` - Received when someone comments on your profile or replies to one of your comments.
13 | - `#!python "curatorinvite"` - Received when you are invited to become a curator of a studio.
14 | - `#!python "remixproject"` - Received when someone remixes one of your projects.
15 | - `#!python "studioactivity"` - Received when there is activity in a studio that you curate.
16 | - `#!python "forumpost"` - Received when there is a post on a forum topic you either follow or own.
17 | - `#!python "becomeownerstudio"` - Received when you become manager of a studio.
18 | !!! note
19 | I wonder if this is why they changed the name to "host" instead of "owner".
20 | - `#!python "becomehoststudio"` - Received when you become the host of a studio.
21 | - `#!python "userjoin"` - Received when you join Scratch.
22 |
23 | ###`#!python actor : str` { #actor data-toc-label="actor" }
24 |
25 | The username of the person who caused the message to be sent (I.E. the person who sent a comment or caused activity in a studio).
26 |
27 | ###`#!python actor_id : int` { #actor_id data-toc-label="actor_id" }
28 |
29 | The user ID of the person who caused the message to be sent.
30 |
31 | ###`#!python created_timestamp : str` { #created_timestamp data-toc-label="created_timestamp" }
32 |
33 | An [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) timestamp representing the date the message was created.
34 |
35 | ---
36 |
37 | A `#!python Message` might have other attributes depending on its `#!python type`.
38 |
39 | ###`#!python project_id : int` { #project_id data-toc-label="project_id" }
40 |
41 | Appears when the `#!python type` is either `#!python "loveproject"`, `#!python "favoriteproject"` or `#!python "remixproject"`. This is the ID of the project that was loved, favorited, or remixed.
42 |
43 | ###`#!python title : str` { #title data-toc-label="title" }
44 |
45 | Appears when the `#!python type` is either `#!python "loveproject"`, `#!python "remixproject"`, `#!python "curatorinvite"`, or `#!python "studioactivity"`. If the `#!python type` is `#!python "loveproject"` or `#!python "remixproject"`, this is the title of the project where the action occurred. Otherwise, this is the title of the studio where the action occurred.
46 |
47 | ###`#!python project_title : str` { #project_title data-toc-label="project_title" }
48 |
49 | Appears when the `#!python type` is `#!python "favoriteproject"`. This is the title of the project being favorited.
50 |
51 | ###`#!python parent_id : int` { #parent_id data-toc-label="parent_id" }
52 |
53 | Appears when the `#!python type` is `#!python "remixproject"`. This is the ID of the parent project that has been remixed.
54 |
55 | ###`#!python parent_title : str` { #parent_title data-toc-label="parent_title" }
56 |
57 | Appears when the `#!python type` is `#!python "remixproject"`. This is the title of the parent project that has been remixed.
58 |
59 | ###`#!python comment_id : int` { #comment_id data-toc-label="comment_id" }
60 |
61 | Appears when the `#!python type` is `#!python "addcomment"`. This is the ID of the comment that was sent.
62 |
63 | ###`#!python comment_fragment : str` { #comment_fragment data-toc-label="comment_fragment" }
64 |
65 | Appears when the `#!python type` is `#!python "addcomment"`. This is the fragment of the comment that is shown in the message.
66 |
67 | ###`#!python commentee_username : str | None` { #commentee_username data-toc-label="commentee_username" }
68 |
69 | Appears when the `#!python type` is `#!python "addcomment"`. If the comment is a reply, this is the username of the person who was replied to. Otherwise, this is `#!python None`.
70 |
71 | ###`#!python comment_obj_id : int` { #comment_obj_id data-toc-label="comment_obj_id" }
72 |
73 | Appears when the `#!python type` is `#!python "addcomment"`. This is the ID of the user, project, or studio where the comment was posted.
74 |
75 | ###`#!python comment_obj_title : str` { #comment_obj_title data-toc-label="comment_obj_title" }
76 |
77 | Appears when the `#!python type` is `#!python "addcomment"`. If the comment occurred on a studio or project, this is the title of the studio or project. Otherwise, this is the username of the user whose profile the comment was posted on.
78 |
79 | ###`#!python comment_type : Literal[0] | Literal[1] | Literal[2]` { #comment_type data-toc-label="comment_type" }
80 |
81 | Appears when the `#!python type` is `#!python "addcomment"`. If the comment occurred on a project, this is `#!python 0`. If it occurred on a profile, this is `#!python 1`. If it occurred on a studio, this is `#!python 2`.
82 |
83 | ###`#!python gallery_id : int` { #gallery_id data-toc-label="gallery_id" }
84 |
85 | Appears when the `#!python type` is `#!python "curatorinvite"`, `#!python "studioactivity"`, `#!python "becomeownerstudio"`, or `#!python "becomehoststudio"`. This is the ID of the studio where the action occurred.
86 |
87 | ###`#!python gallery_title : str` { #gallery_title data-toc-label="gallery_title" }
88 |
89 | Appears when the `#!python type` is `#!python "becomeownerstudio"`, or `#!python "becomehoststudio"`. This is the title of the studio where the action occurred.
90 |
91 | ###`#!python topic_id : int` { #topic_id data-toc-label="topic_id" }
92 |
93 | Appears when the `#!python type` is `#!python "forumpost"`. This is the ID of the topic where the post occurred.
94 |
95 | ###`#!python topic_title : str` { #topic_title data-toc-label="topic_title" }
96 |
97 | Appears when the `#!python type` is `#!python "forumpost"`. This is the title of the topic where the post occurred.
98 |
--------------------------------------------------------------------------------
/test/test.py:
--------------------------------------------------------------------------------
1 | # Tests a bunch of stuff, just to make sure it works.
2 | # Mainly just for me to test the library, not meant for
3 | # anyone else.
4 |
5 | import time
6 |
7 | from scratchclient import ScratchSession
8 |
9 | session = ScratchSession()
10 |
11 | # Scratch user with semi-average stats
12 | user = session.get_user("codubee")
13 | print(vars(user))
14 | print(user.get_projects(all=True))
15 | print(user.get_curating())
16 | print(user.get_favorites())
17 | print(user.get_following(all=True))
18 | print(user.get_followers())
19 | print(user.get_message_count())
20 | print(user.get_featured_project())
21 |
22 | project = session.get_project(104)
23 | print(vars(project))
24 | print(vars(project.get_comment(488)))
25 | print(project.get_scripts())
26 | print(project.get_remixtree())
27 | print(project.get_remixes(all=True))
28 | print(project.get_studios())
29 | print(project.get_comments())
30 |
31 | studio = session.get_studio(30136012)
32 | print(vars(studio))
33 | print(studio.get_projects(all=True))
34 | print(studio.get_curators())
35 | print(studio.get_managers(all=True))
36 | print(studio.get_comments())
37 | print(studio.get_activity())
38 |
39 | print(session.get_news())
40 | print(session.explore_projects())
41 | print(session.explore_studios(query="test"))
42 | print(session.search_projects(query="the", language="es"))
43 | print(session.search_studios(query="test", mode="trending"))
44 | print(session.get_front_page())
45 | print(session.forums.get_post_source(5154718))
46 | print(vars(session.forums.get_latest_topic_posts(506810)[0]))
47 | print(session.forums.get_latest_category_posts(1))
48 | print(session.scraping.get_signature(6371373))
49 | print(session.scraping.get_signature(6373382))
50 |
51 | session = ScratchSession(sys.argv[1], sys.argv[2])
52 |
53 | print(session.get_messages())
54 | print(session.get_activity())
55 |
56 | print(session.get_own_projects())
57 | print(session.get_own_studios())
58 |
59 | project_json = {
60 | "targets": [
61 | {
62 | "isStage": True,
63 | "name": "Stage",
64 | "variables": {"`jEk@4|i[#Fk?(8x)AV.-my variable": ["my variable", 0]},
65 | "lists": {},
66 | "broadcasts": {},
67 | "blocks": {},
68 | "comments": {},
69 | "currentCostume": 0,
70 | "costumes": [
71 | {
72 | "name": "backdrop1",
73 | "dataFormat": "svg",
74 | "assetId": "cd21514d0531fdffb22204e0ec5ed84a",
75 | "md5ext": "cd21514d0531fdffb22204e0ec5ed84a.svg",
76 | "rotationCenterX": 240,
77 | "rotationCenterY": 180,
78 | }
79 | ],
80 | "sounds": [
81 | {
82 | "name": "pop",
83 | "assetId": "83a9787d4cb6f3b7632b4ddfebf74367",
84 | "dataFormat": "wav",
85 | "format": "",
86 | "rate": 48000,
87 | "sampleCount": 1123,
88 | "md5ext": "83a9787d4cb6f3b7632b4ddfebf74367.wav",
89 | }
90 | ],
91 | "volume": 100,
92 | "layerOrder": 0,
93 | "tempo": 60,
94 | "videoTransparency": 50,
95 | "videoState": "on",
96 | "textToSpeechLanguage": None,
97 | },
98 | {
99 | "isStage": False,
100 | "name": "Sprite1",
101 | "variables": {},
102 | "lists": {},
103 | "broadcasts": {},
104 | "blocks": {},
105 | "comments": {},
106 | "currentCostume": 0,
107 | "costumes": [
108 | {
109 | "name": "costume1",
110 | "bitmapResolution": 1,
111 | "dataFormat": "svg",
112 | "assetId": "bcf454acf82e4504149f7ffe07081dbc",
113 | "md5ext": "bcf454acf82e4504149f7ffe07081dbc.svg",
114 | "rotationCenterX": 48,
115 | "rotationCenterY": 50,
116 | },
117 | {
118 | "name": "costume2",
119 | "bitmapResolution": 1,
120 | "dataFormat": "svg",
121 | "assetId": "0fb9be3e8397c983338cb71dc84d0b25",
122 | "md5ext": "0fb9be3e8397c983338cb71dc84d0b25.svg",
123 | "rotationCenterX": 46,
124 | "rotationCenterY": 53,
125 | },
126 | ],
127 | "sounds": [
128 | {
129 | "name": "Meow",
130 | "assetId": "83c36d806dc92327b9e7049a565c6bff",
131 | "dataFormat": "wav",
132 | "format": "",
133 | "rate": 48000,
134 | "sampleCount": 40681,
135 | "md5ext": "83c36d806dc92327b9e7049a565c6bff.wav",
136 | }
137 | ],
138 | "volume": 100,
139 | "layerOrder": 1,
140 | "visible": True,
141 | "x": 0,
142 | "y": 0,
143 | "size": 100,
144 | "direction": 90,
145 | "draggable": False,
146 | "rotationStyle": "all around",
147 | },
148 | ],
149 | "monitors": [],
150 | "extensions": [],
151 | "meta": {
152 | "semver": "3.0.0",
153 | "vm": "0.2.0-prerelease.20220601111129",
154 | "agent": "Mozilla/5.0 (X11; CrOS x86_64 14588.123.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.72 Safari/537.36",
155 | },
156 | }
157 |
158 | project_id = session.create_project(project_json)
159 | project = session.get_project(project_id)
160 | project.love()
161 | project.favorite()
162 | project.unlove()
163 | project.unfavorite()
164 |
165 | project_json["meta"][
166 | "agent"
167 | ] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.101 Safari/537.36"
168 | project.save(project_json)
169 | print(project.get_scripts())
170 | project.toggle_commenting()
171 | project.view()
172 | project.set_title("test")
173 | project.share()
174 | project.turn_on_commenting()
175 | comment = project.post_comment("testing")
176 | print(vars(comment))
177 | time.sleep(5)
178 | reply = comment.reply("testing2")
179 | print(vars(reply))
180 | time.sleep(5)
181 | other_comment = project.post_comment("testing3")
182 | other_comment.delete()
183 | project.unshare()
184 | project.delete()
185 |
186 | studio = session.get_studio(session.create_studio())
187 | studio.set_title("test")
188 | studio.set_description("test")
189 | studio.add_project(104)
190 | studio.add_project(105)
191 | studio.remove_project(105)
192 | studio.follow()
193 | studio.unfollow()
194 | studio.toggle_commenting()
195 | studio.toggle_commenting()
196 | comment = studio.post_comment("testing")
197 | print(vars(comment))
198 | time.sleep(5)
199 | reply = comment.reply("testing2")
200 | print(vars(reply))
201 | time.sleep(5)
202 | other_comment = studio.post_comment("testing3")
203 | other_comment.delete()
204 | studio.delete()
205 |
--------------------------------------------------------------------------------
/scratchclient/Forums.py:
--------------------------------------------------------------------------------
1 | import requests
2 | import xml.etree.ElementTree
3 |
4 | from .ScratchExceptions import *
5 |
6 | ns = {"atom": "http://www.w3.org/2005/Atom"}
7 |
8 |
9 | class ForumPost:
10 | def __init__(self, data):
11 | self.title = data["title"]
12 | self.link = data["link"]
13 | self.published = data["published"]
14 |
15 | self.author = data["author"]
16 | self.id = data["id"]
17 | self.content = data["summary"]
18 |
19 |
20 | class ForumSession:
21 | def __init__(self, client):
22 | self._client = client
23 | self._headers = {
24 | "x-csrftoken": self._client.csrf_token,
25 | "X-Token": self._client.token,
26 | "x-requested-with": "XMLHttpRequest",
27 | "Cookie": f"scratchcsrftoken={self._client.csrf_token};scratchlanguage=en;scratchsessionsid={self._client.session_id};",
28 | }
29 |
30 | def create_topic(self, category_id, title, body):
31 | self._client._ensure_logged_in()
32 |
33 | headers = {
34 | **self._headers,
35 | "referer": f"https://scratch.mit.edu/discuss/{category_id}/topic/add",
36 | }
37 |
38 | data = {
39 | "csrfmiddlewaretoken": self._client.csrf_token,
40 | "name": title,
41 | "body": body,
42 | "subscribe": "on",
43 | "AddPostForm": "",
44 | }
45 |
46 | requests.post(
47 | f"https://scratch.mit.edu/discuss/{category_id}/topic/add",
48 | headers=headers,
49 | files=data,
50 | )
51 |
52 | def post(self, topic_id, content):
53 | self._client._ensure_logged_in()
54 |
55 | headers = {
56 | **self._headers,
57 | "referer": f"https://scratch.mit.edu/discuss/topic/{topic_id}/",
58 | }
59 |
60 | response = requests.post(
61 | f"https://scratch.mit.edu/discuss/topic/{topic_id}/?",
62 | headers=headers,
63 | files={
64 | "csrfmiddlewaretoken": self._client.csrf_token,
65 | "body": content,
66 | "AddPostForm": "",
67 | },
68 | )
69 |
70 | if response.status_code == 403:
71 | raise UnauthorizedException("This topic is closed")
72 |
73 | def edit_post(self, post_id, content):
74 | self._client._ensure_logged_in()
75 |
76 | headers = {
77 | **self._headers,
78 | "referer": f"https://scratch.mit.edu/discuss/post/{post_id}/edit",
79 | }
80 |
81 | data = {
82 | "csrfmiddlewaretoken": self._client.csrf_token,
83 | "body": content,
84 | }
85 |
86 | response = requests.post(
87 | f"https://scratch.mit.edu/discuss/post/{post_id}/edit",
88 | headers=headers,
89 | data=data,
90 | )
91 |
92 | if response.status_code == 403:
93 | raise UnauthorizedException("This post is not yours")
94 |
95 | def report_post(self, post_id, reason):
96 | self._client._ensure_logged_in()
97 |
98 | headers = {
99 | **self._headers,
100 | "referer": f"https://scratch.mit.edu/discuss/misc/?action=report&post_id={post_id}",
101 | }
102 |
103 | data = {
104 | "csrfmiddlewaretoken": self._client.csrf_token,
105 | "post": post_id,
106 | "reason": reason,
107 | "submit": "",
108 | }
109 |
110 | requests.post(
111 | f"https://scratch.mit.edu/discuss/misc/?action=report&post_id={post_id}",
112 | headers=headers,
113 | data=data,
114 | )
115 |
116 | def get_post_source(self, post_id):
117 | return requests.get(
118 | f"https://scratch.mit.edu/discuss/post/{post_id}/source/",
119 | ).text
120 |
121 | def follow_topic(self, topic_id):
122 | self._client._ensure_logged_in()
123 |
124 | headers = {
125 | **self._headers,
126 | "referer": f"https://scratch.mit.edu/discuss/topic/{topic_id}/",
127 | }
128 |
129 | requests.post(
130 | f"https://scratch.mit.edu/discuss/subscription/topic/{topic_id}/add/",
131 | headers=headers,
132 | )
133 |
134 | def unfollow_topic(self, topic_id):
135 | self._client._ensure_logged_in()
136 |
137 | headers = {
138 | **self._headers,
139 | "referer": f"https://scratch.mit.edu/discuss/topic/{topic_id}/",
140 | }
141 |
142 | requests.post(
143 | f"https://scratch.mit.edu/discuss/subscription/topic/{topic_id}/delete/",
144 | headers=headers,
145 | )
146 |
147 | def change_signature(self, signature):
148 | self._client._ensure_logged_in()
149 |
150 | headers = {
151 | **self._headers,
152 | "referer": f"https://scratch.mit.edu/discuss/settings/{self._client.username}/",
153 | }
154 |
155 | data = {
156 | "csrfmiddlewaretoken": self._client.csrf_token,
157 | "signature": signature,
158 | "update": "",
159 | }
160 |
161 | requests.post(
162 | f"https://scratch.mit.edu/discuss/settings/{self._client.username}/",
163 | headers=headers,
164 | data=data,
165 | )
166 |
167 | def get_latest_topic_posts(self, topic_id):
168 | rss_feed = requests.get(
169 | f"https://scratch.mit.edu/discuss/feeds/topic/{topic_id}/",
170 | ).text
171 | root = xml.etree.ElementTree.fromstring(rss_feed)
172 |
173 | return [
174 | ForumPost(
175 | {
176 | "title": post.find("atom:title", ns).text,
177 | "link": post.find("atom:link", ns).attrib["href"],
178 | "published": post.find("atom:published", ns).text,
179 | "author": post.find("atom:author", ns).find("atom:name", ns).text,
180 | "id": int(post.find("atom:id", ns).text),
181 | "summary": post.find("atom:summary", ns).text,
182 | }
183 | )
184 | for post in root.findall("atom:entry", ns)
185 | ]
186 |
187 | def get_latest_category_posts(self, category_id):
188 | rss_feed = requests.get(
189 | f"https://scratch.mit.edu/discuss/feeds/forum/{category_id}/",
190 | ).text
191 | root = xml.etree.ElementTree.fromstring(rss_feed)
192 |
193 | return [
194 | ForumPost(
195 | {
196 | "title": post.find("atom:title", ns).text,
197 | "link": post.find("atom:link", ns).attrib["href"],
198 | "published": post.find("atom:published", ns).text,
199 | "author": post.find("atom:author", ns).find("atom:name", ns).text,
200 | "id": int(post.find("atom:id", ns).text),
201 | "summary": post.find("atom:summary", ns).text,
202 | }
203 | )
204 | for post in root.findall("atom:entry", ns)
205 | ]
206 |
--------------------------------------------------------------------------------
/docs/reference/CloudConnection.md:
--------------------------------------------------------------------------------
1 | # CloudConnection
2 |
3 | ## Properties
4 |
5 | ###`#!python project_id : int` { #project_id data-toc-label="project_id" }
6 |
7 | The ID of the project that the connection is on.
8 |
9 | ###`#!python cloud_host : str` { #cloud_host data-toc-label="cloud_host" }
10 |
11 | The hostname of the server where the cloud variables are hosted.
12 |
13 | ## Methods
14 |
15 | ###`#!python get_cloud_variable(name)` { #get_cloud_variable data-toc-label="get_cloud_variable" }
16 |
17 | Gets the value of a cloud variable with the specified name.
18 |
19 | **PARAMETERS**
20 |
21 | - **name** (`#!python str`) - The name of the variable. The name does not necessarily need to include the cloud emoji ("☁️ ").
22 |
23 | **RETURNS** - `#!python str`
24 |
25 | **Example:**
26 |
27 | ```python
28 | connection = session.create_cloud_connection(193290310931)
29 | print(connection.get_cloud_variable("High score"))
30 | # 102930921
31 | ```
32 |
33 | ###`#!python set_cloud_variable(name, value)` { #set_cloud_variable data-toc-label="set_cloud_variable" }
34 |
35 | Sets the value of a cloud variable with the specified name to the specified value. You can only do this 10 times per second.
36 |
37 | **PARAMETERS**
38 |
39 | - **name** (`#!python str`) - The name of the variable. The name does not necessarily need to include the cloud emoji ("☁️ ").
40 | - **value** (`#!python str`) - The value you want to set the cloud variable to. This must be less than 256 characters long and all digits.
41 |
42 | **Example:**
43 |
44 | ```python
45 | connection = session.create_cloud_connection(193290310931)
46 | connection.set_cloud_variable("High score", 102930921)
47 | print(connection.get_cloud_variable("High score"))
48 | # 102930921
49 | ```
50 |
51 | ###`#!python create_cloud_variable(name, initial_value=0)` { #create_cloud_variable data-toc-label="create_cloud_variable" }
52 |
53 | Creates a cloud variable with the specified name and sets it to the specified initial value. You can only do this 10 times per second.
54 |
55 | **PARAMETERS**
56 |
57 | - **name** (`#!python str`) - The name of the new variable. The name does not necessarily need to include the cloud emoji ("☁️ ").
58 | - **initial_value** (`#!python int`) - The value you want to set the cloud variable to. This must be less than 256 characters long and all digits.
59 |
60 | **Example:**
61 |
62 | ```python
63 | connection = session.create_cloud_connection(193290310931)
64 | connection.create_cloud_variable("High score", 10)
65 | ```
66 |
67 | !!! note
68 | This will not update live for other people using the project.
69 |
70 | ###`#!python delete_cloud_variable(name)` { #delete_cloud_variable data-toc-label="delete_cloud_variable" }
71 |
72 | Deletes a cloud variable with the specified name. You can only do this 10 times per second.
73 |
74 | **PARAMETERS**
75 |
76 | - **name** (`#!python str`) - The name of the variable to be deleted. The name does not necessarily need to include the cloud emoji ("☁️ ").
77 |
78 | **Example:**
79 |
80 | ```python
81 | connection = session.create_cloud_connection(193290310931)
82 | connection.delete_cloud_variable("High score")
83 | ```
84 |
85 | !!! note
86 | This will not update live for other people using the project.
87 |
88 | ###`#!python on(key, callback=None, once=False)` { #on data-toc-label="on" }
89 |
90 | Adds an event for the connection listen to. This can either be used as a decorator or a function.
91 |
92 | **PARAMETERS**
93 |
94 | - **key** (`#!python str`) - The key of the event to be listened to.
95 | - **callback** (`#!python callable`) - The function that will run when the event occurs.
96 | - **once** (`#!python bool`) - Whether the event should only be fired once.
97 |
98 | **RETURNS** - `#!python None | callable`
99 |
100 | **Example:**
101 |
102 | ```python
103 | # Use as a function
104 | def on_set(variable):
105 | print(variable.name, variable.value)
106 |
107 | connection.on("set", on_set)
108 |
109 | # Use as a decorator
110 | @connection.on("set")
111 | def on_set(variable):
112 | print(variable.name, variable.value)
113 | ```
114 |
115 | ###`#!python off(key, callback)` { #off data-toc-label="off" }
116 |
117 | Removes an event that the connection was listening to.
118 |
119 | **PARAMETERS**
120 |
121 | - **key** (`#!python str`) - The key of the event to be removed.
122 | - **callback** (`#!python callable`) - The function that runs when the event occurs.
123 |
124 | **Example:**
125 |
126 | ```python
127 | def on_set(variable):
128 | print(variable.name, variable.value)
129 |
130 | connection.on("set", on_set)
131 | connection.off("set", on_set)
132 | ```
133 |
134 | ###`#!python once(key, callback=None)` { #once data-toc-label="once" }
135 |
136 | Adds an event for the connection listen to. The event will only be fired once. This can either be used as a decorator or a function.
137 |
138 | **PARAMETERS**
139 |
140 | - **key** (`#!python str`) - The key of the event to be listened to.
141 | - **callback** (`#!python callable`) - The function that will run when the event occurs.
142 |
143 | **RETURNS** - `#!python None | callable`
144 |
145 | **Example:**
146 |
147 | ```python
148 | # Use as a function
149 | def on_set(variable):
150 | print(variable.name, variable.value)
151 |
152 | connection.once("set", on_set)
153 |
154 | # Use as a decorator
155 | @connection.once("set")
156 | def on_set(variable):
157 | print(variable.name, variable.value)
158 | ```
159 |
160 | ###`#!python listeners(event)` { #listeners data-toc-label="listeners" }
161 |
162 | Returns all the functions that are attached to the event `#!python event`.
163 |
164 | **PARAMETERS**
165 |
166 | - **event** (`#!python event`) - The key of the event that you want to retrieve the listeners of.
167 |
168 | **RETURNS** - `#!python list[callable]`
169 |
170 | **Example:**
171 |
172 | ```python
173 | @connection.on("set")
174 | def on_set(variable):
175 | print(variable.name, variable.value)
176 |
177 | print(connection.listeners("set"))
178 | #
179 | ```
180 |
181 | ## Events
182 |
183 | ###`handshake`
184 |
185 | Fired after the WebSocket connection handshake occurs.
186 |
187 | ###`connect`
188 |
189 | Fired when the WebSocket connection has finished and is ready to receive data.
190 |
191 | ###`outgoing`
192 |
193 | Fired when data is sent to the server.
194 |
195 | **PARAMETERS**
196 |
197 | - **data** (`#!python str`) - The data that is being sent.
198 |
199 | ###`change`
200 |
201 | Fired when a variable value changes, no matter who changed it.
202 |
203 | **PARAMETERS**
204 |
205 | - **variable** (`#!python CloudVariable`) - The variable that has been changed, as a [CloudVariable](../CloudVariable).
206 |
207 | ###`set`
208 |
209 | Fired when a variable value changes, by anyone except yourself.
210 |
211 | **PARAMETERS**
212 |
213 | - **variable** (`#!python CloudVariable`) - The variable that has been changed, as a [CloudVariable](../CloudVariable).
214 |
215 | ###`create`
216 |
217 | Fired when a cloud variable has been created.
218 |
219 | **PARAMETERS**
220 |
221 | - **variable** (`#!python CloudVariable`) - The variable that has been created, as a [CloudVariable](../CloudVariable).
222 |
223 | ###`delete`
224 |
225 | Fired when a cloud variable has been deleted.
226 |
227 | **PARAMETERS**
228 |
229 | - **name** (`#!python str`) - The name of the variable that has been deleted. This includes the cloud emoji at the beginning ("☁ ").
230 |
--------------------------------------------------------------------------------
/scratchclient/__main__.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import datetime
3 | import shutil
4 | from html.parser import HTMLParser
5 | from xml.etree import ElementTree
6 |
7 | from .ScratchSession import ScratchSession
8 |
9 | NEWLINE = "\n"
10 |
11 |
12 | class HTMLToTextParser(HTMLParser):
13 | def __init__(self):
14 | super().__init__()
15 | self.text = ""
16 |
17 | def handle_starttag(self, tag, attrs):
18 | if tag == "br":
19 | self.text += "\n"
20 |
21 | def handle_data(self, data):
22 | self.text += data
23 |
24 |
25 | def html_to_text(html):
26 | parser = HTMLToTextParser()
27 | parser.feed(html)
28 |
29 | return parser.text
30 |
31 |
32 | def iso_to_readable(iso):
33 | timezone = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo
34 |
35 | date = datetime.datetime.fromisoformat(iso.replace("Z", "+00:00"))
36 | date.astimezone(timezone)
37 |
38 | return date.strftime("%Y-%m-%d %I:%M %p")
39 |
40 |
41 | def separator():
42 | return "-" * shutil.get_terminal_size((80, 20))[0]
43 |
44 |
45 | def bold(text):
46 | return "\033[1m{0}\033[0m".format(text)
47 |
48 |
49 | session = ScratchSession()
50 |
51 |
52 | def help():
53 | print(
54 | """Usage: scratchclient [COMMAND] [arguments...]
55 | scratchclient allows you to access data from the Scratch website (https://scratch.mit.edu)
56 |
57 | Examples:
58 | scratchclient user griffpatch # Get griffpatch's user data
59 | scratchclient project 104 # Get data on Scratch project 104
60 | scratchclient studio 123 # Get data on Scratch studio 123
61 | scratchclient news # Get latest news on the Scratch website
62 | scratchclient topic 506810 # Get the latest posts on forum topic 506810
63 | scratchclient forum 1 # Get the latest posts in the "Suggestions" forum category
64 | """
65 | )
66 |
67 |
68 | def user_data(username):
69 | try:
70 | user = session.get_user(username)
71 | except KeyError:
72 | print(f"User '{username}' not found")
73 | return
74 |
75 | print(
76 | f"""Username: {user.username}
77 | User ID: {user.id}
78 | Joined: {iso_to_readable(user.joined_timestamp)}
79 | Status: {"Scratch Team" if user.scratchteam else "Scratcher or New Scratcher"}
80 | Avatar: {user.profile.avatar_URL}
81 | Country: {user.profile.country}
82 | Profile ID: {user.profile.id}
83 |
84 | {separator()}
85 |
86 | About me: {user.profile.bio}
87 |
88 | What I'm working on: {user.profile.status}
89 |
90 | {separator()}
91 |
92 | Follower count: {session.scraping.get_follower_count(user)}
93 | Following count: {session.scraping.get_following_count(user)}
94 | Shared projects count: {session.scraping.get_shared_projects_count(user)}
95 | """
96 | )
97 |
98 |
99 | def project_data(project_id):
100 | try:
101 | project = session.get_project(project_id)
102 | except KeyError:
103 | print(f"Project {project_id} not found")
104 | return
105 |
106 | print(
107 | f"""Title: {project.title}
108 | Project ID: {project.id}
109 | Shared: {project.is_published}
110 | Comments on: {"Yes" if project.comments_allowed else "No"}
111 | Thumbnail: {project.thumbnail_URL}
112 | Created: {iso_to_readable(project.created_timestamp)}
113 | Last modified: {iso_to_readable(project.last_modified_timestamp)}
114 | Shared: {iso_to_readable(project.shared_timestamp)}
115 |
116 | {separator()}
117 |
118 | View count: {project.view_count}
119 | Love count: {project.love_count}
120 | Favorite count: {project.favorite_count}
121 | Remix count: {project.remix_count}
122 |
123 | {separator()}
124 |
125 | {f'''
126 | Remix of: {project.parent}
127 | Original project: {project.root}
128 |
129 | {separator()}
130 |
131 | ''' if project.is_remix else ""}
132 | Author username: {project.author.username}
133 | Author ID: {project.author.id}
134 | Author status: {"Scratch Team" if project.author.scratchteam else "Scratcher or New Scratcher"}
135 | Author join time: {iso_to_readable(project.author.joined_timestamp)}
136 | Author avatar: {project.author.avatar_URL}
137 |
138 | {separator()}
139 |
140 | {f"Instructions: {project.instructions}{NEWLINE}" if project.instructions else ""}
141 | {f"Notes and Credits: {project.description}" if project.description else ""}
142 | """
143 | )
144 |
145 |
146 | def studio_data(studio_id):
147 | try:
148 | studio = session.get_studio(studio_id)
149 | except KeyError:
150 | print(f"Studio {studio_id} not found")
151 | return
152 |
153 | print(
154 | f"""Title: {studio.title}
155 | Studio ID: {studio.id}
156 | Host ID: {studio.host}
157 | Comments on: {"Yes" if studio.comments_allowed else "No"}
158 | Thumbnail: {studio.thumbnail_URL}
159 | Open to everyone: {studio.open_to_public}
160 | Created: {iso_to_readable(studio.created_timestamp)}
161 | Last modified: {iso_to_readable(studio.last_modified_timestamp)}
162 |
163 | {separator()}
164 |
165 | Comment count: {studio.comment_count}
166 | Project count: {studio.project_count}
167 | Manager count: {studio.manager_count}
168 | Follower count: {studio.follower_count}
169 |
170 | {separator()}
171 |
172 | Description: {studio.description}
173 | """
174 | )
175 |
176 |
177 | def news_data():
178 | all_news = session.get_news()
179 | for news in all_news:
180 | print(bold(news.title))
181 | print(news.description)
182 | print(iso_to_readable(news.timestamp))
183 | print(news.src)
184 |
185 | if news is not all_news[-1]:
186 | print("\n{0}\n".format(separator()))
187 |
188 |
189 | def topic_data(topic_id):
190 | try:
191 | posts = session.forums.get_latest_topic_posts(topic_id)
192 | except ElementTree.ParseError:
193 | print(f"Topic {topic_id} not found")
194 | return
195 |
196 | for post in posts:
197 | print(
198 | f"""{bold(post.title)}
199 | {post.link} | {iso_to_readable(post.published)}
200 |
201 | By {post.author}
202 | {separator()}
203 | {html_to_text(post.content)}
204 | """
205 | )
206 | if post is not posts[-1]:
207 | print("\n{0}\n".format(separator()))
208 |
209 |
210 | def forum_category_data(category_id):
211 | try:
212 | posts = session.forums.get_latest_category_posts(category_id)
213 | except ElementTree.ParseError:
214 | print(f"Category {category_id} not found")
215 | return
216 |
217 | for post in posts:
218 | print(
219 | f"""{bold(post.title)}
220 | {post.link} | {iso_to_readable(post.published)}
221 |
222 | By {post.author}
223 | {separator()}
224 | {html_to_text(post.content)}
225 | """
226 | )
227 | if post is not posts[-1]:
228 | print("\n{0}\n".format(separator()))
229 |
230 |
231 | if len(sys.argv) == 1:
232 | help()
233 | exit()
234 |
235 | command = sys.argv[1]
236 | if command == "help":
237 | help()
238 | elif command == "user":
239 | user_data(sys.argv[2])
240 | elif command == "project":
241 | project_data(sys.argv[2])
242 | elif command == "studio":
243 | studio_data(sys.argv[2])
244 | elif command == "news":
245 | news_data()
246 | elif command == "topic":
247 | topic_data(sys.argv[2])
248 | elif command == "forum":
249 | forum_category_data(sys.argv[2])
250 | else:
251 | print(
252 | """Usage: scratchclient [COMMAND] [arguments...]
253 | Try 'scratchclient help' for more information.
254 | """
255 | )
256 |
--------------------------------------------------------------------------------
/docs/reference/AsyncCloudConnection.md:
--------------------------------------------------------------------------------
1 | # CloudConnection
2 |
3 | ## Properties
4 |
5 | ###`#!python project_id : int` { #project_id data-toc-label="project_id" }
6 |
7 | The ID of the project that the connection is on.
8 |
9 | ###`#!python cloud_host : str` { #cloud_host data-toc-label="cloud_host" }
10 |
11 | The hostname of the server where the cloud variables are hosted.
12 |
13 | ## Methods
14 |
15 | ###`#!python run()` { #run data-toc-label="run" }
16 |
17 | Connects to the server and starts listening for variable changes.
18 |
19 | **Example:**
20 |
21 | ```python
22 | connection = session.create_cloud_connection(193290310931, is_async=True)
23 |
24 | @connection.on("connect")
25 | async def on_connect():
26 | print("Connected!")
27 |
28 | @connection.on("set")
29 | async def on_set(variable):
30 | print(variable.name, variable.value)
31 |
32 | connection.run()
33 | ```
34 |
35 | ###`#!python await connect()` { #connect data-toc-label="connect" }
36 |
37 | Connects to the server and starts listening for variable changes. Equivalent to [AsyncCloudConnection.run](#run) except it's a coroutine. Must be called with `#!python await`.
38 |
39 | ###`#!python get_cloud_variable(name)` { #get_cloud_variable data-toc-label="get_cloud_variable" }
40 |
41 | Gets the value of a cloud variable with the specified name.
42 |
43 | **PARAMETERS**
44 |
45 | - **name** (`#!python str`) - The name of the variable. The name does not necessarily need to include the cloud emoji ("☁️ ").
46 |
47 | **RETURNS** - `#!python str`
48 |
49 | **Example:**
50 |
51 | ```python
52 | connection = session.create_cloud_connection(193290310931, is_async=True)
53 |
54 | @connection.on("connect")
55 | async def on_connect():
56 | print(connection.get_cloud_variable("High score"))
57 | # 102930921
58 |
59 | connection.run()
60 | ```
61 |
62 | ###`#!python await set_cloud_variable(name, value)` { #set_cloud_variable data-toc-label="set_cloud_variable" }
63 |
64 | Sets the value of a cloud variable with the specified name to the specified value. You can only do this 10 times per second. This function must be used with `#!python await`.
65 |
66 | **PARAMETERS**
67 |
68 | - **name** (`#!python str`) - The name of the variable. The name does not necessarily need to include the cloud emoji ("☁️ ").
69 | - **value** (`#!python str`) - The value you want to set the cloud variable to. This must be less than 256 characters long and all digits.
70 |
71 | **Example:**
72 |
73 | ```python
74 | connection = session.create_cloud_connection(193290310931, is_async=True)
75 |
76 | @connection.on("connect")
77 | async def on_connect():
78 | await connection.set_cloud_variable("High score", 102930921)
79 | print(connection.get_cloud_variable("High score"))
80 | # 102930921
81 |
82 | connection.run()
83 | ```
84 |
85 | ###`#!python await create_cloud_variable(name, initial_value=0)` { #create_cloud_variable data-toc-label="create_cloud_variable" }
86 |
87 | Creates a cloud variable with the specified name and sets it to the specified initial value. You can only do this 10 times per second. This function must be used with `#!python await`.
88 |
89 | **PARAMETERS**
90 |
91 | - **name** (`#!python str`) - The name of the new variable. The name does not necessarily need to include the cloud emoji ("☁️ ").
92 | - **initial_value** (`#!python int`) - The value you want to set the cloud variable to. This must be less than 256 characters long and all digits.
93 |
94 | **Example:**
95 |
96 | ```python
97 | connection = session.create_cloud_connection(193290310931, is_async=True)
98 |
99 | @connection.on("connect")
100 | async def on_connect():
101 | await connection.create_cloud_variable("High score", 10)
102 |
103 | connection.run()
104 | ```
105 |
106 | !!! note
107 | This will not update live for other people using the project.
108 |
109 | ###`#!python await delete_cloud_variable(name)` { #delete_cloud_variable data-toc-label="delete_cloud_variable" }
110 |
111 | Deletes a cloud variable with the specified name. You can only do this 10 times per second. This function must be used with `#!python await`.
112 |
113 | **PARAMETERS**
114 |
115 | - **name** (`#!python str`) - The name of the variable to be deleted. The name does not necessarily need to include the cloud emoji ("☁️ ").
116 |
117 | **Example:**
118 |
119 | ```python
120 | connection = session.create_cloud_connection(193290310931, is_async=True)
121 |
122 | @connection.on("connect")
123 | def on_connect():
124 | await connection.delete_cloud_variable("High score")
125 |
126 | connection.run()
127 | ```
128 |
129 | !!! note
130 | This will not update live for other people using the project.
131 |
132 | ###`#!python on(key, callback=None, once=False)` { #on data-toc-label="on" }
133 |
134 | Adds an event for the connection listen to. This can either be used as a decorator or a function.
135 |
136 | **PARAMETERS**
137 |
138 | - **key** (`#!python str`) - The key of the event to be listened to.
139 | - **callback** (`#!python callable`) - The function that will run when the event occurs.
140 | - **once** (`#!python bool`) - Whether the event should only be fired once.
141 |
142 | **RETURNS** - `#!python None | callable`
143 |
144 | **Example:**
145 |
146 | ```python
147 | # Use as a function
148 | async def on_set(variable):
149 | print(variable.name, variable.value)
150 |
151 | connection.on("set", on_set)
152 |
153 | # Use as a decorator
154 | @connection.on("set")
155 | async def on_set(variable):
156 | print(variable.name, variable.value)
157 | ```
158 |
159 | ###`#!python off(key, callback)` { #off data-toc-label="off" }
160 |
161 | Removes an event that the connection was listening to.
162 |
163 | **PARAMETERS**
164 |
165 | - **key** (`#!python str`) - The key of the event to be removed.
166 | - **callback** (`#!python callable`) - The function that runs when the event occurs.
167 |
168 | **Example:**
169 |
170 | ```python
171 | async def on_set(variable):
172 | print(variable.name, variable.value)
173 |
174 | connection.on("set", on_set)
175 | connection.off("set", on_set)
176 |
177 | connection.run()
178 | ```
179 |
180 | ###`#!python once(key, callback=None)` { #once data-toc-label="once" }
181 |
182 | Adds an event for the connection listen to. The event will only be fired once. This can either be used as a decorator or a function.
183 |
184 | **PARAMETERS**
185 |
186 | - **key** (`#!python str`) - The key of the event to be listened to.
187 | - **callback** (`#!python callable`) - The function that will run when the event occurs.
188 |
189 | **RETURNS** - `#!python None | callable`
190 |
191 | **Example:**
192 |
193 | ```python
194 | # Use as a function
195 | async def on_set(variable):
196 | print(variable.name, variable.value)
197 |
198 | connection.once("set", on_set)
199 |
200 | # Use as a decorator
201 | @connection.once("set")
202 | async def on_set(variable):
203 | print(variable.name, variable.value)
204 | ```
205 |
206 | ###`#!python listeners(event)` { #listeners data-toc-label="listeners" }
207 |
208 | Returns all the functions that are attached to the event `#!python event`.
209 |
210 | **PARAMETERS**
211 |
212 | - **event** (`#!python event`) - The key of the event that you want to retrieve the listeners of.
213 |
214 | **RETURNS** - `#!python list[callable]`
215 |
216 | **Example:**
217 |
218 | ```python
219 | @connection.on("set")
220 | async def on_set(variable):
221 | print(variable.name, variable.value)
222 |
223 | print(connection.listeners("set"))
224 | #
225 |
226 | connection.run()
227 | ```
228 |
229 | ## Events
230 |
231 | ###`handshake`
232 |
233 | Fired after the WebSocket connection handshake occurs.
234 |
235 | ###`connect`
236 |
237 | Fired when the WebSocket connection has finished and is ready to receive data.
238 |
239 | ###`outgoing`
240 |
241 | Fired when data is sent to the server.
242 |
243 | **PARAMETERS**
244 |
245 | - **data** (`#!python str`) - The data that is being sent.
246 |
247 | ###`change`
248 |
249 | Fired when a variable value changes, no matter who changed it.
250 |
251 | **PARAMETERS**
252 |
253 | - **variable** (`#!python CloudVariable`) - The variable that has been changed, as a [CloudVariable](../CloudVariable).
254 |
255 | ###`set`
256 |
257 | Fired when a variable value changes, by anyone except yourself.
258 |
259 | **PARAMETERS**
260 |
261 | - **variable** (`#!python CloudVariable`) - The variable that has been changed, as a [CloudVariable](../CloudVariable).
262 |
263 | ###`create`
264 |
265 | Fired when a cloud variable has been created.
266 |
267 | **PARAMETERS**
268 |
269 | - **variable** (`#!python CloudVariable`) - The variable that has been created, as a [CloudVariable](../CloudVariable).
270 |
271 | ###`delete`
272 |
273 | Fired when a cloud variable has been deleted.
274 |
275 | **PARAMETERS**
276 |
277 | - **name** (`#!python str`) - The name of the variable that has been deleted. This includes the cloud emoji at the beginning ("☁ ").
278 |
--------------------------------------------------------------------------------
/docs/reference/User.md:
--------------------------------------------------------------------------------
1 | # **User**
2 |
3 | ## Properties
4 |
5 | ###`#!python username : str` { #username data-toc-label="username" }
6 |
7 | The username of the user.
8 |
9 | **Example:**
10 |
11 | ```python
12 | print(session.get_user("you").username)
13 | # you
14 | ```
15 |
16 | ###`#!python id : int` { #id data-toc-label="id" }
17 |
18 | The ID of the user.
19 |
20 | ###`#!python joined_timestamp : str` { #joined_timestamp data-toc-label="joined_timestamp" }
21 |
22 | An [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) timestamp representing the date the user joined Scratch.
23 |
24 | ###`#!python scratchteam : bool` { #scratchteam data-toc-label="scratchteam" }
25 |
26 | A `#!python bool` representing whether the user is a member of the Scratch Team.
27 |
28 | ###`#!python profile : UserProfile` { #profile data-toc-label="profile" }
29 |
30 | A [UserProfile](../UserProfile) object representing data related to the user's profile.
31 |
32 | **Example:**
33 |
34 | ```python
35 | print(session.get_user("mres").profile.bio)
36 | # I'm a professor at MIT Media Lab. But more important: I'm one of the people who created Scratch!
37 | ```
38 |
39 | ## Methods
40 |
41 | ###`#!python get_projects(all=False, limit=20, offset=0)` { #get_projects data-toc-label="get_projects" }
42 |
43 | Gets a list of the user's shared projects. Returns an array of [Project](../Project) objects.
44 |
45 | **PARAMETERS**
46 |
47 | - **all** (`#!python Optional[bool]`) - Whether to retrieve every single project or just `#!python limit` projects.
48 | - **limit** (`#!python Optional[int]`) - How many projects to retrieve if `#!python all` is `#!python False`.
49 | - **offset** (`#!python Optional[int]`) - The offset of the projects from the newest ones - i.e. an offset of 20 would give you the next 20 projects after the first 20.
50 |
51 | **RETURNS** - `#!python list[Project]`
52 |
53 | **Example:**
54 |
55 | ```python
56 | print(session.get_user("griffpatch").get_projects(all=True)[-1].title)
57 | # Pacman HD with full Ghost AI (Scratch 2)
58 | ```
59 |
60 | ###`#!python get_curating(all=False, limit=20, offset=0)` { #get_curating data-toc-label="get_curating" }
61 |
62 | Gets a list of studios the user is curating. Returns an array of [Studio](../Studio) objects.
63 |
64 | **PARAMETERS**
65 |
66 | - **all** (`#!python Optional[bool]`) - Whether to retrieve every single studio or just `#!python limit` studios.
67 | - **limit** (`#!python Optional[int]`) - How many studios to retrieve if `#!python all` is `#!python False`.
68 | - **offset** (`#!python Optional[int]`) - The offset of the studios from the newest ones - i.e. an offset of 20 would give you the next 20 studios after the first 20.
69 |
70 | **RETURNS** - `#!python list[Studio]`
71 |
72 | **Example:**
73 |
74 | ```python
75 | print(session.get_user("griffpatch").get_studios()[0].title)
76 | # The Scratchnapped Series (The epic adventures of Scratch?)
77 | ```
78 |
79 | ###`#!python get_favorites(all=False, limit=20, offset=0)` { #get_favorites data-toc-label="get_favorites" }
80 |
81 | Gets a list of projects the user has favorited. Returns an array of [Project](../Project) objects.
82 |
83 | **PARAMETERS**
84 |
85 | - **all** (`#!python Optional[bool]`) - Whether to retrieve every single project or just `#!python limit` projects.
86 | - **limit** (`#!python Optional[int]`) - How many projects to retrieve if `#!python all` is `#!python False`.
87 | - **offset** (`#!python Optional[int]`) - The offset of the projects from the newest ones - i.e. an offset of 20 would give you the next 20 projects after the first 20.
88 |
89 | **RETURNS** - `#!python list[Project]`
90 |
91 | ###`#!python get_followers(all=False, limit=20, offset=0)` { #get_followers data-toc-label="get_followers" }
92 |
93 | Gets a list of users that are following the user. Returns an array of [User](../User) objects.
94 |
95 | **PARAMETERS**
96 |
97 | - **all** (`#!python Optional[bool]`) - Whether to retrieve every single follower or just `#!python limit` followers.
98 | - **limit** (`#!python Optional[int]`) - How many followers to retrieve if `#!python all` is `#!python False`.
99 | - **offset** (`#!python Optional[int]`) - The offset of the followers from the newest ones - i.e. an offset of 20 would give you the next 20 followers after the first 20.
100 |
101 | **RETURNS** - `#!python list[User]`
102 |
103 | **Example:**
104 |
105 | ```python
106 | print(session.get_user("griffpatch").get_followers()[0].username)
107 | # kaj
108 | ```
109 |
110 | ###`#!python get_following(all=False, limit=20, offset=0)` { #get_following data-toc-label="get_following" }
111 |
112 | Gets a list of users that the user is following. Returns an array of [User](../User) objects.
113 |
114 | **PARAMETERS**
115 |
116 | - **all** (`#!python Optional[bool]`) - Whether to retrieve every single user or just `#!python limit` users.
117 | - **limit** (`#!python Optional[int]`) - How many users to retrieve if `#!python all` is `#!python False`.
118 | - **offset** (`#!python Optional[int]`) - The offset of the users from the newest ones - i.e. an offset of 20 would give you the next 20 users after the first 20.
119 |
120 | **RETURNS** - `#!python list[User]`
121 |
122 | **Example:**
123 |
124 | ```python
125 | print(session.get_user("World_Languages").get_following()[0].username)
126 | # RykerJohnson
127 | ```
128 |
129 | ###`#!python get_message_count()` { #get_message_count data-toc-label="get_message_count" }
130 |
131 | Gets the message count of the user. Returns an `#!python int` with the user's message count.
132 |
133 | !!! info
134 |
135 | Scratch has historically tried to block requests that are trying to retrieve message counts. To prevent weird errors or further restrictions, try to use this sparingly.
136 |
137 | **RETURNS** - `#!python int`
138 |
139 | **Example:**
140 |
141 | ```python
142 | print(session.get_user("isthistaken123").get_message_count())
143 | # 90722
144 | ```
145 |
146 | ###`#!python post_comment(content, parent_id="", commentee_id="")` { #post_comment data-toc-label="post_comment" }
147 |
148 | Posts a comment on the user's profile. You must be logged in for this to not throw an error.
149 |
150 | **PARAMETERS**
151 |
152 | - **content** (`#!python str`) - The content of the comment to be posted.
153 | - **parent_id** (`#!python Optional[Literal[""] | int]`) - If the comment to be posted is a reply, this is the comment ID of the parent comment. Otherwise, this is an empty string `#!python ""`.
154 | - **commentee_id** (`#!python Optiona[Literal[""] | int]`) - If the comment to be posted is a reply, this is the user ID of the author of the parent comment. Otherwise, this an empty string `#!python ""`.
155 |
156 | **Example:**
157 |
158 | ```python
159 | session.get_user("isthistaken123").post_comment("hello my friend", parent_id=140441449, commentee_id=143585)
160 | session.get_user("griffpatch").post_comment("f4f?!?!?!")
161 | ```
162 |
163 | ###`#!python delete_comment(comment_id)` { #delete_comment data-toc-label="delete_comment" }
164 |
165 | Deletes a comment on the user's profile with the specified `#!python comment_id`. You must be logged in, and be the owner of the profile, for this to not throw an error.
166 |
167 | **PARAMETERS**
168 |
169 | - **comment_id** (`#!python int`) - The ID of the comment to be deleted.
170 |
171 | ###`#!python report_comment(comment_id)` { #report_comment data-toc-label="report_comment" }
172 |
173 | Reports a comment on the user's profile with the specified `#!python comment_id`. You must be logged in for this to not throw an error.
174 |
175 | **PARAMETERS**
176 |
177 | - **comment_id** (`#!python int`) - The ID of the comment to be reported.
178 |
179 | ###`#!python report(field)` { #report data-toc-label="report" }
180 |
181 | Reports the user for the reason specified in the `#!python field` parameter. You must be logged in for this to not throw an error.
182 |
183 | **PARAMETERS**
184 |
185 | - **field** (`#!python Literal["username"] | Literal["icon"] | Literal["description"] | Literal["working_on"]`) - The section of the user's profile that you are reporting them for. A value of `#!python "username"` represents the user's username, a value of `#!python "icon"` represents the user's avatar, a value of `#!python "description"` represents the "About Me" section of the user's profile, and a value of `#!python "working_on"` represents the "What I'm Working On" section of the user's profile.
186 |
187 | **Example**
188 | ```python
189 | session.get_user("griffpatch_alt").report("username")
190 | ```
191 |
192 | ###`#!python toggle_commenting()` { #toggle_comments data-toc-label="toggle_comments" }
193 |
194 | Toggles whether people can post comments on the user's profile. You must be logged in, and the owner of the profile, for this to not throw an error.
195 |
196 | **Example:**
197 |
198 | ```python
199 | session.user.post_comment("Aight im leaving scratch, unless I can get 4000 followers by tonight im out")
200 | session.user.toggle_commenting()
201 | ```
202 |
203 | ###`#!python follow()` { #follow data-toc-label="follow" }
204 |
205 | Follows the user. You must be logged in for this to not throw an error. Returns a `#!python dict` with general data about the user's profile.
206 |
207 | **RETURNS** - `#!python dict`
208 |
209 | **Example**
210 | ```python
211 | session.get_user('griffpatch').follow()
212 | ```
213 |
214 | ###`#!python unfollow()` { #unfollow data-toc-label="unfollow" }
215 |
216 | Unfollows the user. You must be logged in for this to not throw an error. Returns a `#!python dict` with general data about the user's profile.
217 |
218 | **RETURNS** - `#!python dict`
219 |
220 | **Example**
221 | ```python
222 | griffpatch = session.get_user('griffpatch')
223 |
224 | griffpatch.unfollow()
225 | griffpatch.post_comment("I thought we promised we'd do f4f :(")
226 | ```
227 |
--------------------------------------------------------------------------------
/scratchclient/Websocket.py:
--------------------------------------------------------------------------------
1 | """
2 | Implements the WebSocket protocol
3 | https://datatracker.ietf.org/doc/html/rfc6455
4 |
5 | scratchclient used to use https://pypi.org/project/websocket-client/ but this was abandoned for three reasons:
6 | - websocket-client has a naming problem that makes it difficult to build scratchclient from source in some environments
7 | - It doesn't support asyncio, which is something I wanted; I found https://pypi.org/project/websockets/ but using both seemed like overkill
8 | - The WebSocket protocol is pretty simple, so it seemed pointless to introduce a dependency for it
9 |
10 | This does not implement the full protocol but it is enough for the purposes of this library. Some features (namely fragmenting) are not included.
11 | """
12 |
13 | import socket
14 | import base64
15 | import secrets
16 | import struct
17 | import functools
18 | import ssl
19 | import urllib.parse
20 | import re
21 | import hashlib
22 | import asyncio
23 | from enum import IntEnum
24 |
25 | GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
26 |
27 |
28 | def xor_mask(data, mask):
29 | encoded = data.encode("utf-8")
30 | data_bytes = struct.unpack(f"{len(encoded)}c", encoded)
31 | return bytes(ord(byte) ^ mask[idx % 4] for idx, byte in enumerate(data_bytes))
32 |
33 |
34 | class Status(IntEnum):
35 | NORMAL = 1000
36 | GOING_AWAY = 1001
37 | PROTOCOL_ERROR = 1002
38 | UNSUPPORTED_DATA_TYPE = 1003
39 | STATUS_NOT_AVAILABLE = 1005
40 | ABNORMAL_CLOSED = 1006
41 | INVALID_PAYLOAD = 1007
42 | POLICY_VIOLATION = 1008
43 | MESSAGE_TOO_BIG = 1009
44 | INVALID_EXTENSION = 1010
45 | UNEXPECTED_CONDITION = 1011
46 | SERVICE_RESTART = 1012
47 | TRY_AGAIN_LATER = 1013
48 | BAD_GATEWAY = 1014
49 | TLS_HANDSHAKE_ERROR = 1015
50 |
51 |
52 | class Opcode(IntEnum):
53 | CONT = 0x0
54 | TEXT = 0x1
55 | BINARY = 0x2
56 | CLOSE = 0x8
57 | PING = 0x9
58 | PONG = 0xA
59 |
60 |
61 | class WebsocketException(Exception):
62 | pass
63 |
64 |
65 | class Frame:
66 | def __init__(self, length, data_start, opcode, fin, rsv1, rsv2, rsv3):
67 | self.length = length
68 | self.data_start = data_start
69 | self.opcode = opcode
70 | self.fin = fin
71 | self.rsv1 = rsv1
72 | self.rsv2 = rsv2
73 | self.rsv3 = rsv3
74 |
75 | @staticmethod
76 | def encode(data, opcode=Opcode.TEXT, masked=1):
77 | # https://datatracker.ietf.org/doc/html/rfc6455#section-5.2
78 | fin, rsv1, rsv2, rsv3 = 1, 0, 0, 0
79 | header = bytes([opcode | rsv3 << 4 | rsv2 << 5 | rsv1 << 6 | fin << 7])
80 |
81 | length = len(data)
82 | len_bytes = bytes(
83 | [
84 | (length if length <= 125 else 126 if length < 65536 else 127)
85 | | masked << 7
86 | ]
87 | )
88 |
89 | if length > 125:
90 | if length >= 65536:
91 | len_bytes += struct.pack("!Q", length)
92 | else:
93 | len_bytes += struct.pack("!H", length)
94 |
95 | if not masked:
96 | return header + len_bytes + data.encode("utf-8")
97 |
98 | mask = secrets.token_bytes(4)
99 | return header + len_bytes + mask + xor_mask(data, mask)
100 |
101 | @staticmethod
102 | def decode_beginning(response):
103 | header = response[0]
104 | fin, rsv1, rsv2, rsv3, opcode = (
105 | (header >> 7) & 1,
106 | (header >> 6) & 1,
107 | (header >> 5) & 1,
108 | (header >> 4) & 1,
109 | header & 0xF,
110 | )
111 |
112 | if response[1] >> 7:
113 | # If frames are masked close the connection
114 | return None, WebsocketException("Server frame was masked")
115 |
116 | data_start = 2
117 | length = response[1]
118 | if length > 125:
119 | if length == 126:
120 | data_start = 4
121 | length_bytes = response[2:4]
122 | elif length == 127:
123 | data_start = 10
124 | length_bytes = response[2:10]
125 |
126 | length = functools.reduce(
127 | lambda current, byte: (current << 8) | byte, length_bytes
128 | )
129 |
130 | return Frame(length, data_start, opcode, fin, rsv1, rsv2, rsv3), None
131 |
132 |
133 | class Websocket:
134 | def __init__(self):
135 | self.connected = False
136 |
137 | def connect(self, url, headers={}):
138 | parsed_url = urllib.parse.urlparse(url)
139 |
140 | unsecure_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
141 | secure = parsed_url.scheme == "wss"
142 |
143 | if secure:
144 | ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS)
145 | ssl_context.verify_mode = ssl.CERT_NONE
146 | self.sock = ssl_context.wrap_socket(
147 | unsecure_sock, server_hostname=parsed_url.hostname
148 | )
149 | else:
150 | self.sock = unsecure_sock
151 |
152 | self.sock.connect(
153 | (
154 | socket.gethostbyname(parsed_url.hostname),
155 | parsed_url.port or (443 if secure else 80),
156 | )
157 | )
158 |
159 | self.handshake(parsed_url, headers, secure)
160 |
161 | def handshake(self, parsed_url, headers, secure):
162 | ws_key = base64.b64encode(secrets.token_bytes(16)).decode("utf-8")
163 |
164 | # https://datatracker.ietf.org/doc/html/rfc6455#section-1.3
165 | default_headers = {
166 | "Host": parsed_url.hostname,
167 | "Upgrade": "websocket",
168 | "Connection": "Upgrade",
169 | "Sec-Websocket-Key": ws_key,
170 | "Sec-Websocket-Version": "13",
171 | "Origin": f"{'https' if secure else 'http'}://{parsed_url.netloc}",
172 | }
173 | handshake_headers = {**default_headers, **headers}
174 |
175 | handshake_str = "".join(
176 | f"{key}: {value}\r\n" for key, value in handshake_headers.items()
177 | )
178 |
179 | self.sock.sendall(
180 | f"GET {parsed_url.path or '/'} HTTP/1.1\r\n{handshake_str}\r\n".encode(
181 | "utf-8"
182 | )
183 | )
184 | response = self.sock.recv(1024).decode("utf-8")
185 |
186 | status_match = re.search("HTTP/\d\.\d 101", response)
187 | key_match = re.search("(?i:Sec-Websocket-Accept\: )(?P.*)\r\n", response)
188 |
189 | if (
190 | not status_match
191 | or not key_match
192 | or key_match.groupdict()["key"]
193 | != base64.b64encode(
194 | hashlib.sha1((ws_key + GUID).encode("utf-8")).digest()
195 | ).decode("utf-8")
196 | ):
197 | self.sock.close()
198 | self.sock = None
199 | raise WebsocketException("Handshake failed")
200 |
201 | self.connected = True
202 |
203 | def send(self, data):
204 | if isinstance(data, bytes):
205 | self.sock.sendall(Frame.encode(data, Opcode.BINARY))
206 | else:
207 | self.sock.sendall(Frame.encode(data, Opcode.TEXT))
208 |
209 | def recv_data(self):
210 | beginning_data = self.sock.recv(10)
211 | frame, err = Frame.decode_beginning(beginning_data)
212 | if err:
213 | self.close(Status.PROTOCOL_ERROR, "Server frame was masked")
214 | raise err
215 |
216 | header_length = frame.data_start
217 |
218 | remaining_data = (
219 | self.sock.recv(frame.length - header_length)
220 | if frame.length + header_length > 10
221 | else b""
222 | )
223 | frame_data = beginning_data + remaining_data
224 | data = frame_data[frame.data_start : frame.data_start + frame.length]
225 |
226 | if frame.opcode == Opcode.CLOSE:
227 | code = int.from_bytes(data[:2], byteorder="big")
228 | self.close(code, data[2:].decode("utf-8"))
229 | elif frame.opcode == Opcode.PING:
230 | self.pong(data.decode("utf-8"))
231 |
232 | return (data, frame.opcode)
233 |
234 | def recv(self):
235 | while True:
236 | data, opcode = self.recv_data()
237 | if opcode == Opcode.BINARY:
238 | return data
239 | elif opcode == Opcode.TEXT:
240 | return data.decode("utf-8")
241 |
242 | def close(self, code=Status.NORMAL, reason=""):
243 | if not self.sock:
244 | return
245 |
246 | # https://datatracker.ietf.org/doc/html/rfc6455#section-1.4
247 | body = code.to_bytes(2, byteorder="big").decode("raw_unicode_escape") + reason
248 | self.sock.sendall(Frame.encode(body, Opcode.CLOSE))
249 |
250 | # The server probably should send a closing handshake
251 | # but it doesn't really matter what happens here
252 | self.sock.close()
253 | self.sock = None
254 | self.connected = False
255 |
256 | def ping(self, data=""):
257 | self.sock.sendall(Frame.encode(data, Opcode.PING))
258 |
259 | def pong(self, data=""):
260 | self.sock.sendall(Frame.encode(data, Opcode.PONG))
261 |
262 |
263 | class AsyncWebsocket:
264 | def __init__(self):
265 | self.connected = False
266 |
267 | async def connect(self, url, headers={}):
268 | parsed_url = urllib.parse.urlparse(url)
269 |
270 | secure = parsed_url.scheme == "wss"
271 |
272 | self.reader, self.writer = await asyncio.open_connection(
273 | parsed_url.hostname, parsed_url.port or (443 if secure else 80), ssl=secure
274 | )
275 |
276 | await self.handshake(parsed_url, headers, secure)
277 |
278 | async def handshake(self, parsed_url, headers, secure):
279 | ws_key = base64.b64encode(secrets.token_bytes(16)).decode("utf-8")
280 |
281 | default_headers = {
282 | "Host": parsed_url.hostname,
283 | "Upgrade": "websocket",
284 | "Connection": "Upgrade",
285 | "Sec-Websocket-Key": ws_key,
286 | "Sec-Websocket-Version": "13",
287 | "Origin": f"{'https' if secure else 'http'}://{parsed_url.netloc}",
288 | }
289 | handshake_headers = {**default_headers, **headers}
290 |
291 | handshake_str = "".join(
292 | f"{key}: {value}\r\n" for key, value in handshake_headers.items()
293 | )
294 |
295 | self.writer.write(
296 | f"GET {parsed_url.path or '/'} HTTP/1.1\r\n{handshake_str}\r\n".encode(
297 | "utf-8"
298 | )
299 | )
300 | await self.writer.drain()
301 |
302 | response = (await self.reader.read(1024)).decode("utf-8")
303 |
304 | status_match = re.search("HTTP/\d\.\d 101", response)
305 | key_match = re.search("(?i:Sec-Websocket-Accept\: )(?P.*)\r\n", response)
306 |
307 | if (
308 | not status_match
309 | or not key_match
310 | or key_match.groupdict()["key"]
311 | != base64.b64encode(
312 | hashlib.sha1((ws_key + GUID).encode("utf-8")).digest()
313 | ).decode("utf-8")
314 | ):
315 | self.writer.close()
316 | self.reader = self.writer = None
317 | raise WebsocketException("Handshake failed")
318 |
319 | self.connected = True
320 |
321 | async def send(self, data):
322 | if isinstance(data, bytes):
323 | self.writer.write(Frame.encode(data, Opcode.BINARY))
324 | else:
325 | self.writer.write(Frame.encode(data, Opcode.TEXT))
326 |
327 | await self.writer.drain()
328 |
329 | async def recv_data(self):
330 | beginning_data = await self.reader.read(10)
331 | frame, err = Frame.decode_beginning(beginning_data)
332 | if err:
333 | self.close(Status.PROTOCOL_ERROR, "Server frame was masked")
334 | raise err
335 |
336 | header_length = frame.data_start
337 |
338 | remaining_data = (
339 | await self.reader.read(frame.length - header_length)
340 | if frame.length + header_length > 10
341 | else b""
342 | )
343 | frame_data = beginning_data + remaining_data
344 | data = frame_data[frame.data_start : frame.data_start + frame.length]
345 |
346 | if frame.opcode == Opcode.CLOSE:
347 | code = int.from_bytes(data[:2], byteorder="big")
348 | await self.close(code, data[2:].decode("utf-8"))
349 | elif frame.opcode == Opcode.PING:
350 | await self.pong(data.decode("utf-8"))
351 |
352 | return (data, frame.opcode)
353 |
354 | async def recv(self):
355 | while True:
356 | data, opcode = await self.recv_data()
357 | if opcode == Opcode.BINARY:
358 | return data
359 | elif opcode == Opcode.TEXT:
360 | return data.decode("utf-8")
361 |
362 | async def close(self, code=Status.NORMAL, reason=""):
363 | if not self.writer:
364 | return
365 |
366 | body = code.to_bytes(2, byteorder="big").decode("raw_unicode_escape") + reason
367 | self.writer.write(Frame.encode(body, Opcode.CLOSE))
368 | await self.writer.drain()
369 |
370 | # The server probably should send a closing handshake but it doesn't really matter what happens here
371 | self.writer.close()
372 | self.reader = self.writer = None
373 | self.connected = False
374 |
375 | async def ping(self, data=""):
376 | self.writer.write(Frame.encode(data, Opcode.PING))
377 | await self.writer.drain()
378 |
379 | async def pong(self, data=""):
380 | self.writer.write(Frame.encode(data, Opcode.PONG))
381 | await self.writer.drain()
382 |
--------------------------------------------------------------------------------
/scratchclient/Studio.py:
--------------------------------------------------------------------------------
1 | import requests
2 | import json
3 |
4 | from .Comment import StudioComment
5 | from .Activity import Activity
6 | from .Incomplete import IncompleteProject
7 | from .ScratchExceptions import *
8 | from .util import get_data_list
9 |
10 |
11 | class Studio:
12 | def __init__(self, data, client):
13 | global User
14 | global Project
15 | from .User import User
16 | from .Project import Project
17 |
18 | self.id = data["id"]
19 | self.title = data["title"]
20 | # Backwards compatibility
21 | self.host = self.owner = data["host"]
22 | self.description = data["description"] if "description" in data else None
23 | self.thumbnail_URL = data["image"]
24 | self.visible = data["visibility"] == "visibile"
25 | self.open_to_public = data["open_to_all"]
26 | self.comments_allowed = data["comments_allowed"]
27 |
28 | self.created_timestamp = data["history"]["created"]
29 | self.last_modified_timestamp = data["history"]["modified"]
30 |
31 | self.curator_count = None
32 | if data["stats"]:
33 | if "curators" in data["stats"]:
34 | self.follower_count = None
35 | self.manager_count = None
36 | self.curator_count = data["stats"]["curators"]
37 | else:
38 | self.follower_count = data["stats"]["followers"]
39 | self.manager_count = data["stats"]["managers"]
40 |
41 | self.comment_count = data["stats"]["comments"]
42 | self.project_count = data["stats"]["projects"]
43 | else:
44 | self.follower_count = None
45 | self.comment_count = None
46 | self.manager_count = None
47 | self.project_count = None
48 |
49 | self._client = client
50 | self._headers = {
51 | "x-csrftoken": self._client.csrf_token,
52 | "X-Token": self._client.token,
53 | "x-requested-with": "XMLHttpRequest",
54 | "Cookie": f"scratchcsrftoken={self._client.csrf_token};scratchlanguage=en;scratchsessionsid={self._client.session_id};",
55 | "referer": f"https://scratch.mit.edu/studios/{self.id}/",
56 | }
57 |
58 | def add_project(self, project):
59 | self._client._ensure_logged_in()
60 |
61 | project_id = project if isinstance(project, (str, int)) else project.id
62 | headers = self._headers
63 | headers["referer"] = f"https://scratch.mit.edu/projects/{project_id}/"
64 |
65 | response = requests.post(
66 | f"https://api.scratch.mit.edu/studios/{self.id}/project/{project_id}/",
67 | headers=headers,
68 | )
69 |
70 | if response.status_code == 403:
71 | raise UnauthorizedException("You are not allowed to do this")
72 |
73 | def remove_project(self, project):
74 | self._client._ensure_logged_in()
75 |
76 | project_id = project if isinstance(project, (str, int)) else project.id
77 | headers = self._headers.copy()
78 | headers["referer"] = f"https://scratch.mit.edu/projects/{project_id}/"
79 |
80 | response = requests.delete(
81 | f"https://api.scratch.mit.edu/studios/{self.id}/project/{project_id}/",
82 | headers=headers,
83 | )
84 |
85 | if response.status_code == 403:
86 | raise UnauthorizedException("You are not allowed to do this")
87 |
88 | def get_projects(self, all=False, limit=20, offset=0):
89 | return get_data_list(
90 | all,
91 | limit,
92 | offset,
93 | f"https://api.scratch.mit.edu/studios/{self.id}/projects",
94 | lambda project: IncompleteProject(project),
95 | )
96 |
97 | def get_curators(self, all=False, limit=20, offset=0):
98 | return get_data_list(
99 | all,
100 | limit,
101 | offset,
102 | f"https://api.scratch.mit.edu/studios/{self.id}/curators",
103 | lambda curator: User(curator, self._client),
104 | )
105 |
106 | def get_managers(self, all=False, limit=20, offset=0):
107 | return get_data_list(
108 | all,
109 | limit,
110 | offset,
111 | f"https://api.scratch.mit.edu/studios/{self.id}/managers",
112 | lambda manager: User(manager, self._client),
113 | )
114 |
115 | def get_roles(self):
116 | self._client._ensure_logged_in()
117 |
118 | return requests.get(
119 | f"https://api.scratch.mit.edu/studios/{self.id}/users/{self._client.username}"
120 | ).json()
121 |
122 | def follow(self):
123 | self._client._ensure_logged_in()
124 |
125 | return requests.put(
126 | f"https://scratch.mit.edu/site-api/users/bookmarkers/{self.id}/add/?usernames={self._client.username}",
127 | headers=self._headers,
128 | ).json()
129 |
130 | def unfollow(self):
131 | self._client._ensure_logged_in()
132 |
133 | return requests.put(
134 | f"https://scratch.mit.edu/site-api/users/bookmarkers/{self.id}/remove/?usernames={self._client.username}",
135 | headers=self._headers,
136 | ).json()
137 |
138 | def open_to_public(self):
139 | self._client._ensure_logged_in()
140 |
141 | response = requests.put(
142 | f"https://scratch.mit.edu/site-api/galleries/{self.id}/mark/open/",
143 | headers=self._headers,
144 | )
145 |
146 | if response.status_code == 403:
147 | raise UnauthorizedException("You are not allowed to do this")
148 |
149 | def close_to_public(self):
150 | self._client._ensure_logged_in()
151 |
152 | response = requests.put(
153 | f"https://scratch.mit.edu/site-api/galleries/{self.id}/mark/open/",
154 | headers=self._headers,
155 | )
156 |
157 | if response.status_code == 403:
158 | raise UnauthorizedException("You are not allowed to do this")
159 |
160 | def toggle_commenting(self):
161 | self._client._ensure_logged_in()
162 |
163 | headers = self._headers.copy()
164 | headers["referer"] = f"https://scratch.mit.edu/studios/{self.id}/comments/"
165 |
166 | response = requests.post(
167 | f"https://scratch.mit.edu/site-api/comments/gallery/{self.id}/toggle-comments/",
168 | headers=headers,
169 | )
170 |
171 | if response.status_code == 403:
172 | raise UnauthorizedException("You are not allowed to do this")
173 |
174 | def get_comment(self, comment_id):
175 | data = requests.get(
176 | f"https://api.scratch.mit.edu/studios/{self.id}/comments/{comment_id}/"
177 | ).json()
178 | return StudioComment(self, data, self._client)
179 |
180 | def get_comments(self, all=False, limit=20, offset=0):
181 | return get_data_list(
182 | all,
183 | limit,
184 | offset,
185 | f"https://api.scratch.mit.edu/studios/{self.id}/comments",
186 | lambda comment: StudioComment(self, comment, self._client),
187 | )
188 |
189 | def get_activity(self, all=False, limit=20, offset=0):
190 | return get_data_list(
191 | all,
192 | limit,
193 | offset,
194 | f"https://api.scratch.mit.edu/studios/{self.id}/activity",
195 | Activity,
196 | )
197 |
198 | def post_comment(self, content, parent_id="", commentee_id=""):
199 | self._client._ensure_logged_in()
200 |
201 | if not self.comments_allowed:
202 | raise UnauthorizedException("Comments are closed in this studio")
203 |
204 | headers = self._headers.copy()
205 | headers["referer"] = f"https://scratch.mit.edu/studios/{self.id}/comments/"
206 | data = {
207 | "commentee_id": commentee_id,
208 | "content": content,
209 | "parent_id": parent_id,
210 | }
211 |
212 | response = requests.post(
213 | f"https://scratch.mit.edu/site-api/comments/gallery/{self.id}/add/",
214 | headers=headers,
215 | data=json.dumps(data),
216 | ).json()
217 |
218 | if "rejected" in response:
219 | raise RejectedException("Your comment did not post")
220 |
221 | return StudioComment(self, response, self._client)
222 |
223 | def delete_comment(self, comment_id):
224 | self._client._ensure_logged_in()
225 |
226 | headers = self._headers.copy()
227 | headers["referer"] = f"https://scratch.mit.edu/studios/{self.id}/comments/"
228 | data = {"id": comment_id}
229 |
230 | response = requests.post(
231 | f"https://scratch.mit.edu/site-api/comments/gallery/{self.id}/del/",
232 | headers=headers,
233 | data=json.dumps(data),
234 | )
235 |
236 | if response.status_code == 403:
237 | raise UnauthorizedException("You are not allowed to do this")
238 |
239 | def report_comment(self, comment_id):
240 | self._client._ensure_logged_in()
241 |
242 | headers = self._headers.copy()
243 | headers["referer"] = f"https://scratch.mit.edu/studios/{self.id}/comments/"
244 |
245 | data = {"id": comment_id}
246 | requests.post(
247 | f"https://scratch.mit.edu/site-api/comments/gallery/{self.id}/rep/",
248 | headers=headers,
249 | data=json.dumps(data),
250 | )
251 |
252 | def invite_curator(self, user):
253 | self._client._ensure_logged_in()
254 |
255 | username = user if isinstance(user, str) else user.username
256 | headers = self._headers.copy()
257 | headers["referer"] = f"https://scratch.mit.edu/studios/{self.id}/curators/"
258 |
259 | response = requests.put(
260 | f"https://scratch.mit.edu/site-api/users/curators-in/{self.id}/invite_curator/?usernames={username}",
261 | headers=headers,
262 | )
263 |
264 | if response.status_code == 403:
265 | raise UnauthorizedException("You are not allowed to do this")
266 |
267 | def accept_curator(self):
268 | self._client._ensure_logged_in()
269 |
270 | username = username = user if isinstance(user, str) else user.username
271 | headers = self._headers.copy()
272 | headers["referer"] = f"https://scratch.mit.edu/studios/{self.id}/curators/"
273 |
274 | response = requests.put(
275 | f"https://scratch.mit.edu/site-api/users/curators-in/{self.id}/add/?usernames={username}",
276 | headers=headers,
277 | )
278 |
279 | if response.status_code == 403:
280 | raise UnauthorizedException("You are not allowed to do this")
281 |
282 | def promote_curator(self, user):
283 | self._client._ensure_logged_in()
284 |
285 | username = username = user if isinstance(user, str) else user.username
286 | headers = self._headers.copy()
287 | headers["referer"] = f"https://scratch.mit.edu/studios/{self.id}/curators/"
288 |
289 | response = requests.put(
290 | f"https://scratch.mit.edu/site-api/users/curators-in/{self.id}/promote/?usernames={username}",
291 | headers=headers,
292 | )
293 |
294 | if response.status_code == 403:
295 | raise UnauthorizedException("You are not allowed to do this")
296 |
297 | def transfer_host(self, user, password):
298 | self._client._ensure_logged_in()
299 |
300 | username = username = user if isinstance(user, str) else user.username
301 | body = {"password": password}
302 |
303 | response = requests.put(
304 | f"https://api.scratch.mit.edu/studios/{self.id}/transfer/{username}",
305 | headers=self._headers,
306 | body=body,
307 | )
308 |
309 | if response.status_code == 403:
310 | raise UnauthorizedException("You are not allowed to do this")
311 |
312 | def set_description(self, content):
313 | self._client._ensure_logged_in()
314 |
315 | if self.host != self._client.user.id:
316 | raise UnauthorizedException("You are not allowed to do this")
317 |
318 | data = {"description": content}
319 | requests.put(
320 | f"https://scratch.mit.edu/site-api/galleries/all/{self.id}/",
321 | headers=self._headers,
322 | data=json.dumps(data),
323 | )
324 | self.description = content
325 |
326 | def set_title(self, content):
327 | self._client._ensure_logged_in()
328 |
329 | if self.host != self._client.user.id:
330 | raise UnauthorizedException("You are not allowed to do this")
331 |
332 | data = {"title": content}
333 | requests.put(
334 | f"https://scratch.mit.edu/site-api/galleries/all/{self.id}/",
335 | headers=self._headers,
336 | data=json.dumps(data),
337 | )
338 | self.title = content
339 |
340 | def set_thumbnail(self, file_or_data):
341 | self._client._ensure_logged_in()
342 |
343 | if self.host != self._client.user.id:
344 | raise UnauthorizedException("You are not allowed to do this")
345 |
346 | data = (
347 | file_or_data
348 | if isinstance(file_or_data, bytes)
349 | else open(file_or_data, "rb").read()
350 | )
351 | requests.post(
352 | f"https://scratch.mit.edu/site-api/galleries/all/{self.id}",
353 | data=data,
354 | headers=self._headers,
355 | )
356 |
357 | def delete(self):
358 | self._client._ensure_logged_in()
359 |
360 | if self.host != self._client.user.id:
361 | raise UnauthorizedException("You are not allowed to do that")
362 |
363 | data = {"visibility": "delbyusr"}
364 |
365 | requests.put(
366 | f"https://scratch.mit.edu/site-api/galleries/all/{self.id}/",
367 | data=json.dumps(data),
368 | headers=self._json_headers,
369 | )
370 |
--------------------------------------------------------------------------------
/scratchclient/Project.py:
--------------------------------------------------------------------------------
1 | import requests
2 | import json
3 |
4 | from .Incomplete import IncompleteUser, RemixtreeProject
5 | from .ScratchExceptions import UnauthorizedException
6 | from .Comment import ProjectComment
7 | from .util import get_data_list
8 |
9 |
10 | class Project:
11 | def __init__(self, data, client):
12 | global Studio
13 | from .Studio import Studio
14 |
15 | self.id = data["id"]
16 | self.title = data["title"]
17 |
18 | self.description = data["description"] if "description" in data else None
19 | self.instructions = data["instructions"] if "instructions" in data else None
20 |
21 | self.visible = data["visibility"] == "visible"
22 | self.public = data["public"]
23 | self.comments_allowed = data["comments_allowed"]
24 | self.is_published = data["is_published"]
25 | self.author = IncompleteUser(data["author"])
26 | self.thumbnail_URL = data["image"]
27 |
28 | self.created_timestamp = data["history"]["created"]
29 | self.last_modified_timestamp = data["history"]["modified"]
30 | self.shared_timestamp = data["history"]["shared"]
31 |
32 | self.view_count = data["stats"]["views"]
33 | self.love_count = data["stats"]["loves"]
34 | self.favorite_count = data["stats"]["favorites"]
35 | self.remix_count = data["stats"]["remixes"]
36 |
37 | if "remix" in data:
38 | self.parent = data["remix"]["parent"]
39 | self.root = data["remix"]["root"]
40 | self.is_remix = bool(self.parent)
41 | else:
42 | self.parent = None
43 | self.root = None
44 | self.is_remix = None
45 |
46 | self._client = client
47 | self._headers = {
48 | "x-csrftoken": self._client.csrf_token,
49 | "X-Token": self._client.token,
50 | "x-requested-with": "XMLHttpRequest",
51 | "Cookie": f"scratchcsrftoken={self._client.csrf_token};scratchlanguage=en;scratchsessionsid={self._client.session_id};",
52 | "referer": f"https://scratch.mit.edu/projects/{self.id}/",
53 | }
54 | self._json_headers = {
55 | **self._headers,
56 | "accept": "application/json",
57 | "Content-Type": "application/json",
58 | }
59 |
60 | def get_comment(self, comment_id):
61 | data = requests.get(
62 | f"https://api.scratch.mit.edu/users/{self.author.username}/projects/{self.id}/comments/{comment_id}/"
63 | ).json()
64 | return ProjectComment(self, data, self._client)
65 |
66 | def love(self):
67 | self._client._ensure_logged_in()
68 |
69 | return requests.post(
70 | f"https://api.scratch.mit.edu/proxy/projects/{self.id}/loves/user/{self._client.username}",
71 | headers=self._headers,
72 | ).json()["userLove"]
73 |
74 | def unlove(self):
75 | self._client._ensure_logged_in()
76 |
77 | return requests.delete(
78 | f"https://api.scratch.mit.edu/proxy/projects/{self.id}/loves/user/{self._client.username}",
79 | headers=self._headers,
80 | ).json()["userLove"]
81 |
82 | def favorite(self):
83 | self._client._ensure_logged_in()
84 |
85 | return requests.post(
86 | f"https://api.scratch.mit.edu/proxy/projects/{self.id}/favorites/user/{self._client.username}",
87 | headers=self._headers,
88 | ).json()["userFavorite"]
89 |
90 | def unfavorite(self):
91 | self._client._ensure_logged_in()
92 |
93 | return requests.delete(
94 | f"https://api.scratch.mit.edu/proxy/projects/{self.id}/favorites/user/{self._client.username}",
95 | headers=self._headers,
96 | ).json()["userFavorite"]
97 |
98 | def get_scripts(self):
99 | return requests.get(f"https://projects.scratch.mit.edu/{self.id}/").json()
100 |
101 | def save(self, project):
102 | self._client._ensure_logged_in()
103 |
104 | if self.author.username != self._client.username:
105 | raise UnauthorizedException("You are not allowed to do that")
106 |
107 | requests.put(
108 | f"https://projects.scratch.mit.edu/{self.id}",
109 | headers=self._json_headers,
110 | data=project,
111 | )
112 |
113 | def get_remixes(self, all=False, limit=20, offset=0):
114 | return get_data_list(
115 | all,
116 | limit,
117 | offset,
118 | f"https://api.scratch.mit.edu/projects/{self.id}/remixes",
119 | lambda project: Project(project, self._client),
120 | )
121 |
122 | def get_remixtree(self):
123 | response = requests.get(
124 | f"https://scratch.mit.edu/projects/{self.id}/remixtree/bare"
125 | )
126 |
127 | if response.text == "no data" or response.status_code == 404:
128 | return []
129 |
130 | tree = []
131 | for key, value in response.json().items():
132 | if key == "root_id":
133 | continue
134 |
135 | tree.append(
136 | RemixtreeProject(
137 | {
138 | **value,
139 | "id": key,
140 | }
141 | )
142 | )
143 |
144 | return tree
145 |
146 | def get_studios(self, all=False, limit=20, offset=0):
147 | return get_data_list(
148 | all,
149 | limit,
150 | offset,
151 | f"https://api.scratch.mit.edu/users/{self.author.username}/projects/{self.id}/studios",
152 | lambda studio: Studio(studio, self._client),
153 | )
154 |
155 | def post_comment(self, content, parent_id="", commentee_id=""):
156 | self._client._ensure_logged_in()
157 |
158 | if not self.comments_allowed:
159 | raise UnauthorizedException("Comments are closed on this project")
160 |
161 | data = {
162 | "commentee_id": commentee_id,
163 | "content": content,
164 | "parent_id": parent_id,
165 | }
166 | response = requests.post(
167 | f"https://api.scratch.mit.edu/proxy/comments/project/{self.id}/",
168 | headers=self._json_headers,
169 | data=json.dumps(data),
170 | ).json()
171 |
172 | if "rejected" in response:
173 | raise RejectedException("Your comment did not post")
174 |
175 | return ProjectComment(self, response, self._client)
176 |
177 | def get_comments(self, all=False, limit=20, offset=0):
178 | return get_data_list(
179 | all,
180 | limit,
181 | offset,
182 | f"https://api.scratch.mit.edu/users/{self.author.username}/projects/{self.id}/comments",
183 | lambda comment: ProjectComment(self, comment, self._client),
184 | )
185 |
186 | def get_cloud_logs(self, all=False, limit=20, offset=0):
187 | return get_data_list(
188 | all,
189 | limit,
190 | offset,
191 | "https://clouddata.scratch.mit.edu/logs",
192 | lambda log: log,
193 | params=f"&projectid={self.id}",
194 | headers=self._headers,
195 | )
196 |
197 | def get_visibility(self):
198 | self._ensure_logged_in()
199 |
200 | return requests.get(
201 | f"https//api.scratch.mit.edu/users/{self._client.username}/projects/{self.id}/visibility",
202 | headers=self._headers,
203 | ).json()
204 |
205 | def toggle_commenting(self):
206 | self._client._ensure_logged_in()
207 |
208 | if self.author.username != self._client.username:
209 | raise UnauthorizedException("You are not allowed to do that")
210 |
211 | data = {"comments_allowed": not self.comments_allowed}
212 | self.comments_allowed = not self.comments_allowed
213 | return Project(
214 | requests.put(
215 | f"https://api.scratch.mit.edu/projects/{self.id}/",
216 | data=json.dumps(data),
217 | headers=self._json_headers,
218 | ).json(),
219 | self._client,
220 | )
221 |
222 | def turn_on_commenting(self):
223 | self._client._ensure_logged_in()
224 |
225 | if self.author.username != self._client.username:
226 | raise UnauthorizedException("You are not allowed to do that")
227 |
228 | data = {"comments_allowed": True}
229 | self.comments_allowed = True
230 | return Project(
231 | requests.put(
232 | f"https://api.scratch.mit.edu/projects/{self.id}/",
233 | data=json.dumps(data),
234 | headers=self._json_headers,
235 | ).json(),
236 | self._client,
237 | )
238 |
239 | def turn_off_commenting(self):
240 | self._client._ensure_logged_in()
241 |
242 | if self.author.username != self._client.username:
243 | raise UnauthorizedException("You are not allowed to do that")
244 |
245 | data = {"comments_allowed": False}
246 | self.comments_allowed = False
247 | return Project(
248 | requests.put(
249 | f"https://api.scratch.mit.edu/projects/{self.id}/",
250 | data=json.dumps(data),
251 | headers=self._json_headers,
252 | ).json(),
253 | self._client,
254 | )
255 |
256 | def report(self, category, reason, image=None):
257 | self._client._ensure_logged_in()
258 |
259 | if not image:
260 | image = self.thumbnail_URL
261 | data = {"notes": reason, "report_category": category, "thumbnail": image}
262 |
263 | requests.post(
264 | f"https://api.scratch.mit.edu/proxy/comments/project/{self.id}/",
265 | data=json.dumps(data),
266 | headers=self._json_headers,
267 | )
268 |
269 | def unshare(self):
270 | self._client._ensure_logged_in()
271 |
272 | if self.author.username != self._client.username:
273 | raise UnauthorizedException("You are not allowed to do that")
274 |
275 | requests.put(
276 | f"https://api.scratch.mit.edu/proxy/projects/{self.id}/unshare/",
277 | headers=self._json_headers,
278 | )
279 |
280 | self.public = False
281 |
282 | def share(self):
283 | self._client._ensure_logged_in()
284 |
285 | if self.author.username != self._client.username:
286 | raise UnauthorizedException("You are not allowed to do that")
287 |
288 | requests.put(
289 | f"https://api.scratch.mit.edu/proxy/projects/{self.id}/share/",
290 | headers=self._json_headers,
291 | )
292 |
293 | self.public = True
294 |
295 | def delete(self):
296 | self._client._ensure_logged_in()
297 |
298 | if self.author.username != self._client.username:
299 | raise UnauthorizedException("You are not allowed to do that")
300 |
301 | data = {"visibility": "trshbyusr"}
302 |
303 | requests.put(
304 | f"https://scratch.mit.edu/site-api/projects/all/{self.id}/",
305 | data=json.dumps(data),
306 | headers=self._json_headers,
307 | )
308 |
309 | def restore_deleted(self):
310 | self._client._ensure_logged_in()
311 |
312 | if self.author.username != self._client.username:
313 | raise UnauthorizedException("You are not allowed to do that")
314 |
315 | data = {"visibility": "visible"}
316 |
317 | requests.put(
318 | f"https://scratch.mit.edu/site-api/projects/all/{self.id}/",
319 | data=json.dumps(data),
320 | headers=self._json_headers,
321 | )
322 |
323 | def view(self):
324 | requests.post(
325 | f"https://api.scratch.mit.edu/users/{self.author.username}/projects/{self.id}/views/",
326 | headers=self._headers,
327 | )
328 |
329 | def set_thumbnail(self, file_or_data):
330 | self._client._ensure_logged_in
331 |
332 | if self.author.username != self._client.username:
333 | raise UnauthorizedException("You are not allowed to do that")
334 |
335 | data = (
336 | file_or_data
337 | if isinstance(file_or_data, bytes)
338 | else open(file_or_data, "rb").read()
339 | )
340 | requests.post(
341 | f"https://scratch.mit.edu/internalapi/project/thumbnail/{self.id}/set",
342 | data=data,
343 | headers=self._headers,
344 | )
345 |
346 | def set_title(self, title):
347 | self._client._ensure_logged_in()
348 |
349 | if self.author.username != self._client.username:
350 | raise UnauthorizedException("You are not allowed to do that")
351 |
352 | data = {"title": title}
353 | requests.put(
354 | f"https://api.scratch.mit.edu/projects/{self.id}/",
355 | data=json.dumps(data),
356 | headers=self._json_headers,
357 | )
358 |
359 | self.title = title
360 |
361 | def set_instructions(self, instructions):
362 | self._client._ensure_logged_in()
363 |
364 | if self.author.username != self._client.username:
365 | raise UnauthorizedException("You are not allowed to do that")
366 |
367 | data = {"instructions": instructions}
368 | requests.put(
369 | f"https://api.scratch.mit.edu/projects/{self.id}/",
370 | data=json.dumps(data),
371 | headers=self._json_headers,
372 | )
373 |
374 | self.instructions = instructions
375 |
376 | def set_description(self, description):
377 | self._client._ensure_logged_in()
378 |
379 | if self.author.username != self._client.username:
380 | raise UnauthorizedException("You are not allowed to do that")
381 |
382 | data = {"description": description}
383 | requests.put(
384 | f"https://api.scratch.mit.edu/projects/{self.id}/",
385 | data=json.dumps(data),
386 | headers=self._json_headers,
387 | )
388 |
389 | self.description = description
390 |
--------------------------------------------------------------------------------
/docs/reference/Studio.md:
--------------------------------------------------------------------------------
1 | # **Studio**
2 |
3 | ## Properties
4 |
5 | ###`#!python id : int` { #id data-toc-label="id" }
6 |
7 | The ID of the studio.
8 |
9 | **Example:**
10 |
11 | ```python
12 | print(session.get_studio(14).id)
13 | # 14
14 | ```
15 |
16 | ###`#!python title : str` { #title data-toc-label="title" }
17 |
18 | The title of the studio.
19 |
20 | **Example:**
21 |
22 | ```python
23 | print(session.get_studio(14).title)
24 | # Citizen Schools @ ML-14
25 | ```
26 |
27 | ###`#!python host : int` { #host data-toc-label="host" }
28 |
29 | The user ID of the host (owner) of the studio.
30 |
31 | ###`#!python description : str` { #description data-toc-label="description" }
32 |
33 | The description of the studio.
34 |
35 | ###`#!python visible : bool` { #visible data-toc-label="visible" }
36 |
37 | A boolean value representing whether the studio is deleted or not.
38 |
39 | ###`#!python open_to_public : bool` { #open_to_public data-toc-label="open_to_public" }
40 |
41 | A boolean value representing whether anyone can add projects to the studio.
42 |
43 | ###`#!python comments_allowed : bool` { #comments_allowed data-toc-label="comments_allowed" }
44 |
45 | A boolean value representing if comments are allowed on the studio.
46 |
47 | ###`#!python thumbnail_URL : str` { #thumbnail_URL data-toc-label="thumbnail_URL" }
48 |
49 | The URL of the thumbnail of the studio.
50 |
51 | ###`#!python created_timestamp : str` { #created_timestamp data-toc-label="created_timestamp" }
52 |
53 | An [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) timestamp representing the date the studio was created.
54 |
55 | **Example:**
56 |
57 | ```python
58 | import datetime
59 |
60 | def iso_to_readable(iso):
61 | timezone = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo
62 |
63 | date = datetime.datetime.fromisoformat(iso.replace("Z", "+00:00"))
64 | date.astimezone(timezone)
65 |
66 | return date.strftime("%Y-%m-%d %I:%M %p")
67 |
68 | print(iso_to_readable(session.get_studio(14).created_timestamp))
69 | # 2008-05-03 1:01 PM
70 | ```
71 |
72 | ###`#!python last_modified_timestamp : str` { #last_modified_timestamp data-toc-label="last_modified_timestamp" }
73 |
74 | An [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) timestamp representing the date the description or thumbnail of the studio was most recently modified.
75 |
76 | ###`#!python curator_count : int | None` { #curator_count data-toc-label="curator_count" }
77 |
78 | The number of curators the studio has.
79 |
80 | ###`#!python follower_count : int | None` { #follower_count data-toc-label="follower_count" }
81 |
82 | The number of followers the studio has.
83 |
84 | ###`#!python manager_count : int | None` { #manager_count data-toc-label="manager_count" }
85 |
86 | The number of managers the studio has.
87 |
88 | ###`#!python curator_count : int | None` { #curator_count data-toc-label="curator_count" }
89 |
90 | The number of curators the studio has.
91 |
92 | ###`#!python project_count : int | None` { #project_count data-toc-label="project_count" }
93 |
94 | The number of projects the studio has.
95 |
96 | ## Methods
97 |
98 | ###`#!python get_comment(comment_id)` { #get_comment data-toc-label="get_comment" }
99 |
100 | Gets a comment on the studio with the ID `#!python comment_id` as a [StudioComment](../StudioComment) object.
101 |
102 | **PARAMETERS**
103 |
104 | - **comment_id** (`#!python int`) - The comment ID of the comment to be retrieved
105 |
106 | **RETURNS** - `#!python StudioComment`
107 |
108 | **Example:**
109 |
110 | ```python
111 | print(session.get_studio(14).get_comment(25224).content)
112 | # I was born there
113 | ```
114 |
115 | ###`#!python add_project(project)` { #add_project data-toc-label="add_project" }
116 |
117 | Adds a project to the studio. You must be logged in and have permission to add projects to the studio for this to not throw an error.
118 |
119 | **PARAMETERS**
120 |
121 | - **project** (`#!python int | str | IncompleteProject | RemixtreeProject | Project`) - The project to be added to the studio, either as an `#!python int` or `#!python str` representing the project's ID, or a corresponding project object.
122 |
123 | ###`#!python remove_project(project)` { #remove_project data-toc-label="remove_project" }
124 |
125 | Removes a project from the studio. You must be logged in and be a curator of the studio for this to not throw an error.
126 |
127 | **PARAMETERS**
128 |
129 | - **project** (`#!python int | str | IncompleteProject | RemixtreeProject | Project`) - The project to be removed from the studio, either as an `#!python int` or `#!python str` representing the project's ID, or a corresponding project object.
130 |
131 | ###`#!python get_projects(all=False, limit=20, offset=0)` { #get_projects data-toc-label="get_projects" }
132 |
133 | Gets a list of projects in the studio. Returns an array of [Project](../Project) objects.
134 |
135 | **PARAMETERS**
136 |
137 | - **all** (`#!python Optional[bool]`) - Whether to retrieve every single project or just `#!python limit` projects.
138 | - **limit** (`#!python Optional[int]`) - How many projects to retrieve if `#!python all` is `#!python False`.
139 | - **offset** (`#!python Optional[int]`) - The offset of the projects from the newest ones - i.e. an offset of 20 would give you the next 20 projects after the first 20.
140 |
141 | **RETURNS** - `#!python list[Project]`
142 |
143 | **Example:**
144 |
145 | ```python
146 | print(session.get_studio(14).get_projects()[0].title)
147 | # football, basket and baseball
148 | ```
149 |
150 | ###`#!python get_curators(all=False, limit=20, offset=0)` { #get_curators data-toc-label="get_curators" }
151 |
152 | Gets a list of the curators of the studio. Returns an array of [User](../User) objects.
153 |
154 | **PARAMETERS**
155 |
156 | - **all** (`#!python Optional[bool]`) - Whether to retrieve every single curator or just `#!python limit` curators.
157 | - **limit** (`#!python Optional[int]`) - How many curators to retrieve if `#!python all` is `#!python False`.
158 | - **offset** (`#!python Optional[int]`) - The offset of the curators from the newest ones - i.e. an offset of 20 would give you the next 20 curators after the first 20.
159 |
160 | **RETURNS** - `#!python list[User]`
161 |
162 | **Example:**
163 |
164 | ```python
165 | print(session.get_studio(30136012).get_curators()[0].username)
166 | # wvj
167 | ```
168 |
169 | ###`#!python get_managers(all=False, limit=20, offset=0)` { #get_managers data-toc-label="get_managers" }
170 |
171 | Gets a list of the managers of the studio. Returns an array of [User](../User) objects.
172 |
173 | **PARAMETERS**
174 |
175 | - **all** (`#!python Optional[bool]`) - Whether to retrieve every single manager or just `#!python limit` managers.
176 | - **limit** (`#!python Optional[int]`) - How many managers to retrieve if `#!python all` is `#!python False`.
177 | - **offset** (`#!python Optional[int]`) - The offset of the managers from the newest ones - i.e. an offset of 20 would give you the next 20 managers after the first 20.
178 |
179 | **RETURNS** - `#!python list[User]`
180 |
181 | **Example:**
182 |
183 | ```python
184 | print(session.get_studio(30136012).get_managers()[0].username)
185 | # CatsUnited
186 | ```
187 |
188 | ###`#!python get_roles()` { #get_roles data-toc-label="get_roles" }
189 |
190 | Retrieves the roles the logged-in user has in the studio. You must be logged in for this to not throw an error. Returns a `#!python dict` containing the following items:
191 |
192 | - **manager** (`#!python bool`) - Whether you are a manager of the studio.
193 | - **curator** (`#!python bool`) - Whether you are a curator of the studio.
194 | - **invited** (`#!python bool`) - Whether you have a pending invitation to the studio.
195 | - **following** (`#!python bool`) - Whether you are following the studio.
196 |
197 | **RETURNS** - `#!python dict`
198 |
199 | **Example:**
200 |
201 | ```python
202 | studio = session.get_studio(14)
203 | print(studio.get_roles()["following"])
204 | # False
205 | studio.follow()
206 | print(studio.get_roles()["following"])
207 | # True
208 | ```
209 |
210 | ###`#!python follow()` { #follow data-toc-label="follow" }
211 |
212 | Follows the studio. You must be logged in for this to not throw an error.
213 |
214 | ###`#!python unfollow()` { #unfollow data-toc-label="unfollow" }
215 |
216 | Unfollows the studio. You must be logged in for this to not throw an error.
217 |
218 | ###`#!python open_to_public()` { #open_to_public data-toc-label="open_to_public" }
219 |
220 | Opens the studio to the public so anyone can add projects. You must be logged in and a manager of the studio for this to not throw an error.
221 |
222 | ###`#!python close_to_public()` { #close_to_public data-toc-label="close_to_public" }
223 |
224 | Closes the studio to the public so only curators can add projects. You must be logged in and a manager of the studio for this to not throw an error.
225 |
226 | ###`#!python toggle_commenting()` { #toggle_commenting data-toc-label="toggle_commenting" }
227 |
228 | Toggles the ability for people to comment in the studio. You must be logged in and a manager of the studio for this to not throw an error.
229 |
230 | **Example:**
231 |
232 | ```python
233 | studio = session.get_studio(30136012)
234 | studio.post_comment("Scratch sucks so I'm closing this studio")
235 | studio.toggle_commenting()
236 | ```
237 |
238 | ###`#!python get_comments(all=False, limit=20, offset=0)` { #get_comments data-toc-label="get_comments" }
239 |
240 | Gets a list of comments on the studio. Returns an array of [StudioComment](../StudioComment) objects.
241 |
242 | **PARAMETERS**
243 |
244 | - **all** (`#!python Optional[bool]`) - Whether to retrieve every single comment or just `#!python limit` comments.
245 | - **limit** (`#!python Optional[int]`) - How many comments to retrieve if `#!python all` is `#!python False`.
246 | - **offset** (`#!python Optional[int]`) - The offset of the comments from the newest ones - i.e. an offset of 20 would give you the next 20 comments after the first 20.
247 |
248 | **RETURNS** - `#!python list[StudioComment]`
249 |
250 | **Example:**
251 |
252 | ```python
253 | print(session.get_studio(30136012).get_comments()[0].content)
254 | # hot take: we should ban all people that don't like scratch
255 | ```
256 |
257 | ###`#!python get_activity(all=False, limit=20, offset=0)` { #get_activity data-toc-label="get_activity" }
258 |
259 | Gets the activity in the studio. Returns an array of [Activity](../Activity) objects.
260 |
261 | **PARAMETERS**
262 |
263 | - **all** (`#!python Optional[bool]`) - Whether to retrieve every single activity or just `#!python limit` activities.
264 | - **limit** (`#!python Optional[int]`) - How many activities to retrieve if `#!python all` is `#!python False`.
265 | - **offset** (`#!python Optional[int]`) - The offset of the activities from the newest ones - i.e. an offset of 20 would give you the next 20 activities after the first 20.
266 |
267 | **RETURNS** - `#!python list[Activity]`
268 |
269 | **Example:**
270 |
271 | ```python
272 | print(session.get_studio(30136012).get_activity()[0].type)
273 | # addprojectostudio
274 | ```
275 |
276 | ###`#!python post_comment(content, parent_id="", commentee_id="")` { #post_comment data-toc-label="post_comment" }
277 |
278 | Posts a comment on the studio. You must be logged in for this to not throw an error. Returns the posted comment as a `#!python StudioComment`.
279 |
280 | **PARAMETERS**
281 |
282 | - **content** (`#!python str`) - The content of the comment to be posted.
283 | - **parent_id** (`#!python Optional[Literal[""] | int]`) - If the comment to be posted is a reply, this is the comment ID of the parent comment. Otherwise, this is an empty string `#!python ""`.
284 | - **commentee_id** (`#!python Optiona[Literal[""] | int]`) - If the comment to be posted is a reply, this is the user ID of the author of the parent comment. Otherwise, this an empty string `#!python ""`.
285 |
286 | **RETURNS** - `#!python StudioComment`
287 |
288 | **Example:**
289 |
290 | ```python
291 | session.get_project(14).post_comment("OMG first studio on Scratch")
292 | session.get_project(14).post_comment("OMG first comment on the first studio on scratch", parent_id=25224, commentee_id=35153)
293 | ```
294 |
295 | ###`#!python delete_comment(comment_id)` { #delete_comment data-toc-label="delete_comment" }
296 |
297 | Deletes a comment on the studio. You must be logged in, a manager of the studio, and the author of the comment, for this to not throw an error.
298 |
299 | !!! warning
300 |
301 | This is deprecated. It's recommended to use `#!python StudioComment.delete` instead. See [this](../StudioComment#delete) for more details.
302 |
303 | **PARAMETERS**
304 |
305 | - **comment_id** (`#!python int`) - The ID of the comment to be deleted.
306 |
307 | ###`#!python report_comment(comment_id)` { #report_comment data-toc-label="report_comment" }
308 |
309 | Reports a comment on the studio. You must be logged in for this to not throw an error.
310 |
311 | !!! warning
312 |
313 | This is deprecated. It's recommended to use `#!python StudioComment.report` instead. See [this](../StudioComment#report) for more details.
314 |
315 | **PARAMETERS**
316 |
317 | - **comment_id** (`#!python int`) - The ID of the comment to be reported.
318 |
319 | ###`#!python invite_curator(user)` { #invite_curator data-toc-label="invite_curator" }
320 |
321 | Invites a user to become a curator of the studio. You must be logged in, and a manager of the studio, for this to not throw an error.
322 |
323 | **PARAMETERS**
324 |
325 | - **user** (`#!python str | User | IncompleteUser`) - The username of the user to be invited, or an object representing the user.
326 |
327 | ###`#!python accept_curator(user)` { #accept_curator data-toc-label="accept_curator" }
328 |
329 | Accepts any pending curator invitations to the studio. You must be logged in, and having been invited to be a curator of the studio, for this to not throw an error.
330 |
331 | ###`#!python promote_curator(user)` { #promote_curator data-toc-label="promote_curator" }
332 |
333 | Promotes a user to a manager of the studio. You must be logged in, and a manager of the studio, for this to not throw an error.
334 |
335 | **PARAMETERS**
336 |
337 | - **user** (`#!python str | User | IncompleteUser`) - The username of the user to be promoted, or an object representing the user. The user must already be a curator for this to not throw an error.
338 |
339 | ###`#!python transfer_host(user, password)` { #transfer_host data-toc-label="transfer_host" }
340 |
341 | Transfers ownership of the studio. You must be logged in, and the host of the studio, for this to not throw an error.
342 |
343 | **PARAMETERS**
344 |
345 | - **user** (`#!python str | User | IncompleteUser`) - The username of the user that will become the new host, or an object representing the user. The user must already be a manager for this to not throw an error.
346 | - **password** (`#!python str`) - The password to your account. This is necessary for authentication.
347 |
348 | ###`#!python set_description(description)` { #set_description data-toc-label="set_description" }
349 |
350 | Sets the description of the studio. You must be logged in, and the host of the studio, for this to not throw an error.
351 |
352 | **PARAMETERS**
353 |
354 | **description** (`#!python str`) - The description that the description of the studio should be set to.
355 |
356 | ###`#!python set_title(content)` { #set_title data-toc-label="set_title" }
357 |
358 | Sets the title of the studio. You must be logged in, and the host of the studio, for this to not throw an error.
359 |
360 | **PARAMETERS**
361 |
362 | **content** (`#!python str`) - The title that the title of the studio should be set to.
363 |
364 | ###`#!python set_thumbnail(file_or_data)` { #set_thumbnail data-toc-label="set_thumbnail" }
365 |
366 | Sets the thumbnail of the studio. You must be logged in, and the host of the studio, for this to not throw an error.
367 |
368 | **PARAMETERS**
369 |
370 | **file_or_data** (`#!python bytes | str`) - The file that the thumbnail should be set to. If this is a `#!python str`, then it will be interpreted as a path to a file; otherwise, it will be interpreted as the data in the image.
371 |
372 | ###`#!python delete()` { #delete data-toc-label="delete" }
373 |
374 | Deletes the studio. You must be logged in, and the host of the studio, for this to not throw an error.
--------------------------------------------------------------------------------
/scratchclient/CloudConnection.py:
--------------------------------------------------------------------------------
1 | import json
2 | import time
3 | import threading
4 | import asyncio
5 |
6 | from .Websocket import Websocket, AsyncWebsocket
7 | from .ScratchExceptions import *
8 |
9 |
10 | class EventEmitter:
11 | def __init__(self):
12 | self._events = {}
13 |
14 | def on(self, key, callback=None, once=False):
15 | def add_handler(handler):
16 | if not key in self._events:
17 | self._events[key] = []
18 |
19 | self._events[key].append((handler, once))
20 |
21 | if callback:
22 | add_handler(callback)
23 | return
24 |
25 | return add_handler
26 |
27 | def off(self, key, callback):
28 | self._events[key] = [
29 | (handler, once)
30 | for handler, once in self._events[key]
31 | if handler is not callback
32 | ]
33 |
34 | def once(self, key, callback=None):
35 | return self.on(key, callback, True)
36 |
37 | def emit(self, key, *args, **kwargs):
38 | if not key in self._events:
39 | return
40 |
41 | for handler, once in self._events[key]:
42 | if asyncio.iscoroutinefunction(handler):
43 | asyncio.create_task(handler(*args, **kwargs))
44 | else:
45 | handler(*args, **kwargs)
46 |
47 | if once:
48 | self.off(key, handler)
49 |
50 | def listeners(self, event):
51 | return self._events[event][0]
52 |
53 |
54 | class CloudVariable:
55 | def __init__(self, name, value):
56 | self.name = name
57 | self.value = value
58 |
59 |
60 | class BaseCloudConnection(EventEmitter):
61 | def __init__(self, project_id, client, cloud_host):
62 | super().__init__()
63 | self._client = client
64 | self.project_id = project_id
65 | self.cloud_host = cloud_host
66 | self._ws = None
67 |
68 | def _send_packet(self, packet):
69 | return self._ws.send(f"{json.dumps(packet)}\n")
70 |
71 | def get_cloud_variable(self, name):
72 | try:
73 | var = next(
74 | x
75 | for x in self._cloudvariables
76 | if x.name == (f"☁ {name}" if not name.startswith("☁ ") else name)
77 | )
78 | return var.value
79 | except StopIteration:
80 | raise CloudVariableException(
81 | f"Variable '{(f'☁ {name}' if not name.startswith('☁ ') else name)}' is not in this project"
82 | )
83 |
84 |
85 | class CloudConnection(BaseCloudConnection):
86 | def __init__(
87 | self, project_id, client, cloud_host="clouddata.scratch.mit.edu", headers={}
88 | ):
89 | super().__init__(project_id, client, cloud_host)
90 | self.connect(headers)
91 |
92 | def connect(self, headers):
93 | self._ws = Websocket()
94 | self._cloudvariables = []
95 | self._timer = time.time()
96 |
97 | default_headers = {
98 | "Cookie": f"scratchsessionsid={self._client.session_id};",
99 | "Origin": "https://scratch.mit.edu",
100 | }
101 | if self.cloud_host == "clouddata.scratch.mit.edu":
102 | if not self._client.logged_in:
103 | raise UnauthorizedException("You need to be logged in to do this")
104 | else:
105 | # Don't send the session ID unless it's Scratch
106 | del default_headers["Cookie"]
107 |
108 | self._ws.connect(
109 | f"wss://{self.cloud_host}",
110 | headers={**default_headers, **headers},
111 | ) # connect the websocket
112 | self._send_packet(
113 | {
114 | "method": "handshake",
115 | "user": self._client.username,
116 | "project_id": str(self.project_id),
117 | }
118 | )
119 | self.emit("handshake")
120 | response = self._ws.recv().split("\n")
121 | for variable in response:
122 | try:
123 | variable = json.loads(str(variable))
124 | except json.decoder.JSONDecodeError:
125 | pass
126 | else:
127 | self._cloudvariables.append(
128 | CloudVariable(variable["name"], variable["value"])
129 | )
130 | self.emit("connect")
131 | self._start_cloud_var_loop()
132 |
133 | def set_cloud_variable(self, variable, value):
134 | if time.time() - self._timer > 0.1:
135 | if not str(value).isdigit():
136 | raise CloudVariableException(
137 | "Cloud variables can only be set to a combination of numbers"
138 | )
139 |
140 | if len(str(value)) > 256:
141 | raise CloudVariableException(
142 | "Cloud variable values must be less than 256 characters long"
143 | )
144 |
145 | packet = {
146 | "method": "set",
147 | "name": (
148 | f"☁ {variable}" if not variable.startswith("☁ ") else variable
149 | ),
150 | "value": str(value),
151 | "user": self._client.username,
152 | "project_id": str(self.project_id),
153 | }
154 | self._send_packet(packet)
155 | self.emit("outgoing", packet)
156 | self._timer = time.time()
157 | for cloud in self._cloudvariables:
158 | if (
159 | cloud.name == f"☁ {variable}"
160 | if not variable.startswith("☁ ")
161 | else variable
162 | ):
163 | cloud.value = value
164 | self.emit("change", cloud)
165 | break
166 | else:
167 | time.sleep(time.time() - self._timer)
168 | self.set_cloud_variable(variable, value)
169 |
170 | def create_cloud_variable(self, name, initial_value=0):
171 | if time.time() - self._timer > 0.1:
172 | if not str(initial_value).isdigit():
173 | raise CloudVariableException(
174 | "Cloud variables can only be set to a combination of numbers"
175 | )
176 |
177 | if len(str(initial_value)) > 256:
178 | raise CloudVariableException(
179 | "Cloud variable values must be less than 256 characters long"
180 | )
181 |
182 | packet = {
183 | "method": "create",
184 | "name": (f"☁ {name}" if not name.startswith("☁ ") else name),
185 | "value": str(initial_value),
186 | "user": self._client.username,
187 | "project_id": str(self.project_id),
188 | }
189 | self._send_packet(packet)
190 | self.emit("outgoing", packet)
191 | self._timer = time.time()
192 |
193 | new_variable = CloudVariable(f"☁ {name}" if not name.startswith("☁ ") else name, str(initial_value))
194 | self._cloudvariables.append(new_variable)
195 | self.emit("create", new_variable)
196 | self.emit("change", new_variable)
197 | else:
198 | time.sleep(time.time() - self._timer)
199 | self.create_cloud_variable(name, initial_value)
200 |
201 | def delete_cloud_variable(self, name):
202 | if time.time() - self._timer > 0.1:
203 | packet = {
204 | "method": "delete",
205 | "name": (f"☁ {name}" if not name.startswith("☁ ") else name),
206 | "user": self._client.username,
207 | "project_id": str(self.project_id),
208 | }
209 | self._send_packet(packet)
210 | self.emit("outgoing", packet)
211 | self._timer = time.time()
212 |
213 | self.emit("delete", name)
214 | else:
215 | time.sleep(time.time() - self._timer)
216 | self.delete_cloud_variable(name)
217 |
218 | def _cloud_var_loop(self):
219 | while True:
220 | if self._ws.connected:
221 | response = self._ws.recv()
222 | response = json.loads(response)
223 |
224 | if response["method"] != "set":
225 | continue
226 |
227 | try:
228 | cloud = next(
229 | variable
230 | for variable in self._cloudvariables
231 | if response["name"] == variable.name
232 | )
233 | cloud.value = response["value"]
234 | except StopIteration:
235 | # A new variable was created and was set
236 | cloud = CloudVariable(response["name"], response["value"])
237 | self._cloudvariables.append(cloud)
238 | self.emit("create", cloud)
239 |
240 | self.emit("set", cloud)
241 | self.emit("change", cloud)
242 | else:
243 | self.connect()
244 |
245 | def _start_cloud_var_loop(self):
246 | """Will start a new thread that looks for the cloud variables and appends their results onto cloudvariables"""
247 | thread = threading.Thread(target=self._cloud_var_loop)
248 | thread.start()
249 |
250 |
251 | class AsyncCloudConnection(BaseCloudConnection):
252 | def __init__(
253 | self, project_id, client, cloud_host="clouddata.scratch.mit.edu", headers={}
254 | ):
255 | super().__init__(project_id, client, cloud_host)
256 | self._headers = headers
257 |
258 | def run(self):
259 | asyncio.run(self.connect())
260 |
261 | async def connect(self):
262 | self._ws = AsyncWebsocket()
263 | self._cloudvariables = []
264 | self._timer = time.time()
265 |
266 | default_headers = {
267 | "Cookie": f"scratchsessionsid={self._client.session_id};",
268 | "Origin": "https://scratch.mit.edu",
269 | }
270 | if self.cloud_host == "clouddata.scratch.mit.edu":
271 | if not self._client.logged_in:
272 | raise UnauthorizedException("You need to be logged in to do this")
273 | else:
274 | # Don't send the session ID unless it's Scratch
275 | del default_headers["Cookie"]
276 |
277 | await self._ws.connect(
278 | f"wss://{self.cloud_host}",
279 | headers={**default_headers, **self._headers},
280 | ) # connect the websocket
281 |
282 | await self._send_packet(
283 | {
284 | "method": "handshake",
285 | "user": self._client.username,
286 | "project_id": str(self.project_id),
287 | }
288 | )
289 | self.emit("handshake")
290 | response = (await self._ws.recv()).split("\n")
291 | for variable in response:
292 | try:
293 | variable = json.loads(str(variable))
294 | except json.decoder.JSONDecodeError:
295 | pass
296 | else:
297 | self._cloudvariables.append(
298 | CloudVariable(variable["name"], variable["value"])
299 | )
300 | self.emit("connect")
301 |
302 | await self.cloud_variable_loop()
303 |
304 | async def set_cloud_variable(self, variable, value):
305 | if time.time() - self._timer > 0.1:
306 | if not str(value).isdigit():
307 | raise CloudVariableException(
308 | "Cloud variables can only be set to a combination of numbers"
309 | )
310 |
311 | if len(str(value)) > 256:
312 | raise CloudVariableException(
313 | "Cloud variable values must be less than 256 characters long"
314 | )
315 |
316 | packet = {
317 | "method": "set",
318 | "name": (
319 | f"☁ {variable}" if not variable.startswith("☁ ") else variable
320 | ),
321 | "value": str(value),
322 | "user": self._client.username,
323 | "project_id": str(self.project_id),
324 | }
325 | await self._send_packet(packet)
326 | self.emit("outgoing", packet)
327 | self._timer = time.time()
328 | for cloud in self._cloudvariables:
329 | if (
330 | cloud.name == f"☁ {variable}"
331 | if not variable.startswith("☁ ")
332 | else variable
333 | ):
334 | cloud.value = value
335 | self.emit("change", cloud)
336 | break
337 | else:
338 | await asyncio.sleep(time.time() - self._timer)
339 | await self.set_cloud_variable(variable, value)
340 |
341 | async def create_cloud_variable(self, name, initial_value=0):
342 | if time.time() - self._timer > 0.1:
343 | if not str(initial_value).isdigit():
344 | raise CloudVariableException(
345 | "Cloud variables can only be set to a combination of numbers"
346 | )
347 |
348 | if len(str(initial_value)) > 256:
349 | raise CloudVariableException(
350 | "Cloud variable values must be less than 256 characters long"
351 | )
352 |
353 | packet = {
354 | "method": "create",
355 | "name": (f"☁ {name}" if not name.startswith("☁ ") else name),
356 | "value": str(initial_value),
357 | "user": self._client.username,
358 | "project_id": str(self.project_id),
359 | }
360 | await self._send_packet(packet)
361 | self.emit("outgoing", packet)
362 | self._timer = time.time()
363 |
364 | new_variable = CloudVariable(f"☁ {name}" if not name.startswith("☁ ") else name, str(initial_value))
365 | self._cloudvariables.append(new_variable)
366 | self.emit("create", new_variable)
367 | self.emit("change", new_variable)
368 | else:
369 | await asyncio.sleep(time.time() - self._timer)
370 | await self.create_cloud_variable(name, initial_value)
371 |
372 | async def delete_cloud_variable(self, name):
373 | if time.time() - self._timer > 0.1:
374 | packet = {
375 | "method": "delete",
376 | "name": (f"☁ {name}" if not name.startswith("☁ ") else name),
377 | "user": self._client.username,
378 | "project_id": str(self.project_id),
379 | }
380 | await self._send_packet(packet)
381 | self.emit("outgoing", packet)
382 | self._timer = time.time()
383 |
384 | self.emit("delete", name)
385 | else:
386 | await asyncio.sleep(time.time() - self._timer)
387 | await self.delete_cloud_variable(name)
388 |
389 | async def cloud_variable_loop(self):
390 | while True:
391 | if self._ws.connected:
392 | response = await self._ws.recv()
393 | response = json.loads(response)
394 |
395 | if response["method"] != "set":
396 | continue
397 |
398 | try:
399 | cloud = next(
400 | variable
401 | for variable in self._cloudvariables
402 | if response["name"] == variable.name
403 | )
404 | cloud.value = response["value"]
405 | except StopIteration:
406 | # A new variable was created and was set
407 | cloud = CloudVariable(response["name"], response["value"])
408 | self._cloudvariables.append(cloud)
409 | self.emit("create", cloud)
410 |
411 | self.emit("set", cloud)
412 | self.emit("change", cloud)
413 | else:
414 | await self.connect()
415 |
--------------------------------------------------------------------------------
/docs/reference/Project.md:
--------------------------------------------------------------------------------
1 | # **Project**
2 |
3 | ## Properties
4 |
5 | ###`#!python id : int` { #id data-toc-label="id" }
6 |
7 | The ID of the project.
8 |
9 | **Example:**
10 |
11 | ```python
12 | print(session.get_project(104).id)
13 | # 104
14 | ```
15 |
16 | ###`#!python title : str` { #title data-toc-label="title" }
17 |
18 | The title of the project.
19 |
20 | **Example:**
21 |
22 | ```python
23 | print(session.get_project(104).title)
24 | # Weekend
25 | ```
26 |
27 | ###`#!python instructions : str` { #instructions data-toc-label="instructions" }
28 |
29 | The instructions of the project.
30 |
31 | ###`#!python description : str` { #description data-toc-label="description" }
32 |
33 | The description of the project (the "Notes and Credits" field).
34 |
35 | ###`#!python visible : bool` { #visible data-toc-label="visible" }
36 |
37 | A boolean value representing whether the project is deleted or not.
38 |
39 | ###`#!python public : bool` { #public data-toc-label="public" }
40 |
41 | A boolean value representing whether the project is shared or not.
42 |
43 | ###`#!python comments_allowed : bool` { #comments_allowed data-toc-label="comments_allowed" }
44 |
45 | A boolean value representing if comments are allowed on the project.
46 |
47 | ###`#!python is_published : bool` { #is_published data-toc-label="is_published" }
48 |
49 | A boolean value representing whether the project has been shared or not.
50 |
51 | !!! note
52 | I'm not all too sure about the difference between `#!python public` and `#!python is_published`, but I believe the difference is that projects that have `#!python is_published` as `#!python True` could be unshared, but taken down by the Scratch Team, whereas `#!python public` projects must be visible to everyone.
53 |
54 | ###`#!python author : IncompleteUser` { #author data-toc-label="author" }
55 |
56 | The author of the project as an [IncompleteUser](../IncompleteUser) object.
57 |
58 | ###`#!python thumbnail_URL : str` { #thumbnail_URL data-toc-label="thumbnail_URL" }
59 |
60 | The URL of the thumbnail of the project.
61 |
62 | ###`#!python created_timestamp : str` { #created_timestamp data-toc-label="created_timestamp" }
63 |
64 | An [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) timestamp representing the date the project was created.
65 |
66 | **Example:**
67 |
68 | ```python
69 | import datetime
70 |
71 | def iso_to_readable(iso):
72 | timezone = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo
73 |
74 | date = datetime.datetime.fromisoformat(iso.replace("Z", "+00:00"))
75 | date.astimezone(timezone)
76 |
77 | return date.strftime("%Y-%m-%d %I:%M %p")
78 |
79 | print(iso_to_readable(session.get_project(104).created_timestamp))
80 | # 2007-03-05 10:47 AM
81 | ```
82 |
83 | ###`#!python last_modified_timestamp : str` { #last_modified_timestamp data-toc-label="last_modified_timestamp" }
84 |
85 | An [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) timestamp representing the date the project was most recently modified.
86 |
87 | ###`#!python shared_timestamp : str` { #shared_timestamp data-toc-label="shared_timestamp" }
88 |
89 | An [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) timestamp representing the date the project was shared.
90 |
91 | ###`#!python view_count : int` { #view_count data-toc-label="view_count" }
92 |
93 | The number of views the project has.
94 |
95 | ###`#!python love_count : int` { #love_count data-toc-label="love_count" }
96 |
97 | The number of loves (hearts) the project has.
98 |
99 | ###`#!python favorite_count : int` { #favorite_count data-toc-label="favorite_count" }
100 |
101 | The number of favorites (stars) the project has.
102 |
103 | ###`#!python remix_count : int` { #remix_count data-toc-label="remix_count" }
104 |
105 | The number of remixes the project has.
106 |
107 | ###`#!python parent : int | None` { #parent data-toc-label="parent" }
108 |
109 | If the project is a remix, this is the project ID of the immediate parent of the project (the project it was remixed from). Otherwise, this is `#!python None`.
110 |
111 | ###`#!python root : int | None` { #root data-toc-label="root" }
112 |
113 | If the project is a remix, this is the project ID of the root project of the project (the original project it was remixed from). Otherwise, this is `#!python None`.
114 |
115 | **Example:**
116 |
117 | ```python
118 | project = session.get_project(149159110)
119 |
120 | print(f"""
121 | Based on project {project.parent}.
122 | Thanks to the original project {project.root}.
123 | """)
124 | ```
125 |
126 | ###`#!python is_remix : bool | None` { #is_remix data-toc-label="is_remix" }
127 |
128 | A boolean value representing whether the project is a remix.
129 |
130 | ## Methods
131 |
132 | ###`#!python get_comment(comment_id)` { #get_comment data-toc-label="get_comment" }
133 |
134 | Gets a comment on the project with the ID `#!python comment_id` as a [ProjectComment](../ProjectComment) object.
135 |
136 | **PARAMETERS**
137 |
138 | - **comment_id** (`#!python int`) - The comment ID of the comment to be retrieved
139 |
140 | **RETURNS** - `#!python ProjectComment`
141 |
142 | **Example:**
143 |
144 | ```python
145 | print(session.get_project(104).get_comment(488).content)
146 | # I personally like it fuzz
147 | ```
148 |
149 | ###`#!python love()` { #love data-toc-label="love" }
150 |
151 | Loves the project. Returns a `#!python bool` representing whether the user has loved the project.
152 |
153 | **RETURNS** - `#!python bool`
154 |
155 |
156 | ###`#!python unlove()` { #unlove data-toc-label="unlove" }
157 |
158 | Unloves the project. Returns a `#!python bool` representing whether the user has loved the project.
159 |
160 | **RETURNS** - `#!python bool`
161 |
162 |
163 | ###`#!python favorite()` { #favorite data-toc-label="favorite" }
164 |
165 | Favorites the project. Returns a `#!python bool` representing whether the user has favorited the project.
166 |
167 | **RETURNS** - `#!python bool`
168 |
169 |
170 | ###`#!python unfavorite()` { #unfavorite data-toc-label="unfavorite" }
171 |
172 | Unfavorites the project. Returns a `#!python bool` representing whether the user has favorited the project.
173 |
174 | **RETURNS** - `#!python bool`
175 |
176 | ###`#!python get_scripts()` { #get_scripts data-toc-label="get_scripts" }
177 |
178 | Gets the scripts in the project, as a `#!python dict` with the same structure as the `project.json` file found in projects.
179 |
180 | **RETURNS** - `#!python dict`
181 |
182 | **Example:**
183 |
184 | ```python
185 | scripts = session.get_project(104).get_scripts()
186 |
187 | print(f"The first sprite is called '{scripts['targets'][1]['name']}'")
188 | # The first sprite is called 'girl'
189 | ```
190 |
191 | ###`#!python save(project)` { #save data-toc-label="save" }
192 |
193 | Saves the project with the scripts specified in the parameter `#!python project`.
194 |
195 | **PARAMETERS**
196 |
197 | - **project** (`#!python dict`) - The scripts to be put in the project, with the same format as the `project.json` file found in ordinary projects.
198 |
199 | ###`#!python get_remixes(all=False, limit=20, offset=0)` { #get_remixes data-toc-label="get_remixes" }
200 |
201 | Gets a list of remixes of the project. Returns an array of [Project](../Project) objects.
202 |
203 | **PARAMETERS**
204 |
205 | - **all** (`#!python Optional[bool]`) - Whether to retrieve every single remix or just `#!python limit` remixes.
206 | - **limit** (`#!python Optional[int]`) - How many remixes to retrieve if `#!python all` is `#!python False`.
207 | - **offset** (`#!python Optional[int]`) - The offset of the remixes from the newest ones - i.e. an offset of 20 would give you the next 20 remixes after the first 20.
208 |
209 | **RETURNS** - `#!python list[Project]`
210 |
211 | **Example:**
212 |
213 | ```python
214 | print(session.get_project(10128407).get_remixes()[0].title)
215 | # Paper Minecraft 3D
216 | ```
217 |
218 | ###`#!python get_studios(all=False, limit=20, offset=0)` { #get_studios data-toc-label="get_studios" }
219 |
220 | Gets a list of studios the project is in. Returns an array of [Studio](../Studio) objects.
221 |
222 | **PARAMETERS**
223 |
224 | - **all** (`#!python Optional[bool]`) - Whether to retrieve every single studio or just `#!python limit` studios.
225 | - **limit** (`#!python Optional[int]`) - How many studios to retrieve if `#!python all` is `#!python False`.
226 | - **offset** (`#!python Optional[int]`) - The offset of the studios from the newest ones - i.e. an offset of 20 would give you the next 20 studios after the first 20.
227 |
228 | **RETURNS** - `#!python list[Studio]`
229 |
230 | **Example:**
231 |
232 | ```python
233 | print(session.get_project(10128407).get_studios()[0].title)
234 | # Griffpatch's epic games!!
235 | ```
236 |
237 | ###`#!python get_remixtree()` { #get_remixtree data-toc-label="get_remixtree" }
238 |
239 | Gets the data in the tree of remixes of the project. This data is used to construct the `remixtree` page ([this](https://scratch.mit.edu/projects/104/remixtree/) is an example) Returns an array of [RemixtreeProject](../RemixtreeProject) objects, which is a list of the projects in the tree.
240 |
241 | **RETURNS** - `#!python list[RemixtreeProject]`
242 |
243 | **Example:**
244 |
245 | ```python
246 | print(session.get_project(104).get_remixtree()[0].title)
247 | # Weekend Remake
248 | ```
249 |
250 | ###`#!python get_comments(all=False, limit=20, offset=0)` { #get_comments data-toc-label="get_comments" }
251 |
252 | Gets a list of comments on the project. Returns an array of [ProjectComment](../ProjectComment) objects.
253 |
254 | **PARAMETERS**
255 |
256 | - **all** (`#!python Optional[bool]`) - Whether to retrieve every single comment or just `#!python limit` comments.
257 | - **limit** (`#!python Optional[int]`) - How many comments to retrieve if `#!python all` is `#!python False`.
258 | - **offset** (`#!python Optional[int]`) - The offset of the comments from the newest ones - i.e. an offset of 20 would give you the next 20 comments after the first 20.
259 |
260 | **RETURNS** - `#!python list[ProjectComment]`
261 |
262 | **Example:**
263 |
264 | ```python
265 | print(session.get_project(10128407).get_comments()[0].content)
266 | # follow me please
267 | ```
268 |
269 | ###`#!python get_cloud_logs(all=False, limit=20, offset=0)` { #get_cloud_logs data-toc-label="get_cloud_logs" }
270 |
271 | Gets the cloud logs on the project. Returns an array of `#!python dict`s containing the logs.
272 |
273 | **PARAMETERS**
274 |
275 | - **all** (`#!python Optional[bool]`) - Whether to retrieve every single log or just `#!python limit` logs.
276 | - **limit** (`#!python Optional[int]`) - How many logs to retrieve if `#!python all` is `#!python False`.
277 | - **offset** (`#!python Optional[int]`) - The offset of the logs from the newest ones - i.e. an offset of 20 would give you the next 20 logs after the first 20.
278 |
279 | **RETURNS** - `#!python list[dict]`
280 |
281 | **Example:**
282 |
283 | ```python
284 | print(session.get_project(12785898).get_cloud_logs()[0]["verb"])
285 | # set_var
286 | ```
287 |
288 | ###`#!python post_comment(content, parent_id="", commentee_id="")` { #post_comment data-toc-label="post_comment" }
289 |
290 | Posts a comment on the project. You must be logged in for this to not throw an error. Returns the posted comment as a `#!python ProjectComment`.
291 |
292 | **PARAMETERS**
293 |
294 | - **content** (`#!python str`) - The content of the comment to be posted.
295 | - **parent_id** (`#!python Optional[Literal[""] | int]`) - If the comment to be posted is a reply, this is the comment ID of the parent comment. Otherwise, this is an empty string `#!python ""`.
296 | - **commentee_id** (`#!python Optiona[Literal[""] | int]`) - If the comment to be posted is a reply, this is the user ID of the author of the parent comment. Otherwise, this an empty string `#!python ""`.
297 |
298 | **RETURNS** - `#!python ProjectComment`
299 |
300 | **Example:**
301 |
302 | ```python
303 | session.get_project(104).post_comment("OMG first project on Scratch")
304 | session.get_project(104).post_comment("OMG first comment on the first project on scratch", parent_id=488, commentee_id=6493)
305 | ```
306 |
307 | ###`#!python get_visibility()` { #get_visibility data-toc-label="get_visibility" }
308 |
309 | Gets the visibility and moderation status of the project. You must be logged in and the owner of the project for this to not throw an error. Returns the data as a `#!python dict`, with the following items:
310 |
311 | - **projectId** - The ID of the project (an `#!python int`).
312 | - **creatorId** - The user ID of the creator of the project (an `#!python int`).
313 | - **deleted** - Whether or not the project is deleted (a `#!python bool`).
314 | - **censored** - Whether the project was censored -- this could either be automatically or by the Scratch Team (a `#!python bool`).
315 | - **censoredByAdmin** - Whether the project was censored by the Scratch Team (a `#!python bool`).
316 | - **censoredByCommunity** - Whether the project was censored automatically by community reports (a `#!python bool`).
317 | - **reshareable** - Whether the project can be reshared (a `#!python bool`).
318 | - **message** - If the project was censored, this is the message from the Scratch Team containing the reason why the project was censored. Otherwise, this is an empty string `#!python ""`.
319 |
320 | **RETURNS** - `#!python dict`
321 |
322 | **Example:**
323 |
324 | ```python
325 | print(session.get_project(391293821809312).get_visibility()["censoredByAdmin"])
326 | # True
327 | ```
328 |
329 | ###`#!python toggle_commenting()` { #toggle_comments data-toc-label="toggle_comments" }
330 |
331 | Toggles whether people can post comments on the project. You must be logged in, and the owner of the project, for this to not throw an error. Returns the project.
332 |
333 | **RETURNS** - `#!python Project`
334 |
335 | ###`#!python turn_on_commenting()` { #turn_on_commenting data-toc-label="turn_on_commenting" }
336 |
337 | Enables commenting on the project. You must be logged in, and the owner of the project, for this to not throw an error. Returns the project.
338 |
339 | **RETURNS** - `#!python Project`
340 |
341 | ###`#!python turn_off_commenting()` { #turn_off_commenting data-toc-label="turn_off_commenting" }
342 |
343 | Disables commenting on the project. You must be logged in, and the owner of the project, for this to not throw an error. Returns the project.
344 |
345 | **RETURNS** - `#!python Project`
346 |
347 | **Example:**
348 |
349 | ```python
350 | project = session.get_project(19032190120)
351 | project.post_comment("Closing comments until this project gets 100 loves")
352 | project.turn_off_commenting()
353 | ```
354 |
355 | ###`#!python report(category, reason, image=None)` { #report data-toc-label="report" }
356 |
357 | Reports the project, for the specified `#!python category` and `#!python reason`. You must be logged in for this to not throw an error.
358 |
359 | **PARAMETERS**
360 |
361 | - **category** (`#!python str`) - The category of reasons that the rules were broken with the project. Possible valid values are the following:
362 | - `#!python "0"` - The project is an exact copy of another project.
363 | - `#!python "1"` - The project uses images or music without credit.
364 | - `#!python "3"` - The project contains inappropriate language.
365 | - `#!python "4"` - The project contains inappropriate music.
366 | - `#!python "5"` - The project shares personal contact information.
367 | - `#!python "8"` - The project contains inappropriate images.
368 | - `#!python "9"` - The project is misleading or tricks the community.
369 | - `#!python "10"` - The project contains a face reveal.
370 | - `#!python "11"` - The project disallows remixing.
371 | - `#!python "12"` - You are concerned about the creator's safety.
372 | - `#!python "13"` - Some other reason.
373 | - `#!python "14"` - The project contains scary images.
374 | - `#!python "15"` - The project has a jumpscare.
375 | - `#!python "16"` - The project contains a violent event.
376 | - `#!python "17"` - The project contains realistic weapons.
377 | - `#!python "18"` - The project threatens or bullies another Scratcher.
378 | - `#!python "19"` - The project is disrespectful to a Scratcher or group.
379 | - **reason** (`#!python str`) - Additional info regarding the location of the offending content within the project.
380 | - **image** (`#!python Optional[str | None]`) - The base-64-encoded thumbnail of the project.
381 |
382 | **Example:**
383 |
384 | ```python
385 | session.get_project(104).report("10", "the guy's face is in the project")
386 | ```
387 |
388 | ###`#!python unshare()` { #unshare data-toc-label="unshare" }
389 |
390 | Unshares the project. You must be logged in, and the owner of the project, for this to not throw an error.
391 |
392 | ###`#!python share()` { #share data-toc-label="share" }
393 |
394 | Shares the project. You must be logged in, and the owner of the project, for this to not throw an error.
395 |
396 | ###`#!python delete()` { #delete data-toc-label="delete" }
397 |
398 | Deletes the project. You must be logged in, and the owner of the project, for this to not throw an error.
399 |
400 | ###`#!python restore_deleted()` { #restore_deleted data-toc-label="restore_deleted" }
401 |
402 | Restores the project if it has been deleted. You must be logged in, and the owner of the project, for this to not throw an error.
403 |
404 | ###`#!python view()` { #view data-toc-label="view" }
405 |
406 | Views the project (increments its view count).
407 |
408 | !!! warning
409 |
410 | This is incredibly easy to abuse, but do not as the Scratch Team will not be happy, and they will be able to figure out who you are. Furthermore, this is heavily ratelimited, so it's not very effective anyway.
411 |
412 | ###`#!python set_thumbnail(file_or_data)` { #set_thumbnail data-toc-label="set_thumbnail" }
413 |
414 | Sets the thumbnail of the project. You must be logged in, and the owner of the project, for this to not throw an error.
415 |
416 | **PARAMETERS**
417 |
418 | **file_or_data** (`#!python bytes | str`) - The file that the thumbnail should be set to. If this is a `#!python str`, then it will be interpreted as a path to a file; otherwise, it will be interpreted as the data in the image.
419 |
420 | ###`#!python set_title(title)` { #set_title data-toc-label="title" }
421 |
422 | Sets the title of the project. You must be logged in, and the owner of the project, for this to not throw an error.
423 |
424 | **PARAMETERS**
425 |
426 | **title** (`#!python str`) - The title that the title of the project should be set to.
427 |
428 | **Example:**
429 |
430 | ```python
431 | session.get_project(130921903123).set_title("4D platformer #games #all ?mode=trending")
432 | ```
433 |
434 | ###`#!python set_instructions(instructions)` { #set_instructions data-toc-label="set_instructions" }
435 |
436 | Sets the instructions of the project. You must be logged in, and the owner of the project, for this to not throw an error.
437 |
438 | **PARAMETERS**
439 |
440 | **instructions** (`#!python str`) - The instructions that the instructions of the project should be set to.
441 |
442 | ###`#!python set_description(description)` { #set_description data-toc-label="set_description" }
443 |
444 | Sets the description (the "Notes and Credits" field) of the project.
445 |
446 | **PARAMETERS**
447 |
448 | **description** (`#!python str`) - The description that the description of the project should be set to. You must be logged in, and the owner of the project, for this to not throw an error.
449 |
--------------------------------------------------------------------------------