Skip to content

Commit 991fcd8

Browse files
rkjnsnsubdavis
andauthored
Switch to using web-based activation (#137)
This updates `user add` to use the same web-based activation approach used by Kobo e-readers, rather than trying to scrape the login page. This has three main benefits: 1. No need for the use to provide their password to kobodl. Instead, they log in using the Kobo website from their browser. 2. Avoids the step of using devtools to extract a captcha code. 3. (Hopefully) less prone to breakage, since it doesn't rely on scraping the login page. This change was adapted from TnS-hun/kobo-book-downloader commit 04d8c42. Co-authored-by: Brandon Davis <git@subdavis.com>
1 parent 47795c1 commit 991fcd8

File tree

5 files changed

+89
-146
lines changed

5 files changed

+89
-146
lines changed

README.md

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -199,26 +199,8 @@ kobodl --version
199199
kobodl --debug [OPTIONS] COMMAND [ARGS]...
200200
```
201201

202-
## Getting a reCAPTCHA code
203-
204-
Adding a user requires a bit of hackery to get a reCAPTCHA code from Kobo's website. This GIF helps to explain how to do that.
205-
206-
![Gif explaining how to get reCAPTHCA](docs/captcha.gif)
207-
208202
## Troubleshooting
209203

210-
> I can't log in. My credentials are rejected.
211-
212-
You must have a kobo email login for this tool to work (you can't use an external provider like Google or Facebook). However, you can create a NEW kobo account and link it with your existing account on the user profile page. Go to `My Account -> Account Settings` to link your new kobo login.
213-
214-
> I can't log in. I get a message saying "The page format might have changed"
215-
216-
This happens from time to time, maybe once or twice a year. Kobo changes their login page and makes it hard for the tool to parse out the necessary information. Please open an issue.
217-
218-
> I can't log in, there's a problem with reading the captcha
219-
220-
The clipboard interaction doesn't work for everyone. Try supplying the captcha using `kobodl user add --captcha "YOUR_CAPTCHA_CODE"`.
221-
222204
> Some of my books are missing!
223205
224206
Try `kobodl book list --read` to show all "finished" and "archived" books. You can manage your book status on [the library page](https://kobo.com/library). Try changing the status using the "..." button.
@@ -256,7 +238,7 @@ poetry run tox -e type
256238

257239
## Notes
258240

259-
kobo-book-downloader will prompt for your [Kobo](https://www.kobo.com/) e-mail address and password. Once it has successfully logged in, it won't ask for them again. Your password will not be stored on disk; Kobodl uses access tokens after the initial login.
241+
kobo-book-downloader uses the same web-based activation method to login as the Kobo e-readers. You will have to open an activation link—that uses the official [Kobo](https://www.kobo.com/) site—in your browser and enter the code. You might need to login if kobo.com asks you to. Once kobo-book-downloader has successfully logged in, it won't ask for the activation again. kobo-book-downloader doesn't store your Kobo password in any form; it works with access tokens.
260242

261243
Credit recursively to [kobo-book-downloader](https://github.com/TnS-hun/kobo-book-downloader) and the projects that lead to it.
262244

kobodl/actions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,12 +161,12 @@ def ListBooks(users: List[User], listAll: bool, exportFile: Union[TextIO, None])
161161
)
162162

163163

164-
def Login(user: User, password: str, captcha: str) -> None:
164+
def Login(user: User) -> None:
165165
'''perform device initialization and get token'''
166166
kobo = Kobo(user)
167167
kobo.AuthenticateDevice()
168168
kobo.LoadInitializationSettings()
169-
kobo.Login(user.Email, password, captcha)
169+
kobo.Login()
170170

171171

172172
def GetBookOrBooks(

kobodl/commands/user.py

Lines changed: 3 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -44,40 +44,10 @@ def list(ctx, identifier):
4444

4545

4646
@user.command(name='add', help='add new user')
47-
@click.option('--email', prompt=True, hide_input=False, type=click.STRING, help="kobo.com email.")
48-
@click.option('--captcha', type=click.STRING, help="kobo.com captcha.")
49-
@click.password_option(help="kobo.com password (not stored)")
5047
@click.pass_obj
51-
def add(ctx, email, captcha, password):
52-
user = User(Email=email)
53-
click.echo(
54-
"""
55-
1. Open https://authorize.kobo.com/signin in a private/incognito window in your browser.
56-
2. wait till the page loads (do not login!)
57-
3. open the developer tools (use F12 in Firefox/Chrome, or right-click and choose "inspect")
58-
4. select the console tab,
59-
5. copy-paste the following code to the console there and then press Enter.
60-
61-
var newCaptchaDiv = document.createElement("div");
62-
newCaptchaDiv.id = "new-hcaptcha-container";
63-
var siteKey = document.getElementById('hcaptcha-container').getAttribute('data-sitekey');
64-
document.body.replaceChildren(newCaptchaDiv);
65-
grecaptcha.render(newCaptchaDiv.id, {
66-
sitekey: siteKey,
67-
callback: function(r) {console.log("Captcha response:");console.log(r);}
68-
});
69-
console.log('Click the checkbox to get the code');
70-
71-
A captcha should show up below the Sign-in form. Once you solve the captcha its response will be written
72-
below the pasted code in the browser's console. Copy the response (the line below "Captcha response:")
73-
and paste it here. It will be very long!
74-
"""
75-
)
76-
if not captcha:
77-
input('Press enter after copying the captcha code...')
78-
captcha = pyperclip.paste().strip()
79-
click.echo(f'Read captcha code from clipboard: {captcha}')
80-
actions.Login(user, password, captcha)
48+
def add(ctx):
49+
user = User()
50+
actions.Login(user)
8151
Globals.Settings.UserList.users.append(user)
8252
Globals.Settings.Save()
8353
click.echo('Login Success. Try to list your books with `kobodl book list`')

kobodl/kobo.py

Lines changed: 81 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
import html
44
import os
55
import re
6+
import secrets
7+
import string
68
import sys
9+
import time
710
import urllib
8-
import uuid
911
from enum import Enum
1012
from shutil import copyfile
1113
from typing import Dict, Tuple
@@ -47,10 +49,14 @@ class KoboException(Exception):
4749

4850
class Kobo:
4951
Affiliate = "Kobo"
50-
ApplicationVersion = "8.11.24971"
51-
DefaultPlatformId = "00000000-0000-0000-0000-000000004000"
52+
ApplicationVersion = "4.38.23171"
53+
DefaultPlatformId = "00000000-0000-0000-0000-000000000373"
5254
DisplayProfile = "Android"
53-
UserAgent = "Mozilla/5.0 (Linux; Android 6.0; Google Nexus 7 2013 Build/MRA58K; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/74.0.3729.186 Safari/537.36 KoboApp/8.40.2.29861 KoboPlatform Id/00000000-0000-0000-0000-000000004000 KoboAffiliate/Kobo KoboBuildFlavor/global"
55+
DeviceModel = "Kobo Aura ONE"
56+
DeviceOs = "3.0.35+"
57+
DeviceOsVersion = "NA"
58+
# Use the user agent of the Kobo e-readers
59+
UserAgent = "Mozilla/5.0 (Linux; U; Android 2.0; en-us;) AppleWebKit/538.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/538.1 (Kobo Touch 0373/4.38.23171)"
5460

5561
def __init__(self, user: User):
5662
self.InitializationSettings = {}
@@ -67,6 +73,50 @@ def __GetHeaderWithAccessToken(self) -> dict:
6773
headers = {"Authorization": authorization}
6874
return headers
6975

76+
def __WaitTillActivation(self, activationCheckUrl) -> Tuple[str, str]:
77+
while True:
78+
print("Waiting for you to finish the activation...")
79+
time.sleep(5)
80+
81+
response = self.Session.get(activationCheckUrl)
82+
response.raise_for_status()
83+
jsonResponse = response.json()
84+
if jsonResponse["Status"] == "Complete":
85+
return jsonResponse["UserEmail"], jsonResponse["UserId"], jsonResponse["UserKey"]
86+
87+
def __ActivateOnWeb(self) -> Tuple[str, str]:
88+
print( "Initiating web-based activation" )
89+
90+
params = {
91+
"pwspid": Kobo.DefaultPlatformId,
92+
"wsa": Kobo.Affiliate,
93+
"pwsdid": self.user.DeviceId,
94+
"pwsav": Kobo.ApplicationVersion,
95+
"pwsdm": Kobo.DefaultPlatformId, # In the Android app this is the device model but Nickel sends the platform ID...
96+
"pwspos": Kobo.DeviceOs,
97+
"pwspov": Kobo.DeviceOsVersion,
98+
}
99+
100+
response = self.Session.get("https://auth.kobobooks.com/ActivateOnWeb", params=params)
101+
response.raise_for_status()
102+
htmlResponse = response.text
103+
104+
match = re.search('data-poll-endpoint="([^"]+)"', htmlResponse)
105+
if match is None:
106+
raise KoboException(
107+
"Can't find the activation poll endpoint in the response. The page format might have changed."
108+
)
109+
activationCheckUrl = "https://auth.kobobooks.com" + html.unescape(match.group(1))
110+
111+
match = re.search(r"""qrcodegenerator/generate.+?%26code%3D(\d+)'""", htmlResponse)
112+
if match is None:
113+
raise KoboException(
114+
"Can't find the activation code in the response. The page format might have changed."
115+
)
116+
activationCode = match.group(1)
117+
118+
return activationCheckUrl, activationCode
119+
70120
def __RefreshAuthentication(self) -> None:
71121
headers = self.__GetHeaderWithAccessToken()
72122

@@ -131,43 +181,6 @@ def ReauthenticationHook(r, *args, **kwargs):
131181

132182
return {"response": ReauthenticationHook}
133183

134-
def __GetExtraLoginParameters(self) -> Tuple[str, str, str]:
135-
signInUrl = self.InitializationSettings["sign_in_page"]
136-
137-
params = {
138-
"wsa": Kobo.Affiliate,
139-
"pwsav": Kobo.ApplicationVersion,
140-
"pwspid": Kobo.DefaultPlatformId,
141-
"pwsdid": self.user.DeviceId,
142-
}
143-
144-
response = self.Session.get(signInUrl, params=params)
145-
response.raise_for_status()
146-
htmlResponse = response.text
147-
148-
# The link can be found in the response ('<a class="kobo-link partner-option kobo"') but this will do for now.
149-
parsed = urllib.parse.urlparse(signInUrl)
150-
koboSignInUrl = parsed._replace(query=None, path="/ww/en/signin/signin").geturl()
151-
152-
match = re.search(r"""\?workflowId=([^"]{36})""", htmlResponse)
153-
if match is None:
154-
raise KoboException(
155-
"Can't find the workflow ID in the login form. The page format might have changed."
156-
)
157-
workflowId = html.unescape(match.group(1))
158-
159-
match = re.search(
160-
r"""<input name="__RequestVerificationToken" type="hidden" value="([^"]+)" />""",
161-
htmlResponse,
162-
)
163-
if match is None:
164-
raise KoboException(
165-
"Can't find the request verification token in the login form. The page format might have changed."
166-
)
167-
requestVerificationToken = html.unescape(match.group(1))
168-
169-
return koboSignInUrl, workflowId, requestVerificationToken
170-
171184
def __GetMyBookListPage(self, syncToken: str) -> Tuple[list, str]:
172185
url = self.InitializationSettings["library_sync"]
173186
headers = self.__GetHeaderWithAccessToken()
@@ -286,6 +299,11 @@ def __DownloadAudiobook(self, url, outputPath: str) -> None:
286299
for chunk in response.iter_content(chunk_size=1024 * 256):
287300
f.write(chunk)
288301

302+
@staticmethod
303+
def __GenerateRandomHexDigitString( length: int ) -> str:
304+
id = "".join( secrets.choice( string.hexdigits ) for _ in range( length ) )
305+
return id.lower()
306+
289307
# PUBLIC METHODS:
290308
@staticmethod
291309
def GetProductId(bookMetadata: dict) -> str:
@@ -297,7 +315,8 @@ def GetProductId(bookMetadata: dict) -> str:
297315
# user key can't be used for anything.
298316
def AuthenticateDevice(self, userKey: str = "") -> None:
299317
if len(self.user.DeviceId) == 0:
300-
self.user.DeviceId = str(uuid.uuid4())
318+
self.user.DeviceId = Kobo.__GenerateRandomHexDigitString(64)
319+
self.user.SerialNumber = Kobo.__GenerateRandomHexDigitString(32)
301320
self.user.AccessToken = ""
302321
self.user.RefreshToken = ""
303322

@@ -307,6 +326,7 @@ def AuthenticateDevice(self, userKey: str = "") -> None:
307326
"ClientKey": base64.b64encode(Kobo.DefaultPlatformId.encode()).decode(),
308327
"DeviceId": self.user.DeviceId,
309328
"PlatformId": Kobo.DefaultPlatformId,
329+
"SerialNumber": self.user.SerialNumber,
310330
}
311331

312332
if len(userKey) > 0:
@@ -447,53 +467,23 @@ def LoadInitializationSettings(self) -> None:
447467
print(response.reason, response.text)
448468
raise err
449469

450-
def Login(self, email: str, password: str, captcha: str) -> None:
451-
(
452-
signInUrl,
453-
workflowId,
454-
requestVerificationToken,
455-
) = self.__GetExtraLoginParameters()
456-
457-
postData = {
458-
"LogInModel.WorkflowId": workflowId,
459-
"LogInModel.Provider": Kobo.Affiliate,
460-
"ReturnUrl": "",
461-
"__RequestVerificationToken": requestVerificationToken,
462-
"LogInModel.UserName": email,
463-
"LogInModel.Password": password,
464-
"g-recaptcha-response": captcha,
465-
"h-captcha-response": captcha,
466-
}
467-
468-
response = self.Session.post(signInUrl, data=postData)
469-
debug_data("Login", response.text)
470-
response.raise_for_status()
471-
htmlResponse = response.text
472-
473-
match = re.search(r"'(kobo://UserAuthenticated\?[^']+)';", htmlResponse)
474-
if match is None:
475-
soup = BeautifulSoup(htmlResponse, 'html.parser')
476-
errors = soup.find(class_='validation-summary-errors') or soup.find(
477-
class_='field-validation-error'
478-
)
479-
if errors:
480-
raise KoboException('Login Failure! ' + errors.text)
481-
else:
482-
with open('loginpage_error.html', 'w') as loginpagefile:
483-
loginpagefile.write(htmlResponse)
484-
raise KoboException(
485-
"Authenticated user URL can't be found. The page format might have changed!\n\n"
486-
"The bad page has been written to file 'loginpage_error.html'. \n"
487-
"You should open an issue on GitHub and attach this file for help: https://github.com/subdavis/kobo-book-downloader/issues\n"
488-
"Please be sure to remove any personally identifying information from the file."
489-
)
490-
491-
url = match.group(1)
492-
parsed = urllib.parse.urlparse(url)
493-
parsedQueries = urllib.parse.parse_qs(parsed.query)
494-
self.user.UserId = parsedQueries["userId"][
495-
0
496-
] # We don't call self.Settings.Save here, AuthenticateDevice will do that if it succeeds.
497-
userKey = parsedQueries["userKey"][0]
498-
470+
def Login( self ) -> None:
471+
activationCheckUrl, activationCode = self.__ActivateOnWeb()
472+
473+
print("")
474+
print("kobo-book-downloader uses the same web-based activation method to log in as the")
475+
print("Kobo e-readers. You will have to open the link below in your browser and enter")
476+
print("the code. You might need to login if kobo.com asks you to.")
477+
print("")
478+
print(f"Open https://www.kobo.com/activate and enter {activationCode}.")
479+
print("")
480+
print("kobo-book-downloader will wait now and periodically check for the activation to complete.")
481+
print("")
482+
483+
userEmail, userId, userKey = self.__WaitTillActivation( activationCheckUrl )
484+
print("")
485+
486+
# We don't call Settings.Save here, AuthenticateDevice will do that if it succeeds.
487+
self.user.Email = userEmail
488+
self.user.UserId = userId
499489
self.AuthenticateDevice(userKey)

kobodl/settings.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
@dataclass_json
99
@dataclasses.dataclass
1010
class User:
11-
Email: str
11+
Email: str = ""
1212
DeviceId: str = ""
13+
SerialNumber: str = ""
1314
AccessToken: str = ""
1415
RefreshToken: str = ""
1516
UserId: str = ""

0 commit comments

Comments
 (0)