pytolino 1.7__tar.gz → 2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytolino
3
- Version: 1.7
3
+ Version: 2.0
4
4
  Summary: client for tolino cloud
5
5
  Author: Imam Usmani
6
6
  Project-URL: Source Code, https://github.com/ImamAzim/pytolino
@@ -15,6 +15,8 @@ Description-Content-Type: text/x-rst
15
15
  License-File: LICENSE
16
16
  Requires-Dist: requests
17
17
  Requires-Dist: mechanize
18
+ Requires-Dist: curl_cffi
19
+ Requires-Dist: varboxes
18
20
  Provides-Extra: dev
19
21
  Requires-Dist: pytest; extra == "dev"
20
22
  Requires-Dist: flake8; extra == "dev"
@@ -25,10 +27,16 @@ Requires-Dist: twine; extra == "dev"
25
27
  Requires-Dist: sphinx-rtd-theme; extra == "dev"
26
28
  Dynamic: license-file
27
29
 
30
+ UPDATE
31
+ ===========
32
+
33
+ because of heavy anti-bot protection, it is no longer possible to make a fully automatic login. One can however reuse authorization token after a manual login. The token can then be refreshed automatically, for example with a cronjob (at least once per hour.)
34
+
35
+
28
36
  pytolino
29
37
  ===================
30
38
 
31
- A client to interact (login, upload, delete ebooks, etc..) with the tolino cloud with python. Most of the code is forked from https://github.com/darkphoenix/tolino-calibre-sync
39
+ A client to interact (login, upload, delete ebooks, etc..) with the tolino cloud with python. thanks to https://github.com/darkphoenix/tolino-calibre-sync for the inspiration.
32
40
 
33
41
  One difference is that I aim to create a python package from it and to put it on pypi, so that one can use this python module in other projects.
34
42
 
@@ -42,48 +50,50 @@ Installation
42
50
  Usage
43
51
  =====
44
52
 
53
+ First, login manually, and use an inspector tool in the browser to inspect the requests. After connecting to the digital libray of tolino, there is POST request (named token). From the request response, copy the value of the refresh token (and the expiration time in seconds). Then, in a PATH request, in the request header, find the device_id number.
45
54
 
46
- Before being able to send requests, you need to register your computer on which you will run the code:
55
+ You can then store the token:
47
56
 
48
57
  .. code-block:: python
49
58
 
50
- from pytolino.tolino_cloud import Client, PytolinoException
51
- client = Client()
52
- client.login(USERNAME, PASSWORD)
53
- client.register() # do this only once!
54
- client.logout()
59
+ from pytolino.tolino_cloud import Client
60
+ partner = 'orellfuessli'
61
+ account_name = 'any name for reference'
62
+ client = Client(partner)
63
+ print('login on your browser and get the token.')
64
+ refresh_token = input('refresh token:\n')
65
+ expires_in = int(input('expires_in:\n'))
66
+ hardware_id = input('hardware id:\n')
67
+ Client.store_token(
68
+ account_name, refresh_token, expires_in, hardware_id)
55
69
 
56
- You can then upload, add to a collection or delete ebook on your cloud:
70
+ Then, get a new access token. It will expires in 1 hours, so you might want to create a crontab job to do it regularely:
57
71
 
58
72
  .. code-block:: python
59
73
 
60
- from pytolino.tolino_cloud import Client, PytolinoException
61
- client = Client()
62
- client.login(USERNAME, PASSWORD)
74
+ from pytolino.tolino_cloud import Client
75
+ partner = 'orellfuessli'
76
+ account_name = 'any name for reference'
77
+ client = Client(partner)
78
+ client.get_new_token(account_name)
79
+
80
+ After this, instead of login, you only need to retrieve the access token that is stored on disk and upload, delete books, etc...
81
+
82
+ .. code-block:: python
83
+
84
+ from pytolino.tolino_cloud import Client
85
+ partner = 'orellfuessli'
86
+ account_name = 'any name for reference'
87
+ client = Client(partner)
88
+ client.retrieve_token(account_name)
89
+
63
90
  ebook_id = client.upload(EPUB_FILE_PATH) # return a unique id that can be used for reference
64
91
  client.add_collection(epub_id, 'science fiction') # add the previous book to the collection science-fiction
65
92
  client.add_cover(epub_id, cover_path) # to upload a cover on the book.
66
93
  client.delete_ebook(epub_id) # delete the previousely uploaded ebook
67
94
  inventory = client.get_inventory() # get a list of all the books on the cloud and their metadata
68
95
  client.upload_metadata(epub_id, title='my title', author='someone') # you can upload various kind of metadata
69
- client.logout()
70
-
71
96
 
72
- if you want to unregister your computer:
73
-
74
- .. code-block:: python
75
-
76
- from pytolino.tolino_cloud import Client, PytolinoException
77
- client = Client()
78
- client.login(USERNAME, PASSWORD)
79
- client.register() # now you will not be able to upload books from this computer
80
- client.logout()
81
-
82
- By default, it will connect to 'www.buecher.de'. In principle you could change the partner with:
83
-
84
- .. code-block:: python
85
-
86
- client = Client(server_name='www.orelfuessli') # for example if you have an account at orel fuessli.
87
97
 
88
98
  To get a list of the supported partners:
89
99
 
@@ -92,22 +102,17 @@ To get a list of the supported partners:
92
102
  from pytolino.tolino_cloud import PARTNERS
93
103
  print(PARTNERS)
94
104
 
95
- Unfortunately, the only supported partner now is 'www.buecher.de', because it has a different way of connection... So for now, the only solution is to create an account there and link it to your original account.
96
-
105
+ for now, only orelfuessli is supported, but it should be easy to include the others (but always need of a manual login)
97
106
 
98
107
 
99
108
  Features
100
109
  ========
101
110
 
102
- * login to tolino partner (for now works only with buecher.de)
103
- * register device
104
- * unregister device
105
111
  * upload ebook
106
112
  * delete ebook from the cloud
107
113
  * add a book to a collection
108
114
  * download inventory
109
115
  * upload metadata
110
- * more to come...
111
116
 
112
117
 
113
118
  License
@@ -0,0 +1,92 @@
1
+ UPDATE
2
+ ===========
3
+
4
+ because of heavy anti-bot protection, it is no longer possible to make a fully automatic login. One can however reuse authorization token after a manual login. The token can then be refreshed automatically, for example with a cronjob (at least once per hour.)
5
+
6
+
7
+ pytolino
8
+ ===================
9
+
10
+ A client to interact (login, upload, delete ebooks, etc..) with the tolino cloud with python. thanks to https://github.com/darkphoenix/tolino-calibre-sync for the inspiration.
11
+
12
+ One difference is that I aim to create a python package from it and to put it on pypi, so that one can use this python module in other projects.
13
+
14
+ Installation
15
+ ============
16
+
17
+ .. code-block:: bash
18
+
19
+ pip install pytolino
20
+
21
+ Usage
22
+ =====
23
+
24
+ First, login manually, and use an inspector tool in the browser to inspect the requests. After connecting to the digital libray of tolino, there is POST request (named token). From the request response, copy the value of the refresh token (and the expiration time in seconds). Then, in a PATH request, in the request header, find the device_id number.
25
+
26
+ You can then store the token:
27
+
28
+ .. code-block:: python
29
+
30
+ from pytolino.tolino_cloud import Client
31
+ partner = 'orellfuessli'
32
+ account_name = 'any name for reference'
33
+ client = Client(partner)
34
+ print('login on your browser and get the token.')
35
+ refresh_token = input('refresh token:\n')
36
+ expires_in = int(input('expires_in:\n'))
37
+ hardware_id = input('hardware id:\n')
38
+ Client.store_token(
39
+ account_name, refresh_token, expires_in, hardware_id)
40
+
41
+ Then, get a new access token. It will expires in 1 hours, so you might want to create a crontab job to do it regularely:
42
+
43
+ .. code-block:: python
44
+
45
+ from pytolino.tolino_cloud import Client
46
+ partner = 'orellfuessli'
47
+ account_name = 'any name for reference'
48
+ client = Client(partner)
49
+ client.get_new_token(account_name)
50
+
51
+ After this, instead of login, you only need to retrieve the access token that is stored on disk and upload, delete books, etc...
52
+
53
+ .. code-block:: python
54
+
55
+ from pytolino.tolino_cloud import Client
56
+ partner = 'orellfuessli'
57
+ account_name = 'any name for reference'
58
+ client = Client(partner)
59
+ client.retrieve_token(account_name)
60
+
61
+ ebook_id = client.upload(EPUB_FILE_PATH) # return a unique id that can be used for reference
62
+ client.add_collection(epub_id, 'science fiction') # add the previous book to the collection science-fiction
63
+ client.add_cover(epub_id, cover_path) # to upload a cover on the book.
64
+ client.delete_ebook(epub_id) # delete the previousely uploaded ebook
65
+ inventory = client.get_inventory() # get a list of all the books on the cloud and their metadata
66
+ client.upload_metadata(epub_id, title='my title', author='someone') # you can upload various kind of metadata
67
+
68
+
69
+ To get a list of the supported partners:
70
+
71
+ .. code-block:: python
72
+
73
+ from pytolino.tolino_cloud import PARTNERS
74
+ print(PARTNERS)
75
+
76
+ for now, only orelfuessli is supported, but it should be easy to include the others (but always need of a manual login)
77
+
78
+
79
+ Features
80
+ ========
81
+
82
+ * upload ebook
83
+ * delete ebook from the cloud
84
+ * add a book to a collection
85
+ * download inventory
86
+ * upload metadata
87
+
88
+
89
+ License
90
+ =======
91
+
92
+ The project is licensed under GNU GENERAL PUBLIC LICENSE v3.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pytolino"
7
- version = "1.7"
7
+ version = "2.0"
8
8
  authors = [
9
9
  {name="Imam Usmani"},
10
10
  ]
@@ -21,6 +21,8 @@ description = "client for tolino cloud"
21
21
  dependencies = [
22
22
  'requests',
23
23
  'mechanize',
24
+ 'curl_cffi',
25
+ 'varboxes',
24
26
  ]
25
27
 
26
28
  [project.optional-dependencies]
@@ -31,7 +33,7 @@ dev = [
31
33
  'sphinx',
32
34
  'build',
33
35
  'twine',
34
- 'sphinx-rtd-theme'
36
+ 'sphinx-rtd-theme',
35
37
  ]
36
38
 
37
39
  [project.urls]
@@ -0,0 +1,414 @@
1
+ #!/usr/bin/env python3
2
+
3
+
4
+ import logging
5
+ import json
6
+ import time
7
+ import tomllib
8
+ from pathlib import Path
9
+
10
+
11
+ import requests
12
+ import curl_cffi
13
+ from varboxes import VarBox
14
+
15
+
16
+ class PytolinoException(Exception):
17
+ pass
18
+
19
+
20
+ class ExpirationError(PytolinoException):
21
+ pass
22
+
23
+
24
+ SERVERS_SETTINGS_FN = 'servers_settings.toml'
25
+ SERVERS_SETTINGS_FP = Path(__file__).parent / SERVERS_SETTINGS_FN
26
+ servers_settings = tomllib.loads(SERVERS_SETTINGS_FP.read_text())
27
+ PARTNERS = servers_settings.keys()
28
+
29
+
30
+ def main():
31
+ for partner in PARTNERS:
32
+ print(partner)
33
+ for key, val in servers_settings[partner].items():
34
+ print(key, val)
35
+
36
+
37
+ class Client(object):
38
+
39
+ """create a client to communicate with a tolino partner (login, etc..)"""
40
+
41
+ def store_token(
42
+ account_name,
43
+ refresh_token: str,
44
+ expires_in: int,
45
+ hardward_id: str,
46
+ access_token='',
47
+ ):
48
+ """after one has connected in a browser,
49
+ one can store the token for the app.
50
+
51
+ :account_name: internal name for reference
52
+ :refresh_token: given by server after a token POST request
53
+ :expires_in: time in seconds
54
+ :hardward_id: present in payload for every request to API.
55
+
56
+ """
57
+ vb = VarBox('pytolino', app_name=account_name)
58
+ vb.refresh_token = refresh_token
59
+ vb.hardware_id = hardward_id
60
+ vb.expires_in = expires_in
61
+ vb.timestamp = time.time()
62
+ vb.access_token = access_token
63
+
64
+ @property
65
+ def refresh_token(self) -> str:
66
+ """refresh token to get new access token"""
67
+ return self._refresh_token
68
+
69
+ @property
70
+ def hardware_id(self) -> str:
71
+ """hardware id that is sent in request payloads"""
72
+ return self._hardware_id
73
+
74
+ def __init__(self, server_name='orellfuessli'):
75
+
76
+ if server_name not in servers_settings:
77
+ raise PytolinoException(
78
+ f'the partner {server_name} was not found.'
79
+ f'please choose one of the list: {PARTNERS}')
80
+
81
+ self._access_token = None
82
+ self._refresh_token = None
83
+ self._token_expires = None
84
+ self._hardware_id = None
85
+
86
+ self._server_settings = servers_settings[server_name]
87
+ self._session = requests.Session()
88
+ self._session_cffi = curl_cffi.Session()
89
+ self._server_name = server_name
90
+
91
+ def retrieve_token(
92
+ self,
93
+ account_name,
94
+ ) -> tuple[str, str]:
95
+ """get the token and data that were stored previousely.
96
+ raise error if expired
97
+
98
+ :account_name: internal name under which token was stored
99
+ :returns: refresh_token, hardware_id
100
+
101
+ """
102
+ vb = VarBox('pytolino', app_name=account_name)
103
+ if not hasattr(vb, 'refresh_token'):
104
+ raise PytolinoException(
105
+ 'there was no refresh token stored for that name')
106
+ now = time.time()
107
+ expiration_time = vb.timestamp + vb.expires_in
108
+ if now > expiration_time:
109
+ raise ExpirationError('the refresh token has expired')
110
+ else:
111
+ self._refresh_token = vb.refresh_token
112
+ self._hardware_id = vb.hardware_id
113
+ self._access_token = vb.access_token
114
+
115
+ def get_new_token(self, account_name):
116
+ """look at the store token, and get a new access and refresh tokens.
117
+
118
+ :account_name: TODO
119
+ :returns: TODO
120
+
121
+ """
122
+ self.retrieve_token(account_name)
123
+
124
+ headers = {
125
+ 'Referer': 'https://webreader.mytolino.com/',
126
+ }
127
+ payload = {
128
+ 'client_id': 'webreader',
129
+ 'grant_type': 'refresh_token',
130
+ 'refresh_token': self.refresh_token,
131
+ 'scope': 'SCOPE_BOSH',
132
+ }
133
+ host_response = self._session_cffi.post(
134
+ self._server_settings['token_url'],
135
+ data=payload,
136
+ verify=True,
137
+ allow_redirects=True,
138
+ headers=headers,
139
+ impersonate='chrome',
140
+ )
141
+ if not host_response.ok:
142
+ msg = str(host_response)
143
+ logging.error('failed to get a new token')
144
+ logging.error(msg)
145
+ raise PytolinoException('failed to get a new token')
146
+ else:
147
+ j = host_response.json()
148
+ self._access_token = j['access_token']
149
+ self._refresh_token = j['refresh_token']
150
+ self._token_expires = int(j['expires_in'])
151
+ Client.store_token(
152
+ account_name,
153
+ self.refresh_token,
154
+ self._token_expires,
155
+ self.hardware_id,
156
+ access_token=self._access_token,
157
+ )
158
+ logging.info('got a new access token!')
159
+
160
+ def login(self, username, password, fp=None):
161
+ """login to the partner and get access token.
162
+
163
+ """
164
+ msg = 'login does not work anymore because of bot protection'
165
+ 'connect manualy (once) and use store_token and retrieve token'
166
+ 'methods instead'
167
+ raise NotImplementedError(msg)
168
+
169
+ def logout(self):
170
+ """logout from tolino partner host
171
+
172
+ """
173
+ raise NotImplementedError('logout is not necessary with tokens')
174
+
175
+ def register(self):
176
+ raise NotImplementedError('register is not necessary with tokens')
177
+
178
+ def unregister(self, device_id=None):
179
+ raise NotImplementedError('unregister is not necessary with tokens')
180
+
181
+ def get_inventory(self):
182
+ """download a list of the books on the cloud and their information
183
+ :returns: list of dict describing the book, with a epubMetaData dict
184
+
185
+ """
186
+
187
+ host_response = self._session.get(
188
+ self._server_settings['inventory_url'],
189
+ params={'strip': 'true'},
190
+ headers={
191
+ 't_auth_token': self._access_token,
192
+ 'hardware_id': self._hardware_id,
193
+ 'reseller_id': self._server_settings['partner_id'],
194
+ }
195
+ )
196
+
197
+ if not host_response.ok:
198
+ raise PytolinoException(
199
+ f'inventory request failed {host_response}')
200
+
201
+ try:
202
+ j = host_response.json()
203
+ except requests.JSONDecodeError:
204
+ raise PytolinoException(
205
+ 'inventory list request failed because of json error.'
206
+ )
207
+ else:
208
+ try:
209
+ publication_inventory = j['PublicationInventory']
210
+ uploaded_ebooks = publication_inventory['edata']
211
+ purchased_ebook = publication_inventory['ebook']
212
+ except KeyError:
213
+ raise PytolinoException(
214
+ 'inventory list request failed because',
215
+ 'of key error in json.',
216
+ )
217
+ else:
218
+ inventory = uploaded_ebooks + purchased_ebook
219
+ return inventory
220
+
221
+ def add_to_collection(self, book_id, collection_name):
222
+ """add a book to a collection on the cloud
223
+
224
+ :book_id: identify the book on the cloud
225
+ :collection_name: str name
226
+
227
+ """
228
+
229
+ payload = {
230
+ "revision": None,
231
+ "patches": [{
232
+ "op": "add",
233
+ "value": {
234
+ "modified": round(time.time() * 1000),
235
+ "name": collection_name,
236
+ "category": "collection",
237
+ },
238
+ "path": f"/publications/{book_id}/tags"
239
+ }]
240
+ }
241
+
242
+ host_response = self._session.patch(
243
+ self._server_settings['sync_data_url'],
244
+ data=json.dumps(payload),
245
+ headers={
246
+ 'content-type': 'application/json',
247
+ 't_auth_token': self._access_token,
248
+ 'hardware_id': self.hardware_id,
249
+ 'reseller_id': self._server_settings['partner_id'],
250
+ 'client_type': 'TOLINO_WEBREADER',
251
+ }
252
+ )
253
+
254
+ if not host_response.ok:
255
+ raise PytolinoException(
256
+ f'collection add failed {host_response}')
257
+
258
+ def upload_metadata(self, book_id, **new_metadata):
259
+ """upload some metadata to a specific book on the cloud
260
+
261
+ :book_id: ref on the cloud of the book
262
+ :**meta_data: dict of metadata than can be changed
263
+
264
+ """
265
+
266
+ url = self._server_settings['meta_url'] + f'/?deliverableId={book_id}'
267
+ host_response = self._session.get(
268
+ url,
269
+ headers={
270
+ 't_auth_token': self._access_token,
271
+ 'hardware_id': self.hardware_id,
272
+ 'reseller_id': self._server_settings['partner_id'],
273
+ }
274
+ )
275
+
276
+ book = host_response.json()
277
+ if not host_response.ok:
278
+ raise PytolinoException(f'metadata upload failed {host_response}')
279
+
280
+ for key, value in new_metadata.items():
281
+ book['metadata'][key] = value
282
+
283
+ payload = {
284
+ 'uploadMetaData': book['metadata']
285
+ }
286
+
287
+ host_response = self._session.put(
288
+ url,
289
+ data=json.dumps(payload),
290
+ headers={
291
+ 'content-type': 'application/json',
292
+ 't_auth_token': self._access_token,
293
+ 'hardware_id': self.hardware_id,
294
+ 'reseller_id': self._server_settings['partner_id'],
295
+ }
296
+ )
297
+
298
+ if not host_response.ok:
299
+ raise PytolinoException(f'metadata upload failed {host_response}')
300
+
301
+ def upload(self, file_path, name=None, extension=None):
302
+ """upload an ebook to your cloud
303
+
304
+ :file_path: str path to the ebook to upload
305
+ :name: str name of book if different from filename
306
+ :extension: epub or pdf, if not in filename
307
+ :returns: epub_id on the server
308
+
309
+ """
310
+
311
+ if name is None:
312
+ name = file_path.split('/')[-1]
313
+ if extension is None:
314
+ extension = file_path.split('.')[-1]
315
+
316
+ mime = {
317
+ 'pdf': 'application/pdf',
318
+ 'epub': 'application/epub+zip',
319
+ }.get(extension.lower(), 'application/pdf')
320
+
321
+ host_response = self._session.post(
322
+ self._server_settings['upload_url'],
323
+ files=[(
324
+ 'file',
325
+ (
326
+ name,
327
+ open(file_path, 'rb'),
328
+ mime,
329
+ ),
330
+ )],
331
+ headers={
332
+ 't_auth_token': self._access_token,
333
+ 'hardware_id': self.hardware_id,
334
+ 'reseller_id': self._server_settings['partner_id'],
335
+ }
336
+ )
337
+
338
+ if not host_response.ok:
339
+ raise PytolinoException(f'upload failed {host_response}')
340
+
341
+ try:
342
+ j = host_response.json()
343
+ except requests.JSONDecodeError:
344
+ raise PytolinoException('file upload failed.')
345
+ else:
346
+ try:
347
+ return j['metadata']['deliverableId']
348
+ except KeyError:
349
+ raise PytolinoException('file upload failed.')
350
+
351
+ def delete_ebook(self, ebook_id):
352
+ """delete an ebook present on your cloud
353
+
354
+ :ebook_id: id of the ebook to delete.
355
+ :returns: None
356
+
357
+ """
358
+ host_response = self._session.get(
359
+ self._server_settings['delete_url'],
360
+ params={'deliverableId': ebook_id},
361
+ headers={
362
+ 't_auth_token': self._access_token,
363
+ 'hardware_id': self.hardware_id,
364
+ 'reseller_id': self._server_settings['partner_id'],
365
+ }
366
+ )
367
+
368
+ if not host_response.ok:
369
+ try:
370
+ j = host_response.json()
371
+ raise PytolinoException(
372
+ f"delete {ebook_id} failed: ",
373
+ f"{j['ResponseInfo']['message']}"
374
+ )
375
+ except KeyError:
376
+ raise PytolinoException(
377
+ f'delete {ebook_id} failed: reason unknown.')
378
+
379
+ def add_cover(self, book_id, filepath, file_ext=None):
380
+ """upload a a cover to a book on the cloud
381
+
382
+ :book_id: id of the book on the serveer
383
+ :filepath: path to the cover file
384
+ :file_ext: png, jpg or jpeg. only necessary if the
385
+ filepath has no extension
386
+
387
+ """
388
+
389
+ if file_ext is None:
390
+ ext = filepath.split('.')[-1]
391
+
392
+ mime = {
393
+ 'png': 'image/png',
394
+ 'jpeg': 'image/jpeg',
395
+ 'jpg': 'image/jpeg'
396
+ }.get(ext.lower(), 'application/jpeg')
397
+
398
+ host_response = self._session.post(
399
+ self._server_settings['cover_url'],
400
+ files=[('file', ('1092560016', open(filepath, 'rb'), mime))],
401
+ data={'deliverableId': book_id},
402
+ headers={
403
+ 't_auth_token': self._access_token,
404
+ 'hardware_id': self.hardware_id,
405
+ 'reseller_id': self._server_settings['partner_id'],
406
+ },
407
+ )
408
+
409
+ if not host_response.ok:
410
+ raise PytolinoException(f'cover upload failed. {host_response}')
411
+
412
+
413
+ if __name__ == '__main__':
414
+ main()