diff --git a/src/web/mjs/connectors/templates/Lezhin.mjs b/src/web/mjs/connectors/templates/Lezhin.mjs index 616770ed42..943497e5f3 100644 --- a/src/web/mjs/connectors/templates/Lezhin.mjs +++ b/src/web/mjs/connectors/templates/Lezhin.mjs @@ -10,8 +10,8 @@ export default class Lezhin extends Connector { this.tags = []; this.url = undefined; this.apiURL = 'https://www.lezhinus.com'; - this.cdnURL = 'https://cdn.lezhin.com'; - this.userID = undefined; + this.cdnURL = 'https://rcdn.lezhin.com'; + this.token = undefined; this.mangasPerPage = 36; this.config = { username: { @@ -35,33 +35,27 @@ export default class Lezhin extends Connector { }; } - async _initializeAccount() { - if(this.userID) { + async _initializeConnector() { + const data = await this.getLzConfig(); + this.cdnURL = data.contentsCdnUrl ? data.contentsCdnUrl : this.cdnURL; + } + async _initializeAccount() { + if(this.token) { //check if user disconnected - const uri = new URL(this.url); - const checkscript = ` - new Promise((resolve, reject) => { - setTimeout(() => { - resolve(__LZ_CONFIG__); - },5000); - }); - `; - const request = new Request(uri, this.requestOptions); - const data = await Engine.Request.fetchUI(request, checkscript); + const data = await this.getLzConfig(); if (!data.token) { this.requestOptions.headers.delete('Authorization'); - this.userID = ''; + this.token = ''; } } - if(this.userID || !this.config.username.value || !this.config.password.value) { + if(this.token || !this.config.username.value || !this.config.password.value) { return; } const password = this.config.password.value.replace("'", "\\'"); //escape the password, because if it contains a single quote the script will fail let script = ` new Promise((resolve, reject) => { - //try { if($('#log-nav-email').length) { return resolve(); } @@ -75,25 +69,15 @@ export default class Lezhin extends Connector { success: resolve, error: reject }); - // } - // catch(error) { - // reject(error); - // } }); `; let request = new Request(new URL(this.url + '/login'), this.requestOptions); await Engine.Request.fetchUI(request, script); - let response = await fetch(new Request(new URL(this.url + '/account'), this.requestOptions)); - let data = await response.text(); - let cdn = data.match(/cdnUrl\s*:\s*['"]([^'"]+)['"]/); - let user = data.match(/userId\s*:\s*['"](\d+)['"]/); - let token = data.match(/token\s*:\s*['"]([^'"]+)['"]/); - this.requestOptions.headers.set('Authorization', 'Bearer '+token[1]); - this.cdnURL = cdn ? cdn[1] : this.cdnURL; - this.userID = user ? user[1] : undefined; - if(this.userID) { - await fetch(this.url + '/adultkind?path=&sw=all', this.requestOptions); - } + + const data = await this.getLzConfig(); + this.token = data.token; + this.requestOptions.headers.set('Authorization', 'Bearer '+ data.token); + // force user locale user setting to be the same as locale from the currently used website ... // => prevent a warning webpage that would appear otherwise when loading chapters / pages return fetch(this.url + '/locale/' + this.locale, this.requestOptions); @@ -140,7 +124,6 @@ export default class Lezhin extends Connector { new Promise((resolve, reject) => { // wait until episodes have been updated with purchase info ... setTimeout(() => { - // try { let chapters = __LZ_PRODUCT__.all // __LZ_PRODUCT__.product.episodes .filter(chapter => { if(chapter.purchased) { @@ -165,20 +148,29 @@ export default class Lezhin extends Connector { }; }); resolve(chapters); - // } - // catch(error) { - // reject(error); - // } }, 2500); }); `; let request = new Request(new URL('/comic/' + manga.id, this.url), this.requestOptions); - return Engine.Request.fetchUI(request, script); + return await Engine.Request.fetchUI(request, script); } async _getPages(chapter) { await this._initializeAccount(); + //check if chapter is purchased + let script = ` + new Promise((resolve, reject) => { + // wait until episodes have been updated with purchase info ... + setTimeout(() => { + let chapter = __LZ_PRODUCT__.all.filter(chapter => chapter.name == "${chapter.id}"); + resolve(chapter[0].purchased); + }, 2500); + }); + `; + let request = new Request(new URL('/comic/' + chapter.manga.id, this.url), this.requestOptions); + const purchased = await Engine.Request.fetchUI(request, script); + let uri = new URL('https://www.lezhin.com/lz-api/v2/inventory_groups/comic_viewer'); uri.searchParams.set('platform', 'web'); uri.searchParams.set('store', 'web'); @@ -186,11 +178,13 @@ export default class Lezhin extends Connector { uri.searchParams.set('name', chapter.id); uri.searchParams.set('preload', false); uri.searchParams.set('type', 'comic_episode'); - let request = new Request(uri, this.requestOptions); + request = new Request(uri, this.requestOptions); let data = await this.fetchJSON(request); - return data.data.extra.episode.scrollsInfo.map(scroll => { - return this.createConnectorURI({url : scroll.path, infos : JSON.stringify(data)}); + //default to scrollsInfo if pagesInfo doesnt exists (same structure) + const content = data.data.extra.episode.pagesInfo ? data.data.extra.episode.pagesInfo : data.data.extra.episode.scrollsInfo; + return content.map(scroll => { + return this.createConnectorURI({url : scroll.path, infos : JSON.stringify(data), purchased : purchased}); }); } @@ -208,7 +202,7 @@ export default class Lezhin extends Connector { const episode = data.data.extra.episode; const extension = this.config.forceJPEG.value ? '.jpg' : '.webp'; let imageurl = new URL('/v2' + payload.url + extension, this.cdnURL); - let purchased = episode.coin == 0; + let purchased = payload.purchased ? payload.purchased : false; //purchased = purchased || (episode.freedAt && episode.freedAt < Date.now()); const subscribed = data.data.extra.subscribed; const updatedAt = episode.updatedAt; @@ -232,12 +226,224 @@ export default class Lezhin extends Connector { imageurl.searchParams.set('Policy', response.data.Policy); imageurl.searchParams.set('Signature', response.data.Signature); imageurl.searchParams.set('Key-Pair-Id', response.data['Key-Pair-Id']); - request = new Request(imageurl, this.requestOptions); response = await fetch(request); + + const scrambled = data.data.extra.comic.metadata && data.data.extra.comic.metadata.imageShuffle; + data = await response.blob(); + if (scrambled) {//if image is scrambled + data = await this.descrambleimage(data, episode.id); + } data = await this._blobToBuffer(data); this._applyRealMime(data); return data; } + + async getLzConfig() { + const uri = new URL(this.url); + const checkscript = ` + new Promise((resolve, reject) => { + setTimeout(() => { + resolve(__LZ_CONFIG__); + },2500); + }); + `; + const request = new Request(uri, this.requestOptions); + return await Engine.Request.fetchUI(request, checkscript); + } + + async descrambleimage(imageBlob, episodeid) { + const bitmap = await createImageBitmap(imageBlob); + return new Promise(resolve => { + + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width, + canvas.height = bitmap.height; + const ctx = canvas.getContext('2d'); + let scrambleTable = _generateScrambletable(episodeid, 5); + const i = Math.floor(Math.sqrt(scrambleTable.length)); + const dimensions = {width : canvas.width, height : canvas.height}; + + scrambleTable = _addLength(scrambleTable); + scrambleTable = createSuperArray(scrambleTable); + + const piecesData = scrambleTable.map(entry => { + const n = entry[0]; + const r = entry[1]; + return { + from: calculatePieces(dimensions, i, parseInt(n)), + to: calculatePieces(dimensions, i, r) + }; + }).filter(entry => { + return !!entry.from && !!entry.to; + }); + + for (const piece of piecesData) { + const e = piece.from; + const n = piece.to; + ctx.drawImage(bitmap, n.left, n.top, n.width, n.height, e.left, e.top, e.width, e.height ); + } + + canvas.toBlob(data => { + resolve(data); + }, Engine.Settings.recompressionFormat.value, parseFloat( Engine.Settings.recompressionQuality.value )/100); + }); + + } +} + +//************************* +// LEHZIN SCRAMBLING +//************************ + +function _generateScrambletable(episodeid, e) { + return episodeid ? new Randomizer(episodeid, e).get() : []; +} + +const Randomizer = function e(t, n) { + var r = this; + !function (t) { + if (!(t instanceof e)) throw new TypeError('Cannot call a class as a function'); + }(this), + + this.random = function (t) { + + // eslint-disable-next-line no-undef + const BIGT = BigInt(t); + // eslint-disable-next-line no-undef + const big12 = BigInt(12); + // eslint-disable-next-line no-undef + const big25 = BigInt(25); + // eslint-disable-next-line no-undef + const big27 = BigInt(27); + // eslint-disable-next-line no-undef + const big32 = BigInt(32); + // eslint-disable-next-line no-undef + const BigXXX = BigInt('18446744073709551615'); + + let e = r.state; + e = e ^ e >> big12; + const shifter = e << big25 & BigXXX; + e = e ^ shifter; + e = e ^ e >>big27; + r.state = e & BigXXX; + + return parseInt((e >> big32) % BIGT, 10); //22 + + /* +// Detailled code using bigint js library, for references purposes. Test manga : Jinx, Chapter 1, Lezhin EN + var BIGT = bigInt(t); + var big12 = bigInt(12); + var big25 = bigInt(25); + var big27 = bigInt(27); + var big32 = bigInt(32); + var BigXXX = bigInt('18446744073709551615'); + + var e = r.state; //6252351865552896n + e = e.xor(e.shiftRight(big12)); //6253027791257424n + var shifter = e.shiftLeft(big25).and(BigXXX); //3528721484988022784n + e = e.xor(shifter); //3525964907269111632n + e = e.xor(e.shiftRight(big27)); //3525964881051480971n + r.state = e.and(BigXXX); //3525964881051480971n + + return e.shiftRight(big32).remainder(BIGT).toJSNumber(); //22 +*/ + + }, + + this.get = function () { + return r.order; + }, + + this.seed = t, + // eslint-disable-next-line no-undef + this.state = BigInt(this.seed); + for ( + var i = n * n, + o = Array.from({ + length: i + }, function (t, e) { + return e; + }), + a = 0; + a < o.length; + a++ + ) { + var s = this.random(i), + u = o[a]; + o[a] = o[s], + o[s] = u; + } + this.order = o; +}; + +function _addLength(t) { + return [].concat(t, [ + t.length, + t.length + 1 + ]); +} + +function createSuperArray(array) { + //generate "0", "arraylength" array + const indexArray = Array(array.length).fill().map((_, index) => index.toString()); + const resultArray = []; + indexArray.map(element => resultArray.push([element, array[element]])); + return resultArray; +} + +function calculatePieces(t, e, n) { + var r, + i, + o, + a, + s, + u, + c, + l, + f, + h, + d, + p, + v, + g, + m, + y, + b, + w = e * e; + return n < w ? ( + p = e, + v = n, + g = (d = t).width, + m = d.height, + y = Math.floor(g / p), + b = Math.floor(m / p), + { + left: v % p * y, + top: Math.floor(v / p) * b, + width: y, + height: b + } + ) : n === w ? ( + c = e, + l = (u = t).width, + f = u.height, + 0 === (h = l % c) ? null : { + left: l - h, + top: 0, + width: h, + height: f + } + ) : ( + i = e, + o = (r = t).width, + a = r.height, + 0 === (s = a % i) ? null : { + left: 0, + top: a - s, + width: o - o % i, + height: s + } + ); }