Skip to content

Commit 78bedde

Browse files
committed
Get Kobo wishlist script working
1 parent 0a51d89 commit 78bedde

File tree

1 file changed

+154
-129
lines changed

1 file changed

+154
-129
lines changed

script/fetch-kobo-wishlist.ts

+154-129
Original file line numberDiff line numberDiff line change
@@ -1,109 +1,146 @@
11
import { encodeBase64 } from 'jsr:@std/encoding/base64';
2-
import { DOMParser } from 'jsr:@b-fuze/deno-dom';
32

43
// Rewritten from Python (https://github.com/subdavis/kobo-book-downloader/blob/main/kobodl/kobo.py) by Johan.
54
// Huge props to the great reverse engineering by them.
65

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+
728
const Kobo = {
829
Affiliate: 'Kobo',
9-
ApplicationVersion: '10.1.2.39807',
30+
ApplicationVersion: '4.38.23171',
1031
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',
1436
DisplayProfile: 'Android',
1537
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)',
1739
};
1840

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();
2248

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;
3652
};
3753

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…');
4557

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+
});
4763

48-
const json = await res.json();
64+
if (!res.ok) throw new Error(`waitForActivation: Bad status ${res.status}`);
4965

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+
}
5178
};
5279

53-
const loginParams = async (signInUrl: string, deviceId: string) => {
80+
const activateOnWeb = async () => {
81+
console.log('Initiating web-based activation');
82+
5483
const params = {
84+
'pwsdid': crypto.randomUUID(),
85+
'pwspid': Kobo.DefaultPlatformId,
5586
'wsa': Kobo.Affiliate,
5687
'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,
6290
'pwspov': Kobo.DeviceOsVersion,
63-
'pwspt': 'Mobile',
64-
'pwsdm': Kobo.DeviceModel,
6591
};
6692

67-
const requestUrl = new URL(signInUrl);
93+
const requestUrl = new URL('https://auth.kobobooks.com/ActivateOnWeb');
6894
for (const [k, v] of Object.entries(params)) {
6995
requestUrl.searchParams.append(k, v);
7096
}
7197

7298
const res = await fetch(requestUrl, {
7399
headers: {
74-
...defaultHeaders(true),
100+
...defaultHeaders(false),
75101
},
76102
});
77103

78-
if (!res.ok) throw new Error(`loginParams: Bad status ${res.status}`);
104+
if (!res.ok) throw new Error(`activateOnWeb: Bad status ${res.status}`);
79105

80106
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';
85107

86-
let match = html.match(/\?workflowId=([^"]{36})/);
108+
let match = html.match(/data-poll-endpoint="([^"]+)"/);
87109

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`);
89111

90-
const workflowId = match[1];
112+
const activationUrl = 'https://auth.kobobooks.com' + match[1];
91113

92-
match = html.match(/<input name="__RequestVerificationToken" type="hidden" value="([^"]+)" \/>/);
114+
match = html.match(/qrcodegenerator\/generate.+?%26code%3D(\d+)/);
93115

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`);
95117

96-
const requestVerificationToken = match[1];
118+
const activationCode = match[1];
97119

98-
return { koboSigninUrl, workflowId, requestVerificationToken };
120+
return { activationUrl, activationCode };
99121
};
100122

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+
102138
const postData: any = {
103139
'AffiliateName': Kobo.Affiliate,
104140
'AppVersion': Kobo.ApplicationVersion,
105141
'ClientKey': encodeBase64(Kobo.DefaultPlatformId),
106-
'DeviceId': deviceId,
142+
'DeviceId': user!.deviceId,
143+
'SerialNumber': user!.serialNumber,
107144
'PlatformId': Kobo.DefaultPlatformId,
108145
};
109146

@@ -127,76 +164,43 @@ const authenticateDevice = async (deviceId: string, userKey: string = '') => {
127164
throw new Error(`Device authentication returned with an unsupported token type: ${json.TokenType}`);
128165
}
129166

130-
return {
131-
accessToken: json.AccessToken,
132-
refreshToken: json.RefreshToken,
133-
...(userKey ? { userKey: json.UserKey } : {}),
134-
};
135-
};
167+
const accessToken: string = json.AccessToken;
136168

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+
};
142175

143176
type Settings = {
144-
sign_in_page: string;
145177
user_wishlist: string;
146178
[k: string]: unknown;
147179
};
148180

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', {
169183
headers: {
170-
...defaultHeaders(false),
184+
...defaultHeaders(true),
185+
...authHeaders(accessToken),
171186
},
172-
body: new URLSearchParams(postData),
173187
});
174188

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}`);
178190

179-
const match = html.match(/'(kobo:\/\/UserAuthenticated\?[^']+)';/);
191+
const json = await res.json();
180192

181-
if (!match) {
182-
const doc = new DOMParser().parseFromString(
183-
html,
184-
'text/html',
185-
);
193+
return json.Resources;
194+
};
186195

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();
190198

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}`);
194200

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);
198202

199-
return { userId, userKey };
203+
return userKey;
200204
};
201205

202206
const fetchWishlist = async (accessToken: string, wishlistUrl: string) => {
@@ -229,33 +233,54 @@ const fetchWishlist = async (accessToken: string, wishlistUrl: string) => {
229233
return items;
230234
};
231235

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+
// ==================================================
241238

242-
const [email, password, captcha] = Deno.args;
243-
try {
244-
const deviceId = crypto.randomUUID();
239+
const authHeaders = (accessToken: string) => ({
240+
Authorization: `Bearer ${accessToken}`,
241+
});
245242

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+
};
249257

250-
accessToken = (await authenticateDevice(deviceId, userKey)).accessToken;
258+
const sleep = (ms: number) => new Promise((rs) => setTimeout(rs, ms));
251259

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;
256274
}
257275
};
258276

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!
259284
if (import.meta.main) {
260285
main();
261286
}

0 commit comments

Comments
 (0)