supernote 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {supernote-0.2.0/supernote.egg-info → supernote-0.3.0}/PKG-INFO +4 -2
  2. {supernote-0.2.0 → supernote-0.3.0}/README.md +1 -1
  3. {supernote-0.2.0 → supernote-0.3.0}/pyproject.toml +9 -1
  4. {supernote-0.2.0 → supernote-0.3.0}/supernote/__init__.py +2 -0
  5. supernote-0.3.0/supernote/cloud/api_model.py +231 -0
  6. supernote-0.3.0/supernote/cloud/auth.py +67 -0
  7. supernote-0.3.0/supernote/cloud/client.py +184 -0
  8. supernote-0.3.0/supernote/cloud/cloud_client.py +48 -0
  9. supernote-0.3.0/supernote/cloud/exceptions.py +25 -0
  10. supernote-0.3.0/supernote/cloud/login_client.py +174 -0
  11. supernote-0.3.0/supernote/cmds/__init__.py +1 -0
  12. supernote-0.3.0/supernote/cmds/cloud_login_tool.py +247 -0
  13. {supernote-0.2.0 → supernote-0.3.0}/supernote/cmds/supernote_tool.py +21 -0
  14. {supernote-0.2.0 → supernote-0.3.0/supernote.egg-info}/PKG-INFO +4 -2
  15. {supernote-0.2.0 → supernote-0.3.0}/supernote.egg-info/SOURCES.txt +7 -0
  16. {supernote-0.2.0 → supernote-0.3.0}/supernote.egg-info/requires.txt +2 -0
  17. supernote-0.2.0/supernote/cmds/__init__.py +0 -0
  18. {supernote-0.2.0 → supernote-0.3.0}/LICENSE +0 -0
  19. {supernote-0.2.0 → supernote-0.3.0}/setup.cfg +0 -0
  20. {supernote-0.2.0 → supernote-0.3.0}/supernote/color.py +0 -0
  21. {supernote-0.2.0 → supernote-0.3.0}/supernote/converter.py +0 -0
  22. {supernote-0.2.0 → supernote-0.3.0}/supernote/decoder.py +0 -0
  23. {supernote-0.2.0 → supernote-0.3.0}/supernote/exceptions.py +0 -0
  24. {supernote-0.2.0 → supernote-0.3.0}/supernote/fileformat.py +0 -0
  25. {supernote-0.2.0 → supernote-0.3.0}/supernote/manipulator.py +0 -0
  26. {supernote-0.2.0 → supernote-0.3.0}/supernote/parser.py +0 -0
  27. {supernote-0.2.0 → supernote-0.3.0}/supernote/utils.py +0 -0
  28. {supernote-0.2.0 → supernote-0.3.0}/supernote.egg-info/dependency_links.txt +0 -0
  29. {supernote-0.2.0 → supernote-0.3.0}/supernote.egg-info/entry_points.txt +0 -0
  30. {supernote-0.2.0 → supernote-0.3.0}/supernote.egg-info/top_level.txt +0 -0
  31. {supernote-0.2.0 → supernote-0.3.0}/tests/test_init.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: supernote
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Unofficial python library for parsing Supernote notebooks
5
5
  Author-email: jya <jya@wizmy.net>, Allen Porter <allen.porter@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -15,6 +15,8 @@ Requires-Dist: potracer>=0.0.1
15
15
  Requires-Dist: pypng>=0.0.20
16
16
  Requires-Dist: reportlab>=3.6.1
17
17
  Requires-Dist: svgwrite>=1.4
18
+ Requires-Dist: aiohttp>=3.13.2
19
+ Requires-Dist: mashumaro>=3.17
18
20
  Dynamic: license-file
19
21
 
20
22
  # supernote
@@ -28,7 +30,7 @@ having a similar dependency limitation.
28
30
  ## Development
29
31
 
30
32
  ```
31
- uv venv
33
+ uv venv --python=3.14
32
34
  source .venv/bin/activate
33
35
  uv pip install -r requirements_dev.txt
34
36
  ```
@@ -9,7 +9,7 @@ having a similar dependency limitation.
9
9
  ## Development
10
10
 
11
11
  ```
12
- uv venv
12
+ uv venv --python=3.14
13
13
  source .venv/bin/activate
14
14
  uv pip install -r requirements_dev.txt
15
15
  ```
@@ -4,7 +4,7 @@ requires = ["setuptools>=77.0"]
4
4
 
5
5
  [project]
6
6
  name = "supernote"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  license = "Apache-2.0"
9
9
  license-files = ["LICENSE"]
10
10
  description = "Unofficial python library for parsing Supernote notebooks"
@@ -16,6 +16,7 @@ authors = [
16
16
  requires-python = ">=3.13"
17
17
  classifiers = []
18
18
  dependencies = [
19
+ # Notebook dependencies
19
20
  "colour>=0.1.5",
20
21
  "numpy>=1.19.0",
21
22
  "Pillow>=7.2.0",
@@ -23,6 +24,10 @@ dependencies = [
23
24
  "pypng>=0.0.20",
24
25
  "reportlab>=3.6.1",
25
26
  "svgwrite>=1.4",
27
+
28
+ # Cloud dependencies
29
+ "aiohttp>=3.13.2",
30
+ "mashumaro>=3.17",
26
31
  ]
27
32
 
28
33
  [project.urls]
@@ -61,3 +66,6 @@ disallow_untyped_decorators = true
61
66
  disallow_untyped_defs = true
62
67
  warn_return_any = true
63
68
  warn_unreachable = true
69
+
70
+ [tool.pytest.ini_options]
71
+ asyncio_mode = "auto"
@@ -11,3 +11,5 @@
11
11
  # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
+
15
+ __version__ = "0.2.0"
@@ -0,0 +1,231 @@
1
+ """Model classes for the Supernote Cloud API."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from mashumaro import field_options
6
+ from mashumaro.mixins.json import DataClassJSONMixin
7
+ from mashumaro.config import BaseConfig
8
+
9
+ COUNTRY_CODE = 1
10
+ BROWSER = "Chrome142"
11
+ EQUIPMENT = 1
12
+ LANGUAGE = "en"
13
+
14
+
15
+ @dataclass
16
+ class BaseResponse(DataClassJSONMixin):
17
+ """Base response class."""
18
+
19
+ success: bool = True
20
+ error_code: str = field(metadata=field_options(alias="errorCode"), default="")
21
+ error_msg: str = field(metadata=field_options(alias="errorMsg"), default="")
22
+
23
+
24
+ @dataclass
25
+ class QueryUserRequest(DataClassJSONMixin):
26
+ """Request to query user."""
27
+
28
+ account: str
29
+ country_code: int = field(
30
+ metadata=field_options(alias="countryCode"), default=COUNTRY_CODE
31
+ )
32
+
33
+ class Config(BaseConfig):
34
+ serialize_by_alias = True
35
+
36
+
37
+ @dataclass(kw_only=True)
38
+ class QueryUserResponse(BaseResponse):
39
+ """Response from query user call."""
40
+
41
+ user_id: str = field(metadata=field_options(alias="userId"))
42
+ user_name: str = field(metadata=field_options(alias="userName"))
43
+ birthday: str = field(metadata=field_options(alias="birthday"))
44
+ country_code: str = field(
45
+ metadata=field_options(alias="countryCode"), default=COUNTRY_CODE
46
+ )
47
+ telephone: str = field(metadata=field_options(alias="telephone"), default="")
48
+ sex: str = ""
49
+ file_server: str = field(metadata=field_options(alias="fileServer"), default="")
50
+
51
+
52
+ @dataclass
53
+ class TokenRequest(DataClassJSONMixin):
54
+ """Request to token endpoint."""
55
+
56
+ class Config(BaseConfig):
57
+ serialize_by_alias = True
58
+
59
+
60
+ @dataclass
61
+ class TokenResponse(BaseResponse):
62
+ """Response from token endpoint."""
63
+
64
+
65
+ @dataclass
66
+ class UserRandomCodeRequest(DataClassJSONMixin):
67
+ """Request to get a random code."""
68
+
69
+ account: str
70
+ country_code: int = field(
71
+ metadata=field_options(alias="countryCode"), default=COUNTRY_CODE
72
+ )
73
+
74
+ class Config(BaseConfig):
75
+ serialize_by_alias = True
76
+
77
+
78
+ @dataclass
79
+ class UserRandomCodeResponse(BaseResponse):
80
+ """Response from login."""
81
+
82
+ random_code: str = field(metadata=field_options(alias="randomCode"), default="")
83
+ timestamp: str = ""
84
+
85
+
86
+ @dataclass
87
+ class UserLoginRequest(DataClassJSONMixin):
88
+ """Request to login."""
89
+
90
+ account: str
91
+ password: str
92
+ login_method: int = field(metadata=field_options(alias="loginMethod"))
93
+ timestamp: str
94
+ language: str = LANGUAGE
95
+ country_code: int = field(
96
+ metadata=field_options(alias="countryCode"), default=COUNTRY_CODE
97
+ )
98
+ browser: str = BROWSER
99
+ equipment: int = EQUIPMENT
100
+
101
+ class Config(BaseConfig):
102
+ serialize_by_alias = True
103
+
104
+
105
+ @dataclass(kw_only=True)
106
+ class UserLoginResponse(BaseResponse):
107
+ """Response from access token call."""
108
+
109
+ token: str
110
+
111
+
112
+ @dataclass
113
+ class UserSmsLoginRequest(DataClassJSONMixin):
114
+ """Request to login via sms."""
115
+
116
+ telephone: str
117
+ timestamp: str
118
+ valid_code: str = field(metadata=field_options(alias="validCode"))
119
+ # String like "1-{telephone}_validCode"
120
+ valid_code_key: str = field(metadata=field_options(alias="validCodeKey"))
121
+
122
+ country_code: int = field(
123
+ metadata=field_options(alias="countryCode"), default=COUNTRY_CODE
124
+ )
125
+ browser: str = BROWSER
126
+ equipment: int = EQUIPMENT
127
+
128
+ class Config(BaseConfig):
129
+ serialize_by_alias = True
130
+
131
+
132
+ @dataclass
133
+ class UserPreAuthRequest(DataClassJSONMixin):
134
+ """Request for pre-auth."""
135
+
136
+ account: str
137
+
138
+
139
+ @dataclass
140
+ class UserPreAuthResponse(BaseResponse):
141
+ """Response from pre-auth."""
142
+
143
+ token: str = ""
144
+
145
+
146
+ @dataclass
147
+ class UserSendSmsRequest(DataClassJSONMixin):
148
+ """Request to send SMS code."""
149
+
150
+ telephone: str
151
+ timestamp: str
152
+ token: str
153
+ sign: str
154
+ nationcode: int = field(
155
+ metadata=field_options(alias="nationcode"), default=COUNTRY_CODE
156
+ )
157
+
158
+ class Config(BaseConfig):
159
+ serialize_by_alias = True
160
+
161
+
162
+ @dataclass
163
+ class UserSendSmsResponse(BaseResponse):
164
+ """Response from send SMS."""
165
+
166
+ valid_code_key: str = field(
167
+ metadata=field_options(alias="validCodeKey"), default=""
168
+ )
169
+
170
+
171
+ @dataclass(kw_only=True)
172
+ class UserSmsLoginResponse(BaseResponse):
173
+ """Response from access token call."""
174
+
175
+ token: str
176
+
177
+
178
+ @dataclass(kw_only=True)
179
+ class File(DataClassJSONMixin):
180
+ """Representation of a file."""
181
+
182
+ id: int
183
+ directory_id: int = field(metadata=field_options(alias="directoryId"))
184
+ file_name: str = field(metadata=field_options(alias="fileName"))
185
+ size: int = 0
186
+ md5: str = ""
187
+ is_folder: str = field(metadata=field_options(alias="isFolder")) # "Y" or "N"
188
+ create_time: int = field(metadata=field_options(alias="createTime"))
189
+ update_time: int = field(metadata=field_options(alias="updateTime"))
190
+
191
+
192
+ @dataclass
193
+ class FileListRequest(DataClassJSONMixin):
194
+ """Request for file list."""
195
+
196
+ directory_id: int = field(metadata=field_options(alias="directoryId"))
197
+ page_no: int = field(metadata=field_options(alias="pageNo"))
198
+ page_size: int = field(metadata=field_options(alias="pageSize"), default=20)
199
+ order: str = "time"
200
+ sequence: str = "desc"
201
+
202
+ class Config(BaseConfig):
203
+ serialize_by_alias = True
204
+
205
+
206
+ @dataclass(kw_only=True)
207
+ class FileListResponse(BaseResponse):
208
+ """Response from file list call."""
209
+
210
+ total: int
211
+ size: int
212
+ pages: int
213
+ file_list: list[File] = field(metadata=field_options(alias="userFileVOList"))
214
+
215
+
216
+ @dataclass
217
+ class GetFileDownloadUrlRequest(DataClassJSONMixin):
218
+ """Request for file download."""
219
+
220
+ file_id: int = field(metadata=field_options(alias="id"))
221
+ file_type: int = field(metadata=field_options(alias="type"), default=0)
222
+
223
+ class Config(BaseConfig):
224
+ serialize_by_alias = True
225
+
226
+
227
+ @dataclass(kw_only=True)
228
+ class GetFileDownloadUrlResponse(BaseResponse):
229
+ """Response from file download call."""
230
+
231
+ url: str
@@ -0,0 +1,67 @@
1
+ """Library for authentication."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ import logging
5
+ import pickle
6
+ import os
7
+
8
+ _LOGGER = logging.getLogger(__name__)
9
+
10
+
11
+ class AbstractAuth(ABC):
12
+ """Authentication library."""
13
+
14
+ @abstractmethod
15
+ async def async_get_access_token(self) -> str:
16
+ """Return a valid access token."""
17
+
18
+
19
+ class ConstantAuth(AbstractAuth):
20
+ """Authentication library."""
21
+
22
+ def __init__(self, access_token: str):
23
+ """Initialize the auth."""
24
+ self._access_token = access_token
25
+
26
+ async def async_get_access_token(self) -> str:
27
+ """Return a valid access token."""
28
+ return self._access_token
29
+
30
+
31
+ class FileCacheAuth(AbstractAuth):
32
+ """Authentication library that caches token in a file."""
33
+
34
+ def __init__(self, cache_path: str):
35
+ """Initialize the auth."""
36
+ self._cache_path = cache_path
37
+ self._access_token = None
38
+
39
+ async def async_get_access_token(self) -> str:
40
+ """Return a valid access token."""
41
+ if self._access_token:
42
+ return self._access_token
43
+
44
+ if os.path.exists(self._cache_path):
45
+ try:
46
+ with open(self._cache_path, "rb") as f:
47
+ data = pickle.load(f)
48
+ if isinstance(data, dict) and "access_token" in data:
49
+ self._access_token = data["access_token"]
50
+ return self._access_token
51
+ except Exception as err:
52
+ _LOGGER.warning("Failed to load token from cache: %s", err)
53
+
54
+ raise ValueError("No access token found in cache")
55
+
56
+ def save_access_token(self, token: str) -> None:
57
+ """Save access token to cache."""
58
+ self._access_token = token
59
+
60
+ # Ensure directory exists
61
+ os.makedirs(os.path.dirname(self._cache_path), exist_ok=True)
62
+
63
+ try:
64
+ with open(self._cache_path, "wb") as f:
65
+ pickle.dump({"access_token": token}, f)
66
+ except Exception as err:
67
+ _LOGGER.warning("Failed to save token to cache: %s", err)
@@ -0,0 +1,184 @@
1
+ """Library for accessing backups in Supenote Cloud."""
2
+
3
+ import logging
4
+ from typing import Any, Type, TypeVar
5
+
6
+ import aiohttp
7
+ from aiohttp.client_exceptions import ClientError
8
+
9
+ from .api_model import BaseResponse
10
+ from .exceptions import ApiException, UnauthorizedException, ForbiddenException
11
+ from .auth import AbstractAuth
12
+
13
+ _LOGGER = logging.getLogger(__name__)
14
+
15
+ API_URL = "https://cloud.supernote.com/api"
16
+ HEADERS = {
17
+ "Content-Type": "application/json",
18
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36",
19
+ "Referer": "https://cloud.supernote.com/",
20
+ "Origin": "https://cloud.supernote.com",
21
+ }
22
+ ACCESS_TOKEN = "x-access-token"
23
+ XSRF_COOKIE = "XSRF-TOKEN"
24
+ XSRF_HEADER = "X-XSRF-TOKEN"
25
+
26
+
27
+ _T = TypeVar("_T", bound=BaseResponse)
28
+
29
+
30
+ class Client:
31
+ """Library that makes authenticated HTTP requests."""
32
+
33
+ def __init__(
34
+ self,
35
+ websession: aiohttp.ClientSession,
36
+ host: str | None = None,
37
+ auth: AbstractAuth | None = None,
38
+ ):
39
+ """Initialize the auth."""
40
+ self._websession = websession
41
+ self._host = host or API_URL
42
+ self._auth = auth
43
+ self._xsrf_token: str | None = None
44
+
45
+ async def request(
46
+ self,
47
+ method: str,
48
+ url: str,
49
+ headers: dict[str, Any] | None = None,
50
+ **kwargs: Any,
51
+ ) -> aiohttp.ClientResponse:
52
+ """Make a request."""
53
+ if headers is None:
54
+ headers = {
55
+ **HEADERS,
56
+ }
57
+ # Always get a fresh CSRF token
58
+ self._xsrf_token = await self._get_csrf_token()
59
+ headers[XSRF_HEADER] = self._xsrf_token
60
+
61
+ if self._auth and ACCESS_TOKEN not in headers:
62
+ access_token = await self._auth.async_get_access_token()
63
+ headers[ACCESS_TOKEN] = access_token
64
+ if not (url.startswith("http://") or url.startswith("https://")):
65
+ url = f"{self._host}/{url}"
66
+ _LOGGER.debug(
67
+ "request[%s]=%s %s %s",
68
+ method,
69
+ url,
70
+ kwargs.get("params"),
71
+ headers,
72
+ )
73
+ if method != "get" and "json" in kwargs:
74
+ _LOGGER.debug("request[post json]=%s", kwargs["json"])
75
+ response = await self._websession.request(
76
+ method, url, **kwargs, headers=headers
77
+ )
78
+ return response
79
+
80
+ async def get(self, url: str, **kwargs: Any) -> aiohttp.ClientResponse:
81
+ """Make a get request."""
82
+ try:
83
+ resp = await self.request("get", url, **kwargs)
84
+ except ClientError as err:
85
+ raise ApiException(f"Error connecting to API: {err}") from err
86
+ return await self._raise_for_status(resp)
87
+
88
+ async def get_json(
89
+ self,
90
+ url: str,
91
+ data_cls: Type[_T],
92
+ **kwargs: Any,
93
+ ) -> _T:
94
+ """Make a get request and return json response."""
95
+ resp = await self.get(url, **kwargs)
96
+ try:
97
+ result = await resp.text()
98
+ except ClientError as err:
99
+ raise ApiException("Server returned malformed response") from err
100
+ _LOGGER.debug("response=%s", result)
101
+ try:
102
+ data_response = data_cls.from_json(result)
103
+ except (LookupError, ValueError) as err:
104
+ raise ApiException(f"Server return malformed response: {result}") from err
105
+ if not data_response.success:
106
+ raise ApiException(data_response.error_msg)
107
+ return data_response
108
+
109
+ async def post(self, url: str, **kwargs: Any) -> aiohttp.ClientResponse:
110
+ """Make a post request."""
111
+ try:
112
+ resp = await self.request("post", url, **kwargs)
113
+ except ClientError as err:
114
+ raise ApiException(f"Error connecting to API: {err}") from err
115
+ return await self._raise_for_status(resp)
116
+
117
+ async def post_json(self, url: str, data_cls: Type[_T], **kwargs: Any) -> _T:
118
+ """Make a post request and return a json response."""
119
+ resp = await self.post(url, **kwargs)
120
+ try:
121
+ result = await resp.text()
122
+ except ClientError as err:
123
+ raise ApiException("Server returned malformed response") from err
124
+ try:
125
+ data_response = data_cls.from_json(result)
126
+ except (LookupError, ValueError) as err:
127
+ raise ApiException(f"Server return malformed response: {result}") from err
128
+ if not data_response.success:
129
+ raise ApiException(data_response.error_msg)
130
+ return data_response
131
+
132
+ async def _get_csrf_token(self) -> str:
133
+ """Get the CSRF token."""
134
+ url = f"{self._host}/csrf"
135
+ _LOGGER.debug("CSRF request[get]=%s %s", url, HEADERS)
136
+ resp = await self._websession.request("get", url, headers=HEADERS)
137
+ try:
138
+ result = await resp.text()
139
+ except ClientError as err:
140
+ raise ApiException("Server returned malformed response") from err
141
+ _LOGGER.debug("CSRF response=%s", result)
142
+ _LOGGER.debug("CSRF response headers=%s", resp.headers)
143
+ token = resp.headers.get(XSRF_HEADER)
144
+ if token is None:
145
+ raise ApiException("Failed to get CSRF token from header")
146
+ _LOGGER.debug("CSRF token=%s", token)
147
+ _LOGGER.debug("CSRF response cookies=%s", resp.cookies)
148
+ return token
149
+
150
+ @classmethod
151
+ async def _raise_for_status(
152
+ cls, resp: aiohttp.ClientResponse
153
+ ) -> aiohttp.ClientResponse:
154
+ """Raise exceptions on failure methods."""
155
+ error_detail = await cls._error_detail(resp)
156
+ try:
157
+ resp.raise_for_status()
158
+ except aiohttp.ClientResponseError as err:
159
+ if err.status == 401:
160
+ error_message = (
161
+ f"Unauthorized response from API ({err.status}): {error_detail}"
162
+ )
163
+ raise UnauthorizedException(error_message) from err
164
+ if err.status == 403:
165
+ error_message = (
166
+ f"Forbidden response from API ({err.status}): {error_detail}"
167
+ )
168
+ raise ForbiddenException(error_message) from err
169
+ error_message = f"Error response from API ({err.status}): {error_detail}"
170
+ raise ApiException(error_message) from err
171
+ except aiohttp.ClientError as err:
172
+ raise ApiException(f"Error from API: {err}") from err
173
+ return resp
174
+
175
+ @classmethod
176
+ async def _error_detail(cls, resp: aiohttp.ClientResponse) -> str | None:
177
+ """Returns an error message string from the APi response."""
178
+ if resp.status < 400:
179
+ return None
180
+ try:
181
+ result = await resp.text()
182
+ except ClientError:
183
+ return None
184
+ return result
@@ -0,0 +1,48 @@
1
+ """Library for accessing backups in Supenote Cloud."""
2
+
3
+ from .api_model import (
4
+ FileListResponse,
5
+ GetFileDownloadUrlRequest,
6
+ GetFileDownloadUrlResponse,
7
+ FileListRequest,
8
+ QueryUserResponse,
9
+ QueryUserRequest,
10
+ )
11
+ from .client import Client
12
+
13
+
14
+ class SupernoteCloudClient:
15
+ """A client library for Supernote Cloud."""
16
+
17
+ def __init__(self, client: Client):
18
+ """Initialize the client."""
19
+ self._client = client
20
+
21
+ async def query_user(self, account: str) -> QueryUserResponse:
22
+ """Query the user."""
23
+ payload = QueryUserRequest(country_code=1, account=account).to_dict()
24
+ return await self._client.post_json(
25
+ "user/query", QueryUserResponse, json=payload
26
+ )
27
+
28
+ async def file_list(self, directory_id: int = 0) -> FileListResponse:
29
+ """Return a list of files."""
30
+ payload = FileListRequest(
31
+ directory_id=directory_id,
32
+ page_no=1,
33
+ page_size=100,
34
+ order="time",
35
+ sequence="desc",
36
+ ).to_dict()
37
+ return await self._client.post_json(
38
+ "file/list/query", FileListResponse, json=payload
39
+ )
40
+
41
+ async def file_download(self, file_id: int) -> bytes:
42
+ """Download a file."""
43
+ payload = GetFileDownloadUrlRequest(file_id=file_id, file_type=0).to_dict()
44
+ download_url_response = await self._client.post_json(
45
+ "file/download/url", GetFileDownloadUrlResponse, json=payload
46
+ )
47
+ response = await self._client.get(download_url_response.url)
48
+ return await response.read()
@@ -0,0 +1,25 @@
1
+ """Exceptions for supernote cloud."""
2
+
3
+
4
+ class SupernoteException(Exception):
5
+ """Base exception for supernote cloud."""
6
+
7
+
8
+ class SmsVerificationRequired(SupernoteException):
9
+ """Exception raised when SMS verification is required."""
10
+
11
+ def __init__(self, message: str, timestamp: str):
12
+ super().__init__(message)
13
+ self.timestamp = timestamp
14
+
15
+
16
+ class ApiException(SupernoteException):
17
+ """API exception."""
18
+
19
+
20
+ class ForbiddenException(ApiException):
21
+ """API exception."""
22
+
23
+
24
+ class UnauthorizedException(ApiException):
25
+ """Authentication exception."""
@@ -0,0 +1,174 @@
1
+ """Library for accessing backups in Supenote Cloud."""
2
+
3
+ import hashlib
4
+ import logging
5
+ from typing import TypeVar
6
+
7
+ from mashumaro.mixins.json import DataClassJSONMixin
8
+
9
+ from .api_model import (
10
+ UserLoginRequest,
11
+ UserLoginResponse,
12
+ UserRandomCodeRequest,
13
+ UserRandomCodeResponse,
14
+ UserSmsLoginRequest,
15
+ UserSmsLoginResponse,
16
+ UserPreAuthRequest,
17
+ UserPreAuthResponse,
18
+ UserSendSmsRequest,
19
+ UserSendSmsResponse,
20
+ TokenRequest,
21
+ TokenResponse,
22
+ )
23
+ from .client import Client
24
+ from .exceptions import ApiException, SmsVerificationRequired
25
+
26
+ _LOGGER = logging.getLogger(__name__)
27
+
28
+
29
+ _T = TypeVar("_T", bound=DataClassJSONMixin)
30
+
31
+
32
+ def _sha256_s(s: str) -> str:
33
+ return hashlib.sha256(s.encode("utf-8")).hexdigest()
34
+
35
+
36
+ def _md5_s(s: str) -> str:
37
+ return hashlib.md5(s.encode("utf-8")).hexdigest()
38
+
39
+
40
+ def _encode_password(password: str, rc: str) -> str:
41
+ return _sha256_s(_md5_s(password) + rc)
42
+
43
+
44
+ def _extract_real_key(token: str) -> str:
45
+ """Extract real key from token as per JS logic."""
46
+ # var t = e.charAt(e.length - 1)
47
+ # var n = parseInt(t)
48
+ # var a = e.split("-")
49
+ # var o = a[n];
50
+ if not token:
51
+ return ""
52
+ last_char = token[-1]
53
+ try:
54
+ index = int(last_char)
55
+ parts = token.split("-")
56
+ if 0 <= index < len(parts):
57
+ return parts[index]
58
+ except ValueError:
59
+ pass
60
+ return ""
61
+
62
+
63
+ class LoginClient:
64
+ """A client library for logging in."""
65
+
66
+ def __init__(self, client: Client):
67
+ """Initialize the client."""
68
+ self._client = client
69
+
70
+ async def login(self, email: str, password: str) -> str:
71
+ """Log in and return an access token."""
72
+ await self._token()
73
+ random_code_response = await self._get_random_code(email)
74
+ encoded_password = _encode_password(password, random_code_response.random_code)
75
+ access_token_response = await self._get_access_token(
76
+ email, encoded_password, random_code_response.timestamp
77
+ )
78
+ return access_token_response.token
79
+
80
+ async def sms_login(self, telephone: str, code: str, timestamp: str) -> str:
81
+ """Log in via SMS code."""
82
+ # Always get a fresh CSRF token for the SMS login request
83
+ await self._client._get_csrf_token()
84
+
85
+ payload = UserSmsLoginRequest(
86
+ telephone=telephone,
87
+ timestamp=timestamp,
88
+ valid_code=code,
89
+ valid_code_key=f"1-{telephone}_validCode",
90
+ ).to_dict()
91
+
92
+ response = await self._client.post_json(
93
+ "official/user/sms/login", UserSmsLoginResponse, json=payload
94
+ )
95
+ return response.token
96
+
97
+ async def request_sms_code(self, telephone: str, country_code: int = 1) -> None:
98
+ """Request an SMS verification code."""
99
+ # 1. Pre-auth to get token
100
+ # Note: The JS code prefixes the account with the country code for pre-auth
101
+ # account: this.tempAccountInfo.countryCode + this.tempAccountInfo.account
102
+ account_with_code = f"{country_code}{telephone}"
103
+ pre_auth_payload = UserPreAuthRequest(account=account_with_code).to_dict()
104
+
105
+ # Always get a fresh CSRF token
106
+ await self._client._get_csrf_token()
107
+
108
+ pre_auth_response = await self._client.post_json(
109
+ "user/validcode/pre-auth", UserPreAuthResponse, json=pre_auth_payload
110
+ )
111
+
112
+ token = pre_auth_response.token
113
+
114
+ # 2. Extract real key and calculate sign
115
+ # e.sign = e.hash256(n + a) where n is account_with_code and a is real_key
116
+ real_key = _extract_real_key(token)
117
+ sign = _sha256_s(account_with_code + real_key)
118
+
119
+ # 3. Send SMS
120
+ # timestamp is needed here. In the JS it uses e.tempAccountInfo.timestamp
121
+ # We might need to fetch a timestamp first if we don't have one, but let's see.
122
+ # The JS gets timestamp from the initial login failure or a separate query.
123
+ # For now, let's try to get a fresh timestamp/random code first?
124
+ # Actually, the JS flow for "sendVerificationCode" seems to use existing timestamp.
125
+ # But if we are starting fresh, we might need one.
126
+ # Let's assume we can get a fresh random code to get a timestamp.
127
+ random_code_resp = await self._get_random_code(telephone)
128
+ timestamp = random_code_resp.timestamp
129
+
130
+ sms_payload = UserSendSmsRequest(
131
+ telephone=telephone,
132
+ timestamp=timestamp,
133
+ token=token,
134
+ sign=sign,
135
+ nationcode=country_code,
136
+ ).to_dict()
137
+
138
+ await self._client.post_json(
139
+ "user/sms/validcode/send", UserSendSmsResponse, json=sms_payload
140
+ )
141
+
142
+ async def _token(self) -> None:
143
+ """Get a random code."""
144
+ await self._client.post_json(
145
+ "user/query/token",
146
+ TokenResponse,
147
+ json=TokenRequest().to_dict(),
148
+ )
149
+
150
+ async def _get_random_code(self, email: str) -> UserRandomCodeResponse:
151
+ """Get a random code."""
152
+ payload = UserRandomCodeRequest(account=email).to_dict()
153
+ return await self._client.post_json(
154
+ "official/user/query/random/code", UserRandomCodeResponse, json=payload
155
+ )
156
+
157
+ async def _get_access_token(
158
+ self, email: str, encoded_password: str, random_code_timestamp: str
159
+ ) -> UserLoginResponse:
160
+ """Get an access token."""
161
+ payload = UserLoginRequest(
162
+ account=email,
163
+ password=encoded_password,
164
+ login_method=1,
165
+ timestamp=random_code_timestamp,
166
+ ).to_dict()
167
+ try:
168
+ return await self._client.post_json(
169
+ "official/user/account/login/new", UserLoginResponse, json=payload
170
+ )
171
+ except ApiException as err:
172
+ if "verification code" in str(err):
173
+ raise SmsVerificationRequired(str(err), random_code_timestamp) from err
174
+ raise
@@ -0,0 +1 @@
1
+ """Supernote command line tool."""
@@ -0,0 +1,247 @@
1
+ #!/usr/bin/env python3
2
+ """Cloud login debugging tool for Supernote Cloud."""
3
+
4
+ import asyncio
5
+ import logging
6
+ import sys
7
+ import os
8
+
9
+ import aiohttp
10
+
11
+ from supernote.cloud.client import Client
12
+ from supernote.cloud.login_client import LoginClient
13
+ from supernote.cloud.auth import FileCacheAuth
14
+ from supernote.cloud.cloud_client import SupernoteCloudClient
15
+ from supernote.cloud.exceptions import SupernoteException
16
+
17
+
18
+ _LOGGER = logging.getLogger(__name__)
19
+
20
+
21
+ def setup_logging(verbose: bool = False) -> None:
22
+ """Setup logging configuration."""
23
+ level = logging.DEBUG if verbose else logging.INFO
24
+ logging.basicConfig(
25
+ level=level,
26
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
27
+ stream=sys.stdout,
28
+ )
29
+ # Enable debug logging for cloud modules and aiohttp
30
+ if verbose:
31
+ logging.getLogger("supernote.cloud").setLevel(logging.DEBUG)
32
+ logging.getLogger("supernote.cloud.client").setLevel(logging.DEBUG)
33
+ logging.getLogger("aiohttp").setLevel(logging.DEBUG)
34
+
35
+
36
+ async def async_cloud_login(email: str, password: str, verbose: bool = False) -> None:
37
+ """Perform cloud login with detailed debugging output.
38
+
39
+ Args:
40
+ email: User email/account
41
+ password: User password
42
+ verbose: Enable verbose HTTP logging
43
+ """
44
+ setup_logging(verbose)
45
+
46
+ print("=" * 80)
47
+ print("Supernote Cloud Login Debugging Tool")
48
+ print("=" * 80)
49
+ print(f"Email: {email}")
50
+ print(
51
+ f"Verbose Mode: {'ENABLED' if verbose else 'DISABLED (use -v or --verbose for detailed logs)'}"
52
+ )
53
+ print("=" * 80)
54
+ print()
55
+
56
+ async with aiohttp.ClientSession() as session:
57
+ try:
58
+ # Step 1: Create client and login
59
+ print("Step 1: Initializing client...")
60
+ client = Client(session)
61
+ login_client = LoginClient(client)
62
+
63
+ print("Step 2: Starting login flow...")
64
+ print(" - This will get CSRF token")
65
+ print(" - Call token endpoint")
66
+ print(" - Get random code")
67
+ print(" - Encode password")
68
+ print(" - Submit login request")
69
+ print()
70
+
71
+ try:
72
+ access_token = await login_client.login(email, password)
73
+ except SupernoteException as err:
74
+ # Check if it's an SMS verification requirement
75
+ # We do this by checking the exception type, but since we just added it
76
+ # we need to make sure we import it.
77
+ # For now, let's assume if the message contains "verification code" it is one.
78
+ # But better to use the type.
79
+ from supernote.cloud.exceptions import SmsVerificationRequired
80
+
81
+ if isinstance(err, SmsVerificationRequired):
82
+ print()
83
+ print("!" * 80)
84
+ print("SMS Verification Required")
85
+ print("!" * 80)
86
+ print(f"Message: {err}")
87
+ print("The server has sent an SMS verification code to your phone.")
88
+ print()
89
+
90
+ print("Requesting SMS verification code...")
91
+ await login_client.request_sms_code(email)
92
+ print("SMS code requested successfully.")
93
+ print()
94
+
95
+ code = input("Enter verification code: ").strip()
96
+ print()
97
+ print("Submitting verification code...")
98
+
99
+ access_token = await login_client.sms_login(
100
+ email, code, err.timestamp
101
+ )
102
+ else:
103
+ raise
104
+
105
+ print("✓ Login successful!")
106
+ print(
107
+ f"Access Token: {access_token[:20]}..."
108
+ if len(access_token) > 20
109
+ else access_token
110
+ )
111
+ print()
112
+
113
+ # Save token to cache
114
+ cache_path = os.path.expanduser("~/.cache/supernote.pkl")
115
+ print(f"Saving token to {cache_path}...")
116
+ auth = FileCacheAuth(cache_path)
117
+ auth.save_access_token(access_token)
118
+ print("✓ Token saved!")
119
+ print()
120
+
121
+ # Step 2: Test basic functionality
122
+ print("Step 3: Testing basic functionality...")
123
+ # auth = ConstantAuth(access_token) # No longer needed as we use FileCacheAuth
124
+ authenticated_client = Client(session, auth=auth)
125
+ cloud_client = SupernoteCloudClient(authenticated_client)
126
+
127
+ # Test 1: Query user
128
+ print(" Test 1: Querying user information...")
129
+ try:
130
+ user_response = await cloud_client.query_user(email)
131
+ print(" ✓ User query successful!")
132
+ print(f" - User ID: {user_response.user_id}")
133
+ print(f" - User Name: {user_response.user_name}")
134
+ print(f" - Country Code: {user_response.country_code}")
135
+ if user_response.file_server:
136
+ print(f" - File Server: {user_response.file_server}")
137
+ except SupernoteException as err:
138
+ print(f" ✗ User query failed: {err}")
139
+ print()
140
+
141
+ # Test 2: List files
142
+ print(" Test 2: Listing files in root directory...")
143
+ try:
144
+ file_list_response = await cloud_client.file_list(directory_id=0)
145
+ print(" ✓ File list successful!")
146
+ print(f" - Total files: {file_list_response.total}")
147
+ print(f" - Pages: {file_list_response.pages}")
148
+ print(f" - Files in this page: {file_list_response.size}")
149
+
150
+ if file_list_response.file_list:
151
+ print(" - First few files:")
152
+ for i, file in enumerate(file_list_response.file_list[:5]):
153
+ folder_marker = "📁" if file.is_folder == "Y" else "📄"
154
+ print(f" {folder_marker} {file.file_name} (ID: {file.id})")
155
+ else:
156
+ print(" - No files found")
157
+ except SupernoteException as err:
158
+ print(f" ✗ File list failed: {err}")
159
+ print()
160
+
161
+ print("=" * 80)
162
+ print("All tests completed successfully!")
163
+ print("=" * 80)
164
+
165
+ except SupernoteException as err:
166
+ print()
167
+ print("=" * 80)
168
+ print(f"✗ Error during login flow: {err}")
169
+ print("=" * 80)
170
+ if verbose:
171
+ import traceback
172
+
173
+ traceback.print_exc()
174
+ sys.exit(1)
175
+ except Exception as err:
176
+ print()
177
+ print("=" * 80)
178
+ print(f"✗ Unexpected error: {err}")
179
+ print("=" * 80)
180
+ if verbose:
181
+ import traceback
182
+
183
+ traceback.print_exc()
184
+ sys.exit(1)
185
+
186
+
187
+ def subcommand_cloud_login(args) -> None:
188
+ """Handler for cloud-login subcommand.
189
+
190
+ Args:
191
+ args: Parsed command line arguments
192
+ """
193
+ asyncio.run(async_cloud_login(args.email, args.password, args.verbose))
194
+
195
+
196
+ async def async_cloud_ls(verbose: bool = False) -> None:
197
+ """List files in Supernote Cloud using cached credentials.
198
+
199
+ Args:
200
+ verbose: Enable verbose logging
201
+ """
202
+ setup_logging(verbose)
203
+
204
+ cache_path = os.path.expanduser("~/.cache/supernote.pkl")
205
+ if not os.path.exists(cache_path):
206
+ print(f"Error: No cached credentials found at {cache_path}")
207
+ print("Please run 'supernote-tool cloud-login' first.")
208
+ sys.exit(1)
209
+
210
+ async with aiohttp.ClientSession() as session:
211
+ try:
212
+ auth = FileCacheAuth(cache_path)
213
+ client = Client(session, auth=auth)
214
+ cloud_client = SupernoteCloudClient(client)
215
+
216
+ print("Listing files in root directory...")
217
+ file_list_response = await cloud_client.file_list()
218
+
219
+ print(f"Total files: {file_list_response.total}")
220
+ if file_list_response.file_list:
221
+ for file in file_list_response.file_list:
222
+ folder_marker = "📁" if file.is_folder == "Y" else "📄"
223
+ print(f"{folder_marker} {file.file_name} (ID: {file.id})")
224
+
225
+ except SupernoteException as err:
226
+ print(f"Error: {err}")
227
+ if verbose:
228
+ import traceback
229
+
230
+ traceback.print_exc()
231
+ sys.exit(1)
232
+ except Exception as err:
233
+ print(f"Unexpected error: {err}")
234
+ if verbose:
235
+ import traceback
236
+
237
+ traceback.print_exc()
238
+ sys.exit(1)
239
+
240
+
241
+ def subcommand_cloud_ls(args) -> None:
242
+ """Handler for cloud-ls subcommand.
243
+
244
+ Args:
245
+ args: Parsed command line arguments
246
+ """
247
+ asyncio.run(async_cloud_ls(args.verbose))
@@ -29,6 +29,7 @@ from supernote.converter import (
29
29
  TextConverter,
30
30
  )
31
31
  from supernote.converter import VisibilityOverlay
32
+ from supernote.cmds.cloud_login_tool import subcommand_cloud_login, subcommand_cloud_ls
32
33
 
33
34
 
34
35
  def convert_all(converter, total, file_name, save_func, visibility_overlay):
@@ -316,6 +317,26 @@ def main():
316
317
  parser_reconstruct.add_argument("output", type=str, help="output note file")
317
318
  parser_reconstruct.set_defaults(handler=subcommand_reconstruct)
318
319
 
320
+ # 'cloud-login' subcommand
321
+ parser_cloud_login = subparsers.add_parser(
322
+ "cloud-login", help="debug Supernote Cloud login flow"
323
+ )
324
+ parser_cloud_login.add_argument("email", type=str, help="user email/account")
325
+ parser_cloud_login.add_argument("password", type=str, help="user password")
326
+ parser_cloud_login.add_argument(
327
+ "-v", "--verbose", action="store_true", help="Enable verbose logging"
328
+ )
329
+ parser_cloud_login.set_defaults(handler=subcommand_cloud_login)
330
+
331
+ # Cloud ls command
332
+ cloud_ls_parser = subparsers.add_parser(
333
+ "cloud-ls", help="List files in Supernote Cloud"
334
+ )
335
+ cloud_ls_parser.add_argument(
336
+ "-v", "--verbose", action="store_true", help="Enable verbose logging"
337
+ )
338
+ cloud_ls_parser.set_defaults(handler=subcommand_cloud_ls)
339
+
319
340
  args = parser.parse_args()
320
341
  if hasattr(args, "handler"):
321
342
  args.handler(args)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: supernote
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Unofficial python library for parsing Supernote notebooks
5
5
  Author-email: jya <jya@wizmy.net>, Allen Porter <allen.porter@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -15,6 +15,8 @@ Requires-Dist: potracer>=0.0.1
15
15
  Requires-Dist: pypng>=0.0.20
16
16
  Requires-Dist: reportlab>=3.6.1
17
17
  Requires-Dist: svgwrite>=1.4
18
+ Requires-Dist: aiohttp>=3.13.2
19
+ Requires-Dist: mashumaro>=3.17
18
20
  Dynamic: license-file
19
21
 
20
22
  # supernote
@@ -28,7 +30,7 @@ having a similar dependency limitation.
28
30
  ## Development
29
31
 
30
32
  ```
31
- uv venv
33
+ uv venv --python=3.14
32
34
  source .venv/bin/activate
33
35
  uv pip install -r requirements_dev.txt
34
36
  ```
@@ -16,6 +16,13 @@ supernote.egg-info/dependency_links.txt
16
16
  supernote.egg-info/entry_points.txt
17
17
  supernote.egg-info/requires.txt
18
18
  supernote.egg-info/top_level.txt
19
+ supernote/cloud/api_model.py
20
+ supernote/cloud/auth.py
21
+ supernote/cloud/client.py
22
+ supernote/cloud/cloud_client.py
23
+ supernote/cloud/exceptions.py
24
+ supernote/cloud/login_client.py
19
25
  supernote/cmds/__init__.py
26
+ supernote/cmds/cloud_login_tool.py
20
27
  supernote/cmds/supernote_tool.py
21
28
  tests/test_init.py
@@ -5,3 +5,5 @@ potracer>=0.0.1
5
5
  pypng>=0.0.20
6
6
  reportlab>=3.6.1
7
7
  svgwrite>=1.4
8
+ aiohttp>=3.13.2
9
+ mashumaro>=3.17
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes