steam-cloud-backup 0.1.1__tar.gz → 0.1.2__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.
@@ -1,18 +1,17 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: steam-cloud-backup
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: micro-script for downloading all save files from steam cloud
5
5
  Author: q-deltaftactal
6
6
  License-Expression: MIT
7
- Project-URL: Repository, https://github.com/q-deltafractal/steam-cloud-backup.git
8
- Project-URL: Issues, https://github.com/q-deltafractal/steam-cloud-backup/issues
9
- Classifier: Topic :: Utilities
10
- Requires-Python: >=3.14
11
- Description-Content-Type: text/markdown
12
7
  License-File: LICENSE
8
+ Classifier: Topic :: Utilities
13
9
  Requires-Dist: aiofiles>=25.1.0
14
10
  Requires-Dist: aiohttp[speedups]>=3.14.1
15
- Dynamic: license-file
11
+ Requires-Python: >=3.14
12
+ Project-URL: Repository, https://github.com/q-deltafractal/steam-cloud-backup.git
13
+ Project-URL: Issues, https://github.com/q-deltafractal/steam-cloud-backup/issues
14
+ Description-Content-Type: text/markdown
16
15
 
17
16
  # steam-cloud-backup
18
17
 
@@ -20,6 +19,20 @@ micro-script for downloading all save files from [steam cloud](https://store.ste
20
19
 
21
20
  <br>
22
21
 
22
+ ## installation
23
+
24
+ from (PyPI)[https://pypi.org/project/steam-cloud-backup/]:
25
+
26
+ ```bash
27
+ # with pip
28
+ pip install steam-cloud-backup
29
+ ```
30
+
31
+ ```bash
32
+ # or pipx
33
+ pipx install steam-cloud-backup
34
+ ```
35
+
23
36
  ## backup process
24
37
 
25
38
  ### requirements
@@ -4,6 +4,20 @@ micro-script for downloading all save files from [steam cloud](https://store.ste
4
4
 
5
5
  <br>
6
6
 
7
+ ## installation
8
+
9
+ from (PyPI)[https://pypi.org/project/steam-cloud-backup/]:
10
+
11
+ ```bash
12
+ # with pip
13
+ pip install steam-cloud-backup
14
+ ```
15
+
16
+ ```bash
17
+ # or pipx
18
+ pipx install steam-cloud-backup
19
+ ```
20
+
7
21
  ## backup process
8
22
 
9
23
  ### requirements
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "steam-cloud-backup"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  authors = [
5
5
  { name="q-deltaftactal" }
6
6
  ]
@@ -21,8 +21,19 @@ dependencies = [
21
21
  Repository = "https://github.com/q-deltafractal/steam-cloud-backup.git"
22
22
  Issues = "https://github.com/q-deltafractal/steam-cloud-backup/issues"
23
23
 
24
+ [project.scripts]
25
+ scb-cli = 'steam-cloud-backup.cli:main'
26
+
27
+ [build-system]
28
+ requires = ["uv_build>=0.11.28,<0.12"]
29
+ build-backend = "uv_build"
30
+
31
+ [tool.uv.build-backend]
32
+ module-name = "source"
33
+ module-root = ""
34
+
24
35
  [tool.setuptools]
25
- py-modules = ['source']
36
+ packages = ['source']
26
37
 
27
38
  [tool.ruff.format]
28
39
  quote-style = "single"
File without changes
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env python
2
+
3
+ __all__ = ()
4
+
5
+ import logging
6
+ import asyncio
7
+ import argparse
8
+ from pathlib import Path
9
+
10
+ from source.errors import BadSessionException, ZeroAnswerException
11
+ from source.lib import SESSION_ID, STEAM_LOGIN_SECURE, parse
12
+
13
+
14
+ # connection
15
+ DEFAULT_USER_AGENT = (
16
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
17
+ 'AppleWebKit/537.36 (KHTML, like Gecko) '
18
+ 'Chrome/150.0.0.0 Safari/537.36'
19
+ )
20
+
21
+
22
+ class _ArgumentParser(argparse.ArgumentParser):
23
+ def _print_message(self, message: str, file=None) -> None:
24
+ super()._print_message(f'\n{message}\n', file=file)
25
+
26
+
27
+ def main() -> None:
28
+ parser = _ArgumentParser(
29
+ prog='steam-cloud-backup',
30
+ description='micro-script for downloading all save files from steam cloud',
31
+ )
32
+
33
+ # session group
34
+ group = parser.add_argument_group('session')
35
+ group.add_argument(
36
+ '-i',
37
+ f'--{SESSION_ID}',
38
+ type=str,
39
+ )
40
+ group.add_argument(
41
+ '-l',
42
+ f'--{STEAM_LOGIN_SECURE}',
43
+ type=str,
44
+ )
45
+
46
+ # connection group
47
+ group = parser.add_argument_group('connection')
48
+ group.add_argument(
49
+ '-j',
50
+ '--max-concurrent-connections',
51
+ default=16,
52
+ help='default: 16',
53
+ type=int,
54
+ )
55
+ group.add_argument(
56
+ '--connect-timeout',
57
+ default=60,
58
+ help='default: 60 (s)',
59
+ type=int,
60
+ )
61
+ group.add_argument(
62
+ '--useragent',
63
+ default=DEFAULT_USER_AGENT,
64
+ type=str,
65
+ )
66
+
67
+ # out group
68
+ group = parser.add_argument_group('out')
69
+ group.add_argument('folder', type=Path)
70
+ group.add_argument(
71
+ '-v',
72
+ '--verbose',
73
+ choices=(0, 1, 2),
74
+ default=1,
75
+ help="""logging type:
76
+ 0 - off;
77
+ 1 (default) - info;
78
+ 2 - debug""",
79
+ type=int,
80
+ )
81
+
82
+ args = parser.parse_args()
83
+ del group
84
+
85
+ # data
86
+ try:
87
+ for k in (STEAM_LOGIN_SECURE,):
88
+ attr = getattr(args, k)
89
+ if not attr:
90
+ if not (value := input(f'{k}: ')):
91
+ raise
92
+ setattr(args, k, value)
93
+ except Exception, KeyboardInterrupt:
94
+ parser.exit(1, '\nnot enough data\n')
95
+
96
+ logging_level: int | None
97
+ match args.verbose:
98
+ case 0:
99
+ logging_level = None
100
+ case 1:
101
+ logging_level = logging.INFO
102
+ case 2:
103
+ logging_level = logging.DEBUG
104
+
105
+ logging.basicConfig(level=logging_level)
106
+
107
+ # parser
108
+ try:
109
+ asyncio.run(
110
+ parse(
111
+ c_session_id=getattr(args, SESSION_ID),
112
+ c_steam_login_secure=getattr(args, STEAM_LOGIN_SECURE),
113
+ concurrent_connections=args.max_concurrent_connections,
114
+ connect_timeout=args.connect_timeout,
115
+ path_to_folder=args.folder,
116
+ useragent=args.useragent,
117
+ )
118
+ )
119
+ except BadSessionException:
120
+ parser.exit(2, 'bad cookie session\n')
121
+ except ZeroAnswerException:
122
+ parser.exit(3, 'steam return zero page\n')
123
+ except KeyboardInterrupt:
124
+ parser.exit(4, 'user kill task')
125
+
126
+
127
+ if __name__ == '__main__':
128
+ main()
@@ -0,0 +1,23 @@
1
+ __all__ = (
2
+ 'ParserException',
3
+ 'BadSessionException',
4
+ 'ZeroAnswerException',
5
+ 'DownloadException',
6
+ )
7
+
8
+ from pathlib import Path
9
+
10
+
11
+ class ParserException(Exception): ...
12
+
13
+
14
+ class BadSessionException(ParserException): ...
15
+
16
+
17
+ class ZeroAnswerException(ParserException): ...
18
+
19
+
20
+ class DownloadException(ParserException):
21
+ def __init__(self, path: Path) -> None:
22
+ super().__init__()
23
+ self.path = path
@@ -0,0 +1,135 @@
1
+ __all__ = ('parse',)
2
+
3
+ import math
4
+ import random
5
+ import string
6
+ import logging
7
+ import asyncio
8
+ from pathlib import Path
9
+
10
+ import aiofiles
11
+ import aiohttp
12
+ from aiohttp import TCPConnector
13
+
14
+ from source.tools import TableParser
15
+ from source.errors import BadSessionException, DownloadException, ZeroAnswerException
16
+
17
+
18
+ # datalink
19
+ REMOTE_STORAGE_URL = 'https://store.steampowered.com/account/remotestorage'
20
+
21
+ # cookie key names
22
+ SESSION_ID = 'sessionId'
23
+ STEAM_LOGIN_SECURE = 'steamLoginSecure'
24
+
25
+ # max rows per page
26
+ STEAM_PER_PAGE = 50
27
+
28
+ # logging
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ async def parse(
33
+ c_steam_login_secure: str,
34
+ path_to_folder: Path,
35
+ useragent: str,
36
+ concurrent_connections: int,
37
+ connect_timeout: int,
38
+ c_session_id: str | None = None,
39
+ ) -> None:
40
+ if c_session_id is None:
41
+ # steam default `sessionId` scheme
42
+ c_session_id = ''.join(
43
+ random.choices(string.ascii_lowercase + string.digits, k=24)
44
+ )
45
+
46
+ async with aiohttp.ClientSession(
47
+ connector=TCPConnector(limit=concurrent_connections, ttl_dns_cache=300),
48
+ timeout=aiohttp.ClientTimeout(total=connect_timeout),
49
+ headers={
50
+ 'User-Agent': useragent,
51
+ },
52
+ cookies={
53
+ SESSION_ID: c_session_id,
54
+ STEAM_LOGIN_SECURE: c_steam_login_secure,
55
+ },
56
+ cookie_jar=aiohttp.CookieJar(),
57
+ ) as session:
58
+ text: str
59
+ async with session.get(url=REMOTE_STORAGE_URL) as r:
60
+ logger.debug('%s', str(r))
61
+
62
+ if not r.ok:
63
+ raise BadSessionException
64
+ text = await r.text()
65
+
66
+ if 'g_AccountID = 0;' in text:
67
+ raise BadSessionException
68
+
69
+ g_parser = TableParser()
70
+ g_parser.feed(text)
71
+
72
+ path_to_folder.mkdir(parents=True, exist_ok=True)
73
+
74
+ if not (games_table := g_parser.rows):
75
+ raise ZeroAnswerException
76
+ # / size
77
+ for name, rows_c, _, g_url in games_table:
78
+ logger.info('iter game: %s; files count: %s', name, rows_c)
79
+
80
+ game_path = path_to_folder / name
81
+
82
+ for page in range(math.ceil(int(rows_c) / STEAM_PER_PAGE)):
83
+ params = {'index': page * STEAM_PER_PAGE} if page else None
84
+
85
+ first_iter = True
86
+ bad_files: set[Path] = set()
87
+ while first_iter or bad_files:
88
+ logger.debug('%s', str(bad_files))
89
+ try:
90
+ l_parser: TableParser
91
+ async with session.get(url=g_url, params=params) as dr:
92
+ l_parser = TableParser()
93
+ l_parser.feed(await dr.text())
94
+
95
+ async with asyncio.TaskGroup() as task_g:
96
+ # / date written
97
+ for folder, file_name, size, _, l_url in l_parser.rows:
98
+ file_path = game_path / folder / file_name
99
+ if first_iter or (
100
+ file_path in bad_files
101
+ and bad_files.remove(file_path)
102
+ is None # deleting file_name in bad_files if find
103
+ ):
104
+ file_path.parent.mkdir(parents=True, exist_ok=True)
105
+ task_g.create_task(
106
+ download_file(session, l_url, file_path, size)
107
+ )
108
+ except* DownloadException as errs:
109
+ for e in errs.exceptions:
110
+ bad_files.add(e.path)
111
+ except* Exception:
112
+ await asyncio.sleep(1)
113
+
114
+ first_iter = False
115
+
116
+
117
+ async def download_file(
118
+ session: aiohttp.ClientSession,
119
+ url: str,
120
+ path: Path,
121
+ size: str,
122
+ chunk_size: int = 8_192,
123
+ ) -> None:
124
+ try:
125
+ async with session.get(url=url) as r:
126
+ r.raise_for_status()
127
+
128
+ async with aiofiles.open(path, 'wb') as f:
129
+ logger.info('download file %s; size: %s', path, size)
130
+ async for chunk in r.content.iter_chunked(chunk_size):
131
+ await f.write(chunk)
132
+
133
+ except Exception as err:
134
+ logger.debug('%s: %s', type(err), str(err))
135
+ raise DownloadException(path)
@@ -0,0 +1,73 @@
1
+ __all__ = ('TableParser',)
2
+
3
+ import io
4
+ from html.parser import HTMLParser
5
+
6
+
7
+ class _CellIO(io.StringIO):
8
+ """override io.StringIO with write blocking"""
9
+
10
+ def __init__(self, *args, **kwargs) -> None:
11
+ super().__init__(*args, **kwargs)
12
+
13
+ self.shadow_close = False
14
+
15
+ def write(self, *args, **kwargs) -> int:
16
+ if not self.shadow_close:
17
+ return super().write(*args, **kwargs)
18
+ return -1
19
+
20
+
21
+ class TableParser(HTMLParser):
22
+ """parse steam backend table in py objects"""
23
+
24
+ _current_row: list[str]
25
+ _cell_buf: _CellIO
26
+
27
+ def __init__(self) -> None:
28
+ super().__init__()
29
+
30
+ self._in_tr = False
31
+ self._in_td = False
32
+
33
+ self.rows: list[tuple[str, ...]] = []
34
+
35
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
36
+ tag = tag.lower()
37
+
38
+ if tag == 'tr':
39
+ self._in_tr = True
40
+ self._current_row = []
41
+ elif self._in_tr and tag == 'td':
42
+ self._in_td = True
43
+ self._cell_buf = _CellIO()
44
+ elif tag == 'a':
45
+ # `href=` attr
46
+ self._cell_buf.write(attrs[0][1])
47
+ self._cell_buf.shadow_close = True
48
+
49
+ def handle_endtag(self, tag: str) -> None:
50
+ tag = tag.lower()
51
+
52
+ if tag == 'tr':
53
+ if self._current_row:
54
+ self.rows.append(tuple(self._current_row))
55
+ self._in_tr = False
56
+ if self._in_tr and tag == 'td':
57
+ self._cell_buf.seek(0)
58
+ cell = self._cell_buf.read().strip()
59
+ # void is not a correct folder name
60
+ self._current_row.append(cell if cell else '_')
61
+
62
+ self._in_td = False
63
+
64
+ def handle_data(self, data: str) -> None:
65
+ if self._in_tr and self._in_td:
66
+ self._cell_buf.write(data)
67
+
68
+ def feed(self, html: str, *args, **kwargs) -> None:
69
+ super().feed(
70
+ html[html.index('<tbody>') + 7 : html.rindex('</tbody>')].strip(),
71
+ *args,
72
+ **kwargs,
73
+ )
@@ -1,116 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: steam-cloud-backup
3
- Version: 0.1.1
4
- Summary: micro-script for downloading all save files from steam cloud
5
- Author: q-deltaftactal
6
- License-Expression: MIT
7
- Project-URL: Repository, https://github.com/q-deltafractal/steam-cloud-backup.git
8
- Project-URL: Issues, https://github.com/q-deltafractal/steam-cloud-backup/issues
9
- Classifier: Topic :: Utilities
10
- Requires-Python: >=3.14
11
- Description-Content-Type: text/markdown
12
- License-File: LICENSE
13
- Requires-Dist: aiofiles>=25.1.0
14
- Requires-Dist: aiohttp[speedups]>=3.14.1
15
- Dynamic: license-file
16
-
17
- # steam-cloud-backup
18
-
19
- micro-script for downloading all save files from [steam cloud](https://store.steampowered.com/account/remotestorage)
20
-
21
- <br>
22
-
23
- ## backup process
24
-
25
- ### requirements
26
-
27
- - [ ] [uv](https://docs.astral.sh/uv/) python package manager
28
- - [ ] [git](https://git-scm.com/install/) version control
29
-
30
- _latest versions_
31
-
32
- ### preparation
33
-
34
- ```bash
35
- # clone repo
36
- git clone https://github.com/q-deltafractal/steam-cloud-backup.git
37
- cd steam-cloud-backup
38
- ```
39
-
40
- ```bash
41
- # setup virtual env
42
- uv sync
43
- ```
44
-
45
- ### backup
46
-
47
- ```console
48
- $ uv run main.py backup-folder/
49
- steamLoginSecure: <input>
50
- INFO:__main__:iter game: Steam Client; files count: 2
51
- ...
52
- ```
53
-
54
- <br>
55
-
56
- To identify login to your account, steam uses `steamLoginSecure` cookie, therefore the script requires it to work.
57
- Method of obtaining this cookie:
58
-
59
- - [ ] log in to https://store.steampowered.com/login/
60
- - [ ] open Dev Tools via `ctrl + shift + i`
61
- - [ ] go to the application _(or storage)_ tab
62
- - [ ] in the cookies section, select `https://store.steampowered.com`
63
- - [ ] copy the entire contents of the `steamLoginSecure` column
64
-
65
- <br>
66
-
67
- ## usage
68
-
69
- ```console block=api
70
- usage: steam-cloud-backup [-h] [-i SESSIONID] [-l STEAMLOGINSECURE]
71
- [-j MAX_CONCURRENT_CONNECTIONS]
72
- [--connect-timeout CONNECT_TIMEOUT]
73
- [--useragent USERAGENT] [-v (0, 1, 2)]
74
- folder
75
-
76
- micro-script for downloading all save files from steam cloud
77
-
78
- options:
79
- -h, --help show this help message and exit
80
-
81
- session:
82
- -i, --sessionId SESSIONID
83
- -l, --steamLoginSecure STEAMLOGINSECURE
84
-
85
- connection:
86
- -j, --max-concurrent-connections MAX_CONCURRENT_CONNECTIONS
87
- default: 16
88
- --connect-timeout CONNECT_TIMEOUT
89
- default: 60 (s)
90
- --useragent USERAGENT
91
-
92
- out:
93
- folder
94
- -v, --verbose (0, 1, 2)
95
- logging type: 0 - off; 1 (default) - info; 2 - debug
96
- ```
97
-
98
- <br>
99
-
100
- ## license
101
-
102
- project is licensed under:
103
-
104
- <table>
105
- <tr>
106
- <td>
107
- MIT
108
- </td>
109
- <td>
110
- <a href="LICENSE">LICENSE</a>
111
- </td>
112
- <td>
113
- https://mit-license.org/
114
- </td>
115
- </tr>
116
- </table>
@@ -1,4 +0,0 @@
1
- [egg_info]
2
- tag_build =
3
- tag_date = 0
4
-
@@ -1,8 +0,0 @@
1
- LICENSE
2
- README.md
3
- pyproject.toml
4
- steam_cloud_backup.egg-info/PKG-INFO
5
- steam_cloud_backup.egg-info/SOURCES.txt
6
- steam_cloud_backup.egg-info/dependency_links.txt
7
- steam_cloud_backup.egg-info/requires.txt
8
- steam_cloud_backup.egg-info/top_level.txt
@@ -1,2 +0,0 @@
1
- aiofiles>=25.1.0
2
- aiohttp[speedups]>=3.14.1