├── .github └── workflows │ └── build-docs.yml ├── .gitignore ├── LICENSE.txt ├── README.md ├── docs ├── assets │ ├── lightbulb.svg │ └── session-id.png ├── examples │ ├── basic-usage.md │ ├── simultaneous-connections.md │ └── stats-viewer.md ├── index.md ├── reference │ ├── Activity.md │ ├── AsyncCloudConnection.md │ ├── BackpackItem.md │ ├── CloudConnection.md │ ├── CloudVariable.md │ ├── ForumPost.md │ ├── ForumSession.md │ ├── IncompleteProject.md │ ├── IncompleteStudio.md │ ├── IncompleteUser.md │ ├── Message.md │ ├── News.md │ ├── ProfileComment.md │ ├── Project.md │ ├── ProjectComment.md │ ├── RemixtreeProject.md │ ├── ScrapingSession.md │ ├── ScratchSession.md │ ├── Studio.md │ ├── StudioComment.md │ ├── User.md │ └── UserProfile.md ├── replit.md └── stylesheets │ └── extra.css ├── mkdocs.yml ├── scratchclient ├── Activity.py ├── Backpack.py ├── CloudConnection.py ├── Comment.py ├── Forums.py ├── Incomplete.py ├── Message.py ├── News.py ├── Project.py ├── Scraping.py ├── ScratchExceptions.py ├── ScratchSession.py ├── Studio.py ├── User.py ├── UserProfile.py ├── Websocket.py ├── __init__.py ├── __main__.py └── util.py ├── setup.cfg ├── setup.py └── test └── test.py /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /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/assets/lightbulb.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/assets/session-id.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CubeyTheCube/scratchclient/cbabc79bbdeec1064e8de1c88480efdaebd5e1e7/docs/assets/session-id.png -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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/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/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/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. -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | ![Getting the session ID from browser devtools](../assets/session-id.png) 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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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/__init__.py: -------------------------------------------------------------------------------- 1 | from .ScratchSession import ScratchSession 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------