@@ -10,16 +10,10 @@ const TOKEN_PATH = './.kobo';
10
10
// deno run --allow-net --allow-read --allow-write ./script/fetch-kobo-wishlist.ts
11
11
const main = async ( ) => {
12
12
try {
13
- const existingToken = await safeRead ( TOKEN_PATH ) ;
13
+ const auth = await acquireAccessToken ( ) ;
14
14
15
- if ( existingToken ) console . log ( '> Found existing access token' ) ;
16
-
17
- const accessToken = existingToken || await acquireAccessToken ( ) ;
18
-
19
- if ( ! existingToken ) await Deno . writeTextFile ( TOKEN_PATH , accessToken ) ;
20
-
21
- const settings = await loadInitSettings ( accessToken ) ;
22
- const wishlist = await fetchWishlist ( accessToken , settings . user_wishlist ) ;
15
+ const settings = await loadInitSettings ( auth ) ;
16
+ const wishlist = await fetchWishlist ( auth , settings . user_wishlist ) ;
23
17
24
18
const books = wishlist . map < WishListBook > ( ( w ) => ( {
25
19
title : w . ProductMetadata . Book . Title ,
@@ -47,17 +41,31 @@ const Kobo = {
47
41
'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)' ,
48
42
} ;
49
43
44
+ interface Auth {
45
+ accessToken : string ;
46
+ refreshToken : string ;
47
+ }
48
+
50
49
// 1. authenticateDevice() with null creds
51
50
// 2. login() does "activation"
52
51
// 3. authenticateDevice() again with user from 1) and key from 2)
53
- const acquireAccessToken = async ( ) => {
52
+ const acquireAccessToken = async ( ) : Promise < Auth > => {
53
+ const existingTokens = await readTokens ( TOKEN_PATH ) ;
54
+
55
+ if ( existingTokens ) {
56
+ console . log ( '> Found existing tokens' ) ;
57
+ return existingTokens ;
58
+ }
59
+
54
60
console . log ( '> Acquiring new access token' ) ;
55
61
const { user } = await authenticateDevice ( null , null ) ;
56
62
const userKey = await login ( ) ;
57
63
58
- const { accessToken } = await authenticateDevice ( user , userKey ) ;
64
+ const { auth } = await authenticateDevice ( user , userKey ) ;
65
+
66
+ if ( ! existingTokens ) await writeTokens ( auth ) ;
59
67
60
- return accessToken ;
68
+ return auth ;
61
69
} ;
62
70
63
71
const waitForActivation = async ( activationUrl : string ) => {
@@ -174,24 +182,25 @@ const authenticateDevice = async (user: User | null, userKey: string | null) =>
174
182
}
175
183
176
184
const accessToken : string = json . AccessToken ;
185
+ const refreshToken : string = json . RefreshToken ;
177
186
178
187
if ( userKey ) {
179
188
user . key = userKey ;
180
189
}
181
190
182
- return { user, accessToken } ;
191
+ return { user, auth : { accessToken, refreshToken } } ;
183
192
} ;
184
193
185
194
type Settings = {
186
195
user_wishlist : string ;
187
196
[ k : string ] : unknown ;
188
197
} ;
189
198
190
- const loadInitSettings = async ( accessToken : string ) : Promise < Settings > => {
199
+ const loadInitSettings = async ( auth : Auth ) : Promise < Settings > => {
191
200
const res = await fetch ( 'https://storeapi.kobo.com/v1/initialization' , {
192
201
headers : {
193
202
...defaultHeaders ( true ) ,
194
- ...authHeaders ( accessToken ) ,
203
+ ...authHeaders ( auth . accessToken ) ,
195
204
} ,
196
205
} ) ;
197
206
@@ -212,7 +221,60 @@ const login = async () => {
212
221
return userKey ;
213
222
} ;
214
223
215
- const fetchWishlist = async ( accessToken : string , wishlistUrl : string ) => {
224
+ const refreshAuth = async ( auth : Auth ) : Promise < Auth > => {
225
+ const postData = {
226
+ 'AppVersion' : Kobo . ApplicationVersion ,
227
+ 'ClientKey' : encodeBase64 ( Kobo . DefaultPlatformId ) ,
228
+ 'PlatformId' : Kobo . DefaultPlatformId ,
229
+ 'RefreshToken' : auth . refreshToken ,
230
+ } ;
231
+
232
+ const res = await fetch ( 'https://storeapi.kobo.com/v1/auth/refresh' , {
233
+ method : 'POST' ,
234
+ body : JSON . stringify ( postData ) ,
235
+ headers : {
236
+ ...authHeaders ( auth . accessToken ) ,
237
+ ...defaultHeaders ( true ) ,
238
+ } ,
239
+ } ) ;
240
+
241
+ if ( ! res . ok ) throw new Error ( `authenticateDevice: Bad status ${ res . status } ` ) ;
242
+
243
+ const json = await res . json ( ) ;
244
+
245
+ if ( json . TokenType != 'Bearer' ) {
246
+ throw new Error ( `refreshAuth: returned with an unsupported token type: ${ json . TokenType } ` ) ;
247
+ }
248
+
249
+ return { accessToken : json . AccessToken as string , refreshToken : json . RefreshToken as string } ;
250
+ } ;
251
+
252
+ const fetchWithRefresh = async ( auth : Auth , input : RequestInfo | URL , init ?: RequestInit ) => {
253
+ const req = new Request ( input , init ) ;
254
+ const res = await fetch ( req ) ;
255
+
256
+ if ( res . status != 401 ) return res ;
257
+
258
+ console . log ( `> Got status 401, refreshing auth` ) ;
259
+
260
+ // Need to refresh auth
261
+ const newTokens = await refreshAuth ( auth ) ;
262
+ const retriedReq = req . clone ( ) ;
263
+
264
+ const { Authorization : bearer } = authHeaders ( newTokens . accessToken ) ;
265
+ retriedReq . headers . set ( 'Authorization' , bearer ) ;
266
+
267
+ const retried = await fetch ( retriedReq ) ;
268
+
269
+ if ( retried . ok ) {
270
+ console . log ( `> Writing new auth tokens after refresh` ) ;
271
+ await writeTokens ( newTokens ) ;
272
+ }
273
+
274
+ return retried ;
275
+ } ;
276
+
277
+ const fetchWishlist = async ( auth : Auth , wishlistUrl : string ) => {
216
278
const items = [ ] ;
217
279
let page = 0 ;
218
280
@@ -221,10 +283,10 @@ const fetchWishlist = async (accessToken: string, wishlistUrl: string) => {
221
283
url . searchParams . append ( 'PageIndex' , String ( page ) ) ;
222
284
url . searchParams . append ( 'PageSize' , '100' ) ;
223
285
224
- const res = await fetch ( url , {
286
+ const res = await fetchWithRefresh ( auth , url , {
225
287
headers : {
226
288
...defaultHeaders ( true ) ,
227
- ...authHeaders ( accessToken ) ,
289
+ ...authHeaders ( auth . accessToken ) ,
228
290
} ,
229
291
} ) ;
230
292
@@ -271,9 +333,13 @@ const printOverwrite = async (str: string) => {
271
333
await Deno . stdout . write ( enc ) ;
272
334
} ;
273
335
274
- const safeRead = async ( path : string ) => {
336
+ const readTokens = async ( path : string ) : Promise < Auth | null > => {
275
337
try {
276
- return await Deno . readTextFile ( path ) ;
338
+ const str = await Deno . readTextFile ( path ) ;
339
+ if ( ! str . trim ( ) . length ) return null ;
340
+ const tokens = str . split ( '\n' ) ;
341
+ if ( tokens . length < 2 ) return null ;
342
+ return { accessToken : tokens [ 0 ] , refreshToken : tokens [ 1 ] } ;
277
343
} catch ( ex ) {
278
344
if ( ! ( ex instanceof Deno . errors . NotFound ) ) {
279
345
throw ex ;
@@ -283,6 +349,10 @@ const safeRead = async (path: string) => {
283
349
}
284
350
} ;
285
351
352
+ const writeTokens = async ( tokens : Auth ) => {
353
+ await Deno . writeTextFile ( TOKEN_PATH , `${ tokens . accessToken } \n${ tokens . refreshToken } ` ) ;
354
+ } ;
355
+
286
356
const randomHexString = ( len : number ) => {
287
357
const bytes = new Uint8Array ( len ) ;
288
358
crypto . getRandomValues ( bytes ) ;
0 commit comments