gablilli commited on
Commit
1b8ec0a
·
verified ·
1 Parent(s): b6af68a

fix: improvements, ui

Browse files
Files changed (1) hide show
  1. providers/sanoma.js +152 -466
providers/sanoma.js CHANGED
@@ -1,509 +1,195 @@
1
- import axios from 'axios';
2
- import { wrapper } from 'axios-cookiejar-support';
3
- import { CookieJar } from 'tough-cookie';
4
- import * as cheerio from 'cheerio';
5
- import { URL } from 'url';
6
-
7
- const PLACE_BOOKS_DATA_URL = 'https://place.sanoma.it/prodotti_digitali/__data.json';
8
- const PLACE_BOOKS_PAGE_URL = 'https://place.sanoma.it/prodotti_digitali';
9
- const DISPLAY_BOOKS_URL = 'https://npmitaly-pro-apidistribucion.sanoma.it/mcs/msproducts/api/products/display-books';
10
- const EBOOK_ORIGIN = 'https://ebook.sanoma.it';
11
- const DEFAULT_HEADERS = {
12
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
13
- 'Accept-Language': 'it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7'
14
- };
15
-
16
- export async function loginSanoma(email, password) {
17
- const jar = new CookieJar();
18
- const client = wrapper(axios.create({
19
- jar,
20
- withCredentials: true,
21
- maxRedirects: 0,
22
- validateStatus: (status) => status >= 200 && status < 400,
23
- headers: {
24
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
25
- 'Accept-Language': 'it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7'
26
- }
27
- }));
28
-
29
- const redirectUri = 'https://place.sanoma.it/';
30
- let authUrl = null;
31
- let clientId = null;
32
-
33
- try {
34
- await client.get('https://place.sanoma.it/login', {
35
- headers: { 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' }
36
- });
37
-
38
- const initParams = new URLSearchParams({
39
- ref: 'https://place.sanoma.it/',
40
- context: '',
41
- text: email
42
- });
43
-
44
- const initRes = await client.post('https://place.sanoma.it/login?/status', initParams.toString(), {
45
- headers: {
46
- 'Content-Type': 'application/x-www-form-urlencoded',
47
- 'Accept': 'application/json',
48
- 'Origin': 'https://place.sanoma.it',
49
- 'Referer': 'https://place.sanoma.it/login',
50
- 'x-sveltekit-action': 'true'
51
- }
52
- });
53
-
54
- let data = initRes.data;
55
- if (typeof data === 'string') {
56
- try { data = JSON.parse(data); } catch (e) {}
57
- }
58
-
59
- if (data && data.type === 'redirect' && data.location) {
60
- authUrl = data.location;
61
- }
62
- } catch (err) {
63
- if (err.response?.data?.type === 'redirect' && err.response?.data?.location) {
64
- authUrl = err.response.data.location;
65
- } else if (err.response?.headers?.location) {
66
- authUrl = err.response.headers.location;
67
- } else {
68
- throw err;
69
- }
70
- }
71
-
72
- if (!authUrl) throw new Error('Failed to get Auth0 redirect URL from /login?/status');
73
- if (!authUrl.startsWith('http')) authUrl = 'https://login.sanoma.it' + (authUrl.startsWith('/') ? '' : '/') + authUrl;
74
-
75
- const parsedInitUrl = new URL(authUrl);
76
- clientId = parsedInitUrl.searchParams.get('client_id');
77
- if (!clientId) throw new Error('Client ID missing from SvelteKit authorization URL');
78
-
79
- let authPageRes = await client.get(authUrl, {
80
- headers: {
81
- 'Referer': 'https://place.sanoma.it/',
82
- 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8'
83
- }
84
- });
85
-
86
- while (authPageRes.status >= 300 && authPageRes.status < 400 && authPageRes.headers.location) {
87
- let nextUrl = authPageRes.headers.location;
88
- if (!nextUrl.startsWith('http')) nextUrl = 'https://login.sanoma.it' + nextUrl;
89
- authUrl = nextUrl;
90
- authPageRes = await client.get(authUrl, {
91
- headers: {
92
- 'Referer': 'https://place.sanoma.it/',
93
- 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8'
94
- }
95
- });
96
- }
97
-
98
- const $ = cheerio.load(authPageRes.data);
99
- const cookies = await jar.getCookies('https://login.sanoma.it');
100
- const csrfCookie = cookies.find(c => c.key === '_csrf');
101
- const csrfToken = $('input[name="_csrf"]').val() || (csrfCookie ? csrfCookie.value : '');
102
-
103
- const parsedUrl = new URL(authUrl);
104
- const state = parsedUrl.searchParams.get('state');
105
- if (!state) throw new Error('State parameter not found in Auth0 URL');
106
-
107
- const loginPayload = {
108
- client_id: clientId,
109
- redirect_uri: redirectUri,
110
- tenant: "sanoma-italy",
111
- response_type: "code",
112
- scope: "openid profile email",
113
- state,
114
- connection: "Sanoma-Italy-Database",
115
- username: email,
116
- password,
117
- popup_options: {},
118
- sso: true,
119
- protocol: "oauth2",
120
- _csrf: csrfToken,
121
- _intstate: "deprecated"
122
- };
123
-
124
- let loginRes;
125
- try {
126
- loginRes = await client.post('https://login.sanoma.it/usernamepassword/login', loginPayload, {
127
- headers: {
128
- 'Content-Type': 'application/json',
129
- 'Origin': 'https://login.sanoma.it',
130
- 'Referer': authUrl,
131
- 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
132
- }
133
- });
134
- } catch (err) {
135
- throw new Error(`Login failed: ${err.response?.status ?? 'Unknown'} ${err.response?.statusText ?? ''}`);
136
- }
137
-
138
- const $login = cheerio.load(loginRes.data);
139
- const wa = $login('input[name="wa"]').val();
140
- const wresult = $login('input[name="wresult"]').val();
141
- const wctx = $login('input[name="wctx"]').val();
142
-
143
- if (!wa || !wresult || !wctx) {
144
- throw new Error('Login failed: callback form not found (wrong credentials?)');
145
- }
146
 
147
- let finalCodeUrl = null;
148
- try {
149
- const callbackRes = await client.post(
150
- 'https://login.sanoma.it/login/callback',
151
- `wa=${encodeURIComponent(wa)}&wresult=${encodeURIComponent(wresult)}&wctx=${encodeURIComponent(wctx)}`,
152
- {
153
- headers: {
154
- 'Content-Type': 'application/x-www-form-urlencoded',
155
- 'Origin': 'https://login.sanoma.it',
156
- 'Referer': 'https://login.sanoma.it/',
157
- 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
158
- }
159
- }
160
- );
161
- if (callbackRes.status >= 300 && callbackRes.status < 400) {
162
- finalCodeUrl = callbackRes.headers.location;
163
- }
164
- } catch(err) {
165
- if (err.response?.status >= 300 && err.response?.status < 400) {
166
- finalCodeUrl = err.response.headers.location;
167
- } else {
168
- throw err;
169
- }
170
  }
 
 
171
 
172
- if (!finalCodeUrl) throw new Error('Final redirect URL not found after callback');
173
-
174
- if (!finalCodeUrl.startsWith('http')) {
175
- finalCodeUrl = finalCodeUrl.startsWith('/authorize')
176
- ? 'https://login.sanoma.it' + finalCodeUrl
177
- : 'https://place.sanoma.it' + (finalCodeUrl.startsWith('/') ? '' : '/') + finalCodeUrl;
178
- }
179
-
180
- let currentUrl = finalCodeUrl;
181
- for (let i = 0; i < 15; i++) {
182
- try {
183
- const res = await client.get(currentUrl, {
184
- headers: {
185
- 'Referer': i === 0 ? 'https://login.sanoma.it/' : currentUrl,
186
- 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
187
- }
188
- });
189
- if (res.status >= 300 && res.status < 400 && res.headers.location) {
190
- let next = res.headers.location;
191
- if (!next.startsWith('http')) next = next.startsWith('/authorize') ? 'https://login.sanoma.it' + next : 'https://place.sanoma.it' + next;
192
- currentUrl = next;
193
- } else {
194
- break;
195
- }
196
- } catch (err) {
197
- if (err.response?.status >= 300 && err.response?.status < 400) {
198
- let next = err.response.headers.location;
199
- if (!next.startsWith('http')) next = next.startsWith('/authorize') ? 'https://login.sanoma.it' + next : 'https://place.sanoma.it' + next;
200
- currentUrl = next;
201
- } else {
202
- break;
203
- }
204
- }
205
- }
206
 
207
- return client;
208
  }
209
 
210
- export async function fetchBooks(client) {
211
- const response = await client.get(PLACE_BOOKS_DATA_URL, {
212
- headers: {
213
- ...DEFAULT_HEADERS,
214
- 'Referer': PLACE_BOOKS_PAGE_URL,
215
- 'Accept': 'application/json',
216
- 'X-Sveltekit-Invalidated': '01'
217
- }
218
- });
219
 
220
- const lines = response.data.split('\n').filter(line => line.trim());
221
- const jsonObjects = lines.map(line => JSON.parse(line));
222
 
223
- let allData = [];
224
-
225
- jsonObjects.forEach(obj => {
226
- if (obj.data && Array.isArray(obj.data)) {
227
- for (let i = 0; i < obj.data.length; i++) {
228
- if (obj.data[i] !== undefined) allData[i] = obj.data[i];
229
- }
230
- }
231
- if (obj.nodes) {
232
- obj.nodes.forEach(node => {
233
- if (node && Array.isArray(node.data)) {
234
- for (let i = 0; i < node.data.length; i++) {
235
- if (node.data[i] !== undefined) allData[i] = node.data[i];
236
- }
237
- }
238
- });
239
- }
240
- });
241
 
242
- jsonObjects.filter(obj => obj.type === 'chunk' && obj.data).forEach(chunk => {
243
- let chunkData = chunk.data;
244
- if (Array.isArray(chunkData[0])) chunkData = chunkData[0];
245
- for (let i = 0; i < chunkData.length; i++) {
246
- if (chunkData[i] !== undefined) allData[i] = chunkData[i];
247
- }
248
- });
249
-
250
- const books = [];
251
- const seenOperas = new Set();
252
- const resolved = new Map();
253
-
254
- function decompressValue(val) {
255
- if (typeof val === 'number') {
256
- if (val < 0 || val >= allData.length || allData[val] === undefined) return val;
257
- if (resolved.has(val)) return resolved.get(val);
258
- const target = allData[val];
259
- if (Array.isArray(target)) {
260
- const newArr = [];
261
- resolved.set(val, newArr);
262
- for (let j = 0; j < target.length; j++) newArr.push(decompressValue(target[j]));
263
- return newArr;
264
- } else if (target && typeof target === 'object') {
265
- const newObj = {};
266
- resolved.set(val, newObj);
267
- for (const key in target) newObj[key] = decompressValue(target[key]);
268
- return newObj;
269
- } else {
270
- resolved.set(val, target);
271
- return target;
272
- }
273
- }
274
- return val;
275
- }
276
 
277
- for (let i = 0; i < allData.length; i++) {
278
- const item = allData[i];
279
-
280
- if (item && typeof item === 'object' && !Array.isArray(item) && 'opera_id' in item && 'display_name' in item) {
281
- const fullyResolved = decompressValue(i);
282
- if (!fullyResolved || !fullyResolved.opera_id || seenOperas.has(fullyResolved.opera_id)) continue;
283
- seenOperas.add(fullyResolved.opera_id);
284
-
285
- const productsMap = new Map();
286
- const crawlVisited = new Set();
287
-
288
- function extractProducts(node, namePath, inheritedIsbn) {
289
- if (!node || typeof node !== 'object') return;
290
- if (crawlVisited.has(node)) return;
291
- crawlVisited.add(node);
292
-
293
- let currentNames = [...namePath];
294
- const potentialNames = [node.display_name, node.title, node.name, node.category_label, node.category_name];
295
-
296
- for (const n of potentialNames) {
297
- const str = String(n || '').trim();
298
- if (str && str !== 'Prodotti' && str !== 'null' && str !== 'undefined' && str !== '[object Object]' && !/^\d+$/.test(str)) {
299
- let isRedundant = false;
300
- for (let j = 0; j < currentNames.length; j++) {
301
- const existing = currentNames[j];
302
- if (existing.toLowerCase() === str.toLowerCase()) { isRedundant = true; break; }
303
- if (str.toLowerCase().includes(existing.toLowerCase()) && str.length > existing.length) { currentNames[j] = str; isRedundant = true; break; }
304
- if (existing.toLowerCase().includes(str.toLowerCase())) { isRedundant = true; break; }
305
- }
306
- if (!isRedundant) currentNames.push(str);
307
- }
308
- }
309
-
310
- const currentIsbn = node.isbn || node.paper_isbn || inheritedIsbn;
311
- let gediCode = null;
312
- if (node.external_id && /^\d{5,10}$/.test(String(node.external_id))) gediCode = String(node.external_id);
313
- else if (node.id && /^\d{5,10}$/.test(String(node.id))) gediCode = String(node.id);
314
-
315
- if (gediCode) {
316
- let finalParts = [];
317
- for (let j = 0; j < currentNames.length; j++) {
318
- let isRedundant = false;
319
- let currFirstWord = currentNames[j].trim().split(/[\s\-_]+/)[0].toLowerCase();
320
- for (let k = j + 1; k < currentNames.length; k++) {
321
- let nextFirstWord = currentNames[k].trim().split(/[\s\-_]+/)[0].toLowerCase();
322
- if (currFirstWord && currFirstWord === nextFirstWord) { isRedundant = true; break; }
323
- }
324
- if (!isRedundant) finalParts.push(currentNames[j]);
325
- }
326
- let finalName = finalParts.join(' - ') || `Volume (${gediCode})`;
327
-
328
- if (!productsMap.has(gediCode)) {
329
- productsMap.set(gediCode, { isbn: currentIsbn || '', name: finalName, gedi: gediCode, resources: [] });
330
- } else if (finalName.length > productsMap.get(gediCode).name.length) {
331
- productsMap.get(gediCode).name = finalName;
332
- }
333
-
334
- productsMap.get(gediCode).resources.push({
335
- type: node.category_name || '',
336
- category_id: node.category_id || '',
337
- external_id: node.external_id || '',
338
- code: node.internal_code || '',
339
- url: node.url || ''
340
- });
341
- }
342
-
343
- if (Array.isArray(node)) {
344
- for (let k = 0; k < node.length; k++) {
345
- if (typeof node[k] === 'object') extractProducts(node[k], currentNames, currentIsbn);
346
- }
347
- } else {
348
- for (const key in node) {
349
- if (typeof node[key] === 'object') extractProducts(node[key], currentNames, currentIsbn);
350
- }
351
- }
352
- }
353
-
354
- let initialPath = [];
355
- if (fullyResolved.display_name) initialPath.push(fullyResolved.display_name);
356
- extractProducts(fullyResolved.included || fullyResolved, initialPath, '');
357
-
358
- for (const product of productsMap.values()) {
359
- books.push({ name: product.name, opera_id: fullyResolved.opera_id, products: [product] });
360
- }
361
- }
362
  }
363
 
364
- return books;
365
- }
 
 
366
 
367
- function normalizePlaceBookUrl(url) {
368
- if (!url || typeof url !== 'string') return null;
369
- if (url.startsWith('http://') || url.startsWith('https://')) return url;
370
- if (url.startsWith('/')) return `https://place.sanoma.it${url}`;
371
- return `https://place.sanoma.it/${url}`;
372
- }
373
 
374
- function getProductPlaceUrl(product) {
375
- const candidates = [
376
- product?.url,
377
- ...(Array.isArray(product?.resources) ? product.resources.map((resource) => resource?.url) : [])
378
- ];
379
 
380
- for (const candidate of candidates) {
381
- const normalized = normalizePlaceBookUrl(candidate);
382
- if (normalized && normalized.includes('/prodotti_digitali/')) {
383
- return normalized;
384
- }
385
- }
386
 
387
- return null;
388
- }
 
389
 
390
- function getAllProducts(books) {
391
- const products = [];
392
- for (const book of books) {
393
- for (const product of book.products || []) {
394
- products.push(product);
395
- }
396
  }
397
- return products;
398
- }
399
 
400
- export async function getBookCatalog(client) {
401
- const books = await fetchBooks(client);
402
- const products = getAllProducts(books);
403
 
404
- return products.map((product) => ({
405
- ...product,
406
- placeUrl: getProductPlaceUrl(product)
407
- }));
408
- }
409
 
410
- export async function getBookMetadata(client, gedi) {
411
- const products = await getBookCatalog(client);
412
- const product = products.find((entry) => String(entry.gedi) === String(gedi));
413
 
414
- if (!product) {
415
- throw new Error(`Libro con GEDI ${gedi} non trovato nella libreria Sanoma.`);
416
- }
 
 
 
417
 
418
- return product;
419
- }
 
420
 
421
- export async function fetchKToken(client, placeUrl) {
422
- const normalizedPlaceUrl = normalizePlaceBookUrl(placeUrl);
423
- if (!normalizedPlaceUrl) {
424
- throw new Error('URL del libro Sanoma non valido o mancante.');
425
- }
 
 
426
 
427
- let response;
428
- try {
429
- response = await client.get(normalizedPlaceUrl, {
430
- headers: {
431
- ...DEFAULT_HEADERS,
432
- 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
433
- 'Referer': PLACE_BOOKS_PAGE_URL
434
- }
435
- });
436
- } catch (err) {
437
- if (err.response) {
438
- response = err.response;
439
- } else {
440
- throw err;
441
- }
442
  }
443
 
444
- const location = response.headers?.location;
445
- if (!location) {
446
- throw new Error(`Redirect verso open-book non trovato per ${normalizedPlaceUrl}.`);
 
 
 
447
  }
448
 
449
- const redirectUrl = new URL(location, normalizedPlaceUrl);
450
- const ktoken = redirectUrl.searchParams.get('ktoken');
451
 
452
- if (!ktoken) {
453
- throw new Error(`ktoken non trovato nel redirect di ${normalizedPlaceUrl}.`);
454
- }
 
455
 
456
- return ktoken;
457
- }
458
 
459
- export async function fetchBookAccess(client, gedi, placeUrl) {
460
- const product = placeUrl
461
- ? { gedi, placeUrl: normalizePlaceBookUrl(placeUrl) }
462
- : await getBookMetadata(client, gedi);
 
463
 
464
- if (!product.placeUrl) {
465
- throw new Error(`URL place.sanoma.it non trovato per il libro GEDI ${gedi}.`);
 
466
  }
467
 
468
- const xAuthToken = await fetchKToken(client, product.placeUrl);
469
- const response = await client.get(DISPLAY_BOOKS_URL, {
470
- headers: {
471
- ...DEFAULT_HEADERS,
472
- 'Accept': 'application/json, text/plain, */*',
473
- 'Referer': `${EBOOK_ORIGIN}/`,
474
- 'Origin': EBOOK_ORIGIN,
475
- 'Sec-Fetch-Dest': 'empty',
476
- 'Sec-Fetch-Mode': 'cors',
477
- 'Sec-Fetch-Site': 'same-site',
478
- 'Sec-GPC': '1',
479
- 'TE': 'trailers',
480
- 'X-Auth-Token': xAuthToken
481
  }
 
482
  });
483
 
484
- const payload = response.data;
485
- const firstEntry = Array.isArray(payload?.data) ? payload.data[0] : null;
486
- const bookData = firstEntry?.book || payload?.book || payload?.data?.book || null;
487
- const resolvedGedi = firstEntry?.gedi || payload?.gedi || gedi;
488
- const cookies = bookData?.cookies || {};
489
 
490
- const cookieKeys = ['CloudFront-Policy', 'CloudFront-Signature', 'CloudFront-Key-Pair-Id'];
491
- const missingKeys = cookieKeys.filter((key) => !cookies[key]);
492
- if (!bookData?.url || missingKeys.length > 0) {
493
- throw new Error(`Risposta display-books incompleta per GEDI ${gedi}.`);
494
- }
495
 
496
- return {
497
- gedi: String(resolvedGedi),
498
- placeUrl: product.placeUrl,
499
- xAuthToken,
500
- baseUrl: String(bookData.url).replace(/\/$/, ''),
501
- cookies,
502
- cookieHeader: cookieKeys.map((key) => `${key}=${cookies[key]}`).join('; ')
503
- };
504
  }
505
 
506
- export async function fetchCloudfrontCookies(client, gedi, placeUrl) {
507
- const access = await fetchBookAccess(client, gedi, placeUrl);
508
- return access.cookieHeader;
 
 
 
 
 
 
 
 
 
 
 
509
  }
 
1
+ import yargs from 'yargs';
2
+ import PromptSync from 'prompt-sync';
3
+ import fetch from 'node-fetch';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import { pipeline } from 'stream';
7
+ import { loginSanoma, getBookCatalog, fetchBookAccess } from './src/sanoma/auth.js';
8
+
9
+ const prompt = PromptSync({ sigint: true });
10
+
11
+ function findPdfUrlInMaster(node, seen = new Set()) {
12
+ if (!node || typeof node !== 'object') return null;
13
+ if (seen.has(node)) return null;
14
+ seen.add(node);
15
+
16
+ if (typeof node.pdf === 'string' && node.pdf) {
17
+ return node.pdf;
18
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
+ if (Array.isArray(node)) {
21
+ for (const item of node) {
22
+ const found = findPdfUrlInMaster(item, seen);
23
+ if (found) return found;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  }
25
+ return null;
26
+ }
27
 
28
+ for (const value of Object.values(node)) {
29
+ const found = findPdfUrlInMaster(value, seen);
30
+ if (found) return found;
31
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
+ return null;
34
  }
35
 
36
+ export async function run(options = {}) {
37
+ const argv = yargs(process.argv.slice(2))
38
+ .option('id', { alias: 'i', type: 'string', description: 'user id (email)' })
39
+ .option('password', { alias: 'p', type: 'string', description: 'user password' })
40
+ .option('gedi', { alias: 'g', type: 'string', description: "book's gedi" })
41
+ .option('output', { alias: 'o', type: 'string', description: 'Output file' })
42
+ .help()
43
+ .argv;
 
44
 
45
+ const { id, password, gedi } = options;
 
46
 
47
+ console.log("Avvio provider Sanoma...");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
+ const outputDir = process.env.OURBOOKS_OUTPUT_DIR || '.';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
+ function promisify(api) {
52
+ return function (...args) {
53
+ return new Promise((resolve, reject) => {
54
+ api(...args, (err, response) => {
55
+ if (err) return reject(err);
56
+ resolve(response);
57
+ });
58
+ });
59
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  }
61
 
62
+ (async () => {
63
+ let userId = id || argv.id;
64
+ let userPassword = password || argv.password;
65
+ let bookGedi = gedi || argv.gedi;
66
 
67
+ console.log('Warning: this script might log you out of other devices');
 
 
 
 
 
68
 
69
+ while (!userId) userId = prompt('Enter account email: ');
70
+ while (!userPassword) userPassword = prompt('Enter account password: ', { echo: '*' });
 
 
 
71
 
72
+ // login myplace
73
+ console.log('Logging in to MyPlace...');
74
+ const skClient = await loginSanoma(userId, userPassword).catch(err => {
75
+ console.error('Failed to log in:', err.message);
76
+ process.exit(1);
77
+ });
78
 
79
+ // fetch book list
80
+ console.log('Fetching book list...');
81
+ const catalog = await getBookCatalog(skClient);
82
 
83
+ const tableObj = {};
84
+ for (const product of catalog) {
85
+ tableObj[product.gedi] = product.name;
 
 
 
86
  }
 
 
87
 
88
+ console.log('Books (MyPlace):');
89
+ console.table(tableObj);
 
90
 
91
+ let gediCode = bookGedi;
92
+ while (!gediCode) gediCode = prompt("Enter the book's gedi: ");
 
 
 
93
 
94
+ const selectedProduct = catalog.find((product) => String(product.gedi) === String(gediCode));
95
+ const targetBookName = tableObj[gediCode] || `GEDI ${gediCode}`;
 
96
 
97
+ // get cookies from display books
98
+ console.log('Obtaining access credentials for "' + targetBookName + '"...');
99
+ const bookAccess = await fetchBookAccess(skClient, gediCode, selectedProduct?.placeUrl).catch(err => {
100
+ console.error('Failed to obtain book access:', err.message);
101
+ process.exit(1);
102
+ });
103
 
104
+ // fetch master so we can get pdf url
105
+ const masterUrl = `${bookAccess.baseUrl}/assets/book/data/master.json?t=${Date.now()}`;
106
+ console.log('Fetching book metadata...');
107
 
108
+ const masterRes = await fetch(masterUrl, {
109
+ headers: {
110
+ 'Accept': 'application/json, text/plain, */*',
111
+ 'Cookie': bookAccess.cookieHeader,
112
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
113
+ },
114
+ });
115
 
116
+ if (!masterRes.ok) {
117
+ console.error(`master.json request failed: HTTP ${masterRes.status}`);
118
+ process.exit(1);
 
 
 
 
 
 
 
 
 
 
 
 
119
  }
120
 
121
+ const master = await masterRes.json();
122
+ const pdfUrl = findPdfUrlInMaster(master);
123
+
124
+ if (!pdfUrl) {
125
+ console.error('PDF URL not found in master.json. Response keys:', Object.keys(master));
126
+ process.exit(1);
127
  }
128
 
129
+ console.log('PDF URL found:', pdfUrl);
 
130
 
131
+ // download pdf
132
+ let baseName = argv.output || options.output;
133
+ if (!baseName) baseName = targetBookName.replace(/[\\/:*?"<>|]/g, '') + '.pdf';
134
+ const outFilePath = path.join(outputDir, baseName);
135
 
136
+ console.log('Downloading "' + targetBookName + '"...');
 
137
 
138
+ const pdfRes = await fetch(pdfUrl, {
139
+ headers: {
140
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
141
+ },
142
+ });
143
 
144
+ if (!pdfRes.ok) {
145
+ console.error(`PDF download failed: HTTP ${pdfRes.status}`);
146
+ process.exit(1);
147
  }
148
 
149
+ const totalBytes = parseInt(pdfRes.headers.get('content-length'), 10);
150
+ let downloadedBytes = 0;
151
+ let lastLoggedPercent = 0;
152
+
153
+ pdfRes.body.on('data', (chunk) => {
154
+ downloadedBytes += chunk.length;
155
+ if (totalBytes) {
156
+ const percent = Math.floor((downloadedBytes / totalBytes) * 100);
157
+ if (percent >= lastLoggedPercent + 10) {
158
+ process.stdout.write(`...${percent}%`);
159
+ lastLoggedPercent = percent;
 
 
160
  }
161
+ }
162
  });
163
 
164
+ await promisify(pipeline)(pdfRes.body, fs.createWriteStream(outFilePath));
165
+ console.log('\nDownload completato!');
 
 
 
166
 
167
+ console.log('Done. Output:', outFilePath);
168
+ console.log(`OURBOOKS_OUTPUT:${outFilePath}`);
169
+ })();
170
+ }
 
171
 
172
+ export async function login(username, password) {
173
+ try {
174
+ await loginSanoma(username, password);
175
+ return { id: username, password };
176
+ } catch (err) {
177
+ throw new Error('Login failed: ' + err.message);
178
+ }
 
179
  }
180
 
181
+ export async function getBooks(session) {
182
+ const { id, password } = session;
183
+ const skClient = await loginSanoma(id, password);
184
+ const catalog = await getBookCatalog(skClient);
185
+
186
+ return [{
187
+ id: 'sanoma',
188
+ name: 'Sanoma',
189
+ products: catalog.map((product) => ({
190
+ id: product.gedi,
191
+ name: product.name,
192
+ url: product.placeUrl || ''
193
+ }))
194
+ }];
195
  }