mirror of
https://github.com/venera-app/venera-configs.git
synced 2025-09-27 16:37:23 +00:00
1642 lines
43 KiB
JavaScript
1642 lines
43 KiB
JavaScript
/** @type {import('./_venera_.js')} */
|
||
|
||
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,
|
||
};
|
||
}
|
||
|
||
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://git.nyne.dev/nyne/venera-configs/raw/branch/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<ComicDetails>}
|
||
*/
|
||
loadInfo: async (id) => {
|
||
const data = await get_gallery_detail(id);
|
||
|
||
const tags = new Map();
|
||
if ("type" in data) tags.set("type", [data.type]);
|
||
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,
|
||
maxPage: data.files.length,
|
||
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<ImageLoadingConfig>}
|
||
*/
|
||
onImageLoad: (url, comicId, epId) => {
|
||
return {
|
||
url,
|
||
headers: {
|
||
referer: refererUrl,
|
||
},
|
||
};
|
||
},
|
||
/**
|
||
* [Optional] provide configs for a thumbnail loading
|
||
* @param url {string}
|
||
* @returns {ImageLoadingConfig | Promise<ImageLoadingConfig>}
|
||
*
|
||
* `ImageLoadingConfig.modifyImage` and `ImageLoadingConfig.onLoadFailed` will be ignored.
|
||
* They are not supported for thumbnails.
|
||
*/
|
||
onThumbnailLoad: (url) => {
|
||
return {
|
||
url,
|
||
headers: {
|
||
referer: refererUrl,
|
||
},
|
||
};
|
||
},
|
||
onClickTag: (namespace, tag) => {
|
||
let fixedNamespace = undefined;
|
||
switch (namespace) {
|
||
case "type":
|
||
fixedNamespace = "type";
|
||
break;
|
||
case "groups":
|
||
fixedNamespace = "group";
|
||
break;
|
||
case "artists":
|
||
fixedNamespace = "artist";
|
||
break;
|
||
case "language":
|
||
fixedNamespace = "language";
|
||
break;
|
||
case "series":
|
||
fixedNamespace = "series";
|
||
break;
|
||
case "characters":
|
||
fixedNamespace = "character";
|
||
break;
|
||
case "females":
|
||
fixedNamespace = "female";
|
||
break;
|
||
case "males":
|
||
fixedNamespace = "male";
|
||
break;
|
||
case "others":
|
||
fixedNamespace = "tag";
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
if (!fixedNamespace) {
|
||
throw new Error("不支持的标签命名空间: " + namespace);
|
||
}
|
||
const keyword = fixedNamespace + ":" + 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,
|
||
};
|
||
}
|