1
1
import { encodeBase64 } from 'jsr:@std/encoding/base64' ;
2
- import { DOMParser } from 'jsr:@b-fuze/deno-dom' ;
3
2
4
3
// Rewritten from Python (https://github.com/subdavis/kobo-book-downloader/blob/main/kobodl/kobo.py) by Johan.
5
4
// Huge props to the great reverse engineering by them.
6
5
6
+ const TOKEN_PATH = './.kobo' ;
7
+
8
+ // deno run --allow-net --allow-read --allow-write ./script/fetch-kobo-wishlist.ts
9
+ const main = async ( ) => {
10
+ try {
11
+ const existingToken = await safeRead ( TOKEN_PATH ) ;
12
+
13
+ if ( existingToken ) console . log ( '> Found existing access token' ) ;
14
+
15
+ const accessToken = existingToken || await acquireAccessToken ( ) ;
16
+
17
+ if ( ! existingToken ) await Deno . writeTextFile ( TOKEN_PATH , accessToken ) ;
18
+
19
+ const settings = await loadInitSettings ( accessToken ) ;
20
+ const wishlist = await fetchWishlist ( accessToken , settings . user_wishlist ) ;
21
+
22
+ console . log ( JSON . stringify ( wishlist , null , 2 ) ) ;
23
+ } catch ( error ) {
24
+ console . error ( error ) ;
25
+ }
26
+ } ;
27
+
7
28
const Kobo = {
8
29
Affiliate : 'Kobo' ,
9
- ApplicationVersion : '10.1.2.39807 ' ,
30
+ ApplicationVersion : '4.38.23171 ' ,
10
31
CarrierName : '310270' ,
11
- DefaultPlatformId : '00000000-0000-0000-0000-000000004000' ,
12
- DeviceModel : 'Pixel' ,
13
- DeviceOsVersion : '33' ,
32
+ DefaultPlatformId : '00000000-0000-0000-0000-000000000373' ,
33
+ DeviceModel : 'Kobo Aura ONE' ,
34
+ DeviceOs : '3.0.35+' ,
35
+ DeviceOsVersion : 'NA' ,
14
36
DisplayProfile : 'Android' ,
15
37
UserAgent :
16
- 'Mozilla/5.0 (Linux; Android 13; Pixel Build/TQ2B.230505.005.A1; wv ) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/101.0.4951.61 Safari/537.36 KoboApp/10.1.2.39807 KoboPlatform Id/00000000-0000-0000-0000-000000004000 KoboAffiliate/Kobo KoboBuildFlavor/global ' ,
38
+ '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) ' ,
17
39
} ;
18
40
19
- const authHeaders = ( accessToken : string ) => ( {
20
- Authorization : `Bearer ${ accessToken } ` ,
21
- } ) ;
41
+ // 1. authenticateDevice() with null creds
42
+ // 2. login() does "activation"
43
+ // 3. authenticateDevice() again with user from 1) and key from 2)
44
+ const acquireAccessToken = async ( ) => {
45
+ console . log ( '> Acquiring new access token' ) ;
46
+ const { user } = await authenticateDevice ( null , null ) ;
47
+ const userKey = await login ( ) ;
22
48
23
- const defaultHeaders = ( json : boolean ) => {
24
- return {
25
- 'User-Agent' : Kobo . UserAgent ,
26
- 'X-Requested-With' : 'com.kobobooks.android' ,
27
- 'x-kobo-affiliatename' : Kobo . Affiliate ,
28
- 'x-kobo-appversion' : Kobo . ApplicationVersion ,
29
- 'x-kobo-carriername' : Kobo . CarrierName ,
30
- 'x-kobo-devicemodel' : Kobo . DeviceModel ,
31
- 'x-kobo-deviceos' : Kobo . DisplayProfile ,
32
- 'x-kobo-deviceosversion' : Kobo . DeviceOsVersion ,
33
- 'x-kobo-platformid' : Kobo . DefaultPlatformId ,
34
- ...( json ? { 'Content-Type' : 'application/json' } : { 'Content-Type' : 'application/x-www-form-urlencoded' } ) ,
35
- } ;
49
+ const { accessToken } = await authenticateDevice ( user , userKey ) ;
50
+
51
+ return accessToken ;
36
52
} ;
37
53
38
- const loadInitSettings = async ( accessToken : string ) : Promise < Settings > => {
39
- const res = await fetch ( 'https://storeapi.kobo.com/v1/initialization' , {
40
- headers : {
41
- ...defaultHeaders ( true ) ,
42
- ...authHeaders ( accessToken ) ,
43
- } ,
44
- } ) ;
54
+ const waitForActivation = async ( activationUrl : string ) => {
55
+ while ( true ) {
56
+ printOverwrite ( 'Waiting for activation…' ) ;
45
57
46
- if ( ! res . ok ) throw new Error ( `loadInitSettings: Bad status ${ res . status } ` ) ;
58
+ const res = await fetch ( activationUrl , {
59
+ headers : {
60
+ ...defaultHeaders ( true ) ,
61
+ } ,
62
+ } ) ;
47
63
48
- const json = await res . json ( ) ;
64
+ if ( ! res . ok ) throw new Error ( `waitForActivation: Bad status ${ res . status } ` ) ;
49
65
50
- return json . Resources ;
66
+ const json = await res . json ( ) ;
67
+
68
+ if ( json [ 'Status' ] == 'Complete' ) {
69
+ return {
70
+ userEmail : json [ 'UserEmail' ] as string ,
71
+ userId : json [ 'UserId' ] as string ,
72
+ userKey : json [ 'UserKey' ] as string ,
73
+ } ;
74
+ }
75
+
76
+ await sleep ( 1_000 ) ;
77
+ }
51
78
} ;
52
79
53
- const loginParams = async ( signInUrl : string , deviceId : string ) => {
80
+ const activateOnWeb = async ( ) => {
81
+ console . log ( 'Initiating web-based activation' ) ;
82
+
54
83
const params = {
84
+ 'pwsdid' : crypto . randomUUID ( ) ,
85
+ 'pwspid' : Kobo . DefaultPlatformId ,
55
86
'wsa' : Kobo . Affiliate ,
56
87
'pwsav' : Kobo . ApplicationVersion ,
57
- 'pwspid' : Kobo . DefaultPlatformId ,
58
- 'pwsdid' : deviceId ,
59
- 'wscfv' : '1.5' ,
60
- 'wscf' : 'kepub' ,
61
- 'wsmc' : Kobo . CarrierName ,
88
+ 'pwsdm' : Kobo . DefaultPlatformId ,
89
+ 'pwspos' : Kobo . DeviceOs ,
62
90
'pwspov' : Kobo . DeviceOsVersion ,
63
- 'pwspt' : 'Mobile' ,
64
- 'pwsdm' : Kobo . DeviceModel ,
65
91
} ;
66
92
67
- const requestUrl = new URL ( signInUrl ) ;
93
+ const requestUrl = new URL ( 'https://auth.kobobooks.com/ActivateOnWeb' ) ;
68
94
for ( const [ k , v ] of Object . entries ( params ) ) {
69
95
requestUrl . searchParams . append ( k , v ) ;
70
96
}
71
97
72
98
const res = await fetch ( requestUrl , {
73
99
headers : {
74
- ...defaultHeaders ( true ) ,
100
+ ...defaultHeaders ( false ) ,
75
101
} ,
76
102
} ) ;
77
103
78
- if ( ! res . ok ) throw new Error ( `loginParams : Bad status ${ res . status } ` ) ;
104
+ if ( ! res . ok ) throw new Error ( `activateOnWeb : Bad status ${ res . status } ` ) ;
79
105
80
106
const html = await res . text ( ) ;
81
- const koboSigninUrl = URL . parse ( signInUrl ) ;
82
- if ( ! koboSigninUrl ) throw new Error ( `Couldn't parse signin URL: ${ signInUrl } ` ) ;
83
- koboSigninUrl . search = '' ;
84
- koboSigninUrl . pathname = '/za/en/signin/signin' ;
85
107
86
- let match = html . match ( / \? w o r k f l o w I d = ( [ ^ " ] { 36 } ) / ) ;
108
+ let match = html . match ( / d a t a - p o l l - e n d p o i n t = " ( [ ^ " ] + ) " / ) ;
87
109
88
- if ( ! match ) throw new Error ( `Can't find the workflow ID in the login form ` ) ;
110
+ if ( ! match ) throw new Error ( `Can't find poll endpoint in HTML ` ) ;
89
111
90
- const workflowId = match [ 1 ] ;
112
+ const activationUrl = 'https://auth.kobobooks.com' + match [ 1 ] ;
91
113
92
- match = html . match ( / < i n p u t n a m e = " _ _ R e q u e s t V e r i f i c a t i o n T o k e n " t y p e = " h i d d e n " v a l u e = " ( [ ^ " ] + ) " \/ > / ) ;
114
+ match = html . match ( / q r c o d e g e n e r a t o r \/ g e n e r a t e . + ? % 2 6 c o d e % 3 D ( \d + ) / ) ;
93
115
94
- if ( ! match ) throw new Error ( `Can't find the request verification token in the login form ` ) ;
116
+ if ( ! match ) throw new Error ( `Can't find activation code in response ` ) ;
95
117
96
- const requestVerificationToken = match [ 1 ] ;
118
+ const activationCode = match [ 1 ] ;
97
119
98
- return { koboSigninUrl , workflowId , requestVerificationToken } ;
120
+ return { activationUrl , activationCode } ;
99
121
} ;
100
122
101
- const authenticateDevice = async ( deviceId : string , userKey : string = '' ) => {
123
+ interface User {
124
+ deviceId : string ;
125
+ serialNumber : string ;
126
+ key : string | null ;
127
+ }
128
+
129
+ const authenticateDevice = async ( user : User | null , userKey : string | null ) => {
130
+ if ( ! user ) {
131
+ user = {
132
+ deviceId : randomHexString ( 64 ) ,
133
+ serialNumber : randomHexString ( 32 ) ,
134
+ key : null ,
135
+ } ;
136
+ }
137
+
102
138
const postData : any = {
103
139
'AffiliateName' : Kobo . Affiliate ,
104
140
'AppVersion' : Kobo . ApplicationVersion ,
105
141
'ClientKey' : encodeBase64 ( Kobo . DefaultPlatformId ) ,
106
- 'DeviceId' : deviceId ,
142
+ 'DeviceId' : user ! . deviceId ,
143
+ 'SerialNumber' : user ! . serialNumber ,
107
144
'PlatformId' : Kobo . DefaultPlatformId ,
108
145
} ;
109
146
@@ -127,76 +164,43 @@ const authenticateDevice = async (deviceId: string, userKey: string = '') => {
127
164
throw new Error ( `Device authentication returned with an unsupported token type: ${ json . TokenType } ` ) ;
128
165
}
129
166
130
- return {
131
- accessToken : json . AccessToken ,
132
- refreshToken : json . RefreshToken ,
133
- ...( userKey ? { userKey : json . UserKey } : { } ) ,
134
- } ;
135
- } ;
167
+ const accessToken : string = json . AccessToken ;
136
168
137
- interface Creds {
138
- email : string ;
139
- password : string ;
140
- captcha : string ;
141
- }
169
+ if ( userKey ) {
170
+ user . key = userKey ;
171
+ }
172
+
173
+ return { user, accessToken } ;
174
+ } ;
142
175
143
176
type Settings = {
144
- sign_in_page : string ;
145
177
user_wishlist : string ;
146
178
[ k : string ] : unknown ;
147
179
} ;
148
180
149
- const login = async ( creds : Creds , settings : Settings , deviceId : string ) => {
150
- const {
151
- koboSigninUrl,
152
- workflowId,
153
- requestVerificationToken,
154
- } = await loginParams ( settings . sign_in_page , deviceId ) ;
155
-
156
- const postData = {
157
- 'LogInModel.WorkflowId' : workflowId ,
158
- 'LogInModel.Provider' : Kobo . Affiliate ,
159
- 'ReturnUrl' : '' ,
160
- '__RequestVerificationToken' : requestVerificationToken ,
161
- 'LogInModel.UserName' : creds . email ,
162
- 'LogInModel.Password' : creds . password ,
163
- 'g-recaptcha-response' : creds . captcha ,
164
- 'h-captcha-response' : creds . captcha ,
165
- } ;
166
-
167
- const res = await fetch ( koboSigninUrl , {
168
- method : 'POST' ,
181
+ const loadInitSettings = async ( accessToken : string ) : Promise < Settings > => {
182
+ const res = await fetch ( 'https://storeapi.kobo.com/v1/initialization' , {
169
183
headers : {
170
- ...defaultHeaders ( false ) ,
184
+ ...defaultHeaders ( true ) ,
185
+ ...authHeaders ( accessToken ) ,
171
186
} ,
172
- body : new URLSearchParams ( postData ) ,
173
187
} ) ;
174
188
175
- if ( ! res . ok ) throw new Error ( `login: Bad status ${ res . status } ` ) ;
176
-
177
- const html = await res . text ( ) ;
189
+ if ( ! res . ok ) throw new Error ( `loadInitSettings: Bad status ${ res . status } ` ) ;
178
190
179
- const match = html . match ( / ' ( k o b o : \/ \/ U s e r A u t h e n t i c a t e d \? [ ^ ' ] + ) ' ; / ) ;
191
+ const json = await res . json ( ) ;
180
192
181
- if ( ! match ) {
182
- const doc = new DOMParser ( ) . parseFromString (
183
- html ,
184
- 'text/html' ,
185
- ) ;
193
+ return json . Resources ;
194
+ } ;
186
195
187
- const field = doc . querySelector ( '.validation-summary-errors' ) || doc . querySelector ( '.field-validation-error' ) ;
188
- throw new Error ( `Error message from login page: ${ field ?. textContent } ` ) ;
189
- }
196
+ const login = async ( ) => {
197
+ const { activationUrl, activationCode } = await activateOnWeb ( ) ;
190
198
191
- const url = new URL ( match [ 1 ] ) ;
192
- const userId = url . searchParams . get ( 'userId' ) ;
193
- const userKey = url . searchParams . get ( 'userKey' ) ;
199
+ console . log ( `Open https://www.kobo.com/activate and enter: ${ activationCode } ` ) ;
194
200
195
- if ( ! userId || ! userKey ) {
196
- throw new Error ( `login: No userId or userKey in search params: ${ url . searchParams . toString ( ) } ` ) ;
197
- }
201
+ const { userKey } = await waitForActivation ( activationUrl ) ;
198
202
199
- return { userId , userKey } ;
203
+ return userKey ;
200
204
} ;
201
205
202
206
const fetchWishlist = async ( accessToken : string , wishlistUrl : string ) => {
@@ -229,33 +233,54 @@ const fetchWishlist = async (accessToken: string, wishlistUrl: string) => {
229
233
return items ;
230
234
} ;
231
235
232
- // 1. authenticateDevice()
233
- // 2. loadInitSettings()
234
- // 3. login()
235
- // deno run --allow-net ./script/fetch-kobo-wishlist.ts $KOBO_LOGIN $KOBO_PASS $KOBO_CAPTCHA
236
- const main = async ( ) => {
237
- if ( Deno . args . length < 3 ) {
238
- console . error ( 'Usage: deno run --allow-net script.ts <email> <password> <captcha>' ) ;
239
- return ;
240
- }
236
+ // UTILS
237
+ // ==================================================
241
238
242
- const [ email , password , captcha ] = Deno . args ;
243
- try {
244
- const deviceId = crypto . randomUUID ( ) ;
239
+ const authHeaders = ( accessToken : string ) => ( {
240
+ Authorization : `Bearer ${ accessToken } ` ,
241
+ } ) ;
245
242
246
- let { accessToken } = await authenticateDevice ( deviceId ) ;
247
- const settings = await loadInitSettings ( accessToken ) ;
248
- const { userKey } = await login ( { email, password, captcha } , settings , deviceId ) ;
243
+ const defaultHeaders = ( json : boolean ) => {
244
+ return {
245
+ 'User-Agent' : Kobo . UserAgent ,
246
+ 'X-Requested-With' : 'com.kobobooks.android' ,
247
+ 'x-kobo-affiliatename' : Kobo . Affiliate ,
248
+ 'x-kobo-appversion' : Kobo . ApplicationVersion ,
249
+ 'x-kobo-carriername' : Kobo . CarrierName ,
250
+ 'x-kobo-devicemodel' : Kobo . DeviceModel ,
251
+ 'x-kobo-deviceos' : Kobo . DisplayProfile ,
252
+ 'x-kobo-deviceosversion' : Kobo . DeviceOsVersion ,
253
+ 'x-kobo-platformid' : Kobo . DefaultPlatformId ,
254
+ ...( json ? { 'Content-Type' : 'application/json' } : { 'Content-Type' : 'application/x-www-form-urlencoded' } ) ,
255
+ } ;
256
+ } ;
249
257
250
- accessToken = ( await authenticateDevice ( deviceId , userKey ) ) . accessToken ;
258
+ const sleep = ( ms : number ) => new Promise ( ( rs ) => setTimeout ( rs , ms ) ) ;
251
259
252
- const wishlist = await fetchWishlist ( accessToken , settings . user_wishlist ) ;
253
- console . log ( JSON . stringify ( wishlist , null , 2 ) ) ;
254
- } catch ( error ) {
255
- console . error ( error ) ;
260
+ const printOverwrite = async ( str : string ) => {
261
+ const enc = new TextEncoder ( ) . encode ( str + '\r' ) ;
262
+ await Deno . stdout . write ( enc ) ;
263
+ } ;
264
+
265
+ const safeRead = async ( path : string ) => {
266
+ try {
267
+ return await Deno . readTextFile ( path ) ;
268
+ } catch ( ex ) {
269
+ if ( ! ( ex instanceof Deno . errors . NotFound ) ) {
270
+ throw ex ;
271
+ }
272
+
273
+ return null ;
256
274
}
257
275
} ;
258
276
277
+ const randomHexString = ( len : number ) => {
278
+ const bytes = new Uint8Array ( len ) ;
279
+ crypto . getRandomValues ( bytes ) ;
280
+ return Array . from ( bytes , ( byte ) => byte . toString ( 16 ) . padStart ( 2 , '0' ) ) . join ( '' ) . slice ( 0 , len ) ;
281
+ } ;
282
+
283
+ // Run!
259
284
if ( import . meta. main ) {
260
285
main ( ) ;
261
286
}
0 commit comments