From f072b27d6726ede3e4a523ff9a83ebf9add2d8f3 Mon Sep 17 00:00:00 2001 From: nyne Date: Tue, 15 Oct 2024 20:55:14 +0800 Subject: [PATCH] update template & add picacg --- README.md | 8 + _template_.js | 626 ++++++++++++++++++++++++++++++++++++++++ _venera_.js | 767 ++++++++++++++++++++++++++++++++++++++++++++++++++ index.json | 6 + picacg.js | 649 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 2056 insertions(+) create mode 100644 _template_.js create mode 100644 _venera_.js create mode 100644 picacg.js diff --git a/README.md b/README.md index 6dbe24b..d5f707f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,11 @@ # venera-configs Configuration file repository for venera + +## Create a new configuration + +1. Download `_template_.js`, `_venera_.js`, put them in the same directory +2. Rename `_template_.js` to `your_config_name.js` +3. Edit `your_config_name.js` to your needs. + - The `_template_.js` file contains comments to help you with that. + - The `_venera_.js` is used for code completion in your IDE. \ No newline at end of file diff --git a/_template_.js b/_template_.js new file mode 100644 index 0000000..0309a43 --- /dev/null +++ b/_template_.js @@ -0,0 +1,626 @@ +class NewComicSource extends ComicSource { + // Note: The fields which are marked as [Optional] should be removed if not used + + // name of the source + name = "" + + // unique id of the source + key = "" + + version = "1.0.0" + + minAppVersion = "1.0.0" + + // update url + url = "" + + /** + * [Optional] init function + */ + init() { + + } + + // [Optional] account related + account = { + /** + * login, return any value to indicate success + * @param account {string} + * @param pwd {string} + * @returns {Promise} + */ + login: async (account, pwd) => { + /* + Use Network to send request + Use this.saveData to save data + `account` and `pwd` will be saved to local storage automatically if login success + ``` + let res = await Network.post('https://example.com/login', { + 'content-type': 'application/x-www-form-urlencoded;charset=utf-8' + }, `account=${account}&password=${pwd}`) + + if(res.status == 200) { + let json = JSON.parse(res.body) + this.saveData('token', json.token) + return 'ok' + } + + throw 'Failed to login' + ``` + */ + + }, + + /** + * logout function, clear account related data + */ + logout: () => { + /* + ``` + this.deleteData('token') + Network.deleteCookies('https://example.com') + ``` + */ + }, + + // {string?} - register url + registerWebsite: null + } + + // explore page list + explore = [ + { + // title of the page. + // title is used to identify the page, it should be unique + title: "", + + /// singlePageWithMultiPart or multiPageComicList + type: "singlePageWithMultiPart", + + /** + * load function + * @param page {number | null} - page number, null for `singlePageWithMultiPart` type + * @returns {{}} - for `singlePageWithMultiPart` type, return {[string]: Comic[]}; for `multiPageComicList` type, return {comics: Comic[], maxPage: number} + */ + load: async (page) => { + /* + ``` + let res = await Network.get("https://example.com") + + if (res.status !== 200) { + throw `Invalid status code: ${res.status}` + } + + let data = JSON.parse(res.body) + + function parseComic(comic) { + // ... + + return new Comic({ + id: id, + title: title, + subTitle: author, + cover: cover, + tags: tags, + description: description + }) + } + + let comics = {} + comics["hot"] = data["results"]["recComics"].map(parseComic) + comics["latest"] = data["results"]["newComics"].map(parseComic) + + return comics + ``` + */ + } + } + ] + + // categories + category = { + /// title of the category page, used to identify the page, it should be unique + title: "", + parts: [ + { + // title of the part + name: "Theme", + + // fixed or random + // if random, need to provide `randomNumber` field, which indicates the number of comics to display at the same time + type: "fixed", + + // number of comics to display at the same time + // randomNumber: 5, + + categories: ["All", "Adventure", "School"], + + // category or search + // if `category`, use categoryComics.load to load comics + // if `search`, use search.load to load comics + itemType: "category", + + // [Optional] must have same length as categories, used to provide loading param for each category + categoryParams: ["all", "adventure", "school"] + } + ], + // enable ranking page + enableRankingPage: false, + } + + /// category comic loading related + categoryComics = { + /** + * load comics of a category + * @param category {string} - category name + * @param param {string?} - category param + * @param options {string[]} - options from optionList + * @param page {number} - page number + * @returns {Promise<{comics: Comic[], maxPage: number}>} + */ + load: async (category, param, options, page) => { + /* + ``` + let data = JSON.parse((await Network.get('...')).body) + let maxPage = data.maxPage + + function parseComic(comic) { + // ... + + return new Comic({ + id: id, + title: title, + subTitle: author, + cover: cover, + tags: tags, + description: description + }) + } + + return { + comics: data.list.map(parseComic), + maxPage: maxPage + } + ``` + */ + }, + // provide options for category comic loading + optionList: [ + { + // For a single option, use `-` to separate the value and text, left for value, right for text + options: [ + "newToOld-New to Old", + "oldToNew-Old to New" + ], + // [Optional] {string[]} - show this option only when the value not in the list + notShowWhen: null, + // [Optional] {string[]} - show this option only when the value in the list + showWhen: null + } + ], + ranking: { + // For a single option, use `-` to separate the value and text, left for value, right for text + options: [ + "day-Day", + "week-Week" + ], + /** + * load ranking comics + * @param option {string} - option from optionList + * @param page {number} - page number + * @returns {Promise<{comics: Comic[], maxPage: number}>} + */ + load: async (option, page) => { + /* + ``` + let data = JSON.parse((await Network.get('...')).body) + let maxPage = data.maxPage + + function parseComic(comic) { + // ... + + return new Comic({ + id: id, + title: title, + subTitle: author, + cover: cover, + tags: tags, + description: description + }) + } + + return { + comics: data.list.map(parseComic), + maxPage: maxPage + } + ``` + */ + } + } + } + + /// search related + search = { + /** + * load search result + * @param keyword {string} + * @param options {string[]} - options from optionList + * @param page {number} + * @returns {Promise<{comics: Comic[], maxPage: number}>} + */ + load: async (keyword, options, page) => { + /* + ``` + let data = JSON.parse((await Network.get('...')).body) + let maxPage = data.maxPage + + function parseComic(comic) { + // ... + + return new Comic({ + id: id, + title: title, + subTitle: author, + cover: cover, + tags: tags, + description: description + }) + } + + return { + comics: data.list.map(parseComic), + maxPage: maxPage + } + ``` + */ + }, + + // provide options for search + optionList: [ + { + // For a single option, use `-` to separate the value and text, left for value, right for text + options: [ + "0-time", + "1-popular" + ], + // option label + label: "sort" + } + ] + } + + // favorite related + favorites = { + // whether support multi folders + multiFolder: false, + /** + * add or delete favorite. + * throw `Login expired` to indicate login expired, App will automatically re-login and re-add/delete favorite + * @param comicId {string} + * @param folderId {string} + * @param isAdding {boolean} - true for add, false for delete + * @returns {Promise} - return any value to indicate success + */ + addOrDelFavorite: async (comicId, folderId, isAdding) => { + /* + ``` + let res = await Network.post('...') + if (res.status === 401) { + throw `Login expired`; + } + return 'ok' + ``` + */ + }, + /** + * load favorite folders. + * throw `Login expired` to indicate login expired, App will automatically re-login retry. + * if comicId is not null, return favorite folders which contains the comic. + * @param comicId {string?} + * @returns {Promise<{folders: {[p: string]: string}, favorited: string[]}>} - `folders` is a map of folder id to folder name, `favorited` is a list of folder id which contains the comic + */ + loadFolders: async (comicId) => { + /* + ``` + let data = JSON.parse((await Network.get('...')).body) + + let folders = {} + + data.folders.forEach((f) => { + folders[f.id] = f.name + }) + + return { + folders: folders, + favorited: data.favorited + } + ``` + */ + }, + /** + * load comics in a folder + * throw `Login expired` to indicate login expired, App will automatically re-login retry. + * @param page {number} + * @param folder {string?} - folder id, null for non-multi-folder + * @returns {Promise<{comics: Comic[], maxPage: number}>} + */ + loadComics: async (page, folder) => { + /* + ``` + let data = JSON.parse((await Network.get('...')).body) + let maxPage = data.maxPage + + function parseComic(comic) { + // ... + + return new Comic{ + id: id, + title: title, + subTitle: author, + cover: cover, + tags: tags, + description: description + } + } + + return { + comics: data.list.map(parseComic), + maxPage: maxPage + } + ``` + */ + } + } + + /// single comic related + comic = { + /** + * load comic info + * @param id {string} + * @returns {Promise} + */ + loadInfo: async (id) => { + + }, + /** + * [Optional] load thumbnails of a comic + * @param id {string} + * @param next {string?} - next page token, null for first page + * @returns {Promise<{thumbnails: string[], next: string?}>} - `next` is next page token, null for no more + */ + loadThumbnails: async (id, next) => { + /* + ``` + let data = JSON.parse((await Network.get('...')).body) + + return { + thumbnails: data.list, + next: next, + } + ``` + */ + }, + /** + * load images of a chapter + * @param comicId {string} + * @param epId {string?} + * @returns {Promise<{images: string[]}>} + */ + loadEp: async (comicId, epId) => { + /* + ``` + return { + // string[] + images: images + } + ``` + */ + }, + /** + * [Optional] provide configs for an image loading + * @param url + * @param comicId + * @param epId + * @returns {{}} + */ + onImageLoad: (url, comicId, epId) => { + /* + ``` + return { + url: `${url}?id=comicId`, + // http method + method: 'GET', + // any + data: null, + headers: { + 'user-agent': 'pica_comic/v3.1.0', + }, + // * modify response data + // * @param data {ArrayBuffer} + // * @returns {ArrayBuffer} + onResponse: (data) => { + return data + } + } + ``` + */ + + return {} + }, + /** + * [Optional] provide configs for a thumbnail loading + * @param url {string} + * @returns {{}} + */ + onThumbnailLoad: (url) => { + /* + ``` + return { + url: `${url}?id=comicId`, + // http method + method: 'GET', + // {any} + data: null, + headers: { + 'user-agent': 'pica_comic/v3.1.0', + }, + // modify response data + onResponse: (data) => { + return data + } + } + ``` + */ + return {} + }, + /** + * [Optional] like or unlike a comic + * @param id {string} + * @param isLike {boolean} - true for like, false for unlike + * @returns {Promise} + */ + likeComic: async (id, isLike) => { + + }, + /** + * [Optional] load comments + * @param comicId {string} + * @param subId {string?} - ComicDetails.subId + * @param page {number} + * @param replyTo {string?} - commentId to reply, not null when reply to a comment + * @returns {Promise<{comments: Comment[], maxPage: number?}>} + */ + loadComments: async (comicId, subId, page, replyTo) => { + /* + ``` + // ... + + return { + comments: data.results.list.map(e => { + return new Comment({ + // string + userName: e.user_name, + // string + avatar: e.user_avatar, + // string + content: e.comment, + // string? + time: e.create_at, + // number? + replyCount: e.count, + // string + id: e.id, + }) + }), + // number + maxPage: data.results.maxPage, + } + ``` + */ + }, + /** + * [Optional] send a comment, return any value to indicate success + * @param comicId {string} + * @param subId {string?} - ComicDetails.subId + * @param content {string} + * @param replyTo {string?} - commentId to reply, not null when reply to a comment + * @returns {Promise} + */ + sendComment: async (comicId, subId, content, replyTo) => { + + }, + /** + * [Optional] like or unlike a comment + * @param comicId {string} + * @param subId {string?} - ComicDetails.subId + * @param commentId {string} + * @param isLike {boolean} - true for like, false for unlike + * @returns {Promise} + */ + likeComment: async (comicId, subId, commentId, isLike) => { + + }, + /** + * [Optional] vote a comment + * @param id {string} - comicId + * @param subId {string?} - ComicDetails.subId + * @param commentId {string} - commentId + * @param isUp {boolean} - true for up, false for down + * @param isCancel {boolean} - true for cancel, false for vote + * @returns {Promise} - new score + */ + voteComment: async (id, subId, commentId, isUp, isCancel) => { + + }, + // {string?} - regex string, used to identify comic id from user input + idMatch: null, + /** + * [Optional] Handle tag click event + * @param namespace {string} + * @param tag {string} + * @returns {{action: string, keyword: string, param: string?}} + */ + onClickTag: (namespace, tag) => { + /* + ``` + return { + // 'search' or 'category' + action: 'search', + keyword: tag, + // {string?} only for category action + param: null, + } + */ + }, + } + + + /* + [Optional] settings related + Use this.loadSetting to load setting + ``` + let setting1Value = this.loadSetting('setting1') + console.log(setting1Value) + ``` + */ + settings = { + setting1: { + // title + title: "Setting1", + // type: input, select, switch + type: "select", + // options + options: [ + { + // value + value: 'o1', + // [Optional] text, if not set, use value as text + text: 'Option 1', + }, + ], + default: 'o1', + }, + setting2: { + title: "Setting2", + type: "switch", + default: true, + }, + setting3: { + title: "Setting3", + type: "input", + validator: null, // string | null, regex string + default: '', + } + } + + // [Optional] translations for the strings in this config + translation = { + 'zh_CN': { + 'Setting1': '设置1', + 'Setting2': '设置2', + 'Setting3': '设置3', + }, + 'zh_TW': {}, + 'en': {} + } +} \ No newline at end of file diff --git a/_venera_.js b/_venera_.js new file mode 100644 index 0000000..d88b492 --- /dev/null +++ b/_venera_.js @@ -0,0 +1,767 @@ +/* +Venera JavaScript Library + +This library provides a set of APIs for interacting with the Venera app. +*/ + +/// encode, decode, hash, decrypt +let Convert = { + /** + * @param str {string} + * @returns {ArrayBuffer} + */ + encodeUtf8: (str) => { + return sendMessage({ + method: "convert", + type: "utf8", + value: str, + isEncode: true + }); + }, + + /** + * @param value {ArrayBuffer} + * @returns {string} + */ + decodeUtf8: (value) => { + return sendMessage({ + method: "convert", + type: "utf8", + value: value, + isEncode: false + }); + }, + + /** + * @param {ArrayBuffer} value + * @returns {string} + */ + encodeBase64: (value) => { + return sendMessage({ + method: "convert", + type: "base64", + value: value, + isEncode: true + }); + }, + + /** + * @param {string} value + * @returns {ArrayBuffer} + */ + decodeBase64: (value) => { + return sendMessage({ + method: "convert", + type: "base64", + value: value, + isEncode: false + }); + }, + + /** + * @param {ArrayBuffer} value + * @returns {ArrayBuffer} + */ + md5: (value) => { + return sendMessage({ + method: "convert", + type: "md5", + value: value, + isEncode: true + }); + }, + + /** + * @param {ArrayBuffer} value + * @returns {ArrayBuffer} + */ + sha1: (value) => { + return sendMessage({ + method: "convert", + type: "sha1", + value: value, + isEncode: true + }); + }, + + /** + * @param {ArrayBuffer} value + * @returns {ArrayBuffer} + */ + sha256: (value) => { + return sendMessage({ + method: "convert", + type: "sha256", + value: value, + isEncode: true + }); + }, + + /** + * @param {ArrayBuffer} value + * @returns {ArrayBuffer} + */ + sha512: (value) => { + return sendMessage({ + method: "convert", + type: "sha512", + value: value, + isEncode: true + }); + }, + + /** + * @param key {ArrayBuffer} + * @param value {ArrayBuffer} + * @param hash {string} - md5, sha1, sha256, sha512 + * @returns {ArrayBuffer} + */ + hmac: (key, value, hash) => { + return sendMessage({ + method: "convert", + type: "hmac", + value: value, + key: key, + hash: hash, + isEncode: true + }); + }, + + /** + * @param key {ArrayBuffer} + * @param value {ArrayBuffer} + * @param hash {string} - md5, sha1, sha256, sha512 + * @returns {string} - hex string + */ + hmacString: (key, value, hash) => { + return sendMessage({ + method: "convert", + type: "hmac", + value: value, + key: key, + hash: hash, + isEncode: true, + isString: true + }); + }, + + /** + * @param {ArrayBuffer} value + * @param {ArrayBuffer} key + * @returns {ArrayBuffer} + */ + decryptAesEcb: (value, key) => { + return sendMessage({ + method: "convert", + type: "aes-ecb", + value: value, + key: key, + isEncode: false + }); + }, + + /** + * @param {ArrayBuffer} value + * @param {ArrayBuffer} key + * @param {ArrayBuffer} iv + * @returns {ArrayBuffer} + */ + decryptAesCbc: (value, key, iv) => { + return sendMessage({ + method: "convert", + type: "aes-ecb", + value: value, + key: key, + iv: iv, + isEncode: false + }); + }, + + /** + * @param {ArrayBuffer} value + * @param {ArrayBuffer} key + * @param {number} blockSize + * @returns {ArrayBuffer} + */ + decryptAesCfb: (value, key, blockSize) => { + return sendMessage({ + method: "convert", + type: "aes-cfb", + value: value, + key: key, + blockSize: blockSize, + isEncode: false + }); + }, + + /** + * @param {ArrayBuffer} value + * @param {ArrayBuffer} key + * @param {number} blockSize + * @returns {ArrayBuffer} + */ + decryptAesOfb: (value, key, blockSize) => { + return sendMessage({ + method: "convert", + type: "aes-ofb", + value: value, + key: key, + blockSize: blockSize, + isEncode: false + }); + }, + + /** + * @param {ArrayBuffer} value + * @param {ArrayBuffer} key + * @returns {ArrayBuffer} + */ + decryptRsa: (value, key) => { + return sendMessage({ + method: "convert", + type: "rsa", + value: value, + key: key, + isEncode: false + }); + } +} + +/** + * create a time-based uuid + * + * Note: the engine will generate a new uuid every time it is called + * + * To get the same uuid, please save it to the local storage + * + * @returns {string} + */ +function createUuid() { + return sendMessage({ + method: "uuid" + }); +} + +function randomInt(min, max) { + return sendMessage({ + method: 'random', + min: min, + max: max + }); +} + +class _Timer { + delay = 0; + + callback = () => { }; + + status = false; + + constructor(delay, callback) { + this.delay = delay; + this.callback = callback; + } + + run() { + this.status = true; + this._interval(); + } + + _interval() { + if (!this.status) { + return; + } + this.callback(); + setTimeout(this._interval.bind(this), this.delay); + } + + cancel() { + this.status = false; + } +} + +function setInterval(callback, delay) { + let timer = new _Timer(delay, callback); + timer.run(); + return timer; +} + +function Cookie(name, value, domain = null) { + let obj = {}; + obj.name = name; + obj.value = value; + if (domain) { + obj.domain = domain; + } + return obj; +} + +/** + * Network object for sending HTTP requests and managing cookies. + * @namespace Network + */ +let Network = { + /** + * Sends an HTTP request. + * @param {string} method - The HTTP method (e.g., GET, POST, PUT, PATCH, DELETE). + * @param {string} url - The URL to send the request to. + * @param {Object} headers - The headers to include in the request. + * @param data - The data to send with the request. + * @returns {Promise} The response from the request. + */ + async fetchBytes(method, url, headers, data) { + let result = await sendMessage({ + method: 'http', + http_method: method, + bytes: true, + url: url, + headers: headers, + data: data, + }); + + if (result.error) { + throw result.error; + } + + return result; + }, + + /** + * Sends an HTTP request. + * @param {string} method - The HTTP method (e.g., GET, POST, PUT, PATCH, DELETE). + * @param {string} url - The URL to send the request to. + * @param {Object} headers - The headers to include in the request. + * @param data - The data to send with the request. + * @returns {Promise} The response from the request. + */ + async sendRequest(method, url, headers, data) { + let result = await sendMessage({ + method: 'http', + http_method: method, + url: url, + headers: headers, + data: data, + }); + + if (result.error) { + throw result.error; + } + + return result; + }, + + /** + * Sends an HTTP GET request. + * @param {string} url - The URL to send the request to. + * @param {Object} headers - The headers to include in the request. + * @returns {Promise} The response from the request. + */ + async get(url, headers) { + return this.sendRequest('GET', url, headers); + }, + + /** + * Sends an HTTP POST request. + * @param {string} url - The URL to send the request to. + * @param {Object} headers - The headers to include in the request. + * @param data - The data to send with the request. + * @returns {Promise} The response from the request. + */ + async post(url, headers, data) { + return this.sendRequest('POST', url, headers, data); + }, + + /** + * Sends an HTTP PUT request. + * @param {string} url - The URL to send the request to. + * @param {Object} headers - The headers to include in the request. + * @param data - The data to send with the request. + * @returns {Promise} The response from the request. + */ + async put(url, headers, data) { + return this.sendRequest('PUT', url, headers, data); + }, + + /** + * Sends an HTTP PATCH request. + * @param {string} url - The URL to send the request to. + * @param {Object} headers - The headers to include in the request. + * @param data - The data to send with the request. + * @returns {Promise} The response from the request. + */ + async patch(url, headers, data) { + return this.sendRequest('PATCH', url, headers, data); + }, + + /** + * Sends an HTTP DELETE request. + * @param {string} url - The URL to send the request to. + * @param {Object} headers - The headers to include in the request. + * @returns {Promise} The response from the request. + */ + async delete(url, headers) { + return this.sendRequest('DELETE', url, headers); + }, + + /** + * Sets cookies for a specific URL. + * @param {string} url - The URL to set the cookies for. + * @param {Cookie[]} cookies - The cookies to set. + */ + setCookies(url, cookies) { + sendMessage({ + method: 'cookie', + function: 'set', + url: url, + cookies: cookies, + }); + }, + + /** + * Retrieves cookies for a specific URL. + * @param {string} url - The URL to get the cookies from. + * @returns {Promise} The cookies for the given URL. + */ + getCookies(url) { + return sendMessage({ + method: 'cookie', + function: 'get', + url: url, + }); + }, + + /** + * Deletes cookies for a specific URL. + * @param {string} url - The URL to delete the cookies from. + */ + deleteCookies(url) { + sendMessage({ + method: 'cookie', + function: 'delete', + url: url, + }); + }, +}; + +/** + * HtmlDocument class for parsing HTML and querying elements. + */ +class HtmlDocument { + static _key = 0; + + key = 0; + + /** + * Constructor for HtmlDocument. + * @param {string} html - The HTML string to parse. + */ + constructor(html) { + this.key = HtmlDocument._key; + HtmlDocument._key++; + sendMessage({ + method: "html", + function: "parse", + key: this.key, + data: html + }) + } + + /** + * Query a single element from the HTML document. + * @param {string} query - The query string. + * @returns {HtmlElement} The first matching element. + */ + querySelector(query) { + let k = sendMessage({ + method: "html", + function: "querySelector", + key: this.key, + query: query + }) + if(!k) return null; + return new HtmlElement(k); + } + + /** + * Query all matching elements from the HTML document. + * @param {string} query - The query string. + * @returns {HtmlElement[]} An array of matching elements. + */ + querySelectorAll(query) { + let ks = sendMessage({ + method: "html", + function: "querySelectorAll", + key: this.key, + query: query + }) + return ks.map(k => new HtmlElement(k)); + } +} + +/** + * HtmlDom class for interacting with HTML elements. + */ +class HtmlElement { + key = 0; + + /** + * Constructor for HtmlDom. + * @param {number} k - The key of the element. + */ + constructor(k) { + this.key = k; + } + + /** + * Get the text content of the element. + * @returns {string} The text content. + */ + get text() { + return sendMessage({ + method: "html", + function: "getText", + key: this.key + }) + } + + /** + * Get the attributes of the element. + * @returns {Object} The attributes. + */ + get attributes() { + return sendMessage({ + method: "html", + function: "getAttributes", + key: this.key + }) + } + + /** + * Query a single element from the current element. + * @param {string} query - The query string. + * @returns {HtmlElement} The first matching element. + */ + querySelector(query) { + let k = sendMessage({ + method: "html", + function: "dom_querySelector", + key: this.key, + query: query + }) + if(!k) return null; + return new HtmlElement(k); + } + + /** + * Query all matching elements from the current element. + * @param {string} query - The query string. + * @returns {HtmlElement[]} An array of matching elements. + */ + querySelectorAll(query) { + let ks = sendMessage({ + method: "html", + function: "dom_querySelectorAll", + key: this.key, + query: query + }) + return ks.map(k => new HtmlElement(k)); + } + + /** + * Get the children of the current element. + * @returns {HtmlElement[]} An array of child elements. + */ + get children() { + let ks = sendMessage({ + method: "html", + function: "getChildren", + key: this.key + }) + return ks.map(k => new HtmlElement(k)); + } +} + +function log(level, title, content) { + sendMessage({ + method: 'log', + level: level, + title: title, + content: content, + }) +} + +let console = { + log: (content) => { + log('info', 'JS Console', content) + }, + warn: (content) => { + log('warning', 'JS Console', content) + }, + error: (content) => { + log('error', 'JS Console', content) + }, +}; + +/** + * Create a comic object + * @param id {string} + * @param title {string} + * @param subtitle {string} + * @param cover {string} + * @param tags {string[]} + * @param description {string} + * @param maxPage {number | null} + * @constructor + */ +function Comic({id, title, subtitle, cover, tags, description, maxPage}) { + this.id = id; + this.title = title; + this.subtitle = subtitle; + this.cover = cover; + this.tags = tags; + this.description = description; + this.maxPage = maxPage; +} + +/** + * Create a comic details object + * @param title {string} + * @param cover {string} + * @param description {string | null} + * @param tags {Map | {} | null} + * @param chapters {Map | {} | null} - key: chapter id, value: chapter title + * @param isFavorite {boolean | null} - favorite status. If the comic source supports multiple folders, this field should be null + * @param subId {string | null} - a param which is passed to comments api + * @param thumbnails {string[] | null} - for multiple page thumbnails, set this to null, and use `loadThumbnails` api to load thumbnails + * @param recommend {Comic[] | null} - related comics + * @param commentCount {number | null} + * @param likesCount {number | null} + * @param isLiked {boolean | null} + * @param uploader {string | null} + * @param updateTime {string | null} + * @param uploadTime {string | null} + * @param url {string | null} + * @constructor + */ +function ComicDetails({title, cover, description, tags, chapters, isFavorite, subId, thumbnails, recommend, commentCount, likesCount, isLiked, uploader, updateTime, uploadTime, url}) { + this.title = title; + this.cover = cover; + this.description = description; + this.tags = tags; + this.chapters = chapters; + this.isFavorite = isFavorite; + this.subId = subId; + this.thumbnails = thumbnails; + this.recommend = recommend; + this.commentCount = commentCount; + this.likesCount = likesCount; + this.isLiked = isLiked; + this.uploader = uploader; + this.updateTime = updateTime; + this.uploadTime = uploadTime; + this.url = url; +} + +/** + * Create a comment object + * @param userName {string} + * @param avatar {string?} + * @param content {string} + * @param time {string?} + * @param replyCount {number?} + * @param id {string?} + * @param isLiked {boolean?} + * @param score {number?} + * @param voteStatus {number?} - 1: upvote, -1: downvote, 0: none + * @constructor + */ +function Comment({userName, avatar, content, time, replyCount, id, isLiked, score, voteStatus}) { + this.userName = userName; + this.avatar = avatar; + this.content = content; + this.time = time; + this.replyCount = replyCount; + this.id = id; + this.isLiked = isLiked; + this.score = score; + this.voteStatus = voteStatus; +} + +class ComicSource { + name = "" + + key = "" + + version = "" + + minAppVersion = "" + + url = "" + + /** + * load data with its key + * @param {string} dataKey + * @returns {any} + */ + loadData(dataKey) { + return sendMessage({ + method: 'load_data', + key: this.key, + data_key: dataKey + }) + } + + /** + * load a setting with its key + * @param key {string} + * @returns {any} + */ + loadSetting(key) { + return sendMessage({ + method: 'load_setting', + key: this.key, + setting_key: key + }) + } + + /** + * save data + * @param {string} dataKey + * @param data + */ + saveData(dataKey, data) { + return sendMessage({ + method: 'save_data', + key: this.key, + data_key: dataKey, + data: data + }) + } + + /** + * delete data + * @param {string} dataKey + */ + deleteData(dataKey) { + return sendMessage({ + method: 'delete_data', + key: this.key, + data_key: dataKey, + }) + } + + /** + * + * @returns {boolean} + */ + get isLogged() { + return sendMessage({ + method: 'isLogged', + key: this.key, + }); + } + + init() { } + + static sources = {} +} \ No newline at end of file diff --git a/index.json b/index.json index 73a32d9..6a8fc2b 100644 --- a/index.json +++ b/index.json @@ -22,5 +22,11 @@ "fileName": "baozi.js", "key": "baozi", "version": "1.0.0" + }, + { + "name": "Picacg", + "fileName": "picacg.js", + "key": "picacg", + "version": "1.0.0" } ] diff --git a/picacg.js b/picacg.js new file mode 100644 index 0000000..4d88206 --- /dev/null +++ b/picacg.js @@ -0,0 +1,649 @@ +class Picacg extends ComicSource { + name = "Picacg" + + key = "picacg" + + version = "1.0.0" + + minAppVersion = "1.0.0" + + url = "https://raw.githubusercontent.com/venera-app/venera_configs/master/picacg.js" + + api = "https://picaapi.picacomic.com" + + apiKey = "C69BAF41DA5ABD1FFEDC6D2FEA56B"; + + createSignature(path, nonce, time, method) { + let data = path + time + nonce + method + this.apiKey + let key = '~d}$Q7$eIni=V)9\\RK/P.RM4;9[7|@/CA}b~OW!3?EV`:<>M7pddUBL5n|0/*Cn' + let s = Convert.encodeUtf8(key) + let h = Convert.encodeUtf8(data.toLowerCase()) + return Convert.hmacString(s, h, 'sha256') + } + + buildHeaders(method, path, token) { + let uuid = createUuid() + let nonce = uuid.replace(/-/g, '') + let time = (new Date().getTime() / 1000).toFixed(0) + let signature = this.createSignature(path, nonce, time, method.toUpperCase()) + return { + "api-key": "C69BAF41DA5ABD1FFEDC6D2FEA56B", + "accept": "application/vnd.picacomic.com.v1+json", + "app-channel": this.loadSetting('appChannel'), + "authorization": token ?? "", + "time": time, + "nonce": nonce, + "app-version": "2.2.1.3.3.4", + "app-uuid": "defaultUuid", + "image-quality": this.loadSetting('imageQuality'), + "app-platform": "android", + "app-build-version": "45", + "Content-Type": "application/json; charset=UTF-8", + "user-agent": "okhttp/3.8.1", + "version": "v1.4.1", + "Host": "picaapi.picacomic.com", + "signature": signature, + } + } + + account = { + login: async (account, pwd) => { + let res = await Network.post( + `${this.api}/auth/sign-in`, + this.buildHeaders('POST', 'auth/sign-in'), + { + email: account, + password: pwd + }) + + if (res.status === 200) { + let json = JSON.parse(res.body) + if (!json.data?.token) { + throw 'Failed to get token\nResponse: ' + res.body + } + this.saveData('token', json.data.token) + return 'ok' + } + + throw 'Failed to login' + }, + + logout: () => { + this.deleteData('token') + }, + + registerWebsite: "https://manhuabika.com/pregister/?" + } + + parseComic(comic) { + let tags = [] + tags.push(...(comic.tags ?? [])) + tags.push(...(comic.categories ?? [])) + return new Comic({ + id: comic._id, + title: comic.title, + subTitle: comic.author, + cover: comic.thumb.fileServer + '/static/' + comic.thumb.path, + tags: tags, + description: `${comic.totalLikes} likes`, + maxPage: comic.pagesCount, + }) + } + + explore = [ + { + title: "Picacg Random", + type: "multiPageComicList", + load: async (page) => { + if (!this.isLogged) { + throw 'Not logged in' + } + let res = await Network.get( + `${this.api}/comics/random`, + this.buildHeaders('GET', 'comics/random', this.loadData('token')) + ) + if (res.status !== 200) { + throw 'Invalid status code: ' + res.status + } + let data = JSON.parse(res.body) + let comics = [] + data.data.comics.forEach(c => { + comics.push(this.parseComic(c)) + }) + return { + comics: comics + } + } + }, + { + title: "Picacg Latest", + type: "multiPageComicList", + load: async (page) => { + if (!this.isLogged) { + throw 'Not logged in' + } + let res = await Network.get( + `${this.api}/comics?page=${page}&s=dd`, + this.buildHeaders('GET', `comics?page=${page}&s=dd`, this.loadData('token')) + ) + if (res.status !== 200) { + throw 'Invalid status code: ' + res.status + } + let data = JSON.parse(res.body) + let comics = [] + data.data.comics.docs.forEach(c => { + comics.push(this.parseComic(c)) + }) + return { + comics: comics + } + } + } + ] + + /// 分类页面 + /// 一个漫画源只能有一个分类页面, 也可以没有, 设置为null禁用分类页面 + category = { + /// 标题, 同时为标识符, 不能与其他漫画源的分类页面重复 + title: "Picacg", + parts: [ + { + name: "主题", + type: "fixed", + categories: [ + "大家都在看", + "大濕推薦", + "那年今天", + "官方都在看", + "嗶咔漢化", + "全彩", + "長篇", + "同人", + "短篇", + "圓神領域", + "碧藍幻想", + "CG雜圖", + "英語 ENG", + "生肉", + "純愛", + "百合花園", + "耽美花園", + "偽娘哲學", + "後宮閃光", + "扶他樂園", + "單行本", + "姐姐系", + "妹妹系", + "SM", + "性轉換", + "足の恋", + "人妻", + "NTR", + "強暴", + "非人類", + "艦隊收藏", + "Love Live", + "SAO 刀劍神域", + "Fate", + "東方", + "WEBTOON", + "禁書目錄", + "歐美", + "Cosplay", + "重口地帶" + ], + itemType: "category", + } + ], + enableRankingPage: true, + } + + /// 分类漫画页面, 即点击分类标签后进入的页面 + categoryComics = { + load: async (category, param, options, page) => { + let type = param ?? 'c' + let res = await Network.get( + `${this.api}/comics?page=${page}&${type}=${encodeURIComponent(category)}&s=${options[0]}`, + this.buildHeaders('GET', `comics?page=${page}&${type}=${encodeURIComponent(category)}&s=${options[0]}`, this.loadData('token')) + ) + if (res.status !== 200) { + throw 'Invalid status code: ' + res.status + } + let data = JSON.parse(res.body) + let comics = [] + data.data.comics.docs.forEach(c => { + comics.push(this.parseComic(c)) + }) + return { + comics: comics, + maxPage: data.data.comics.pages + } + }, + // 提供选项 + optionList: [ + { + options: [ + "dd-New to old", + "da-Old to new", + "ld-Most likes", + "vd-Most nominated", + ], + } + ], + ranking: { + options: [ + "H24-Day", + "D7-Week", + "D30-Month", + ], + load: async (option, page) => { + let res = await Network.get( + `${this.api}/comics/leaderboard?tt=${option}&ct=VC`, + this.buildHeaders('GET', `comics/leaderboard?tt=${option}&ct=VC`, this.loadData('token')) + ) + if (res.status !== 200) { + throw 'Invalid status code: ' + res.status + } + let data = JSON.parse(res.body) + let comics = [] + data.data.comics.forEach(c => { + comics.push(this.parseComic(c)) + }) + return { + comics: comics, + maxPage: 1 + } + } + } + } + + /// 搜索 + search = { + load: async (keyword, options, page) => { + let res = await Network.post( + `${this.api}/comics/advanced-search?page=${page}`, + this.buildHeaders('POST', `comics/advanced-search?page=${page}`, this.loadData('token')), + JSON.stringify({ + keyword: keyword, + sort: options[0], + }) + ) + if (res.status !== 200) { + throw 'Invalid status code: ' + res.status + } + let data = JSON.parse(res.body) + let comics = [] + data.data.comics.docs.forEach(c => { + comics.push(this.parseComic(c)) + }) + return { + comics: comics, + maxPage: data.data.comics.pages + } + }, + optionList: [ + { + options: [ + "dd-New to old", + "da-Old to new", + "ld-Most likes", + "vd-Most nominated", + ], + label: "Sort" + } + ] + } + + /// 收藏 + favorites = { + multiFolder: false, + /// 添加或者删除收藏 + addOrDelFavorite: async (comicId, folderId, isAdding) => { + let res = await Network.post( + `${this.api}/comics/${comicId}/favourite`, + this.buildHeaders('POST', `comics/${comicId}/favourite`, this.loadData('token')), + '{}' + ) + if(res.status !== 200) { + throw 'Invalid status code: ' + res.status + } + return 'ok' + }, + /// 加载漫画 + loadComics: async (page, folder) => { + let sort = this.loadSetting('favoriteSort') + let res = await Network.get( + `${this.api}/users/favourite?page=${page}&s=${sort}`, + this.buildHeaders('GET', `users/favourite?page=${page}&s=${sort}`, this.loadData('token')) + ) + if (res.status !== 200) { + throw 'Invalid status code: ' + res.status + } + let data = JSON.parse(res.body) + let comics = [] + data.data.comics.docs.forEach(c => { + comics.push(this.parseComic(c)) + }) + return { + comics: comics, + maxPage: data.data.comics.pages + } + } + } + + /// 单个漫画相关 + comic = { + // 加载漫画信息 + loadInfo: async (id) => { + let infoLoader = async () => { + let res = await Network.get( + `${this.api}/comics/${id}`, + this.buildHeaders('GET', `comics/${id}`, this.loadData('token')) + ) + if (res.status !== 200) { + throw 'Invalid status code: ' + res.status + } + let data = JSON.parse(res.body) + return data.data.comic + } + let epsLoader = async () => { + let eps = new Map() + let i = 1; + let j = 1; + while(true) { + let res = await Network.get( + `${this.api}/comics/${id}/eps?page=${i}`, + this.buildHeaders('GET', `comics/${id}/eps?page=${i}`, this.loadData('token')) + ) + if (res.status !== 200) { + throw 'Invalid status code: ' + res.status + } + let data = JSON.parse(res.body) + data.data.eps.docs.forEach(e => { + eps.set(j.toString(), e.title) + j++ + }) + if(data.data.eps.pages === i) { + break + } + i++ + } + return eps + } + let relatedLoader = async () => { + let res = await Network.get( + `${this.api}/comics/${id}/recommendation`, + this.buildHeaders('GET', `comics/${id}/recommendation`, this.loadData('token')) + ) + if (res.status !== 200) { + throw 'Invalid status code: ' + res.status + } + let data = JSON.parse(res.body) + let comics = [] + data.data.comics.forEach(c => { + comics.push(this.parseComic(c)) + }) + return comics + } + let [info, eps, related] = await Promise.all([infoLoader(), epsLoader(), relatedLoader()]) + let tags = {} + if(info.author) { + tags['Author'] = [info.author]; + } + if(info.chineseTeam) { + tags['Chinese Team'] = [info.chineseTeam]; + } + return new ComicDetails({ + title: info.title, + cover: info.thumb.fileServer + '/static/' + info.thumb.path, + description: info.description, + tags: { + ...tags, + 'Categories': info.categories, + 'Tags': info.tags, + }, + chapters: eps, + isFavorite: info.isFavourite ?? false, + isLiked: info.isLiked ?? false, + recommend: related, + commentCount: info.commentsCount, + likesCount: info.likesCount, + uploader: info._creator.name, + updateTime: info.updated_at, + }) + }, + // 获取章节图片 + loadEp: async (comicId, epId) => { + let images = [] + let i = 1 + while(true) { + let res = await Network.get( + `${this.api}/comics/${comicId}/order/${epId}/pages?page=${i}`, + this.buildHeaders('GET', `comics/${comicId}/order/${epId}/pages?page=${i}`, this.loadData('token')) + ) + if (res.status !== 200) { + throw 'Invalid status code: ' + res.status + } + let data = JSON.parse(res.body) + data.data.pages.docs.forEach(p => { + images.push(p.media.fileServer + '/static/' + p.media.path) + }) + if(data.data.pages.pages === i) { + break + } + i++ + } + return { + images: images + } + }, + likeComic: async (id, isLike) => { + var res = await Network.post( + `${this.api}/comics/${id}/like`, + this.buildHeaders('POST', `comics/${id}/like`, this.loadData('token')), + {} + ); + if (res.status !== 200) { + throw 'Invalid status code: ' + res.status + } + return 'ok' + }, + // 加载评论 + loadComments: async (comicId, subId, page, replyTo) => { + function parseComment(c) { + return new Comment({ + userName: c._user.name, + avatar: c._user.avatar ? c._user.avatar.fileServer + '/static/' + c._user.avatar.path : undefined, + id: c._id, + content: c.content, + isLiked: c.isLiked, + score: c.likesCount ?? 0, + replyCount: c.commentsCount, + time: c.created_at, + }) + } + let comments = [] + + let maxPage = 1 + + if(replyTo) { + let res = await Network.get( + `${this.api}/comments/${replyTo}/childrens?page=${page}`, + this.buildHeaders('GET', `comments/${replyTo}/childrens?page=${page}`, this.loadData('token')) + ) + if (res.status !== 200) { + throw 'Invalid status code: ' + res.status + } + let data = JSON.parse(res.body) + data.data.comments.docs.forEach(c => { + comments.push(parseComment(c)) + }) + maxPage = data.data.comments.pages + } else { + let res = await Network.get( + `${this.api}/comics/${comicId}/comments?page=${page}`, + this.buildHeaders('GET', `comics/${comicId}/comments?page=${page}`, this.loadData('token')) + ) + if (res.status !== 200) { + throw 'Invalid status code: ' + res.status + } + let data = JSON.parse(res.body) + data.data.comments.docs.forEach(c => { + comments.push(parseComment(c)) + }) + maxPage = data.data.comments.pages + } + return { + comments: comments, + maxPage: maxPage + } + }, + // 发送评论, 返回任意值表示成功 + sendComment: async (comicId, subId, content, replyTo) => { + if(replyTo) { + let res = await Network.post( + `${this.api}/comments/${replyTo}`, + this.buildHeaders('POST', `/comments/${replyTo}`, this.loadData('token')), + JSON.stringify({ + content: content + }) + ) + if (res.status !== 200) { + throw 'Invalid status code: ' + res.status + } + } else { + let res = await Network.post( + `${this.api}/comics/${comicId}/comments`, + this.buildHeaders('POST', `/comics/${comicId}/comments`, this.loadData('token')), + JSON.stringify({ + content: content + }) + ) + if (res.status !== 200) { + throw 'Invalid status code: ' + res.status + } + } + return 'ok' + }, + likeComment: async (comicId, subId, commentId, isLike) => { + let res = await Network.post( + `${this.api}/comments/${commentId}/like`, + this.buildHeaders('POST', `/comments/${commentId}/like`, this.loadData('token')), + '{}' + ) + if (res.status !== 200) { + throw 'Invalid status code: ' + res.status + } + return 'ok' + }, + onClickTag: (namespace, tag) => { + if(namespace === 'Author') { + return { + action: 'category', + keyword: tag, + param: 'a', + } + } else if (namespace === 'Categories') { + return { + action: 'category', + keyword: tag, + param: 'c', + } + } else { + return { + action: 'search', + keyword: tag, + } + } + } + } + + settings = { + 'imageQuality': { + type: 'select', + title: 'Image quality', + options: [ + { + value: 'original', + }, + { + value: 'medium' + }, + { + value: 'low' + } + ], + default: 'original', + }, + 'appChannel': { + type: 'select', + title: 'App channel', + options: [ + { + value: '1', + }, + { + value: '2' + }, + { + value: '3' + } + ], + default: '3', + }, + 'favoriteSort': { + type: 'select', + title: 'Favorite sort', + options: [ + { + value: 'dd', + text: 'New to old' + }, + { + value: 'da', + text: 'Old to new' + }, + ], + default: 'dd', + } + } + + translation = { + 'zh_CN': { + 'Picacg Random': "哔咔随机", + 'Picacg Latest': "哔咔最新", + 'New to old': "新到旧", + 'Old to new': "旧到新", + 'Most likes': "最多喜欢", + 'Most nominated': "最多指名", + 'Day': "日", + 'Week': "周", + 'Month': "月", + 'Author': "作者", + 'Chinese Team': "汉化组", + 'Categories': "分类", + 'Tags': "标签", + 'Image quality': "图片质量", + 'App channel': "分流", + 'Favorite sort': "收藏排序", + }, + 'zh_TW': { + 'Picacg Random': "哔咔隨機", + 'Picacg Latest': "哔咔最新", + 'New to old': "新到舊", + 'Old to new': "舊到新", + 'Most likes': "最多喜歡", + 'Most nominated': "最多指名", + 'Day': "日", + 'Week': "周", + 'Month': "月", + 'Author': "作者", + 'Chinese Team': "漢化組", + 'Categories': "分類", + 'Tags': "標籤", + 'Image quality': "圖片質量", + 'App channel': "分流", + 'Favorite sort': "收藏排序", + }, + } +} \ No newline at end of file