Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixes Piccoma & PiccomaFR #7024

Merged
merged 1 commit into from
May 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 115 additions & 53 deletions src/web/mjs/connectors/Piccoma.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,80 +8,150 @@ export default class Piccoma extends Connector {
super.id = 'piccoma';
super.label = 'Piccoma';
this.tags = ['manga', 'webtoon', 'japanese'];
this.url = 'https://jp.piccoma.com/';
this.url = 'https://piccoma.com';
this.viewer = '/web/viewer/';
}

canHandleURI(uri) {
return /https?:\/\/jp\.piccoma\.com/.test(uri);
return new RegExp('^https://(jp\\.)?piccoma.com/web/product/\\d+').test(uri);
}

async _getMangaFromURI(uri) {
const id = uri.pathname.split('/')[3];
const request = new Request(uri.href);
const [data] = await this.fetchDOM(request, '.PCM-productTitle');
const title = data.textContent.trim();
return new Manga(this, id, title);
uri.pathname = uri.pathname.split('/').slice(0, 4).join('/');
const [ element ] = await this.fetchDOM(new Request(uri, this.requestOptions), 'h1.PCM-productTitle');
return new Manga(this, id, element.textContent.trim());
}

async _getMangas() {
const mangas = [];
let totalPage = 1;
for (let i = 1; i <= totalPage; i++) {
const request = new Request(`${this.url}/web/next_page/list?result_id=2&list_type=C&sort_type=N&page_id=${i}`, this.requestOptions);
const res = await this.fetchJSON(request);
totalPage = res.data.total_page;
const products = res.data.products;
mangas.push(...products.map(({ id, title }) => {
return { id, title };
}));
const genres = [ 1, 2, 3, 4, 5, 6, 7, 9, 10 ];
const mangaList = [];
try {
for(const genre of genres) {
const result = await this.getMangasFromPage(genre, 1);
mangaList.push(...result.mangas);
for (let page = 2; page <= result.pages; page++) {
const {mangas} = await this.getMangasFromPage(genre, page);
mangaList.push(...mangas);
}

}
} catch(error) {
//
}
return mangas;
return [...new Set(mangaList.map(manga => manga.id))].map(id => mangaList.find(manga => manga.id === id));
}

async getMangasFromPage(genre, page) {
const uri = new URL('/web/next_page/list', this.url);
uri.searchParams.set('list_type', 'G');
uri.searchParams.set('result_id', `${genre}`);
uri.searchParams.set('page_id', `${page}`);
const { data } = await this.fetchJSON(new Request(uri, this.requestOptions));
return {
pages: data.total_page,
mangas: data.products.map(entry => {
return {
id: entry.id,
title : entry.title
};
})
};
}

async _getChapters(manga) {
const request = new Request(`${this.url}/web/product/${manga.id}/episodes?etype=E`);
const data = await this.fetchDOM(request, '.PCM-product_episodeList > a');
return [
... await this.fetchEpisodes(manga),
... await this.fetchVolumes(manga),
].sort((self, other) => self.title.localeCompare(other.title));
}

async fetchEpisodes(manga) {
const request = new Request(`${this.url}/web/product/${manga.id}/episodes?etype=E`, this.requestOptions);
const data = await this.fetchDOM(request, 'ul.PCM-epList li a[data-episode_id]');
return data.map(element => {
return {
id: element.dataset.episode_id,
title : element.querySelector('div.PCM-epList_title h2').textContent.trim()
};
});
}

async fetchVolumes(manga) {
const request = new Request(`${this.url}/web/product/${manga.id}/episodes?etype=V`);
const data = await this.fetchDOM(request, 'ul.PCM-volList li');
return data.map(element => {
const volume = [ ...element.querySelectorAll('div.PCM-prdVol_btns > a:not([class*="buyBtn"])') ].pop();
const title = [
element.querySelector('div.PCM-prdVol_title h2').innerText.trim(),
volume.classList.contains('PCM-prdVol_freeBtn') ? ` (${ volume.innerText.trim() })` : '',
volume.classList.contains('PCM-prdVol_trialBtn') ? ` (${ volume.innerText.trim() })` : '',
].join('');
return {
id: `${manga.id}/${element.dataset.episode_id}`,
title: element.querySelector('.PCM-epList_title').textContent.trim(),
id : volume.dataset.episode_id,
title : title
};
});
}

async _getPages(chapter) {
const request = new Request(`${this.url}/web/viewer/${chapter.id}`);
const pdata = await Engine.Request.fetchUI(request, 'window._pdata_ || {}');
const images = pdata.img;
if (images == null) {

const script = `
new Promise((resolve, reject) => {

function _getSeed(url) {
const uri = new URL(url.startsWith('http') ? url : 'https:'+url);
let checksum = uri.searchParams.get('q') || url.split('/').slice(-2)[0]; //PiccomaFR use q=, JP is the other
const expires = uri.searchParams.get('expires');
const total = expires.split('').reduce((total, num2) => total + parseInt(num2), 0);
const ch = total % checksum.length;
checksum = checksum.slice(ch * -1) + checksum.slice(0, ch * -1);
return globalThis.dd(checksum);
}

try {
const pdata = window.__NEXT_DATA__ ? __NEXT_DATA__.props.pageProps.initialState.viewer.pData : window._pdata_; //PiccomaFR VS JP
if (!pdata) reject();
if (!pdata.img) reject();

const images = pdata.img
.filter(img => !!img.path)
.map(img => {
return {
url : img.path.startsWith('http') ? img.path : 'https:' + img.path,
key : pdata.isScrambled ? _getSeed(img.path) : null,
}
});
resolve(images);
}
catch (error) {
}
reject();
});
`;

const request = new Request(`${this.url}${this.viewer}${chapter.manga.id}/${chapter.id}`, this.requestOptions);
const images = await Engine.Request.fetchUI(request, script, 10000);
if (!images) {
throw new Error(`The chapter '${chapter.title}' is neither public, nor purchased!`);
}
return images
.filter(img => !!img.path)
.map(img => {
const link = img.path.startsWith('http') ? img.path : `https:${img.path}`;
return this.createConnectorURI({
url: link,
key: this._getSeed(link),
pdata
});
});
return images.map(image => this.createConnectorURI({...image}));

}

async _handleConnectorURI(payload) {
const image = await this._loadImage(payload.url);
if (payload.pdata.isScrambled) {
if (payload.key) {
const image = await this._loadImage(payload.url);
const canvas = this._unscramble(image, 50, payload.key);
const blob = await this._canvasToBlob(canvas);
return this._blobToBuffer(blob);
} else {
const uri = new URL(payload.url, this.url);
const request = new Request(uri, this.requestOptions);
const response = await fetch(request);
let data = await response.blob();
return this._blobToBuffer(data);
}
const canvas = document.createElement("canvas");
const ctx = canvas.getContext('2d');
canvas.width = image.width;
canvas.height = image.height;
ctx.drawImage(image, 0, 0, image.width, image.height, 0, 0, image.width, image.height);
const blob = await this._canvasToBlob(canvas);
return this._blobToBuffer(blob);
}

async _canvasToBlob(canvas) {
Expand All @@ -92,14 +162,6 @@ export default class Piccoma extends Connector {
});
}

_getSeed(url) {
const checksum = url.split('/').slice(-2)[0];
const expires = new URL(url).searchParams.get('expires');
const total = expires.split('').reduce((total, num2) => total + parseInt(num2), 0);
const ch = total % checksum.length;
return checksum.slice(ch * -1) + checksum.slice(0, ch * -1);
}

_loadImage(url) {
return new Promise((resolve, reject) => {
const image = new Image();
Expand Down Expand Up @@ -287,4 +349,4 @@ function mixkey(seed, key) {
mask & (smear ^= key[mask & j] * 19) + stringseed.charCodeAt(j++);
}
return String.fromCharCode.apply(0, key);
}
}
108 changes: 39 additions & 69 deletions src/web/mjs/connectors/PiccomaFR.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,91 +9,61 @@ export default class PiccomaFR extends Piccoma {
super.label = 'Piccoma (French)';
this.tags = ['manga', 'webtoon', 'french'];
this.url = 'https://piccoma.com/fr';
this.viewer = '/viewer/';
this.requestOptions.headers.set('x-referer', 'https://piccoma.com/fr');
}

getAPI(endpoint) {
return new URL(this.url + '/api/haribo/api/public/v2' + endpoint);
}

canHandleURI(uri) {
return /https?:\/\/piccoma\.com\/fr/.test(uri);
return new RegExp('^https://(fr\\.)?piccoma.com/fr/product(/episode)?/\\d+$').test(uri);
}

async _getMangaFromURI(uri) {
const id = uri.pathname;
const request = new Request(uri, this.requestOptions);
const data = await this._getNextData(request);
const title = data.props.pageProps.initialState.productHome.productHome.product.title;
return new Manga(this, id, title);
const id = uri.split('/').pop();
const request = new Request(`${this.url}/product/${id}`, this.requestOptions);
const [ element ] = await this.fetchDOM(request, 'meta[property="og:title"]');
return new Manga(this, id, element.content.split('|').shift().trim());
}

async _getMangas() {
let msg = 'This website does not provide a manga list, please copy and paste the URL containing the chapters directly from your browser into HakuNeko.';
throw new Error(msg);
const mangaList = [];
const vowels = 'aeiou'.split('');
for (const word of vowels) {
for (let page = 1, run = true; run; page++) {
const mangas = await this.getMangasFromPage(word, page);
mangas.length > 0 ? mangaList.push(...mangas) : run = false;
}
}
return [...new Set(mangaList.map(manga => manga.id))].map(id => mangaList.find(manga => manga.id === id));
}

async _getChapters(manga) {
const type = manga.id.split('/').slice(-2)[0];
const productId = manga.id.split('/').pop();
const path = type == 'product' ? `/fr/product/episode/${productId}` : manga.id;
const uri = new URL(path, this.url);
const request = new Request(uri, this.requestOptions);
const nextData = await this._getNextData(request);
const episodes = nextData.props.pageProps.initialState.episode.episodeList.episode_list;
return episodes.map(ep => {
async getMangasFromPage(word, page) {
const uri = this.getAPI('/search/product');
uri.searchParams.set('search_type', 'P');
uri.searchParams.set('word', word);
uri.searchParams.set('page', `${page}`);
const { data: { p_products: entries } } = await this.fetchJSON(new Request(uri, this.requestOptions));
return entries.map(entry => {
return {
id: `${nextData.buildId}/fr/viewer/${productId}/${ep.id}`,
title: ep.title,
id: entry.product_id,
title: entry.title,
};
}).reverse();
}

async _getPages(chapter) {
const result = await this._fetchChapterNextData(chapter);
if (!result.pageProps.initialState) {
throw new Error(`The chapter '${chapter.title}' is neither public, nor purchased!`);
}

const pdata = result.pageProps.initialState.viewer.pData;
const images = pdata.img;
if (images == null) {
throw new Error(`The chapter '${chapter.title}' is neither public, nor purchased!`);
}
return images
.filter(img => !!img.path)
.map(img => {
return this.createConnectorURI({
url: img.path,
key: this._getSeed(img.path),
pdata
});
});
}

_getSeed(url) {
const uri = new URL(url);
const checksum = uri.searchParams.get('q');
const expires = uri.searchParams.get('expires');
const total = expires.split('').reduce((total, num2) => total + parseInt(num2), 0);
const ch = total % checksum.length;
return checksum.slice(ch * -1) + checksum.slice(0, ch * -1);
});
}

async _getNextData(request) {
const [data] = await this.fetchDOM(request, '#__NEXT_DATA__');
return JSON.parse(data.textContent);
async _getChapters(manga) {
const request = new Request(`${this.url}/product/episode/${manga.id}`, this.requestOptions);
const [ { text: json } ] = await this.fetchDOM(request, 'script#__NEXT_DATA__');
const chapters = JSON.parse(json).props.pageProps.initialState.episode.episodeList.episode_list;
return chapters.map(chapter => {
return {
id: chapter.id,
title: chapter.title
};
});
}

async _fetchChapterNextData(chapter) {
const parts = chapter.id.split('/');
const productId = parts[3];
const episodeId = parts[4];
const uri = new URL(`fr/_next/data/${chapter.id}.json`, this.url);
uri.searchParams.set('productId', productId);
uri.searchParams.set('episodeId', episodeId);
const request = new Request(uri, this.requestOptions);
try {
return await this.fetchJSON(request);
} catch (error) {
console.error(error);
throw new Error(`The chapter '${chapter.title}' is neither public, nor purchased!`);
}
}
}
Loading