From 2789f852efea301bbda92a7165b8ebd87576ab59 Mon Sep 17 00:00:00 2001 From: Gandum2077 Date: Thu, 5 Jun 2025 20:33:20 +0800 Subject: [PATCH] add support for hitomi.la (#82) --- hitomi.js | 1626 ++++++++++++++++++++++++++++++++++++++++++++++++++++ index.json | 6 + 2 files changed, 1632 insertions(+) create mode 100644 hitomi.js diff --git a/hitomi.js b/hitomi.js new file mode 100644 index 0000000..483f21b --- /dev/null +++ b/hitomi.js @@ -0,0 +1,1626 @@ +const domain2 = "gold-usergeneratedcontent.net"; +const domain = "ltn." + domain2; + +const nozomiextension = ".nozomi"; + +const separator = "-"; +const extension = ".html"; +const galleriesdir = "galleries"; +const index_dir = "tagindex"; +const galleries_index_dir = "galleriesindex"; +const languages_index_dir = "languagesindex"; +const nozomiurl_index_dir = "nozomiurlindex"; +const max_node_size = 464; +const B = 16; +const compressed_nozomi_prefix = "n"; +const tag_index_domain = `tagindex.hitomi.la`; + +const namespaces = [ + "artist", + "character", + "female", + "group", + "language", + "male", + "series", + "tag", + "type", +]; + +const refererUrl = "https://hitomi.la/"; +let galleries_index_version = ""; +let gg = undefined; + +/** + * 求交集 + * @param arrays + * @returns + */ +function intersectAll(arrays) { + if (!arrays.length) return []; + if (arrays.length === 1) return arrays[0]; + + return arrays.reduce((acc, curr) => { + const set = new Set(curr); + return acc.filter((x) => set.has(x)); + }); +} + +/** + * 求差集(A - B) + * @param arrA + * @param arrB + * @returns + */ +function subtract(arrA, arrB) { + const setB = new Set(arrB); + return arrA.filter((x) => !setB.has(x)); +} + +/** + * 求并集 + * @param arrays 数组列表 + * @returns 返回一个包含所有唯一元素的数组 + */ +function unionAll(arrays) { + return Array.from(new Set(arrays.flat())); +} + +/** + * 将一个数组随机排序 + * @param arr + * @returns + */ +function shuffleArray(arr) { + const array = arr.slice(); // 创建副本以避免修改原数组 + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); // 从 0 到 i 之间随机选择索引 + [array[i], array[j]] = [array[j], array[i]]; // 交换元素 + } + return array; +} + +function toISO8601(s) { + // 1. 空格换成 T + // 2. 如果末尾只有 ±HH,就补上 ":00" + return s.replace(" ", "T").replace(/([+-]\d{2})$/, "$1:00"); +} + +function formatDate(date) { + if (typeof date === "string") { + date = toISO8601(date); + date = new Date(date); + } + // 辅助:不足两位则前面补 '0' + function pad(n) { + return n < 10 ? "0" + n : n; + } + const year = date.getFullYear(); // 本地年 + const month = pad(date.getMonth() + 1); // 月份从 0 开始,+1 + const day = pad(date.getDate()); // 日 + const hour = pad(date.getHours()); // 时 + const minute = pad(date.getMinutes()); // 分 + + return `${year}-${month}-${day} ${hour}:${minute}`; +} + +const hash_term = function (term) { + return new Uint8Array(Convert.sha256(Convert.encodeUtf8(term))).slice(0, 4); +}; + +function getUint64(view, byteOffset, littleEndian = false) { + const left = view.getUint32(byteOffset, littleEndian); + const right = view.getUint32(byteOffset + 4, littleEndian); + const combined = littleEndian + ? left + 2 ** 32 * right + : 2 ** 32 * left + right; + + if (!Number.isSafeInteger(combined)) { + console.warn( + `${combined} exceeds MAX_SAFE_INTEGER – precision may be lost` + ); + } + return combined; +} + +function decode_node(data) { + let node = { + keys: [], + datas: [], + subnode_addresses: [], + }; + + let view = new DataView(data.buffer); + let pos = 0; + + const number_of_keys = view.getInt32(pos, false /* big-endian */); + pos += 4; + + let keys = []; + for (let i = 0; i < number_of_keys; i++) { + const key_size = view.getInt32(pos, false /* big-endian */); + if (!key_size || key_size > 32) { + throw new Error("fatal: !key_size || key_size > 32"); + } + pos += 4; + + keys.push(data.slice(pos, pos + key_size)); + pos += key_size; + } + + const number_of_datas = view.getInt32(pos, false /* big-endian */); + pos += 4; + + let datas = []; + for (let i = 0; i < number_of_datas; i++) { + const offset = getUint64(view, pos, false /* big-endian */); + pos += 8; + + const length = view.getInt32(pos, false /* big-endian */); + pos += 4; + + datas.push([offset, length]); + } + + const number_of_subnode_addresses = B + 1; + let subnode_addresses = []; + for (let i = 0; i < number_of_subnode_addresses; i++) { + let subnode_address = getUint64(view, pos, false /* big-endian */); + pos += 8; + + subnode_addresses.push(subnode_address); + } + + node.keys = keys; + node.datas = datas; + node.subnode_addresses = subnode_addresses; + + return node; +} + +async function get_url_at_range(url, range) { + const headers = { referer: refererUrl }; + if (range) headers.range = `bytes=${range[0]}-${range[1]}`; + const res = await Network.fetchBytes("GET", url, headers); + if (res.status !== 200 && res.status !== 206) { + throw new Error("get_url_at_range: " + res.status); + } + return new Uint8Array(res.body); +} + +async function get_node_at_address(field, address) { + if (!galleries_index_version) + throw new Error("galleries_index_version is not set"); + const url = + "https://" + + domain + + "/" + + "galleriesindex/galleries." + + galleries_index_version + + ".index"; + const data = await get_url_at_range(url, [ + address, + address + max_node_size - 1, + ]); + return decode_node(data); +} + +async function get_galleryids_from_data(data) { + let url = + "https://" + + domain + + "/" + + galleries_index_dir + + "/galleries." + + galleries_index_version + + ".data"; + let [offset, length] = data; + if (length > 100000000 || length <= 0) { + throw new Error("length " + length + " is too long"); + } + const inbuf = await get_url_at_range(url, [offset, offset + length - 1]); + let galleryids = []; + + let pos = 0; + let view = new DataView(inbuf.buffer); + let number_of_galleryids = view.getInt32(pos, false /* big-endian */); + pos += 4; + + let expected_length = number_of_galleryids * 4 + 4; + + if (number_of_galleryids > 10000000 || number_of_galleryids <= 0) { + throw new Error( + "number_of_galleryids " + number_of_galleryids + " is too long" + ); + } else if (inbuf.byteLength !== expected_length) { + throw new Error( + "inbuf.byteLength " + + inbuf.byteLength + + " !== expected_length " + + expected_length + ); + } + + for (let i = 0; i < number_of_galleryids; ++i) { + galleryids.push(view.getInt32(pos, false /* big-endian */)); + pos += 4; + } + + return galleryids; +} + +async function B_search(field, key, node) { + const compare_arraybuffers = function (dv1, dv2) { + const top = Math.min(dv1.length, dv2.length); + for (let i = 0; i < top; i++) { + if (dv1[i] < dv2[i]) { + return -1; + } else if (dv1[i] > dv2[i]) { + return 1; + } + } + return 0; + }; + const locate_key = function (key, node) { + let cmp_result = -1; + let i; + for (i = 0; i < node.keys.length; i++) { + cmp_result = compare_arraybuffers(key, node.keys[i]); + if (cmp_result <= 0) { + break; + } + } + return [!cmp_result, i]; + }; + + const is_leaf = function (node) { + for (let i = 0; i < node.subnode_addresses.length; i++) { + if (node.subnode_addresses[i]) { + return false; + } + } + return true; + }; + + if (!node || !node.keys.length) { + return; + } + + let [there, where] = locate_key(key, node); + if (there) { + return node.datas[where]; + } else if (is_leaf(node)) { + return; + } + + if (node.subnode_addresses[where] == 0) { + console.error("non-root node address 0"); + return; + } + + //it's in a subnode + const subnode = await get_node_at_address( + field, + node.subnode_addresses[where] + ); + return await B_search(field, key, subnode); +} + +async function get_galleryids_for_query_without_namespace(query) { + query = query.replace(/_/g, " "); + + const key = hash_term(query); + + const field = "galleries"; + const node = await get_node_at_address(field, 0); + + const data = await B_search(field, key, node); + if (!data) { + return []; + } else { + return await get_galleryids_from_data(data); + } +} + +/** + * 根据当前状态生成Nozomi地址 + * @param state 当前状态 + * @param with_prefix 是否包含前缀("n") + * @returns 返回Nozomi地址 + */ +function nozomi_address_from_state(state, with_prefix) { + if (state.orderby !== "date" || state.orderbykey === "published") { + if (state.area === "all") + //ltn.hitomi.la/popular/year-all.nozomi + return ( + "https://" + + domain + + "/" + + (with_prefix ? compressed_nozomi_prefix + "/" : "") + + [state.orderby, [state.orderbykey, state.language].join("-")].join( + "/" + ) + + nozomiextension + ); + //ltn.hitomi.la/tag/popular/week/female:sole%20female-czech.nozomi + return ( + "https://" + + domain + + "/" + + (with_prefix ? compressed_nozomi_prefix + "/" : "") + + [ + state.area, + state.orderby, + state.orderbykey, + [encodeURI(state.tag), state.language].join("-"), + ].join("/") + + nozomiextension + ); + } + + if (state.area === "all") + return ( + "https://" + + domain + + "/" + + (with_prefix ? compressed_nozomi_prefix + "/" : "") + + [[encodeURI(state.tag), state.language].join("-")].join("/") + + nozomiextension + ); + return ( + "https://" + + domain + + "/" + + (with_prefix ? compressed_nozomi_prefix + "/" : "") + + [state.area, [encodeURI(state.tag), state.language].join("-")].join("/") + + nozomiextension + ); +} + +/** + * 获取指定HMState的gallery IDs + * @param state + * @returns + */ +async function get_galleryids_from_state(state) { + const url = nozomi_address_from_state(state, true); + const data = await get_url_at_range(url); + var nozomi = []; + var view = new DataView(data.buffer); + var total = view.byteLength / 4; + for (var i = 0; i < total; i++) { + nozomi.push(view.getInt32(i * 4, false /* big-endian */)); + } + return nozomi; +} + +async function get_galleryids_and_count({ range, state }) { + const headers = { referer: refererUrl }; + if (range) headers.range = range; + const resp = await Network.fetchBytes( + "GET", + nozomi_address_from_state(state, false), + headers + ); + if (resp.status !== 200 && resp.status !== 206) { + throw `failed fetch: ${resp.status}`; + } + let itemCount = 0; + const temp = parseInt( + resp.headers["content-range"]?.replace(/^[Bb]ytes \d+-\d+\//, "") + ); + if (!isNaN(temp) && temp > 0) { + itemCount = temp / 4; + } + + const arrayBuffer = resp.body; + const nozomi = []; + if (arrayBuffer) { + const view = new DataView(arrayBuffer); + const total = view.byteLength / 4; + for (let i = 0; i < total; i++) { + nozomi.push(view.getInt32(i * 4, false /* big-endian */)); + } + } + return { galleryids: nozomi, count: itemCount }; +} + +async function get_single_galleryblock(gid) { + const url = "https://" + domain + "/" + `galleryblock/${gid}.html`; + const res = await Network.get(url, { referer: refererUrl }); + return parseGalleryBlockInfo(res.body); +} + +async function get_galleryblocks(gids) { + if (gids.length > 25) throw new Error("Be careful: too many blocks"); + return await Promise.all(gids.map((n) => get_single_galleryblock(n))); +} + +/** + * 获取索引版本号 + * @param name 索引名称,默认为 "galleriesindex" + * @returns 返回索引版本号 + */ +async function get_index_version(name = "galleriesindex") { + const url = + "https://" + domain + "/" + name + "/version?_=" + new Date().getTime(); + const resp = await Network.get(url, { referer: refererUrl }); + if (resp.status === 200) { + return resp.body; + } else { + throw new Error(resp.status); + } +} + +async function update_galleries_index_version() { + galleries_index_version = await get_index_version(); +} + +async function get_image_srcs(files) { + const resp = await Network.get( + "https://" + domain + "/" + "gg.js?_=" + new Date().getTime(), + { + referer: refererUrl, + } + ); + if (resp.status >= 400) { + throw new Error(resp.status); + } + + eval(resp.body); + if (!gg.b) throw new Error(); + + const subdomain_from_url = (url, base, dir) => { + var retval = ""; + if (!base) { + if (dir === "webp") { + retval = "w"; + } else if (dir === "avif") { + retval = "a"; + } + } + + var b = 16; + + var r = /\/[0-9a-f]{61}([0-9a-f]{2})([0-9a-f])/; + var m = r.exec(url); + if (!m) { + return retval; + } + + var g = parseInt(m[2] + m[1], b); + if (!isNaN(g)) { + if (base) { + retval = String.fromCharCode(97 + gg.m(g)) + base; + } else { + retval = retval + (1 + gg.m(g)); + } + } + return retval; + }; + + const url_from_url = (url, base, dir) => { + return url.replace( + /\/\/..?\.(?:gold-usergeneratedcontent\.net|hitomi\.la)\//, + "//" + subdomain_from_url(url, base, dir) + "." + domain2 + "/" + ); + }; + + const url_from_url_from_hash = (galleryid, image, dir, ext, base) => { + if ("tn" === base) { + return url_from_url( + "https://a." + + domain2 + + "/" + + dir + + "/" + + real_full_path_from_hash(image.hash) + + "." + + ext, + base + ); + } + return url_from_url(url_from_hash(galleryid, image, dir, ext), base, dir); + }; + + const url_from_hash = (galleryid, image, dir, ext) => { + ext = ext || dir || image.name.split(".").pop(); + if (dir === "webp" || dir === "avif") { + dir = ""; + } else { + dir += "/"; + } + + return ( + "https://a." + + domain2 + + "/" + + dir + + full_path_from_hash(image.hash) + + "." + + ext + ); + }; + + const full_path_from_hash = (hash) => { + return gg.b + gg.s(hash) + "/" + hash; + }; + + const real_full_path_from_hash = (hash) => { + return hash.replace(/^.*(..)(.)$/, "$2/$1/" + hash); + }; + return files.map((image) => url_from_url_from_hash(0, image, "avif")); +} + +/** + * 构建缩略图(small / big)URL + * @param {string} hash 64 位文件哈希 + * @param {boolean} bigTn 是否返回大缩略图 + * @returns {string} + */ +function get_thumbnail_url_from_hash(hash, bigTn) { + return ( + "https://atn." + + domain2 + + "/" + + `${bigTn ? "avifbigtn" : "avifsmalltn"}/${hash.slice(-1)}/${hash.slice( + -3, + -1 + )}/${hash}.avif` + ); +} + +async function get_gallery_detail(gid) { + const resp = await Network.get( + "https://" + domain + "/" + `galleries/${gid}.js`, + { referer: refererUrl } + ); + if (resp.status !== 200) { + throw new Error(resp.status); + } + return parseGalleryDetail(resp.body); +} + +function parseQuery(query) { + const positive_terms = []; + const negative_terms = []; + let or_terms = [[]]; + const terms = query.toLowerCase().trim().split(/\s+/); + terms.forEach((term, i) => { + if (term === "or") return; + + let namespace = undefined; + let value = ""; + if (term.split("").filter((n) => n === ":").length > 1) { + throw new Error("不合法的标签,请使用namespace:tag的格式"); + } + if (term.includes(":")) { + const splits = term.split(":"); + const left = splits[0].replace(/^-/, ""); + if (namespaces.includes(left)) { + namespace = left; + } else { + throw new Error("不合法的namespace"); + } + if (!splits[1]) throw new Error("不合法,标签为空"); + value = splits[1].replace(/_/g, " "); + } else { + value = term.replace(/_/g, " "); + } + + const or_previous = i > 0 && terms[i - 1] === "or"; + const or_next = i + 1 < terms.length && terms[i + 1] === "or"; + if (or_previous || or_next) { + if (term.match(/^-/)) + throw new Error("不合法,或搜索中只能使用正向关键词"); + or_terms[or_terms.length - 1].push({ namespace, value }); + if (!or_next) { + or_terms.push([]); + } + return; + } + + if (term.match(/^-/)) { + negative_terms.push({ namespace, value }); + } else { + positive_terms.push({ namespace, value }); + } + }); + + or_terms + .filter((n) => n.length === 1) + .forEach((n) => { + positive_terms.push(n[0]); + }); + or_terms = or_terms.filter((n) => n.length > 1); + if ( + (or_terms.length > 0 || negative_terms.length > 0) && + positive_terms.length === 0 + ) { + positive_terms.push({ value: "" }); + } + return { + positive_terms, + negative_terms, + or_terms, + }; +} + +async function getSingleTagSearchPage({ state, page }) { + return await get_galleryids_and_count({ + state, + range: "bytes=" + `${page * 100}-${(page + 1) * 100 - 1}`, + }); +} + +async function multiTagSearch(options) { + const getPromise = (n) => { + if (!n.value) { + const state = { + area: "all", + tag: "index", + language: "all", + orderby: options.orderby, + orderbykey: options.orderbykey, + orderbydirection: options.orderbydirection, + }; + return get_galleryids_from_state(state); + } else if (!n.namespace) { + return get_galleryids_for_query_without_namespace(n.value); + } else if (n.namespace === "language") { + const state = { + area: "all", + tag: "index", + language: n.value, + orderby: options.orderby, + orderbykey: options.orderbykey, + orderbydirection: options.orderbydirection, + }; + return get_galleryids_from_state(state); + } else { + const state = { + area: + n.namespace === "female" || n.namespace === "male" + ? "tag" + : n.namespace, + tag: + n.namespace === "female" + ? "female:" + n.value + : n.namespace === "male" + ? "male:" + n.value + : n.value, + language: "all", + orderby: options.orderby, + orderbykey: options.orderbykey, + orderbydirection: options.orderbydirection, + }; + return get_galleryids_from_state(state); + } + }; + const parsed = parseQuery(options.term); + const promises = [ + ...parsed.positive_terms.map((n) => getPromise(n)), + ...parsed.negative_terms.map((n) => getPromise(n)), + ...parsed.or_terms.flat().map((n) => getPromise(n)), + ]; + const result = await Promise.all(promises); + const lp = parsed.positive_terms.length; + const ln = parsed.negative_terms.length; + let r = intersectAll(result.slice(0, lp)); + for (let i = lp; i < lp + ln; i++) { + r = subtract(r, result[i]); + } + let i = lp + ln; + for (const or_term of parsed.or_terms) { + const length = or_term.length; + r = intersectAll([r, unionAll(result.slice(i, i + length))]); + i += length; + } + + return r; +} + +async function search(options) { + const parsed = parseQuery(options.term); + if (!options.term.trim() && options.orderbydirection === "desc") { + const state = { + area: "all", + tag: "index", + language: "all", + orderby: options.orderby, + orderbykey: options.orderbykey, + orderbydirection: options.orderbydirection, + }; + const { galleryids, count } = await getSingleTagSearchPage({ + state, + page: 0, + }); + return { + type: "single", + gids: galleryids, + count, + state, + }; + } else if ( + parsed.negative_terms.length === 0 && + parsed.or_terms.length === 0 && + parsed.positive_terms.length === 1 && + parsed.positive_terms[0].namespace && + options.orderbydirection === "desc" + ) { + const state = { + area: "all", + tag: "index", + language: "all", + orderby: options.orderby, + orderbykey: options.orderbykey, + orderbydirection: options.orderbydirection, + }; + const n = parsed.positive_terms[0]; + if (!n.namespace) throw new Error(""); + if (n.namespace === "language") { + state.language = n.value; + } else { + state.area = + n.namespace === "female" || n.namespace === "male" + ? "tag" + : n.namespace; + state.tag = + n.namespace === "female" + ? "female:" + n.value + : n.namespace === "male" + ? "male:" + n.value + : n.value; + } + + const { galleryids, count } = await getSingleTagSearchPage({ + state, + page: 0, + }); + return { + type: "single", + gids: galleryids, + count, + state, + }; + } else { + await update_galleries_index_version(); + const gids = await multiTagSearch(options); + const rgids = + options.orderbydirection === "random" + ? shuffleArray(gids) + : options.orderbydirection === "asc" + ? gids.toReversed() + : gids; + return { + type: "all", + gids: rgids, + count: rgids.length, + }; + } +} + +function parseGalleryBlockInfo(body) { + const mangaEl = new HtmlDocument(body); + + // 标题和详情页链接 + const titleLink = mangaEl.querySelector("h1.lillie > a"); + const gid = /-(\d+)\.html$/.exec(titleLink.attributes["href"]).at(1); + const title = titleLink.text; + + // 封面图URL + const thumbnail_hashs = []; + const srcs = Array.from(mangaEl.querySelectorAll("img")).map((a) => + a.attributes["data-src"].trim() + ); + srcs.forEach((src) => { + const r = /\/(\w{64})\./.exec(src); + if (r) { + const hash = r[1]; + thumbnail_hashs.push(hash); + } + }); + + // 作者列表 + const artists = Array.from(mangaEl.querySelectorAll(".artist-list li a")).map( + (a) => a.text.trim() + ); + + let language = undefined; + let series = []; + let type = undefined; + // 描述表格:Series / Type / Language + const rows = mangaEl.querySelectorAll(".dj-desc tr"); + rows.forEach((row) => { + const key = row.children[0].text.trim().toLowerCase(); + const valueCell = row.children[1]; + switch (key) { + case "series": { + const text = valueCell.text.trim(); + if (text !== "N/A") { + const as = valueCell.querySelectorAll("a"); + as.forEach((a) => series.push(a.text.trim())); + } + break; + } + case "type": { + type = valueCell.text.trim(); + break; + } + case "language": { + if (valueCell.querySelector("a")) { + const href = valueCell.querySelector("a").attributes["href"]; + const r = /\/index-(\w+)\.html/.exec(href); + if (r) { + language = r[1]; + } + } + break; + } + } + }); + + // 标签列表 + const females = []; + const males = []; + const others = []; + Array.from(mangaEl.querySelectorAll(".relatedtags li a")).map((a) => { + const text = a.text.trim(); + if (text.endsWith(" ♀")) { + females.push(text.slice(0, -2)); + } else if (text.endsWith(" ♂")) { + males.push(text.slice(0, -2)); + } else { + others.push(text); + } + }); + + // 发布日期(原始字符串) + const postedRaw = mangaEl.querySelector(".date").text.trim(); + const posted_time = new Date(toISO8601(postedRaw)); + + return { + gid, + title, + type, + language, + artists, + series, + females, + males, + others, + thumbnail_hashs, + posted_time, + }; +} + +function parseGalleryDetail(text) { + const data = JSON.parse(text.slice(18)); + const artists = []; + const groups = []; + const series = []; + const characters = []; + const females = []; + const males = []; + const others = []; + const translations = []; + const related_gids = []; + if ( + "artists" in data && + Array.isArray(data.artists) && + data.artists.length > 0 + ) { + data.artists.forEach((n) => artists.push(n.artist)); + } + if ( + "groups" in data && + Array.isArray(data.groups) && + data.groups.length > 0 + ) { + data.groups.forEach((n) => groups.push(n.group)); + } + if ( + "parodys" in data && + Array.isArray(data.parodys) && + data.parodys.length > 0 + ) { + data.parodys.forEach((n) => series.push(n.parody)); + } + if ( + "characters" in data && + Array.isArray(data.characters) && + data.characters.length > 0 + ) { + data.characters.forEach((n) => characters.push(n.character)); + } + if ("tags" in data && Array.isArray(data.tags) && data.tags.length > 0) { + data.tags + .filter((n) => n.female === "1") + .forEach((n) => females.push(n.tag)); + data.tags.filter((n) => n.male === "1").forEach((n) => males.push(n.tag)); + data.tags + .filter((n) => !n.male && !n.female) + .forEach((n) => others.push(n.tag)); + } + if ( + "languages" in data && + Array.isArray(data.languages) && + data.languages.length > 0 + ) { + data.languages.forEach((n) => { + translations.push({ + gid: n.galleryid, + language: n.name, + }); + }); + } + if ( + "related" in data && + Array.isArray(data.related) && + data.related.length > 0 + ) { + data.related.forEach((n) => related_gids.push(n)); + } + + return { + gid: parseInt(data.id), + title: data.title, + url: "https://hitomi.la" + data.galleryurl, + type: data.type, + length: data.files.length, + language: "language" in data && data.language ? data.language : undefined, + artists, + groups, + series, + characters, + females, + males, + others, + thumbnail_hash: data.files[0].hash, + files: data.files, + posted_time: new Date(toISO8601(data.date)), + translations, + related_gids, + }; +} + +/** @type {import('./_venera_.js')} */ +class Hitomi extends ComicSource { + // Note: The fields which are marked as [Optional] should be removed if not used + + // name of the source + name = "hitomi.la"; + + // unique id of the source + key = "hitomi"; + + version = "1.0.0"; + + minAppVersion = "1.4.0"; + + // update url + url = "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@main/hitomi.js"; + + galleryCache = []; + categoryResultCache = undefined; + searchResultCache = undefined; + + _mapGalleryBlockInfoToComic(n) { + return new Comic({ + id: n.gid, + title: n.title, + subTitle: n.artists.length ? n.artists.join(" ") : "", + cover: get_thumbnail_url_from_hash(n.thumbnail_hashs[0], true), + tags: [ + ...n.series, + ...n.females.map((m) => "f:" + m), + ...n.males.map((m) => "m:" + m), + ...n.others.map((m) => "f:" + m), + ], + language: n.language, + description: n.type + ? n.type + "\n" + formatDate(n.posted_time) + : n.posted_time, + }); + } + + /** + * [Optional] init function + */ + init() {} + + // explore page list + explore = [ + { + // title of the page. + // title is used to identify the page, it should be unique + title: "hitomi.la", + + /// multiPartPage or multiPageComicList or mixed + type: "multiPageComicList", + + /** + * load function + * @param page {number | null} - page number, null for `singlePageWithMultiPart` type + * @returns {{}} + * - for `multiPartPage` type, return [{title: string, comics: Comic[], viewMore: PageJumpTarget}] + * - for `multiPageComicList` type, for each page(1-based), return {comics: Comic[], maxPage: number} + * - for `mixed` type, use param `page` as index. for each index(0-based), return {data: [], maxPage: number?}, data is an array contains Comic[] or {title: string, comics: Comic[], viewMore: string?} + */ + load: async (page) => { + if (!page) page = 1; + const result = await getSingleTagSearchPage({ + state: { + area: "all", + tag: "index", + language: "all", + orderby: "date", + orderbykey: "added", + orderbydirection: "desc", + }, + page: page - 1, + }); + + const comics = (await get_galleryblocks(result.galleryids)).map((n) => + this._mapGalleryBlockInfoToComic(n) + ); + + return { + comics, + maxPage: Math.ceil(result.count / 25), + }; + }, + + /** + * Only use for `multiPageComicList` type. + * `loadNext` would be ignored if `load` function is implemented. + * @param next {string | null} - next page token, null if first page + * @returns {Promise<{comics: Comic[], next: string?}>} - next is null if no next page. + */ + loadNext(next) {}, + }, + ]; + + // categories + category = { + /// title of the category page, used to identify the page, it should be unique + title: "hitomi.la", + parts: [ + { + name: "Language", + type: "fixed", + categories: [ + { + label: "Chinese", + target: { + page: "category", + attributes: { + category: "language", + param: "chinese", + }, + }, + }, + { + label: "English", + target: { + page: "category", + attributes: { + category: "language", + param: "english", + }, + }, + }, + ], + }, + { + name: "类别", + type: "fixed", + categories: [ + { + label: "doujinshi", + target: { + page: "category", + attributes: { + category: "type", + param: "doujinshi", + }, + }, + }, + { + label: "manga", + target: { + page: "category", + attributes: { + category: "type", + param: "manga", + }, + }, + }, + { + label: "artistcg", + target: { + page: "category", + attributes: { + category: "type", + param: "artistcg", + }, + }, + }, + { + label: "gamecg", + target: { + page: "category", + attributes: { + category: "type", + param: "gamecg", + }, + }, + }, + { + label: "imageset", + target: { + page: "category", + attributes: { + category: "type", + param: "imageset", + }, + }, + }, + { + label: "anime", + target: { + page: "category", + attributes: { + category: "type", + param: "anime", + }, + }, + }, + ], + }, + ], + // enable ranking page + enableRankingPage: true, + }; + + /// 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) => { + if (page === 1) { + const option = parseInt(options[0]); + const term = category + ":" + param; + const searchOptions = { + term, + orderby: "date", + orderbykey: "added", + orderbydirection: "desc", + }; + + switch (option) { + case 1: + searchOptions.orderbykey = "published"; + break; + case 2: + searchOptions.orderby = "popular"; + searchOptions.orderbykey = "today"; + break; + case 3: + searchOptions.orderby = "popular"; + searchOptions.orderbykey = "week"; + break; + case 4: + searchOptions.orderby = "popular"; + searchOptions.orderbykey = "month"; + break; + case 5: + searchOptions.orderby = "popular"; + searchOptions.orderbykey = "year"; + break; + case 6: + searchOptions.orderbydirection = "random"; + break; + default: + break; + } + const result = await search(searchOptions); + if (result.type === "single") { + const comics = (await get_galleryblocks(result.gids)).map((n) => + this._mapGalleryBlockInfoToComic(n) + ); + this.categoryResultCache = { + type: "single", + state: result.state, + count: result.count, + }; + return { + comics, + maxPage: Math.ceil(result.count / 25), + }; + } else { + const comics = ( + await get_galleryblocks( + result.gids.slice(25 * page - 25, 25 * page) + ) + ).map((n) => this._mapGalleryBlockInfoToComic(n)); + this.categoryResultCache = { + type: "all", + gids: result.gids, + count: result.count, + }; + return { + comics, + maxPage: Math.ceil(result.count / 25), + }; + } + } else { + if (this.categoryResultCache.type === "single") { + const result = await getSingleTagSearchPage({ + state: this.categoryResultCache.state, + page: page - 1, + }); + const comics = (await get_galleryblocks(result.galleryids)).map((n) => + this._mapGalleryBlockInfoToComic(n) + ); + return { + comics, + maxPage: Math.ceil(this.categoryResultCache.count / 25), + }; + } else { + const comics = ( + await get_galleryblocks( + this.categoryResultCache.gids.slice(25 * page - 25, 25 * page) + ) + ).map((n) => this._mapGalleryBlockInfoToComic(n)); + return { + comics, + maxPage: Math.ceil(this.categoryResultCache.count / 25), + }; + } + } + }, + // 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: [ + "0-Date Added", + "1-Date Published", + "2-Popular:Today", + "3-Popular:Week", + "4-Popular:Month", + "5-Popular:Year", + "6-Random", + ], + // [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: ["today-Today", "week-Week", "month-Month", "year-Year"], + /** + * load ranking comics + * @param option {string} - option from optionList + * @param page {number} - page number + * @returns {Promise<{comics: Comic[], maxPage: number}>} + */ + load: async (option, page) => { + if (!page) page = 1; + const result = await getSingleTagSearchPage({ + state: { + area: "all", + tag: "index", + language: "all", + orderby: "popular", + orderbykey: option, + orderbydirection: "desc", + }, + page: page - 1, + }); + + const comics = (await get_galleryblocks(result.galleryids)).map((n) => + this._mapGalleryBlockInfoToComic(n) + ); + + return { + comics, + maxPage: Math.ceil(result.count / 25), + }; + }, + }, + }; + + /// 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) => { + if (page === 1) { + const option = parseInt(options[0]); + const term = keyword; + const searchOptions = { + term, + orderby: "date", + orderbykey: "added", + orderbydirection: "desc", + }; + + switch (option) { + case 1: + searchOptions.orderbykey = "published"; + break; + case 2: + searchOptions.orderby = "popular"; + searchOptions.orderbykey = "today"; + break; + case 3: + searchOptions.orderby = "popular"; + searchOptions.orderbykey = "week"; + break; + case 4: + searchOptions.orderby = "popular"; + searchOptions.orderbykey = "month"; + break; + case 5: + searchOptions.orderby = "popular"; + searchOptions.orderbykey = "year"; + break; + case 6: + searchOptions.orderbydirection = "random"; + break; + default: + break; + } + const result = await search(searchOptions); + if (result.type === "single") { + const comics = (await get_galleryblocks(result.gids)).map((n) => + this._mapGalleryBlockInfoToComic(n) + ); + this.searchResultCache = { + type: "single", + state: result.state, + count: result.count, + }; + return { + comics, + maxPage: Math.ceil(result.count / 25), + }; + } else { + const comics = ( + await get_galleryblocks( + result.gids.slice(25 * page - 25, 25 * page) + ) + ).map((n) => this._mapGalleryBlockInfoToComic(n)); + this.searchResultCache = { + type: "all", + gids: result.gids, + count: result.count, + }; + return { + comics, + maxPage: Math.ceil(result.count / 25), + }; + } + } else { + if (this.searchResultCache.type === "single") { + const result = await getSingleTagSearchPage({ + state: this.searchResultCache.state, + page: page - 1, + }); + const comics = (await get_galleryblocks(result.galleryids)).map((n) => + this._mapGalleryBlockInfoToComic(n) + ); + return { + comics, + maxPage: Math.ceil(this.searchResultCache.count / 25), + }; + } else { + const comics = ( + await get_galleryblocks( + this.searchResultCache.gids.slice(25 * page - 25, 25 * page) + ) + ).map((n) => this._mapGalleryBlockInfoToComic(n)); + return { + comics, + maxPage: Math.ceil(this.searchResultCache.count / 25), + }; + } + } + }, + + /** + * load search result with next page token. + * The field will be ignored if `load` function is implemented. + * @param keyword {string} + * @param options {(string)[]} - options from optionList + * @param next {string | null} + * @returns {Promise<{comics: Comic[], maxPage: number}>} + */ + loadNext: async (keyword, options, next) => {}, + + // provide options for search + optionList: [ + { + // [Optional] default is `select` + // type: select, multi-select, dropdown + // For select, there is only one selected value + // For multi-select, there are multiple selected values or none. The `load` function will receive a json string which is an array of selected values + // For dropdown, there is one selected value at most. If no selected value, the `load` function will receive a null + type: "select", + // For a single option, use `-` to separate the value and text, left for value, right for text + options: [ + "0-Date Added", + "1-Date Published", + "2-Popular:Today", + "3-Popular:Week", + "4-Popular:Month", + "5-Popular:Year", + "6-Random", + ], + // option label + label: "sort", + // default selected options. If not set, use the first option as default + default: null, + }, + ], + + // enable tags suggestions + enableTagsSuggestions: true, + }; + + /// single comic related + comic = { + /** + * load comic info + * @param id {string} + * @returns {Promise} + */ + loadInfo: async (id) => { + const data = await get_gallery_detail(id); + + const tags = new Map(); + if ("type" in data) tags.set("type", [data.type]); + tags.set("pages", data.files.length); + if (data.groups.length) tags.set("groups", data.groups); + if (data.artists.length) tags.set("artists", data.artists); + if ("language" in data) tags.set("language", [data.language]); + if (data.series.length) tags.set("series", data.series); + if (data.characters.length) tags.set("characters", data.characters); + if (data.females.length) tags.set("females", data.females); + if (data.males.length) tags.set("males", data.males); + if (data.others.length) tags.set("others", data.others); + + let recommend = undefined; + if (data.related_gids.length) { + recommend = (await get_galleryblocks(data.related_gids)).map((n) => + this._mapGalleryBlockInfoToComic(n) + ); + } + + this.galleryCache = data; + + return new ComicDetails({ + title: data.title, + cover: get_thumbnail_url_from_hash(data.thumbnail_hash, true), + tags, + thumbnails: data.files.map((n) => get_thumbnail_url_from_hash(n.hash)), + uploadTime: formatDate(data.posted_time), + url: data.url, + recommend, + }); + }, + /** + * load images of a chapter + * @param comicId {string} + * @param epId {string?} + * @returns {Promise<{images: string[]}>} + */ + loadEp: async (comicId, epId) => { + const data = this.galleryCache; + if (data.type === "anime") throw new Error("不支持视频浏览"); + const images = await get_image_srcs(data.files); + return { images }; + }, + /** + * [Optional] provide configs for an image loading + * @param url + * @param comicId + * @param epId + * @returns {ImageLoadingConfig | Promise} + */ + onImageLoad: (url, comicId, epId) => { + return { + url, + headers: { + referer: refererUrl, + }, + }; + }, + /** + * [Optional] provide configs for a thumbnail loading + * @param url {string} + * @returns {ImageLoadingConfig | Promise} + * + * `ImageLoadingConfig.modifyImage` and `ImageLoadingConfig.onLoadFailed` will be ignored. + * They are not supported for thumbnails. + */ + onThumbnailLoad: (url) => { + return { + url, + headers: { + referer: refererUrl, + }, + }; + }, + onClickTag: (namespace, tag) => { + const keyword = namespace + ":" + tag.replaceAll(" ", "_"); + return { + page: "search", + attributes: { + keyword, + }, + }; + }, + /** + * [Optional] Handle links + */ + link: { + /** + * set accepted domains + */ + domains: ["hitomi.la"], + /** + * parse url to comic id + * @param url {string} + * @returns {string | null} + */ + linkToId: (url) => { + const reg = /https:\/\/hitomi\.la\/\w+\/[^\/]+-(\d+)\.html/; + const r = reg.exec(url); + if (r) { + return r[1]; + } else { + throw new Error("Invalid gallery url of hitomi.la"); + } + }, + }, + // enable tags translate + enableTagsTranslate: true, + }; + + /* + [Optional] settings related + Use this.loadSetting to load setting + ``` + let setting1Value = this.loadSetting('setting1') + console.log(setting1Value) + ``` + */ + settings = {}; + + // [Optional] translations for the strings in this config + translation = { + zh_CN: { + Setting1: "设置1", + Setting2: "设置2", + Setting3: "设置3", + }, + zh_TW: {}, + en: {}, + }; +} diff --git a/index.json b/index.json index 6e7974c..a3bcf5d 100644 --- a/index.json +++ b/index.json @@ -67,5 +67,11 @@ "fileName": "shonenjumpplus.js", "key": "shonen_jump_plus", "version": "1.0.0" + }, + { + "name": "hitomi.la", + "fileName": "hitomi.js", + "key": "hitomi", + "version": "1.0.0" } ]