// ==UserScript==
// @name E站功能加强
// @namespace https://greasyfork.org/zh-CN/users/1296281
// @version 2.15.3
// @license GPL-3.0
// @description 功能:1、已收藏显隐切换 2、快速添加收藏功能 3、黑名单屏蔽重复、缺页、低质量画廊 4、详情页生成文件名 5、下一页预加载
// @author ShineByPupil
// @match *://exhentai.org/*
// @match *://e-hentai.org/*
// @icon https://e-hentai.org/favicon.ico
// @grant none
// @require https://update.greasyfork.org/scripts/539247/1664278/%E9%80%9A%E7%94%A8%E7%BB%84%E4%BB%B6%E5%BA%93.js
// ==/UserScript==
(async function () {
"use strict";
const FavoriteName = Symbol("FavoriteName");
const IsFilter = Symbol("IsFilter");
const URL = Symbol("URL");
// 页面类型
const pathname = window.location.pathname;
const pageType = ["/", "/watched", "/popular"].includes(pathname)
? "main"
: /^\/tag\/.*$/.test(pathname)
? "tag"
: /^\/g\/\d+\/[a-z0-9]+\/$/.test(pathname)
? "detail"
: pathname.includes("favorites.php")
? "favorites"
: pathname.includes("uconfig")
? "uconfig"
: pathname.includes("mytags")
? "mytags"
: "other";
const inlineType = ["main", "tag"].includes(pageType)
? document.querySelector("select[onchange]")?.value
: null;
class BitFlags {
#value = Number(localStorage.getItem("test") || 0);
positions = new Map([
["isShowDetail", 0], // 是否显示画廊详情信息
["isShowDetailCount", 1], // 是否显示收藏数、评分信息
["isShowDetailTags", 2], // 是否合并关注标签
["isAlwaysFilter", 3], // 是否开启总是过滤
["isShowFavs", 4], // 是否显示收藏
["isShowFilter", 5], // 是否显示过滤
]);
constructor() {}
getVal(key) {
if (this.positions.has(key)) {
const index = this.positions.get(key);
return (this.#value & (1 << index)) !== 0;
} else {
throw new Error(`找不到变量 ${key}`);
}
}
setVal(key, value) {
if (this.positions.has(key)) {
const index = this.positions.get(key);
value ? (this.#value |= 1 << index) : (this.#value &= ~(1 << index));
localStorage.setItem("test", String(this.#value));
} else {
throw new Error(`找不到变量 ${key}`);
}
}
}
const bitFlags = new BitFlags();
// 处理并发请求
const enqueue = (function (activeSize = 5) {
const activeSet = new Set();
const waitArr = [];
const runPromise = function (promise) {
const p = promise().finally(() => {
activeSet.delete(p);
if (waitArr.length > 0) {
runPromise(waitArr.shift());
}
});
activeSet.add(p);
};
return function (promise) {
return new Promise((resolve, reject) => {
const wrappedPromise = () => {
return promise().then(resolve).catch(reject);
};
if (activeSet.size >= activeSize) {
waitArr.push(wrappedPromise);
} else {
runPromise(wrappedPromise);
}
});
};
})();
// 快速收藏
class FavoritesBtn {
constructor() {
this.ulNode = null;
this.gid = null;
this.t = null;
this.init();
}
async init() {
await this.initRender();
await this.initEvent();
}
async initRender() {
const div = document.createElement("div");
div.dataset.type = "favoritesBtn";
const shadow = div.attachShadow({ mode: "open" });
const ulNode = (this.ulNode = document.createElement("ul"));
ulNode.innerHTML = `
<li class="favdel">取消收藏</li>
`;
ulNode.prepend(...(await this.#getFavoriteLi()));
const style = document.createElement("style");
let color = [
{ boderColor: "#000", backgroundColor: "rgba(0, 0, 0, .5)" },
{ boderColor: "#f00", backgroundColor: "rgba(240, 0, 0, .5)" },
{ boderColor: "#fa0", backgroundColor: "rgba(240, 160, 0, .5)" },
{ boderColor: "#dd0", backgroundColor: "rgba(208, 208, 0, .5)" },
{ boderColor: "#080", backgroundColor: "rgba(0, 128, 0, .5)" },
{ boderColor: "#9f4", backgroundColor: "rgba(144, 240, 64, .5)" },
{ boderColor: "#4bf", backgroundColor: "rgba(64, 176, 240, .5)" },
{ boderColor: "#00f", ackgroundColor: "rgba(0, 0, 240, .5)" },
{ boderColor: "#508", backgroundColor: "rgba(80, 0, 128, .5)" },
{ boderColor: "#e8e", backgroundColor: "rgba(224, 128, 224, .5)" },
];
style.textContent = `
ul {
margin: 0;
padding: 0;
display: none;
flex-direction: column;
position: absolute;
z-index: 100;
min-width: 80px;
max-width: 130px;
}
li {
list-style-type: none;
border: 1px solid;
border-color: #4C6EF5;
background-color: rgba(76, 110, 245, .5);
transition: background-color 0.3s ease;
color: #FFFFFF;
cursor: pointer;
padding: 1px 4px;
margin: 2px 0;
border-radius: 5px;
text-align: center;
white-space: nowrap; /* 不换行 */
overflow: hidden; /* 隐藏溢出的内容 */
text-overflow: ellipsis; /* 用省略号表示溢出的文本 */
text-shadow: 1px 1px 3px #000;
}
li:hover {
background-color: #4C6EF5;
}
${color
.map((n, i) => {
return `
.favorite${i} {
border-color: ${n.boderColor};
background-color: ${n.backgroundColor};
}
.favorite${i}:hover {
background-color: ${n.boderColor};
}
`;
})
.join("")}
`;
shadow.appendChild(style);
shadow.appendChild(ulNode);
document.body.appendChild(div);
}
// 初始化事件
async initEvent() {
// 收藏按钮事件委托
this.ulNode.addEventListener("click", async (event) => {
const { target } = event;
const index = target.getAttribute("data-index");
if (target.tagName === "LI") {
if (target.classList.contains("favdel") && this.gid && this.t) {
// 取消收藏
await updateFavorites("favdel", this.gid, this.t);
this.gid = this.t = null;
MxMessage.success("取消收藏成功");
} else if (index && this.gid && this.t) {
// 设置收藏
await updateFavorites(index, this.gid, this.t);
this.gid = this.t = null;
filterBtn?.handleFilter();
MxMessage.success("收藏成功");
favoritesBtn.hide();
}
}
});
const moveTarget = ["main", "favorites"].includes(pageType)
? document.querySelector(".itg")
: pageType === "detail"
? document.querySelector("#gd1 div")
: null;
let contain = null;
const hide = () => {
contain = null;
favoritesBtn.hide();
};
moveTarget?.addEventListener("mouseover", function (event) {
let groups = null;
if (["main", "favorites"].includes(pageType)) {
const { target } = event;
if (target.tagName === "IMG" && target.alt !== "T") {
const A = target.closest("a");
if (!A) return;
contain = event.target.closest("div");
groups = A.getAttribute("href").split("/");
}
} else if (pageType === "detail") {
contain = event.target;
groups = location.pathname.split("/");
}
if (contain && groups) {
favoritesBtn.update(
groups[groups.length - 3],
groups[groups.length - 2],
);
const rect = contain.getBoundingClientRect();
favoritesBtn.show(
`${rect.left + 10 + window.scrollX}px`,
`${rect.top + 10 + window.scrollY}px`,
);
}
});
moveTarget?.addEventListener("mouseout", function (e) {
if (favoritesBtn.ulNode.matches(":hover")) return;
if (pageType === "main" && e.target.tagName !== "IMG") return;
hide();
});
favoritesBtn.ulNode.addEventListener("mouseleave", (e) => {
if (contain?.matches(":hover")) return;
hide();
});
window.addEventListener("blur", () => {
hide();
});
}
async #getFavoriteLi() {
const result = [];
favoriteList = await getFavorites();
for (let i = 0; i < favoriteList.length; i++) {
if (!/^Favorites \d$/.test(favoriteList[i])) {
const favoriteLi = document.createElement("li");
favoriteLi.innerText = favoriteList[i];
favoriteLi.title = favoriteList[i];
favoriteLi.classList.add(`favorite${i}`);
favoriteLi.setAttribute("data-index", i.toString());
result.push(favoriteLi);
}
}
return result;
}
// 更新快捷收藏按钮元素
async updateUlNode() {
this.ulNode.innerHTML = `
<li class="favdel">取消收藏</li>
`;
this.ulNode.prepend(...(await this.#getFavoriteLi()));
}
show(left, top) {
this.ulNode.style.display = "flex";
this.ulNode.style.left = left;
this.ulNode.style.top = top;
}
hide() {
this.ulNode.style.display = "none";
}
update(gid, t) {
this.gid = gid;
this.t = t;
}
}
// 过滤按钮
class FilterBtn {
constructor() {
this.isShowFavs = bitFlags.getVal("isShowFavs");
this.isShowFilter = bitFlags.getVal("isShowFilter");
this.alwaysFilter = localStorage.getItem("alwaysFilter") || "";
this.button__refresh = null;
this.button__toggle = null;
this.button__filter = null;
this.button__filterAll = null;
this.favoriteSup = null;
this.filterSup = null;
this.favoriteCount = 0;
this.filterCount = 0;
this.init();
}
async init() {
this.initRender();
this.initEvent();
this.initObserver();
this.handleFilter();
}
initRender() {
const htmlTemplate = document.createElement("template");
htmlTemplate.innerHTML = `
<div>
<mx-button class="button__config" type="primary" ripple>
<mx-icon type="setting"></mx-icon>设置
</mx-button>
<mx-button class="button__refresh" type="primary" ripple>
<mx-icon type="refresh"></mx-icon>刷新
</mx-button>
<mx-badge class="favoriteCount">
<mx-button class="button__toggle" type="primary" ripple>
${this.isShowFavs ? "隐藏收藏" : "显示收藏"}
</mx-button>
</mx-badge>
<mx-badge class="filterCount">
<mx-button
class="button__filter"
type="primary"
ripple
${!this.alwaysFilter ? "disabled" : ""}
>
${this.isShowFilter ? "隐藏过滤" : "显示过滤"}
</mx-button>
</mx-badge>
<mx-button
class="button__filterAll"
type="primary"
ripple
${!this.alwaysFilter ? "disabled" : ""}
>
过滤全部
</mx-button>
</div>
`;
const cssTemplate = document.createElement("template");
cssTemplate.innerHTML = `
<style>
div {
position: fixed;
gap: 6px;
right: 15px;
bottom: 15px;
z-index: 100;
display: flex;
flex-direction: column;
}
mx-button {
position: relative;
}
mx-button::part(button) {
width: 100%;
}
button.disabled {
background-color: #C0C4CC;
cursor: not-allowed;
}
</style>
`;
const div = document.createElement("div");
div.dataset.type = "filterBtn";
div.attachShadow({ mode: "open" });
div.shadowRoot.append(htmlTemplate.content, cssTemplate.content);
document.body.appendChild(div);
this.button__refresh = div.shadowRoot.querySelector(".button__refresh");
this.button__toggle = div.shadowRoot.querySelector(".button__toggle");
this.button__filter = div.shadowRoot.querySelector(".button__filter");
this.button__filterAll =
div.shadowRoot.querySelector(".button__filterAll");
this.button__config = div.shadowRoot.querySelector(".button__config");
this.favoriteSup = div.shadowRoot.querySelector(".favoriteCount");
this.filterSup = div.shadowRoot.querySelector(".filterCount");
}
initEvent() {
this.button__refresh.addEventListener("click", () => location.reload());
this.button__toggle.handleClick = () => {
this.isShowFavs = !this.isShowFavs;
bitFlags.setVal("isShowFavs", this.isShowFavs);
this.button__toggle.firstChild.data = this.isShowFavs
? "隐藏收藏"
: "显示收藏";
this.handleFilter();
};
this.button__toggle.addEventListener("click", (e) => {
e.target.handleClick();
channel?.postMessage({ type: "button__toggle" });
});
this.button__filter.handleClick = (e) => {
this.isShowFilter = !this.isShowFilter;
bitFlags.setVal("isShowFilter", this.isShowFilter);
this.button__filter.firstChild.data = this.isShowFilter
? "隐藏过滤"
: "显示过滤";
this.handleFilter();
};
this.button__filter.addEventListener("click", (e) => {
e.target.handleClick();
channel?.postMessage({ type: "button__filter" });
});
this.button__filterAll.addEventListener("click", async () => {
const alwaysFilter = localStorage.getItem("alwaysFilter");
const index = favoriteList.indexOf(alwaysFilter);
if (index === -1) {
return MxMessage.error(`过滤全部失败。不存在 ${alwaysFilter} 收藏`);
}
const list = Array.from(
document.querySelector(".itg").querySelectorAll('div[id^="posted_"]'),
)
.filter((n) => n.title === "")
.map((n) => {
const matches = n.onclick
.toString()
.match(/gid=(\d+)&t=([a-z0-9]+)/);
const [, gid, t] = matches;
return { gid, t };
});
await Promise.all(
list.map(({ gid, t }) => {
return enqueue(() => updateFavorites(index, gid, t));
}),
);
MxMessage.success("过滤全部成功");
});
this.button__config.addEventListener("click", () => configDialog?.show());
window.addEventListener("storage", (e) => {
if (e.key === "isShowFavs") {
this.isShowFavs = e.newValue === "true";
this.button__toggle.innerText = this.isShowFavs
? "隐藏收藏"
: "显示收藏";
this.handleFilter();
}
});
}
initObserver() {
const observer = new MutationObserver((mutationsList) => {
const domSet = new WeakSet();
for (let mutation of mutationsList) {
if (
/^posted_\d+$/.test(mutation.target.id) &&
!domSet.has(mutation.target)
) {
domSet.add(mutation.target);
this.handleFilter();
}
}
});
// 开始观察目标节点
const targetNode = document.querySelector(".itg");
if (targetNode) {
observer.observe(targetNode, {
attributes: true,
subtree: true,
});
}
}
// 更新列表视图的显隐状态(根据切换/总是过滤的规则)
handleFilter() {
if (window.location.pathname === "/favorites.php") return;
this.favoriteSup.value = "";
this.filterSup.value = "";
this.favoriteCount = this.filterCount = 0;
const list = getList();
list.forEach((n) => {
if (n[IsFilter]) {
this.filterCount++;
n.style.display = this.isShowFilter ? "" : "none";
} else if (n[FavoriteName]) {
this.favoriteCount++;
n.style.display = this.isShowFavs ? "" : "none";
} else {
n.style.display = "";
}
});
// 更新 count 统计数据
if (this.favoriteCount && !this.isShowFavs) {
this.favoriteSup.value =
this.favoriteCount > 99 ? "99+" : this.favoriteCount;
}
if (this.filterCount && !this.isShowFilter) {
this.filterSup.value = this.filterCount > 99 ? "99+" : this.filterCount;
}
}
}
// 设置
class ConfigDialog {
// 详情信息缓存
detailInfo = new Map();
button = null;
isShowDetail = bitFlags.getVal("isShowDetail");
isShowDetailCount = bitFlags.getVal("isShowDetailCount");
isShowDetailTags = bitFlags.getVal("isShowDetailTags");
isAlwaysFilter = bitFlags.getVal("isAlwaysFilter");
constructor() {
this.init();
}
init() {
this.initRender();
this.initEvent();
this.isShowDetailCount && this.toggleDetail(this.isShowDetailCount);
this.isShowDetailTags && this.mergeTags(this.isShowDetailTags);
}
initRender() {
const alwaysFilter = localStorage.getItem("alwaysFilter") || "";
const div = document.createElement("div");
div.dataset.type = "configDialog";
const shadow = div.attachShadow({ mode: "open" });
shadow.innerHTML = `
<div class="config">
<div class="config__content">
<div class="title">设置</div>
<hr>
<p>
<mx-switch
class="switch__showDetail"
${this.isShowDetail ? "checked" : ""}
state-sync="showDetail"
></mx-switch>
<span>是否显示画廊详情信息</span>
</p>
<p class="level2" style="display: ${this.isShowDetail ? "" : "none"}">
<mx-switch
class="switch__count"
${this.isShowDetailCount ? "checked" : ""}
state-sync="count"
></mx-switch>
<span>是否显示收藏数、评分信息</span>
</p>
<p class="level2" style="display: ${this.isShowDetail ? "" : "none"}">
<mx-switch
class="switch__tags"
${this.isShowDetailTags ? "checked" : ""}
state-sync="tags"
></mx-switch>
<span>是否合并关注标签(详情页更完整)</span>
</p>
<p>
<mx-switch
${this.isAlwaysFilter ? "checked" : ""}
class="switch__alwaysFilter"
></mx-switch>
<span>将收藏夹设置为总是过滤</span>
</p>
<p class="level2">
<mx-radio-group
class="radio__filter"
value="${alwaysFilter}"
state-sync="alwaysFilter"
${this.isAlwaysFilter ? "" : "disabled"}
></mx-radio-group>
</p>
</div>
<button class="config__close">✕</button>
<div class="config__mask"></div>
</div>
<style>
.config {
display: none;
color: #fff;
font-size: 16px;
}
.config__content {
min-width: 500px;
position: fixed;
z-index: 300;
left: 50%;
top: 20vh;
transform: translateX(-50%);
padding: 20px;
}
.config__content .title {
font-size: 26px;
}
.config__content hr {
margin: 0 -20px;
}
.config__content p {
display: flex;
align-items: center;
gap: 10px;
}
.config__content p.level2 {
margin-left: 50px;
}
.config__close {
width: 30px;
height: 30px;
background: transparent;
position: fixed;
z-index: 300;
top: 30px;
right: 30px;
border: 2px solid #fff;
border-radius: 50%;
font-weight: bold;
cursor: pointer;
}
.config__mask {
position: fixed;
z-index: 200;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
}
</style>
`;
this.config = shadow.querySelector(".config");
this.closeBtn = shadow.querySelector(".config__close");
this.switch__showDetail = shadow.querySelector(".switch__showDetail");
this.switch__count = shadow.querySelector(".switch__count");
this.switch__tags = shadow.querySelector(".switch__tags");
this.switch__alwaysFilter = shadow.querySelector(".switch__alwaysFilter");
this.radio__filter = shadow.querySelector(".radio__filter");
this.radio__filter.options = favoriteList
.filter((n) => !/^Favorites \d$/.test(n))
.map((n) => ({ label: n, value: n }));
document.body.appendChild(div);
}
initEvent() {
const close = () => (this.config.style.display = "");
this.closeBtn.addEventListener("click", close);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") close();
});
// 是否显示画廊详情信息
this.switch__showDetail.addEventListener("change", (event) => {
this.isShowDetail = event.target.value;
bitFlags.setVal("isShowDetail", this.isShowDetail);
if (event.target.value) {
this.switch__count.parentElement.style.display = "flex";
this.switch__tags.parentElement.style.display = "flex";
} else {
this.switch__count.parentElement.style.display = "none";
this.switch__tags.parentElement.style.display = "none";
this.switch__count.removeAttribute("checked");
this.switch__tags.removeAttribute("checked");
}
});
// 是否显示收藏数、评分信息
this.switch__count.addEventListener("change", (event) => {
this.isShowDetailCount = event.target.value;
bitFlags.setVal("isShowDetailCount", this.isShowDetailCount);
this.toggleDetail(event.target.value);
});
// 是否合并关注标签
this.switch__tags.addEventListener("change", (event) => {
this.isShowDetailTags = event.target.value;
bitFlags.setVal("isShowDetailTags", this.isShowDetailTags);
this.mergeTags(event.target.value);
});
// 是否开启总是过滤
this.switch__alwaysFilter.addEventListener("change", (event) => {
this.isAlwaysFilter = event.target.value;
bitFlags.setVal("isAlwaysFilter", this.isAlwaysFilter);
if (event.target.value) {
this.radio__filter.removeAttribute("disabled");
} else {
this.radio__filter.value = "";
this.radio__filter.setAttribute("disabled", "");
}
});
// 过滤设置
this.radio__filter.addEventListener("change", (event) => {
localStorage.setItem("alwaysFilter", event.target.value);
if (filterBtn) {
filterBtn.handleFilter();
if (!event.target.value) {
filterBtn.button__filter.setAttribute("disabled", "");
filterBtn.button__filterAll.setAttribute("disabled", "");
} else {
filterBtn.button__filter.removeAttribute("disabled");
filterBtn.button__filterAll.removeAttribute("disabled");
}
}
});
}
// 打开设置
show() {
this.config.style.display = "block";
}
// 获取详情页信息
async getDetailInfo(url) {
if (this.detailInfo.has(url)) {
return this.detailInfo.get(url);
}
const response = await fetch(url);
const domStr = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(domStr, "text/html");
let favcount = doc.querySelector("#favcount")?.innerText;
favcount = favcount.match(/\d*/)[0];
let rating_count = doc.querySelector("#rating_count")?.innerText;
let rating_label = doc.querySelector("#rating_label")?.innerText;
rating_label = rating_label.match(/[\d.]+/)[0];
// 标签配置
const tagConfigMap = await getTags();
// 详情标签信息
let tagList = Array.from(doc.querySelectorAll("#taglist div[id]"))
.map((n) => n.id.replace("td_", "").replace("_", " "))
.filter((n) => tagConfigMap.has(n));
const info = { favcount, rating_count, rating_label, tagList };
this.detailInfo.set(url, info);
return info;
}
// 切换详情信息
toggleDetail(isChecked) {
if (isChecked) {
const list = getList();
for (const n of list) {
enqueue(() => this.getDetailInfo(n[URL])).then((info) => {
// 5 - 将详细页信息插入文档
const div = document.createElement("div");
div.className = "detailInfo";
const shadow = div.attachShadow({ mode: "open" });
shadow.innerHTML = `
<span>收藏数:${info.favcount}</span>
<span>评分:${info.rating_label}(${info.rating_count})</span>
<style>
:host {
display: flex;
justify-content: space-evenly;
padding-top: 10px;
}
</style>
`;
n.querySelector(".gl3e")?.appendChild(div) || n.appendChild(div);
});
}
} else {
// 隐藏信息
document.querySelectorAll(".detailInfo").forEach((n) => n.remove());
}
}
// 合并标签
async mergeTags(isChecked) {
if (isChecked) {
const list = getList();
// 标签设置
const tagConfigMap = await getTags();
const createTagDom = (tag, color) => {
const div = document.createElement("div");
div.className = "gt mergeTag";
div.style.color = "#f1f1f1";
div.style.borderColor = `${color}`;
div.style.background = `${color}`;
div.style.outline = `3px double ${color}`;
div.style.setProperty("margin", "0 4px 5px", "important");
div.title = tag;
div.innerHTML = tag.split(":")[1];
return div;
};
for (const n of list) {
enqueue(() => this.getDetailInfo(n[URL])).then((info) => {
const { tagList } = info;
if (inlineType === "e") {
// 扩展(全量标签)
// 当前的标签
let currentTags = Array.from(
n.querySelectorAll("table td div[title]"),
).map((n) => n.title);
// 缺失的标签
let missTags = tagList.filter(
(tag) => !currentTags.includes(tag),
);
// 缺失的标签分组
let missTagGroups = [
...new Set(missTags.map((tag) => tag.split(":")[0])),
].reverse();
// 标签分类 DOM 集合
let contentMap = new Map(
Array.from(n.querySelectorAll("table tr")).map((tr) => {
const [_, td] = tr.children;
const type = td.firstElementChild.title.split(":")[0];
return [type, { tr, td }];
}),
);
// 补充遗漏分组
let currentGroups = [...contentMap.keys()];
for (let type of missTagGroups) {
// 存在分类就跳过
if (currentGroups.includes(type)) continue;
// 分组顺序
let order = [
"language",
"artist",
"female",
"male",
"mixed",
"other",
];
let index = order.indexOf(type);
console.log(index);
for (let i = index - 1; index >= 0; i--) {
if (contentMap.has(order[i])) {
const tr = document.createElement("tr");
const td1 = Object.assign(document.createElement("td"), {
className: "tc",
innerHTML: type + ":",
});
const td2 = document.createElement("td");
tr.append(td1, td2);
contentMap.get(order[i]).tr.after(tr); // 新组插入
contentMap.set(type, { tr, td: td2 });
break;
}
}
}
for (let tag of missTags) {
const { color, weight } = tagConfigMap.get(tag);
const tagDom = createTagDom(tag, color);
const type = tag.split(":")[0];
const { td: parent } = contentMap.get(type);
if (parent) {
let tagsNodeList = Array.from(parent.childNodes);
const refNode = tagsNodeList.find((n) => {
return weight >= tagConfigMap.get(n.title)?.weight;
});
if (refNode) {
parent.insertBefore(tagDom, refNode);
} else {
parent.appendChild(tagDom);
}
}
}
} else if (inlineType === "t" || pageType === "favorites") {
// 缩略图(关注标签)
let content = n.querySelector(".gl6t");
if (!content) {
content = Object.assign(document.createElement("div"), {
className: "gl6t",
});
n.querySelector(".gl3t").after(content);
}
let tagsNodeList = content ? Array.from(content.childNodes) : [];
let currentTags = tagsNodeList.map((n) => n.title);
for (let tag of tagList) {
if (
!tagConfigMap.has(tag) || // 没有配置
currentTags.includes(tag) // 没有遗漏
)
continue;
const { color, weight } = tagConfigMap.get(tag);
const tagDom = createTagDom(tag, color);
const refNode = tagsNodeList.find((n) => {
return weight >= tagConfigMap.get(n.title).weight;
});
if (refNode) {
content.insertBefore(tagDom, refNode);
} else {
content.appendChild(tagDom);
}
}
}
});
}
} else {
document.querySelectorAll(".mergeTag").forEach((n) => n.remove());
}
}
}
let favoriteList = await getFavorites(); // 获取收藏配置
const favoritesBtn = new FavoritesBtn(); // 收藏按钮组
let filterBtn = null; // 过滤按钮组
const channel = initBroadcastChannel(); // 标签页广播
const configDialog = new ConfigDialog();
// API - 获取收藏配置
async function getFavorites(disableCache = false) {
let favoriteList = localStorage.getItem("favoriteList");
let result = null;
if (favoriteList && disableCache === false) {
result = JSON.parse(favoriteList);
} else {
const response = await fetch(`${location.origin}/uconfig.php`);
const domStr = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(domStr, "text/html");
const list = Array.from(doc.querySelectorAll("#favsel input")).map(
(n) => n.value,
);
if (list.length) {
localStorage.setItem("favoriteList", JSON.stringify(list));
result = list;
} else {
throw new Error(doc.body.innerText);
}
channel?.postMessage({ type: "getFavorites" });
}
return result;
}
// API - 获取标签配置
async function getTags(disableCache = false) {
let tags = localStorage.getItem("tags");
let result = null;
if (tags && disableCache === false) {
result = new Map(JSON.parse(tags));
} else {
const response = await fetch(`${location.origin}/mytags`);
const htmlText = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(htmlText, "text/html");
// 初始化标签 Map
const tagConfigMap = new Map();
const tagDivs = doc.querySelectorAll("#usertags_outer > div");
// 汉化信息
const ehsyringe = JSON.parse(
localStorage.getItem("ehsyringe.databaseMap"),
);
tagDivs.forEach((div) => {
const title = div.querySelector(".gt")?.getAttribute("title");
if (title) {
const ehsTag = div.querySelector(".gt")?.innerText;
const isWatch = div.querySelector("input[id^=tagwatch]").checked;
const weight = parseInt(
div.querySelector("[id^=tagweight]").value,
10,
);
const color = div.querySelector(".tagcolor").value;
tagConfigMap.set(title, {
ehsTag, // 标签简称
ehsTag_zh: (ehsyringe && ehsyringe[ehsTag]) || ehsTag, // 翻译标签
isWatch, // 是否关注
weight, // 权重
color, // 颜色
});
}
});
localStorage.setItem("tags", JSON.stringify([...tagConfigMap]));
result = tagConfigMap;
}
return result;
}
// API - 更新收藏
async function updateFavorites(type, gid, t) {
const formData = new FormData();
formData.append("favcat", type);
formData.append("favnote", "");
formData.append("update", "1");
// 发生请求
const response = await fetch(
`${location.origin}/gallerypopups.php?gid=${gid}&t=${t}&act=addfav`,
{ method: "POST", body: formData },
);
const domStr = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(domStr, "text/html");
const script = Array.from(doc.querySelectorAll("script")).find((n) =>
n.textContent.includes("window.close()"),
);
if (script) {
let codeStr = script.textContent;
codeStr = codeStr.replace(/window.opener.document/g, "window.document");
codeStr = codeStr.replace(/window.close\(\);/g, "");
const dynamicFunction = new Function(codeStr);
dynamicFunction();
channel?.postMessage({
type: "updateFavorites",
data: { type, gid, t, codeStr },
});
}
}
// 获取主页画廊列表(主页)
function getList() {
// 从缓存读取总是过滤配置
const alwaysFilter = localStorage.getItem("alwaysFilter") || "";
// 获得画廊列表(兼容 扩展 or 缩略图)
const list =
inlineType === "e"
? Array.from(document.querySelectorAll("table.itg >tbody >tr"))
: inlineType === "t" || pageType === "favorites"
? Array.from(document.querySelectorAll(".itg.gld .gl1t"))
: [];
list.forEach((n) => {
// 画廊收藏状态
const find = n.querySelector('[id^="posted_"]');
const url = n
.querySelector('a[href^="https://exhentai.org/g/"]')
?.getAttribute("href");
Object.assign(n, {
[FavoriteName]: find.title, // 收藏信息
[IsFilter]: alwaysFilter && find?.title === alwaysFilter, // 是否过滤
[URL]: url, // 详情页地址
});
});
return list;
}
// 生成文件名(详情页)
async function formatFileName() {
// 文件名去除规则
const keyword = [
"同人誌",
"Vol",
"コミティア",
"サンクリ",
"とら祭り",
"COMIC", // 漫画
"成年コミック", // 成年漫画
"C\\d+",
"よろず",
"FF\\d+",
"\\d{4}年\\d{1,2}月",
"コミック", // 漫画
"オリジナル", // 原创
"ページ欠落", // 页面缺失
"汉化组",
"中文",
"汉化",
"漢化",
"翻訳",
"Chinese",
"chinese",
"CHINESE",
"Digital",
"中国語",
"無修正",
"DL版",
"渣翻",
"机翻",
"機翻",
"重嵌",
"嵌字",
"翻译",
"Decensored", // 审查
"Uncensored", // 未经审查
"超分辨率",
"カラー化", // 全彩
"フルカラー版",
"图源",
"无修正",
"快楽天",
];
const parenthesesRule = "\\([^(]*(" + keyword.join("|") + ")[^(]*\\)"; // 圆括号
const squareBracketsRule = "\\[[^[]*(" + keyword.join("|") + ")[^[]*\\]"; // 方括号
const htmlTemplate = document.createElement("template");
htmlTemplate.innerHTML = `
<mx-input></mx-input>
<mx-button type="primary" ripple>复制</mx-button>
`;
const cssTemplate = document.createElement("template");
cssTemplate.innerHTML = `
<style>
:host {
display: grid;
grid-template-columns: 1fr auto auto;
}
mx-input {
background: #34353b;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
text-align: center;
}
mx-button {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
</style>
`;
const div = document.createElement("div");
div.attachShadow({ mode: "open" });
div.shadowRoot.append(htmlTemplate.content, cssTemplate.content);
document.querySelector(".gm").appendChild(div);
const input = div.shadowRoot.querySelector("mx-input");
const button = div.shadowRoot.querySelector("mx-button");
let title =
document.querySelector("#gj").innerText ||
document.querySelector("#gn").innerText;
title = title
// 统一替换全角括号为半角括号
.replace(/[[]()【】]/g, (match) => {
if (match === "[" || match === "【") {
return "[";
} else if (match === "]" || match === "】") {
return "]";
} else if (match === "(") {
return "(";
} else if (match === ")") {
return ")";
}
})
.replace(/^(\[[^\]]+])(\S)/, "$1 $2") // 开头的[xxx]后面需要有一个空格
.replace(/[\/\\:*?"<>|]/g, " ") // 替换文件系统非法字符为空格
.replace(new RegExp(parenthesesRule, "g"), "") // 自定义过滤规则移除标签
.replace(new RegExp(squareBracketsRule, "g"), "") // 自定义过滤规则移除标签
// 处理空格
.replace(/\(\s*/g, "(")
.replace(/\s*\)/g, ")")
.replace(/\[\s*/g, "[")
.replace(/\s*]/g, "]")
.replace(/\s+/g, " ") // 合并连续空格
.trim(); // 去除首尾空格
// 标签设置
const tagConfigMap = await getTags();
// 额外增加标签(无需关注)
let extraTags = {
"other:full color": { weight: -1, ehsTag_zh: "全彩" },
"other:extraneous ads": { weight: -2, ehsTag_zh: "外部广告" },
"other:incomplete": { weight: -3, ehsTag_zh: "缺页" },
};
// 详情页全部标签
const tagDom = Array.from(document.querySelectorAll("#taglist a"));
// 通过 id 获取 tag
const getTag = (id) => id.slice(3).replace(/_/g, " ");
let tags = [
...new Set(
tagDom
.filter((n) => {
const tag = getTag(n.id);
const tagInfo = tagConfigMap.get(tag);
if (/^group|artist/.test(tag)) {
// 排除特定标签类型(社团、艺术家)
return false;
} else if (tag in extraTags) {
n.order = extraTags[tag].weight;
n.ehsTag_zh = extraTags[tag].ehsTag_zh;
return true;
} else if (!tagInfo) {
return false;
} else if (!tagInfo.isWatch) {
return false;
} else {
n.order = tagInfo.weight;
n.ehsTag_zh = tagInfo.ehsTag_zh;
return true;
}
})
.sort((n, m) => m.order - n.order)
.map((n) => `[${n.ehsTag_zh}]`),
),
].join("");
input.value = (title + " " + tags).trim();
button.onclick = function () {
navigator.clipboard.writeText(input.value);
MxMessage.success("复制成功");
};
}
// 鼠标中键标签,快速查询(详情页)
function quickTagSearch() {
const tagList = document.querySelector("#taglist");
tagList &&
tagList.addEventListener("mousedown", function (event) {
if (event.button === 1 && event.target.tagName === "A") {
const [type, tag] = event.target.title.split(":");
event.preventDefault();
window.open(
`${location.origin}/?f_search=${type}:"${tag}$" l:chinese$&f_sto=on`,
"_blank",
);
}
});
}
// 鼠标中键种子下载(详情页)
function torrentDownload() {
const div = document.querySelector(".gm #gmid #gd5");
div &&
div.addEventListener("mousedown", function (event) {
if (
event.button === 1 &&
event.target.tagName === "A" &&
event.target.getAttribute("href") === "#" &&
event.target.getAttribute("onclick")
) {
const match = event.target
.getAttribute("onclick")
.match(/popUp\('([^']+)'/);
if (match) {
const url = match[1];
event.preventDefault();
window.open(url, "_blank");
}
}
});
}
// 广播频道 - 跨标签页通信,同步状态
function initBroadcastChannel() {
if (typeof BroadcastChannel === "undefined") {
return console.error("当前浏览器不支持 BroadcastChannel");
}
const channel = new BroadcastChannel("filterFavorites");
channel.onmessage = function (event) {
const { type, data } = event.data;
switch (type) {
case "updateFavorites":
// 更新收藏显示
if (pageType === "detail") {
const groups = location.pathname.split("/");
if (
groups[groups.length - 3] !== data.gid ||
groups[groups.length - 2] !== data.t
)
return;
}
const dynamicFunction = new Function(data.codeStr);
dynamicFunction();
// 更新过滤
filterBtn?.handleFilter();
break;
case "getFavorites":
favoritesBtn?.updateUlNode();
break;
case "button__toggle":
filterBtn?.button__toggle?.handleClick();
break;
case "button__filter":
filterBtn?.button__filter?.handleClick();
break;
}
};
return channel;
}
// 预获取下一页资源
function prefetch() {
// 兼容性处理:requestIdleCallback 降级方案
const idleCallback =
window.requestIdleCallback ||
function (cb) {
return setTimeout(
() =>
cb({
didTimeout: false, // 模拟 idle 回调对象
timeRemaining: () => 15, // 至少保证15ms剩余时间
}),
500,
); // 延迟500ms作为降级处理
};
// 在空闲时段预加载下一页图片
idleCallback(async (deadline) => {
try {
// 兼容性检查:确保存在 nexturl 属性
if (!window.nexturl) {
return;
}
// 获取下一页内容
const response = await fetch(window.nexturl);
if (!response.ok) throw new Error(`HTTP 错误 ${response.status}`);
// 解析HTML文档
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
// 提取所有非空图片地址
const imageUrls = new Set( // 使用 Set 去重
[...doc.querySelectorAll("img[src]")]
.map((img) => img.src)
.filter((src) => src.trim().length > 0),
);
// 创建文档片段批量插入
const fragment = document.createDocumentFragment();
imageUrls.forEach((url) => {
const link = document.createElement("link");
link.rel = "prefetch";
link.href = url;
fragment.appendChild(link);
});
// 插入到<head>中触发预加载
document.head.appendChild(fragment);
} catch (error) {
console.error("[预加载] 发生错误:", error);
// 可在此添加错误上报逻辑
}
});
}
if (location.host === "exhentai.org") {
document.documentElement.classList.add("ex");
}
switch (pageType) {
case "main":
case "tag":
filterBtn = new FilterBtn();
prefetch();
break;
case "detail":
await formatFileName();
quickTagSearch();
torrentDownload();
break;
case "favorites":
prefetch();
break;
case "uconfig":
getFavorites(true);
break;
case "mytags":
// 更新缓存标签配置
getTags(true);
break;
}
})();