Jav-Pro版:鉴定处理、隐私模式、海报无水印、新作品检测、磁力预览、评论增强、115/WebDAV数据备份、以图识图、字幕海报直传;支持 JavDB / JavBus。
// ==UserScript== // @name JHS-Pro // @namespace https://greasyfork.org/zh-CN/scripts/590210-jhs-pro // @version 2026.09.03 // @author lesspem // @description Jav-Pro版:鉴定处理、隐私模式、海报无水印、新作品检测、磁力预览、评论增强、115/WebDAV数据备份、以图识图、字幕海报直传;支持 JavDB / JavBus。 // @license MIT // @icon https://www.google.com/s2/favicons?sz=64&domain=javdb.com // @homepageURL https://greasyfork.org/zh-CN/scripts/590210-jhs-pro // @match https://javdb.com/* // @match https://www.javbus.com/* // @include https://javdb*.com/* // @include https://*javbus*/* // @include https://*javsee*/* // @include https://*seejav*/* // @include https://javtrailers.com/* // @include https://subtitlecat.com/* // @exclude https://*javbus*/forum/* // @exclude https://*javbus*/*actresses // @exclude https://*javsee*/forum/* // @exclude https://*javsee*/*actresses // @exclude https://*seejav*/forum/* // @exclude https://*seejav*/*actresses // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/js/tabulator.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/layer.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/js/md5.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/src/toastify.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/localforage.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/viewer.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js // @connect javxy.cc.cd // @connect xunlei.com // @connect geilijiasu.com // @connect ja.wikipedia.org // @connect cdn.jsdelivr.net // @connect raw.githubusercontent.com // @connect beta.magnet.pics // @connect jdforrepam.com // @connect cc3001.dmm.co.jp // @connect cc3001.dmm.com // @connect www.dmm.co.jp // @connect www.dmm.com // @connect api.dmm.com // @connect special.dmm.co.jp // @connect adult.contents.fc2.com // @connect fc2ppvdb.com // @connect 123av.com // @connect u3c3.com // @connect u9a9.com // @connect btsow.lol // @connect sukebei.nyaa.si // @connect javstore.net // @connect 3xplanet.com // @connect javbest.net // @connect jpvy.org // @connect javfree.me // @connect projectjav.com // @connect api.imgur.com // @connect javp.cc.cd // @connect lesspem.github.io // @connect missav.live // @connect jable.tv // @connect www.av.gl // @connect jav.rs // @connect javtrailers.com // @connect subtitlecat.com // @connect javdb.com // @connect javbus.com // @connect supjav.com // @connect translate-pa.googleapis.com // @connect 127.0.0.1 // @connect 115.com // 字幕直传 115: sampleinitupload 换来的上传地址在阿里云 OSS 上 // @connect aliyuncs.com // 115备份 下载: pickcode 换来的临时直链在 115 自己的 CDN 上 // @connect 115cdn.net // @connect whatslink.info // @connect * // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_openInTab // @grant GM_setClipboard // @grant unsafeWindow // @run-at document-idle // ==/UserScript== var e, t, n = Object.defineProperty, a = e => { throw TypeError(e); }, i = (e, t, a) => ((e, t, a) => t in e ? n(e, t, { enumerable: !0, configurable: !0, writable: !0, value: a }) : e[t] = a)(e, "symbol" != typeof t ? t + "" : t, a), s = (e, t, n) => (((e, t, n) => { t.has(e) || a("Cannot " + n); })(e, t, "access private method"), n); const o = window.location.href, r = o.includes("javdb"), l = o.includes("javbus") || o.includes("seejav") || o.includes("bus") || o.includes("javsee") || $("title").text().trim().startsWith("JavBus - AV"), c = o.includes("/search?q") || o.includes("/search/") || o.includes("/users/"), d = "filter", h = "favorite", g = "hasDown", p = "hasWatch", m = "屏蔽", u = "已屏蔽", f = "#de3333", v = "收藏", b = "已收藏", w = "#25b1dc", y = "已下载", x = "#7bc73b", k = "已观看", S = "#d7a80c", C = "no", _ = "yes", T = "javdb", I = "javbus", B = "actor", P = "actress", D = "censored", A = "uncensored", L = [ { id: "video-mhb", quality: "dmb_w", text: "旧视频源-中画质宽版 (404p)", canSelect: !1 }, { id: "video-mhb", quality: "sm_s", text: "旧视频源-低画质 (240p)", canSelect: !1 }, { id: "video-mhb", quality: "dm_s", text: "旧视频源-中画质 (360p)", canSelect: !1 }, { id: "video-mhb", quality: "dmb_s", text: "旧视频源-中画质 (480p)", canSelect: !1 }, { id: "video-mhb", quality: "mhb_w", text: "旧视频源-高画质宽版 (404p)", canSelect: !1 }, { id: "video-mmb", quality: "mmb", text: "中画质 (432p)", canSelect: !0 }, { id: "video-mhb", quality: "mhb", text: "高画质 (576p)", canSelect: !0 }, { id: "video-hmb", quality: "hmb", text: "HD (720p)", canSelect: !0 }, { id: "video-hhb", quality: "hhb", text: "FullHD (1080p)", canSelect: !0 }, { id: "video-hhbs", quality: "hhbs", text: "FullHD (1080p60fps)", canSelect: !0 }, { id: "video-4k", quality: "4k", text: "4K (2160p)", canSelect: !0 }, { id: "video-4ks", quality: "4ks", text: "4K (2160p60fps)", canSelect: !0 } ]; function escapeHtml(e) { const t = document.createElement("span"); return t.textContent = e, t.innerHTML; } /** 插件默认关闭名单(插件管理里的初始状态)。用户在插件管理里改过之后, 存储里的值优先。 * 「详情页按钮」默认关: 详情页快捷图标栏(DetailQuickIconPlugin)已经覆盖了它的功能。 */ const DEFAULT_DISABLED_PLUGINS = '[]'; /** * 版本标记: 脚本一加载就写到页面 window 上, 与任何页面类型/插件生命周期无关。 * 控制台输入 __JHS_VERSION__ 即可确认 Tampermonkey 里跑的是哪一版。 */ const JHS_SCRIPT_VERSION = "1.5.5"; (() => { try { unsafeWindow.__JHS_VERSION__ = JHS_SCRIPT_VERSION; } catch (e) {} try { window.__JHS_VERSION__ = JHS_SCRIPT_VERSION; } catch (e) {} console.log("[JHS] 脚本已加载, 版本:", JHS_SCRIPT_VERSION); })(); const CURRENT_DATA_VERSION = 1; let M = ""; window.location.href.includes("hideNav=1") && (M = "\n .navbar-default {\n display: none !important;\n }\n body {\n padding-top:0px!important;\n }\n "); /* N (JavBus CSS) moved to css-injection.js */ let j = ""; window.location.href.includes("hideNav=1") && (j = "\n .main-nav,#search-bar-container {\n display: none !important;\n }\n \n html {\n padding-top:0px!important;\n }\n "); /* E (JavDB CSS) moved to css-injection.js */ /* F (global UI CSS) moved to css-injection.js */ function H(e) { if (e) if (e.includes("<style>")) document.head.insertAdjacentHTML("beforeend", e); else { const t = document.createElement("style"); t.textContent = e, document.head.appendChild(t); } } /** CSS injection for site-specific layouts, global UI, button styles, and mobile responsive design. */ const N = `\n<style>\n .top-bar {\n z-index: 12345689 !important;\n }\n \n ${M}\n\n .masonry {\n height: 100% !important;\n width: 100% !important;\n padding: 0 15px !important;\n }\n .masonry {\n display: grid;\n column-gap: 10px; /* 列间距*/\n row-gap: 10px; /* 行间距 */\n grid-template-columns: repeat(4, minmax(0, 1fr));\n align-items: start;\n }\n .masonry .item {\n /*position: initial !important;*/\n top: initial !important;\n left: initial !important;\n float: none !important;\n background-color:#c4b1b1;\n position: relative !important;\n }\n \n .masonry .item:hover {\n box-shadow: 0 .5em 1em -.125em rgba(10, 10, 10, .1), 0 0 0 1px #485fc7;\n }\n .masonry .movie-box{\n width: 100% !important;\n height: 100% !important;\n margin: 0 !important;\n overflow: inherit !important;\n }\n .masonry .movie-box .photo-frame {\n /*height: 70% !important;*/\n height:auto !important;\n margin: 0 !important;\n position:relative; /* 方便预览视频定位*/\n }\n .masonry .movie-box img {\n max-height: 500px;\n height: 100% !important;\n object-fit: contain;\n object-position: top;\n }\n .masonry .movie-box img:hover {\n transform: scale(1.04);\n transition: transform 0.3s;\n }\n .masonry .photo-info{\n /*height: 30% !important;*/\n }\n .masonry .photo-info span {\n display: inline-block; /* 或者 block */\n max-width: 100%; /* 根据父容器限制宽度 */\n white-space: nowrap; /* 禁止换行 */\n overflow: hidden; /* 隐藏溢出内容 */\n text-overflow: ellipsis; /* 显示省略号 */\n }\n \n /* 无码页面的样式 */\n .photo-frame .mheyzo,\n .photo-frame .mcaribbeancom2{\n margin-left: 0 !important;\n }\n .avatar-box{\n width: calc(100% - 30px) !important;\n display: flex !important;\n margin: 0 15px !important;\n padding: 0 !important;\n box-sizing: border-box !important;\n }\n .avatar-box img{\n max-height: 150px !important;\n width: auto !important;\n object-fit: contain;\n }\n .avatar-box .photo-info{\n display: flex;\n justify-content: center;\n align-items: center;\n gap: 30px;\n flex-direction: row;\n background-color:#fff !important;\n }\n \n footer{\n display: none!important;\n }\n \n \n .video-title {\n white-space: normal !important;\n line-height: 24px !important; /* 显式行高, 让高度正好是整行数 */\n height: 72px; /* 3 行 × 24px, 切口落在行间, 不留第4行的缝 */\n overflow: hidden !important; /* 裁掉溢出的第4行文字 */\n \n display: -webkit-box !important; /* 必须设置,使接下来的属性生效 */\n -webkit-box-orient: vertical; /* 垂直方向堆叠行 */\n -webkit-line-clamp: 3; /* 设置文本最多显示的行数*/\n }\n\n \n</style>\n`; const E = `\n<style>\n ${j}\n \n .navbar {\n z-index: 12345679 !important;\n padding: 0 0;\n }\n \n .navbar-link:not(.is-arrowless) {\n padding-right: 33px;\n }\n \n .sub-header,\n /*#search-bar-container, !*搜索框*!*/\n #footer,\n /*.search-recent-keywords, !*搜索框底部热搜词条*!*/\n .app-desktop-banner,\n h3.main-title,\n .top-meta,\n .float-buttons {\n display: none !important;\n }\n \n div.tabs.no-bottom,\n .tabs ul {\n border-bottom: none !important;\n }\n \n \n /* 视频列表项 相对相对 方便标签绝对定位*/\n .movie-list .item {\n position: relative !important;\n }\n \n .video-title {\n white-space: normal !important;\n height: 80px; /* 固定高度 容器就不会出现高低不一*/\n \n display: -webkit-box; /* 必须设置,使接下来的属性生效 */\n -webkit-box-orient: vertical; /* 垂直方向堆叠行 */\n -webkit-line-clamp: 3; /* 设置文本最多显示的行数*/\n }\n \n /* 列表页顶部分类自适应 */\n .main-tabs, .tabs {\n overflow-x:hidden;\n flex-wrap: wrap;\n justify-content: flex-start;\n }\n \n .main-tabs ul, .tabs ul {\n flex-wrap: wrap;\n flex-grow: 0;\n }\n \n \n /* 二级工具栏 大小封面,可播放,含磁链...*/\n .toolbar {\n display: flex;\n }\n\n</style>\n`; const F = `\n<style>\n /* 全局通用样式 */\n .fr-btn {\n float: right;\n margin-left: 4px !important;\n }\n \n .menu-box {\n position: fixed;\n right: 10px;\n top: 50%;\n transform: translateY(-50%);\n display: flex;\n flex-direction: column;\n z-index: 1000;\n gap: 6px;\n }\n \n .menu-btn {\n display: inline-block;\n min-width: 80px;\n padding: 7px 12px;\n border-radius: 4px;\n color: white !important;\n text-decoration: none;\n font-weight: bold;\n font-size: 12px;\n text-align: center;\n cursor: pointer;\n transition: all 0.3s ease;\n box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);\n text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);\n border: none;\n line-height: 1.3;\n margin: 0;\n }\n \n .menu-btn:hover {\n transform: translateY(-1px);\n box-shadow: 0 3px 6px rgba(0, 0, 0, 0.15);\n opacity: 0.9;\n }\n \n .menu-btn:active {\n transform: translateY(0);\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);\n }\n \n .do-hide {\n display: none !important;\n }\n \n .main-tab-btn {\n border-bottom:none !important; \n border-radius:3px !important; \n height: 30px; \n margin-left: 5px !important; \n }\n\n .jhs-icon {\n width: 16px;\n height: 16px;\n }\n \n .tool-box .jhs-icon {\n width: 1.5rem;\n height: 1.5rem; \n }\n \n \n /*表格内按钮溢出,防止被隐藏*/\n .tabulator .tabulator-row .action-cell-dropdown {\n overflow: visible !important;\n }\n /* 去除行内鼠标小手*/\n .tabulator .tabulator-row.tabulator-selectable:hover {\n cursor: default !important;\n }\n \n /* 排序小箭头颜色 */\n .tabulator .tabulator-col.tabulator-sortable[aria-sort="ascending"] .tabulator-arrow {\n border-bottom-color: #337ab7 !important;\n }\n .tabulator .tabulator-col.tabulator-sortable[aria-sort="descending"] .tabulator-arrow {\n border-top-color: #337ab7 !important;\n }\n \n /* 针对折叠行的容器或内容进行样式修改 */\n .tabulator-responsive-collapse {\n border-top: none !important;\n }\n \n .tabulator-responsive-collapse table{\n margin-left: 50px !important;\n }\n \n .tabulator-cell {\n height:auto !important;\n }\n \n /* 列允许换行,去除省略号 */\n .tabulator .tabulator-cell {\n white-space: normal !important; \n text-overflow: clip !important; \n }\n \n .tabulator-tableholder {\n overflow-x: hidden !important;\n }\n\n ${function() { const e = [ ".jhs-scrollbar", ".content-panel", ".tabulator-tableholder", ".has-navbar-fixed-top", ".layui-layer-content" ], t = (e, t) => e.map((e => `${e}${t}`)).join(","), n = "::-webkit-scrollbar-track", a = "::-webkit-scrollbar-thumb", i = "::-webkit-scrollbar-thumb:hover"; return `\n ${t(e, "::-webkit-scrollbar")}{width:6px;height:6px;}\n ${t(e, n)}{background:#f1f1f1;border-radius:10px;}\n ${t(e, a)}{background:#888;border-radius:10px;}\n ${t(e, i)}{background:#555;}\n `.trim().replace(/\n/g, ""); }()}\n</style>\n`; /** Inject site-specific CSS based on current hostname. */ l && H(N), r && H(E); /** Inject global button styles. */ H("\n<style>\n .a-normal, /* 白色 */\n .a-primary, /* 浅蓝色 */\n .a-success, /* 浅绿色 */\n .a-danger, /* 浅粉色 */\n .a-warning, /* 浅橙色 */\n .a-info /* 灰色 */\n {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 6px 14px;\n margin-right: 10px;\n border-radius: 6px;\n text-decoration: none;\n font-size: 13px;\n font-weight: 500;\n transition: all 0.2s ease;\n cursor: pointer;\n border: 1px solid rgba(0, 0, 0, 0.08);\n white-space: nowrap;\n }\n \n .a-primary {\n background: #e0f2fe;\n color: #0369a1;\n border-color: #bae6fd;\n }\n \n .a-primary:hover {\n background: #bae6fd;\n }\n \n .a-success {\n background: #dcfce7;\n color: #166534;\n border-color: #bbf7d0;\n }\n \n .a-success:hover {\n background: #bbf7d0;\n }\n \n .a-danger {\n background: #fee2e2;\n color: #b91c1c;\n border-color: #fecaca;\n }\n \n .a-danger:hover {\n background: #fecaca;\n }\n \n .a-warning {\n background: #ffedd5;\n color: #9a3412;\n border-color: #fed7aa;\n }\n \n .a-warning:hover {\n background: #fed7aa;\n }\n \n .a-info {\n background: #e2e8f0;\n color: #334155;\n border-color: #cbd5e1;\n }\n \n .a-info:hover {\n background: #cbd5e1;\n }\n \n .a-normal {\n background: transparent;\n color: #64748b;\n border-color: #cbd5e1;\n }\n \n .a-normal:hover {\n background: #f8fafc;\n }\n</style>\n"); /** Inject global UI styles (menu, tabulator overrides, scrollbar). */ H(F); /** 按字段为数组数据构建运行时索引。 */ function createIndexedMap(items, key) { return new Map(items.filter((item => item && item[key])).map((item => [ item[key], item ]))); } /** 为番号状态构建运行时 Set 索引。 */ function createStatusMap(items) { const statusMap = { [d]: new Set, [h]: new Set, [g]: new Set, [p]: new Set }; items.forEach((item => { item && Object.prototype.hasOwnProperty.call(statusMap, item.status) && statusMap[item.status].add(item.carNum); })); return statusMap; } /** 统计指定字段的重复值。 */ function groupDuplicateItems(items, key) { const counts = new Map; items.forEach((item => { const value = item && item[key]; value && counts.set(value, (counts.get(value) || 0) + 1); })); return Array.from(counts.entries()).filter((item => item[1] > 1)); } /** 按字段去重,保留第一条记录并合并后续字段。 */ function dedupeByKey(items, key) { const seen = new Map, list = []; let changed = !1; for (const item of items) { const value = item && item[key]; if (!value) { list.push(item); continue; } if (seen.has(value)) { Object.assign(seen.get(value), item), changed = !0; } else seen.set(value, item), list.push(item); } return { list, changed }; } e = new WeakSet, t = async function(e, t, n) { let a; if (Array.isArray(e)) a = [ ...e ]; else { if (a = await this.forage.getItem(t) || [], a.includes(e)) { const t = `${e} ${n}已存在`; throw show.error(t), new Error(t); } a.push(e); } return await this._setItemAndInvalidate(t, a), a; }; let z = class n { constructor() { var t, s, o; if (t = this, (s = e).has(t) ? a("Cannot add the same private member more than once") : s instanceof WeakSet ? s.add(t) : s.set(t, o), i(this, "car_list_key", "car_list"), i(this, "filter_keyword_title_key", "filter_keyword_title"), i(this, "filter_keyword_review_key", "filter_keyword_review"), i(this, "setting_key", "setting"), i(this, "blacklist_key", "blacklist"), i(this, "blacklist_car_list_key", "blacklist_car_list"), i(this, "third_party_cache_key", "third_party_ttl_cache"), i(this, "favorite_actresses_key", "favorite_actresses"), i(this, "highlighted_tags_key", "highlighted_tags"), i(this, "_actressLock", Promise.resolve()), i(this, "forage", localforage.createInstance({ driver: localforage.INDEXEDDB, name: "JAV-JHS", version: 1, storeName: "appData" })), i(this, "cacheCarList", null), i(this, "cacheBlacklist", null), i(this, "cacheTitleFilterKeyword", null), i(this, "cacheFavoriteActresses", null), i(this, "cache_filter_actor_actress_car_list", null), i(this, "cacheSettingObj", null), i(this, "cacheCarMap", null), i(this, "cacheStatusMap", null), i(this, "cacheBlacklistMap", null), i(this, "cacheFavoriteActressMap", null), i(this, "_cacheStats", { hits: 0, misses: 0 }), n.instance) throw new Error("StorageManager已被实例化过了!"); n.instance = this; } async getDataVersion() { return await this.forage.getItem("data_version") || 0; } async setDataVersion(e) { await this.forage.setItem("data_version", e); } _invalidateCache(e = null) { (!e || e === this.car_list_key) && (this.cacheCarList = null, this.cacheCarMap = null, this.cacheStatusMap = null); (!e || e === this.blacklist_key) && (this.cacheBlacklist = null, this.cacheBlacklistMap = null); (!e || e === this.filter_keyword_title_key) && (this.cacheTitleFilterKeyword = null); (!e || e === this.favorite_actresses_key) && (this.cacheFavoriteActresses = null, this.cacheFavoriteActressMap = null); (!e || e === this.blacklist_car_list_key) && (this.cache_filter_actor_actress_car_list = null); (!e || e === this.setting_key) && (this.cacheSettingObj = null); } async _setItemAndInvalidate(e, t) { await this.forage.setItem(e, t); this._invalidateCache(e); } async withActressLock(fn) { let release; const lock = new Promise(r => release = r); const prev = this._actressLock; this._actressLock = this._actressLock.then(() => lock); await prev; try { return await fn(); } finally { release(); } } async _rawUpdateFavoriteActress(e) { const t = await this.getFavoriteActressList(); const {starId: n, name: a, allName: i, avatar: s, lastCheckTime: o, newVideoList: r, lastPublishTime: l, actressType: c, remark: d} = e; if (!n) throw new Error("缺失starId"); let h = t.find((e => e.starId === n)); if (!h) return clog.error("未找到演员信息", n, a), !1; a && (h.name = a), i && (h.allName = i), s && (h.avatar = s), null != c && (h.actressType = c), o && (h.lastCheckTime = o), r && (h.newVideoList = r), l && (h.lastPublishTime = l), d && (h.remark = d), h.updateDate = utils.getNowStr(), await this._setItemAndInvalidate(this.favorite_actresses_key, t); return true; } async getCarList() { return null === this.cacheCarList && (this.cacheCarList = await this.forage.getItem(this.car_list_key) || []), this.cacheCarList; } async getCarMap() { if (null === this.cacheCarMap) { const e = await this.getCarList(); this.cacheCarMap = createIndexedMap(e, "carNum"); } return this.cacheCarMap; } async getStatusMap() { if (null === this.cacheStatusMap) { const e = await this.getCarList(); this.cacheStatusMap = createStatusMap(e); } return this.cacheStatusMap; } async getCar(e) { return (await this.getCarMap()).get(e); } _saveSingleCar(e, t) { let {carNum: n, url: a, names: i, actionType: s, publishTime: o, starId: r} = e; if (!n) throw show.error("番号为空!"), new Error("番号为空!"); if (!a) throw show.error("url为空!"), new Error("url为空!"); a.includes("http") || (a = window.location.origin + a), i && (i = i.trim()); let l = t.find((e => e.carNum === n)); if (l) i && (l.names = i), a && (l.url = a), o && (l.publishTime = o), l.updateDate = utils.getNowStr(); else { let e = utils.getNowStr(); l = { carNum: n, url: a, names: i, status: "", createDate: e, updateDate: e, publishTime: o }, r && (l.starId = r), t.push(l); } switch (s) { case d: if (l.status === d) { const e = `${n} 已在屏蔽列表中`; throw show.error(e), new Error(e); } l.status = d; break; case h: if (l.status === h) { const e = `${n} 已在收藏列表中`; throw show.error(e), new Error(e); } l.status = h; break; case g: if (l.status === g) { const e = `${n} 已标记为已下载`; throw show.error(e), new Error(e); } l.status = g; break; case p: if (l.status === p) { const e = `${n} 已标记为已观看`; throw show.error(e), new Error(e); } l.status = p; break; default: const e = "actionType错误, 请联系作者更正: " + s; throw show.error(e), new Error(e); } } async saveCar(e) { const t = await this.getCarList(); this._saveSingleCar(e, t), await this._setItemAndInvalidate(this.car_list_key, t), await this.removeNewVideoList([ e.carNum ]); } async updateCarInfo(e) { let {carNum: t, url: n, names: a, actionType: i, publishTime: s, remark: o} = e; if (!t) throw show.error("番号为空!"), new Error("番号为空!"); if (!n) throw show.error("url为空!"), new Error("url为空!"); a && (a = a.trim()); const r = await this.getCarList(); let l = r.find((e => e.carNum === t)); if (!l) { const e = "数据不存在: " + t; throw show.error(e), new Error(e); } switch (l.names = a, l.url = n, l.remark = o, l.updateDate = utils.getNowStr(), i) { case d: l.status = d; break; case h: l.status = h; break; case g: l.status = g; break; case p: l.status = p; break; default: const e = "actionType错误, 请联系作者更正: " + i; throw show.error(e), new Error(e); } await this._setItemAndInvalidate(this.car_list_key, r), await this.removeNewVideoList([ t ]); } async saveCarList(e) { if (!e || !Array.isArray(e) || 0 === e.length) throw show.error("记录列表为空!"), new Error("记录列表为空!"); const t = await this.getCarList(); for (const a of e) try { this._saveSingleCar(a, t); } catch (n) { throw n; } await this._setItemAndInvalidate(this.car_list_key, t), await this.removeNewVideoList(e.map((e => e.carNum))); } async removeNewVideoList(e) { return this.withActressLock(async () => { const t = await this.getFavoriteActressList(); let n = !1; const a = t.map((t => { if (!t.newVideoList || !Array.isArray(t.newVideoList)) return t; const a = t.newVideoList.filter((t => { const a = "string" == typeof t ? t : t.carNum, i = e.includes(a); return i && (clog.log("移除关联女优新作品", t.name, a), n = !0), !i; })); const result = { ...t, newVideoList: a }; if (a.length === 0 && t.lastPublishTime) result.lastPublishTime = null; return result; })); n && await this._setItemAndInvalidate(this.favorite_actresses_key, a); }); } async removeCar(e) { const t = await this.getCarList(), n = t.length, a = t.filter((t => t.carNum !== e)); return a.length === n ? (show.error(`${e} 不存在`), !1) : (await this._setItemAndInvalidate(this.car_list_key, a), !0); } async batchRemoveCars(e) { const t = await this.getCarList(), n = t.length, a = new Set(e), i = t.filter((e => !a.has(e.carNum))), s = n - i.length; return 0 !== s && (await this._setItemAndInvalidate(this.car_list_key, i), s); } async getBlacklist() { return null === this.cacheBlacklist && (this.cacheBlacklist = await this.forage.getItem(this.blacklist_key) || []), this.cacheBlacklist; } async getBlacklistMap() { if (null === this.cacheBlacklistMap) { const e = await this.getBlacklist(); this.cacheBlacklistMap = createIndexedMap(e, "starId"); } return this.cacheBlacklistMap; } async addBlacklistItem(e) { let {starId: t, name: n, allName: a, role: i, movieType: s, url: o} = e; if (!t) throw new Error("缺失starId"); if (!n) throw new Error("缺失name"); if (!i) throw new Error("缺失role"); const r = await this.getBlacklist(), l = r.find((e => e.starId === t)); if (l) l.url = o, l.role = i, l.movieType = s, clog.log("更新黑名单演员信息", l); else { const e = { starId: t, name: n, allName: a || [ n ], createTime: utils.getNowStr(), role: i, movieType: s, url: o }; r.push(e), clog.log("增加黑名单演员信息", e); } await this._setItemAndInvalidate(this.blacklist_key, r); } async updateBlacklistItem(e) { if (!e || !e.starId) throw new Error("参数不全"); const t = await this.getBlacklist(), n = t.find((t => t.starId === e.starId)); if (!n) throw new Error(`未找到黑名单演员信息:${e.name} ${e.starId}`); e.checkTime && (n.checkTime = e.checkTime), e.lastPublishTime && (n.lastPublishTime = e.lastPublishTime), await this._setItemAndInvalidate(this.blacklist_key, t); } async deleteBlacklistItem(e) { const t = await this.getBlacklist(), n = t.filter((t => t.starId !== e)); t.length !== n.length && await this._setItemAndInvalidate(this.blacklist_key, n); } async getBlacklistCarList() { return null !== this.cache_filter_actor_actress_car_list || (this.cache_filter_actor_actress_car_list = await this.forage.getItem(this.blacklist_car_list_key) || []), this.cache_filter_actor_actress_car_list; } async batchSaveBlacklistCarList(e) { const t = await this.getBlacklistCarList(), n = JSON.parse(JSON.stringify(t)); let a = !1, i = []; for (const s of e) { n.find((e => e.carNum === s.carNum)) || (this._saveSingleCar(s, n), clog.log(`屏蔽演员番号: <span style="color: #f40">${escapeHtml(s.names)} ${escapeHtml(s.carNum)}</span>`), a = !0, i.push(s.carNum)); } a && (await this._setItemAndInvalidate(this.blacklist_car_list_key, n), await this.removeNewVideoList(i), window.cleanCache_filter_actor_actress_car_list()); } async removeBlacklistCarList(e) { const t = await this.getBlacklistCarList(), n = t.filter((t => t.starId !== e)); n.length !== t.length && (await this._setItemAndInvalidate(this.blacklist_car_list_key, n), window.cleanCache_filter_actor_actress_car_list()); } async getFavoriteActressList() { return null === this.cacheFavoriteActresses && (this.cacheFavoriteActresses = await this.forage.getItem(this.favorite_actresses_key) || []), this.cacheFavoriteActresses; } async getActressMap() { if (null === this.cacheFavoriteActressMap) { const e = await this.getFavoriteActressList(); this.cacheFavoriteActressMap = createIndexedMap(e, "starId"); } return this.cacheFavoriteActressMap; } async addFavoriteActressList(e) { return this.withActressLock(async () => { const t = await this.getFavoriteActressList(); let n = 0; for (const a of e) { let {starId: e, name: i, allName: s, avatar: o, lastCheckTime: r, lastPublishTime: l, actressType: c} = a; if (!e) throw new Error("缺失starId"); if (!i) throw new Error("缺失name"); s || (s = [ i ]); const d = "(無碼)"; if (!c) { c = i.includes(d) || s.some((e => e.includes(d))) ? A : D; } i = i.replace(d, ""), s = s.map((e => e.replace(d, ""))); let h = t.find((t => t.starId === e)); if (h) { h.avatar && h.avatar.includes("https") || o && (clog.log(o), h.avatar = o, clog.log(`<span style="color: #f40">补全女优头像: ${escapeHtml(i)}</span>`), n++), !h.actressType && c && (h.actressType = c, clog.log(`<span style="color: #f40">补全女优类别: ${escapeHtml(i)} ${escapeHtml(c)}</span>`), n++), h.name.includes(d) && (h.name = i, h.allName = s, clog.log(`<span style="color: #f40">更正女优名字: ${escapeHtml(i)} ${escapeHtml(s)}</span>`), n++); continue; } const g = utils.getNowStr(); t.push({ starId: e, name: i, allName: s, avatar: o, lastCheckTime: r, lastPublishTime: l, createDate: g, updateDate: g, actressType: c }), clog.log(`<span style="color: #f40">同步JavDB已收藏的演员: ${escapeHtml(i)}</span>`), n++; } return n > 0 ? await this._setItemAndInvalidate(this.favorite_actresses_key, t) : clog.log("信息已记录, 无需要进行同步收藏的演员"), n; }); } async removeFavoriteActress(e) { return this.withActressLock(async () => { const t = await this.getFavoriteActressList(), n = t.length, a = t.filter((t => t.starId !== e)); return a.length === n ? (clog.error(`移除演员失败, ${e} 不存在`), !1) : (await this._setItemAndInvalidate(this.favorite_actresses_key, a), !0); }); } async updateFavoriteActress(e) { return this.withActressLock(() => this._rawUpdateFavoriteActress(e)); } async getHighlightedTags() { return await this.forage.getItem(this.highlighted_tags_key) || []; } async setHighlightedTags(e) { return await this.forage.setItem(this.highlighted_tags_key, e); } async saveTitleFilterKeyword(n) { if (await s(this, e, t).call(this, n, this.filter_keyword_title_key, "标题关键词"), Array.isArray(n)) return null; return this.withActressLock(async () => { const a = await this.getFavoriteActressList(); let i = !1; const o = a.map((e => { if (!e.newVideoList || !Array.isArray(e.newVideoList)) return e; const t = e.newVideoList.filter((t => { const s = "string" == typeof t ? t : t.carNum, a = s.startsWith(n); return a && (clog.log("移除关联女优新作品", e.name, s), i = !0), !a; })); return { ...e, newVideoList: t }; })); i && await this._setItemAndInvalidate(this.favorite_actresses_key, o); }); } async getTitleFilterKeyword() { return null === this.cacheTitleFilterKeyword && (this.cacheTitleFilterKeyword = await this.forage.getItem(this.filter_keyword_title_key) || []), this.cacheTitleFilterKeyword; } async getReviewFilterKeywordList() { return await this.forage.getItem(this.filter_keyword_review_key) || []; } async saveReviewFilterKeyword(n) { return s(this, e, t).call(this, n, this.filter_keyword_review_key, "评论关键词"); } async getSetting(e = null, t) { this.cacheSettingObj || (this.cacheSettingObj = await this.forage.getItem(this.setting_key) || {}); let n = this.cacheSettingObj; if (null === e) return n; const a = n[e]; return a ? "true" === a || "false" === a ? "true" === a.toLowerCase() : "string" != typeof a || "" === a.trim() || isNaN(Number(a)) ? a : Number(a) : t; } getSettingSync(e, t) { if (!this.cacheSettingObj) return t; const n = this.cacheSettingObj[e]; return n ? "true" === n || "false" === n ? "true" === n.toLowerCase() : "string" != typeof n || "" === n.trim() || isNaN(Number(n)) ? n : Number(n) : t; } async saveSetting(e) { e ? (await this._setItemAndInvalidate(this.setting_key, e), window.clean_cacheSettingObj()) : show.error("设置对象为空"); } async saveSettingItem(e, t) { if (!e) return void show.error("key 不能为空"); await navigator.locks.request("jhs_setting_lock", async () => { let n = await this.getSetting(); n[e] = t, await this.saveSetting(n); }), window.clean_cacheSettingObj(); } async importData(e) { const VALID_KEYS = new Set(["car_list", "filter_keyword_title", "filter_keyword_review", "setting", "blacklist", "blacklist_car_list", "third_party_ttl_cache", "favorite_actresses", "highlighted_tags"]); const t = []; for (const n in e) { if (!VALID_KEYS.has(n)) { clog.warn(`[导入] 跳过未知数据键: ${n}`); continue; } t.push(this._setItemAndInvalidate(n, e[n])); } await Promise.all(t); const n = []; await this.forage.iterate(((t, a) => { a in e || n.push(this.forage.removeItem(a)); })); await Promise.all(n); this._invalidateCache(); } async exportData() { const e = {}; if (await this.forage.iterate(((t, n) => { e[n] = t; })), 0 === Object.keys(e).length) throw new Error("没有可导出的数据"); return e; } async getThirdPartyCache() { return await this.forage.getItem(this.third_party_cache_key) || {}; } async setThirdPartyCache(e) { await this.forage.setItem(this.third_party_cache_key, e || {}); } async clearThirdPartyCache() { await this.forage.removeItem(this.third_party_cache_key); } async getThirdPartyCacheStats() { const e = await this.getThirdPartyCache(), t = Date.now(); let n = 0, a = 0; return Object.values(e).forEach((e => { n++, e && e.time && e.ttl && t - e.time < e.ttl && a++; })), { total: n, valid: a, expired: n - a }; } async cachedRequest(e, t, n) { const a = Date.now(), i = await this.getThirdPartyCache(), s = i[e]; if (s && s.time && a - s.time < (s.ttl || t)) return this._cacheStats.hits++, s.data; this._cacheStats.misses++; const o = await n(); if (void 0 === o || null === o) return o; return i[e] = { time: a, ttl: t, data: o }, await this.setThirdPartyCache(i), o; } getCacheHitStats() { const e = this._cacheStats.hits + this._cacheStats.misses; return { hits: this._cacheStats.hits, misses: this._cacheStats.misses, total: e, rate: e > 0 ? (this._cacheStats.hits / e * 100).toFixed(1) + "%" : "N/A" }; } resetCacheHitStats() { this._cacheStats = { hits: 0, misses: 0 }; } _groupDuplicateItems(e, t) { return groupDuplicateItems(e, t); } _dedupeByKey(e, t) { return dedupeByKey(e, t); } async inspectDataHealth() { const [e, t, n, a] = await Promise.all([ this.getCarList(), this.getFavoriteActressList(), this.getBlacklist(), this.getBlacklistCarList() ]), i = await this.getCarMap(), s = { checkedAt: utils.getNowStr(), totals: { carList: e.length, favoriteActresses: t.length, blacklist: n.length, blacklistCarList: a.length }, fixable: [], readonly: [] }, o = (e, t, n) => s.fixable.push({ type: e, message: t, count: n }), r = (e, t, n) => s.readonly.push({ type: e, message: t, count: n }); const l = this._groupDuplicateItems(e, "carNum"), c = this._groupDuplicateItems(t, "starId"), d = this._groupDuplicateItems(n, "starId"); l.length && o("duplicate-car", "重复番号记录", l.length), c.length && o("duplicate-actress", "重复收藏演员", c.length), d.length && o("duplicate-blacklist", "重复黑名单演员", d.length); const h = e.filter((e => e && e.actress)).length, g = a.filter((e => e && e.actress)).length, p = t.filter((e => e && Object.prototype.hasOwnProperty.call(e, "dbId"))).length, m = n.filter((e => e && ("key" in e || "recordTime" in e || "isActor" in e))).length; h + g + p + m > 0 && o("legacy-fields", "旧字段残留", h + g + p + m); const u = t.filter((e => e && e.name && !Array.isArray(e.allName))).length + n.filter((e => e && e.name && !Array.isArray(e.allName))).length; u && o("invalid-all-name", "演员别名不是数组", u); let f = 0; t.forEach((e => { Array.isArray(e?.newVideoList) && (f += e.newVideoList.filter((e => { const t = "string" == typeof e ? e : e.carNum; return i.has(t); })).length); })), f && o("stored-new-video", "新作品列表中已有鉴定记录", f); const v = e.filter((e => e && e.carNum && !e.url)).length; v && r("missing-url", "番号记录缺失 url,需要人工确认来源", v); const b = t.filter((e => e && !e.starId)).length + n.filter((e => e && !e.starId)).length; b && r("missing-star-id", "演员缺失 starId,需要人工确认身份", b); const w = new Set(n.map((e => e && e.starId)).filter(Boolean)), y = a.filter((e => e && e.starId && !w.has(e.starId))).length; return y && r("orphan-blacklist-car", "黑名单作品找不到关联演员", y), s; } async repairDataHealth() { let e = 0, t = await this.getCarList(), n = await this.getFavoriteActressList(), a = await this.getBlacklist(), i = await this.getBlacklistCarList(); const s = this._dedupeByKey(t, "carNum"); s.changed && (t = s.list, e++); const o = this._dedupeByKey(n, "starId"); o.changed && (n = o.list, e++); const r = this._dedupeByKey(a, "starId"); r.changed && (a = r.list, e++); t = t.map((t => { if (!t) return t; let n = !1; return void 0 !== t.actress && (t.names = t.actress, delete t.actress, n = !0), n && e++, t; })), i = i.map((t => { if (!t) return t; let n = !1; return void 0 !== t.actress && (t.names = t.actress, delete t.actress, n = !0), Object.prototype.hasOwnProperty.call(t, "type") && (delete t.type, n = !0), n && e++, t; })), n = n.map((t => { if (!t) return t; let n = !1; return Object.prototype.hasOwnProperty.call(t, "dbId") && (t.starId || (t.starId = t.dbId), delete t.dbId, n = !0), t.name && !Array.isArray(t.allName) && (t.allName = t.allName ? [ t.allName ] : [ t.name ], n = !0), n && e++, t; })), a = a.map((t => { if (!t) return t; let n = !1; return Object.prototype.hasOwnProperty.call(t, "isActor") && (t.role || (t.role = t.isActor ? B : P), delete t.isActor, n = !0), Object.prototype.hasOwnProperty.call(t, "recordTime") && (t.createTime || (t.createTime = t.recordTime), delete t.recordTime, n = !0), Object.prototype.hasOwnProperty.call(t, "key") && (delete t.key, n = !0), t.name && !Array.isArray(t.allName) && (t.allName = t.allName ? [ t.allName ] : [ t.name ], n = !0), n && e++, t; })); const l = new Set(t.filter((e => e && e.carNum)).map((e => e.carNum))); n = n.map((t => { if (!Array.isArray(t?.newVideoList)) return t; const n = t.newVideoList.filter((e => { const n = "string" == typeof e ? e : e.carNum; return !l.has(n); })); return n.length !== t.newVideoList.length && (t = { ...t, newVideoList: n }, 0 === n.length && t.lastPublishTime && (t.lastPublishTime = null), e++), t; })), await this._setItemAndInvalidate(this.car_list_key, t), await this._setItemAndInvalidate(this.favorite_actresses_key, n), await this._setItemAndInvalidate(this.blacklist_key, a), await this._setItemAndInvalidate(this.blacklist_car_list_key, i); return { fixedGroups: e, report: await this.inspectDataHealth() }; } /** 数据迁移: 将旧版扁平键名重命名为新格式 */ async merge_table_name() { let e = "filter_actor_actress_info_list", t = await this.forage.getItem(e) || []; t && t.length > 0 && (clog.debug("更正", e), await this._setItemAndInvalidate(this.blacklist_key, t)), await this.forage.removeItem(e), e = "favorite_actresses_info_list", t = await this.forage.getItem(e) || [], t && t.length > 0 && (clog.debug("更正", e), await this._setItemAndInvalidate(this.favorite_actresses_key, t)), await this.forage.removeItem(e), e = "car_list_filter_actor_actress", t = await this.forage.getItem(e) || [], t && t.length > 0 && (clog.debug("更正", e), await this._setItemAndInvalidate(this.blacklist_car_list_key, t)), await this.forage.removeItem(e), e = "title_filter_keyword", t = await this.forage.getItem(e) || [], t && t.length > 0 && (clog.debug("更正", e), await this._setItemAndInvalidate(this.filter_keyword_title_key, t)), await this.forage.removeItem(e), e = "review_filter_keyword", t = await this.forage.getItem(e) || [], t && t.length > 0 && (clog.debug("更正", e), await this._setItemAndInvalidate(this.filter_keyword_review_key, t)), await this.forage.removeItem(e), e = "highlightedTags", t = await this.forage.getItem(e) || [], t && t.length > 0 && (clog.debug("更正", e), await this._setItemAndInvalidate(this.highlighted_tags_key, t)), await this.forage.removeItem(e); } async clean_no_url_blacklist() { const [e, t] = await Promise.all([ this.getBlacklistCarList(), this.getBlacklist() ]); if (e.length && !e[0].actress) return; const n = new Set(t.map((e => e.name))), a = e.filter((e => !e.actress || n.has(e.actress))); e.length !== a.length && (clog.debug("清理 blacklistCarList 前", e.length), clog.debug("清理 blacklistCarList 后", a.length), await this._setItemAndInvalidate(this.blacklist_car_list_key, a)); const i = new Set(a.map((e => e.actress))); let s = t.filter((e => i.has(e.name))); s = s.map((e => { const {key: t, recordTime: n, ...a} = e, i = a; return void 0 !== n && (i.createTime = n), i; })), (t.length !== s.length || t.some((e => "key" in e || "recordTime" in e))) && (clog.debug("清理 Blacklist 前", t.length), clog.debug("清理 Blacklist 后", s.length), await this._setItemAndInvalidate(this.blacklist_key, s)); } async async_merge_other() { const e = await this.getSetting(); let t = !1; const n = { enableCheckFilterActorActress: "enableCheckBlacklist", checkIntervalTime_filterActorActress: "checkBlacklist_intervalTime", checkIntervalTime_ruleTime: "checkNewVideo_ruleTime", checkIntervalTime_newVideo: "checkNewVideo_intervalTime", checkIntervalTime_favoriteActress: "checkFavoriteActress_IntervalTime" }; for (const a in n) { const i = n[a]; Object.prototype.hasOwnProperty.call(e, a) && (e[i] = e[a], delete e[a], t = !0); } e.checkFilterTime && (delete e.checkFilterTime, t = !0), e.checkFilterConcurrencyCount && (delete e.checkFilterConcurrencyCount, t = !0), e.checkFilterSleep && (delete e.checkFilterSleep, t = !0), t && (await this.saveSetting(e), clog.debug("配置数据已更正")); } /** 数据迁移: 补全黑名单条目缺失的 role/starId/allName/movieType 字段 */ async merge_blacklist() { const e = await this.getBlacklist(); if (!e || 0 === e.length) return; let t = !1; const n = e.map((e => { let n = !1; if (Object.prototype.hasOwnProperty.call(e, "isActor") && !e.role && (e.role = e.isActor ? B : P, delete e.isActor, n = !0), !e.starId && e.url) try { const t = new URL(e.url).pathname, a = t.split("/").filter((e => "" !== e.trim())).pop(); e.starId !== a && (e.starId = a, n = !0); } catch (a) { clog.error("提取url-starId发生错误", e.url, a); } if (e.allName || (e.allName = e.name ? [ e.name ] : [], n = !0), e.movieType || (e.movieType = D, n = !0), e.url && e.url.includes("sort_type")) { const t = new URL(e.url); t.searchParams.delete("sort_type"), e.url = t.toString(), clog.debug("去除黑名单地址sort_type参数"); } return n && (t = !0), e; })); t && (clog.debug("更正 Blacklist 数据结构"), await this._setItemAndInvalidate(this.blacklist_key, n)); const a = await this.getBlacklistCarList(); t = !1; const i = a.map((n => { if (!n.starId) { let a = e.find((e => e.name === n.actress)); a && (n.starId = a.starId), t = !0; } return n.type && (delete n.type, t = !0), n; })); t && (clog.debug("更正 blacklistCarList 数据结构"), await this._setItemAndInvalidate(this.blacklist_car_list_key, i)); } async merge_favoriteActress() { const e = await this.getFavoriteActressList(); if (!e || 0 === e.length) return; let t = !1; const n = e.map((e => { let n = !1; return e.dbId && (e.starId = e.dbId, delete e.dbId, n = !0), n && (t = !0), e; })); t && (clog.debug("更正 favoriteActressesInfoList 数据结构"), await this._setItemAndInvalidate(this.favorite_actresses_key, n)); } async merge_tow_car_list_table() { const e = await this.getBlacklistCarList(), t = await this.getCarList(); let n = !1; const a = e.map((e => { let t = !1; return void 0 !== e.actress && (e.names = e.actress, delete e.actress, t = !0), t && (n = !0), e; })); n && (clog.debug("更正 blacklistCarList 数据结构 actress->names"), await this._setItemAndInvalidate(this.blacklist_car_list_key, a)), n = !1; const i = t.map((e => { let t = !1; return void 0 !== e.actress && (e.names = e.actress, delete e.actress, t = !0), t && (n = !0), e; })); n && (clog.debug("更正 carList 数据结构 actress->names"), await this._setItemAndInvalidate(this.car_list_key, i)); } /* ───── 快照管理 ───── */ _snapshotKey() { return "snapshots"; } _snapshotMetaKeys() { return [ "snapshots", "data_version" ]; } async _getSnapshots() { return await this.forage.getItem(this._snapshotKey()) || []; } async _saveSnapshots(e) { await this._setItemAndInvalidate(this._snapshotKey(), e); } async _withSnapshotLock(e) { return await navigator.locks.request("jhs_snapshot_lock", async () => await e()); } async createSnapshot(e = "", t = "manual") { return this._withSnapshotLock(async () => { const n = await this._getSnapshots(), a = await this.exportData(); for (const i of this._snapshotMetaKeys()) delete a[i]; let i = 0; for (const s of Object.values(a)) Array.isArray(s) ? i += s.length : "object" == typeof s && s && i++; const s = { id: "snap_" + Date.now() + "_" + Math.random().toString(36).slice(2, 8), name: e || utils.getNowStr(), source: t, time: utils.getNowStr(), itemCount: i, data: a }; n.push(s), n.length > 10 && n.splice(0, n.length - 10); return await this._saveSnapshots(n), clog.log(`创建快照: ${s.name} (${t})`), s; }); } async getSnapshotList() { return (await this._getSnapshots()).map((e => ({ id: e.id, name: e.name, source: e.source, time: e.time, itemCount: e.itemCount }))); } async getSnapshot(e) { return (await this._getSnapshots()).find((t => t.id === e)) || null; } async deleteSnapshot(e) { return this._withSnapshotLock(async () => { const t = await this._getSnapshots(), n = t.filter((t => t.id !== e)); if (n.length === t.length) return void clog.warn("删除快照失败, ID不存在: " + e); await this._saveSnapshots(n), clog.log("删除快照: " + e); }); } async restoreSnapshot(e) { const t = await this.getSnapshot(e); if (!t) throw new Error("快照不存在: " + e); if (!t.data || "object" != typeof t.data) throw new Error("快照数据损坏"); await this.createSnapshot("恢复前自动备份", "auto-restore"); const n = { ...t.data }; for (const a of this._snapshotMetaKeys()) delete n[a]; await this.importData(n); clog.log("已恢复快照: " + t.name); return t; } /* ───── 差异对比引擎 ───── */ _stableStringify(e) { if (null === e || "object" != typeof e) return JSON.stringify(e); if (Array.isArray(e)) return "[" + e.map((e => this._stableStringify(e))).join(",") + "]"; return "{" + Object.keys(e).sort().map((t => JSON.stringify(t) + ":" + this._stableStringify(e[t]))).join(",") + "}"; } diffData(e, t) { const n = new Set([ ...Object.keys(e), ...Object.keys(t) ]), a = {}, i = { added: 0, removed: 0, modified: 0, unchanged: 0 }; for (const o of n) { const r = e[o], l = t[o]; if (void 0 === r && void 0 === l) continue; if (void 0 === r) { const e = Array.isArray(l) ? l.length : 1; a[o] = { status: "added", oldCount: 0, newCount: e, added: Array.isArray(l) ? l : [], removed: [], modified: [] }, i.added++; } else if (void 0 === l) { const t = Array.isArray(r) ? r.length : 1; a[o] = { status: "removed", oldCount: t, newCount: 0, added: [], removed: Array.isArray(r) ? r : [], modified: [] }, i.removed++; } else if (Array.isArray(r) && Array.isArray(l)) { const e = this._diffArrays(r, l, o); a[o] = { status: e.status, oldCount: r.length, newCount: l.length, ...e }, i[e.status]++; } else if ("object" == typeof r && "object" == typeof l && !Array.isArray(r) && !Array.isArray(l)) { const e = this._diffObjects(r, l); a[o] = { status: e.status, oldCount: Object.keys(r).length, newCount: Object.keys(l).length, added: [], removed: [], modified: e.changes }, i[e.status]++; } else r === l ? (a[o] = { status: "unchanged", oldCount: 1, newCount: 1, added: [], removed: [], modified: [] }, i.unchanged++) : (a[o] = { status: "modified", oldCount: 1, newCount: 1, added: [], removed: [], modified: [{ key: o, changes: { _value: [r, l] } }] }, i.modified++); } return { summary: i, stores: a }; } _getArrayKey(e) { return "car_list" === e || "blacklist_car_list" === e ? "carNum" : "blacklist" === e || "favorite_actresses" === e ? "starId" : null; } _diffArrays(e, t, n) { const a = this._getArrayKey(n); if (!a) { const n = this._stableStringify(e), i = this._stableStringify(t); if (n === i) return { status: "unchanged", added: [], removed: [], modified: [] }; const s = new Set(e.map((e => this._stableStringify(e)))), o = new Set(t.map((e => this._stableStringify(e)))); return { status: "modified", added: t.filter((e => !s.has(this._stableStringify(e)))), removed: e.filter((e => !o.has(this._stableStringify(e)))), modified: [] }; } const i = new Map(e.map((e => [ e[a], e ]))), s = new Map(t.map((e => [ e[a], e ]))), o = [], r = [], l = []; for (const [c, d] of s) { const t = i.get(c); if (!t) o.push(d); else { const e = this._diffObjects(t, d); "unchanged" !== e.status && l.push({ key: c, changes: e.changes }); } } for (const [c] of i) s.has(c) || r.push(i.get(c)); const c = o.length + r.length + l.length; return { status: 0 === c ? "unchanged" : "modified", added: o, removed: r, modified: l }; } _diffObjects(e, t) { const n = new Set([ ...Object.keys(e), ...Object.keys(t) ]), a = {}; let i = 0; for (const s of n) { const o = e[s], r = t[s]; this._stableStringify(o) !== this._stableStringify(r) && (a[s] = [ o, r ], i++); } return { status: 0 === i ? "unchanged" : "modified", changes: a }; } }; const U = "https://jdforrepam.com/api"; /** 生成 JavDB API 请求签名 (HMAC-时间戳+盐+MD5, 20秒缓存) */ function O() { const e = "jhs_review_ts", t = "jhs_review_sign", n = Math.floor(Date.now() / 1e3); if (n - (localStorage.getItem(e) || 0) <= 20) return localStorage.getItem(t); const a = `${n}.lpw6vgqzsp.${md5(`${n}71cf27bb3c0bcdf207b64abecddc970098c7421ee7203b9cdae54478478a199e7d5a6e1a57691123c1a931c057842fb73ba3b3c83bcd69c17ccf174081e3d8aa`)}`; return localStorage.setItem(e, n), localStorage.setItem(t, a), a; } const R = async (e, t = 1, n = 20) => { let a = `${U}/v1/movies/${e}/reviews`, i = { jdSignature: await O() }; return await storageManager.cachedRequest(`reviews:${e}:${t}:${n}`, 864e5, (async () => { const e = await gmHttp.get(a, { page: t, sort_by: "hotly", limit: n }, i); if (!e?.data?.reviews) throw new Error(e?.message || "获取评论失败"); return e.data.reviews; })); }, V = async e => { let t = `${U}/v4/movies/${e}`, n = { jdSignature: await O() }; const a = await storageManager.cachedRequest(`movie-detail:${e}`, 6048e5, (async () => { const e = await gmHttp.get(t, null, n); if (!e.data) throw new Error(e.message || "获取视频详情失败"); return e; })); if (!a.data) throw show.error("获取视频详情失败: " + a.message), new Error(a.message); const i = a.data.movie, s = i.preview_images, o = []; return s.forEach((e => { o.push(e.large_url.replace(/https:\/\/[^/]+\/rhe951l4q/, "https://c0.jdbstatic.com")); })), { movieId: i.id, actors: i.actors, duration: i.duration, title: i.origin_title, carNum: i.number, score: i.score, releaseDate: i.release_date, watchedCount: i.watched_count, imgList: o }; }, K = async (e, t = 1, n = 20) => { let a = `${U}/v1/lists/related?movie_id=${e}&page=${t}&limit=${n}`, i = { jdSignature: await O() }; const s = await storageManager.cachedRequest(`related:${e}:${t}:${n}`, 864e5, (async () => { const e = await gmHttp.get(a, null, i); if (!e?.data?.lists) throw new Error(e?.message || "获取相关清单失败"); return e; })), o = []; return s.data.lists.forEach((e => { o.push({ relatedId: e.id, name: e.name, movieCount: e.movies_count, collectionCount: e.collections_count, viewCount: e.views_count, createTime: utils.formatDate(e.created_at) }); })), o; }, W = async (e = "daily", t = "high_score") => { let n = `${U}/v1/rankings/playback?period=${e}&filter_by=${t}`, a = { jdSignature: await O() }; return (await gmHttp.get(n, null, a)).data.movies; }, q = async (e = "all", t = "", n = 1, a = 40) => { let i = `${U}/v1/movies/top?start_rank=1&type=${e}&type_value=${t}&ignore_watched=false&page=${n}&limit=${a}`; const l = localStorage.getItem("jhs_appAuthorization"), c = await decryptTokenSafe(l); let s = { "user-agent": "Dart/3.5 (dart:io)", "accept-language": "zh-TW", host: "jdforrepam.com", authorization: "Bearer " + c, jdsignature: await O() }; return await gmHttp.get(i, null, s); }; class J { constructor() { return i(this, "intervalContainer", {}), i(this, "mimeTypes", { txt: "text/plain", html: "text/html", css: "text/css", csv: "text/csv", json: "application/json", xml: "application/xml", jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml", pdf: "application/pdf", doc: "application/msword", docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", xls: "application/vnd.ms-excel", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ppt: "application/vnd.ms-powerpoint", pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", zip: "application/zip", rar: "application/x-rar-compressed", "7z": "application/x-7z-compressed", mp3: "audio/mpeg", wav: "audio/wav", mp4: "video/mp4", webm: "video/webm", ogg: "audio/ogg" }), i(this, "timers", new Map), i(this, "insertStyle", (e => { e && (-1 === e.indexOf("<style>") && (e = "<style>" + e + "</style>"), $("head").append(e)); })), i(this, "layerIndexStack", []), J.instance || (J.instance = this), J.instance; } importResource(e) { let t; e.indexOf("css") >= 0 ? (t = document.createElement("link"), t.setAttribute("rel", "stylesheet"), t.href = e) : (t = document.createElement("script"), t.setAttribute("type", "text/javascript"), t.src = e), document.documentElement.appendChild(t); } openPage(e, t, n, a) { if (n = n ?? !0, a && (a.ctrlKey || a.metaKey)) return void GM_openInTab(e.includes("http") ? e : window.location.origin + e, { insert: 0 }); let i = e; e.includes("/actors/") || e.includes("/star/") || (i = e.includes("?") ? `${e}&hideNav=1` : `${e}?hideNav=1`), layer.open({ type: 2, title: t, content: i, scrollbar: !1, shadeClose: n, area: this.getResponsiveArea([ "85%", "90%" ]), isOutAnim: !1, anim: -1, success: (e, t) => { this.setupEscClose(t); } }); } _handleGlobalEscKey(e) { if ("Escape" !== e.key && 27 !== e.keyCode) return; if (0 === this.layerIndexStack.length) return; const t = this.layerIndexStack[this.layerIndexStack.length - 1], n = $(`#layui-layer${t}`); let a = !1; if (n.find(".viewer-container").length > 0) a = !0; else { const e = n.find(`#layui-layer-iframe${t}`)[0]; if (e && e.contentDocument) try { $(e.contentDocument).find(".viewer-container").length > 0 && (a = !0); } catch (i) { clog.warn("无法检查跨域 iframe 内的 .viewer-container"); } } a || (this.layerIndexStack.pop(), layer.close(t)); } setupEscClose(e) { var t; this._boundHandler || (this._boundHandler = this._handleGlobalEscKey.bind(this), $(document).off("keydown.globalLayerEsc"), $(document).on("keydown.globalLayerEsc", this._boundHandler)), -1 === this.layerIndexStack.indexOf(e) && this.layerIndexStack.push(e); const n = $(`#layui-layer-iframe${e}`), a = `keydown.layerEsc${e}`; try { const e = null == (t = n[0]) ? void 0 : t.contentDocument; if (e) { if ("yes" === n.attr("data-esc-bound")) return; $(e).off(a), $(e).on(a, this._boundHandler), n.attr("data-esc-bound", "yes"); } } catch (i) { clog.error("iframe监听失败 (跨域或未加载完毕):", i); } } closePage() { storageManager.getSetting("needClosePage", "yes").then((e => { if ("yes" !== e) return; parent.document.documentElement.style.overflow = "auto"; [ ".layui-layer-shade", ".layui-layer-move", ".layui-layer" ].forEach((function(e) { const t = parent.document.querySelectorAll(e); if (t.length > 0) { const e = t.length > 1 ? t[t.length - 1] : t[0]; e.parentNode.removeChild(e); } })), window.close(); })); } loopDetector(e, t, n = 100, a = 1e4, i = !0) { const s = Math.random(), o = (new Date).getTime(), r = e => { clearInterval(this.intervalContainer[s]), e && t && t(), delete this.intervalContainer[s]; }; this.intervalContainer[s] = setInterval((() => { const t = (new Date).getTime() - o; e() ? r(!0) : t >= a && r(i); }), n); } rightClick(e, t, n) { let a; "string" == typeof e ? a = document.querySelector(e) : e instanceof HTMLElement && (a = e), a || (console.warn("rightClick(), 容器无效或未提供,将使用 document.body 进行全局委托。"), a = document.body), "string" == typeof t && "" !== t.trim() ? a.addEventListener("contextmenu", (e => { const a = e.target.closest(t); a && n(e, a); })) : console.error("rightClick(), 必须提供有效的 targetSelector。"); } q(e, t, n, a) { let o; if (this.isMobileMode()) { o = layer.confirm(t, { title: "提示", btn: [ "确定", "取消" ], shade: 0, zIndex: 999999991 }, (function() { n && n(), layer.close(o); }), (function() { a && a(); })); } else { let i, s; e ? (i = e.clientX - 130, s = e.clientY - 120) : (i = window.innerWidth / 2 - 120, s = window.innerHeight / 2 - 120); o = layer.confirm(t, { offset: [ s, i ], title: "提示", btn: [ "确定", "取消" ], shade: 0, zIndex: 999999991 }, (function() { n && n(), layer.close(o); }), (function() { a && a(); })); } } getNowStr(e = "-", t = ":", n = null) { let a; a = n ? new Date(n) : new Date; const i = a.getFullYear(), s = String(a.getMonth() + 1).padStart(2, "0"), o = String(a.getDate()).padStart(2, "0"), r = String(a.getHours()).padStart(2, "0"), l = String(a.getMinutes()).padStart(2, "0"), c = String(a.getSeconds()).padStart(2, "0"); return `${[ i, s, o ].join(e)} ${[ r, l, c ].join(t)}`; } formatDate(e, t = "-", n = ":") { let a; if (e instanceof Date) a = e; else { if ("string" != typeof e) throw new Error("Invalid date input: must be Date object or date string"); if (a = new Date(e), isNaN(a.getTime())) throw new Error("Invalid date string"); } const i = a.getFullYear(), s = String(a.getMonth() + 1).padStart(2, "0"), o = String(a.getDate()).padStart(2, "0"), r = String(a.getHours()).padStart(2, "0"), l = String(a.getMinutes()).padStart(2, "0"), c = String(a.getSeconds()).padStart(2, "0"); return `${[ i, s, o ].join(t)} ${[ r, l, c ].join(n)}`; } getHourDifference(e, t) { const n = e.getTime(), a = t.getTime(), i = Math.abs(a - n) / 36e5; return Math.floor(i); } download(e, t) { show.info("开始请求下载..."); const n = t.split(".").pop().toLowerCase(); let a, i = this.mimeTypes[n] || "application/octet-stream"; if (e instanceof Blob) a = e; else if (e instanceof ArrayBuffer || ArrayBuffer.isView(e)) a = new Blob([ e ], { type: i }); else if ("string" == typeof e && e.startsWith("data:")) { const t = atob(e.split(",")[1]), n = new ArrayBuffer(t.length), s = new Uint8Array(n); for (let e = 0; e < t.length; e++) s[e] = t.charCodeAt(e); a = new Blob([ s ], { type: i }); } else a = new Blob([ e ], { type: i }); const s = URL.createObjectURL(a), o = document.createElement("a"); o.href = s, o.download = t, document.body.appendChild(o), o.click(), setTimeout((() => { document.body.removeChild(o), URL.revokeObjectURL(s); }), 100); } smoothScrollToTop(e = 500) { return new Promise((t => { const n = performance.now(), a = window.pageYOffset; window.requestAnimationFrame((function i(s) { const o = s - n, r = Math.min(o / e, 1), l = r < .5 ? 4 * r * r * r : 1 - Math.pow(-2 * r + 2, 3) / 2; window.scrollTo(0, a * (1 - l)), r < 1 ? window.requestAnimationFrame(i) : t(); })); })); } simpleId() { return crypto.randomUUID().replace("-", ""); } isUrl(e) { try { return new URL(e), !0; } catch (t) { return !1; } } setHrefParam(e, t) { const n = new URL(window.location.href); n.searchParams.set(e, t), window.history.pushState({}, "", n.toString()); } getUrlParam(e, t) { const n = e.split("?")[1]; if (!n) return null; const a = new RegExp(`(?:^|&)${t}=([^&]*)`), i = n.match(a); let s = ""; return i && i[1] && (s = decodeURIComponent(i[1].replace(/\+/g, " "))), s ? "true" === s || "false" === s ? "true" === s.toLowerCase() : "string" != typeof s || "" === s.trim() || isNaN(Number(s)) ? s : Number(s) : s; } reBuildSignature() { return O(); } getResponsiveArea(e) { const t = window.innerWidth; return this.isMobileMode() ? [ "100%", "90%" ] : t >= 1200 ? e || this.getDefaultArea() : [ "70%", "90%" ]; } getDefaultArea() { return [ "85%", "90%" ]; } isMobile() { const e = navigator.userAgent.toLowerCase(); return [ "iphone", "ipod", "ipad", "android", "blackberry", "windows phone", "nokia", "webos", "opera mini", "mobile", "mobi", "tablet" ].some((t => e.includes(t))); } isMobileMode() { const e = storageManager.getSettingSync("mobileMode", "auto"); return "on" === e || ("off" !== e && (this.isMobile() || window.innerWidth < 768)); } copyToClipboard(e, t) { navigator.clipboard.writeText(t).then((() => show.info(`${e}已复制到剪切板, ${t}`))).catch((e => console.error("复制失败: ", e))); } htmlTo$dom(e) { const t = new DOMParser; return $(t.parseFromString(e, "text/html")); } addCookie(e, t = {}) { const {maxAge: n = 604800, path: a = "/", domain: i = "", secure: s = !1, sameSite: o = "Lax"} = t; e.split(";").forEach((e => { const t = e.trim(); if (t) { const e = t.split("="); if (e.length >= 2 && e[0].trim()) { let t = [ `${e[0].trim()}=${e.slice(1).join("=")}` ]; n > 0 && t.push(`max-age=${n}`), t.push(`path=${a}`), i && t.push(`domain=${i}`), s && t.push("Secure"), o && t.push(`SameSite=${o}`), clog.debug("document.cookie = '" + t.join("; ") + "'"), document.cookie = t.join("; "); } } })); } isHidden(e) { const t = e.jquery ? e[0] : e; return !t || (t.offsetWidth <= 0 && t.offsetHeight <= 0 || "none" === window.getComputedStyle(t).display); } time(e = "default", t = "s", n = 2) { if (this.timers.has(e)) { const t = this.timers.get(e), n = performance.now() - t.startTime; let a, i; return "s" === t.unit ? (a = (n / 1e3).toFixed(t.precision), i = "秒") : (a = n.toFixed(t.precision), i = "毫秒"), this.timers.delete(e), `${e}: ${a}${i}`; } this.timers.set(e, { startTime: performance.now(), unit: t, precision: n }); } sleep(e = 1e3) { return new Promise((t => setTimeout(t, e))); } genericSort(e, t, n = !0) { if (!Array.isArray(e) || 0 === e.length) return []; if (!Array.isArray(t) || 0 === t.length) return [ ...e ]; const i = e => { if (e instanceof Date) return e; if ("string" == typeof e) { const t = new Date(e); if (!isNaN(t.getTime())) return t; } return e; }; const getVal = (e, t) => null != t ? "function" == typeof t ? t(e) : e && "object" == typeof e ? e[t] : void 0 : e; /* Separate nulls and non-nulls, sort non-nulls, then reassemble */ const nulls = [], nonNulls = []; for (const item of e) { let hasNull = !1; for (const s of t) { const val = getVal(item, s.key); if (null == val || void 0 === val) { hasNull = !0; break; } } hasNull ? nulls.push(item) : nonNulls.push(item); } return nonNulls.sort(((e, a) => { for (const s of t) { const {key: t, order: o = "asc"} = s; let r = getVal(e, t), l = getVal(a, t); const c = i(r), d = i(l); let h = c instanceof Date && d instanceof Date ? c.getTime() - d.getTime() : "number" == typeof r && "number" == typeof l ? r - l : "string" == typeof r && "string" == typeof l ? r.localeCompare(l) : String(r).localeCompare(String(l)); "desc" === o && (h *= -1); if (0 !== h) return h; } return 0; })), n ? [ ...nonNulls, ...nulls ] : [ ...nulls, ...nonNulls ]; } async retry(e, t = 3) { let n = 0; for (;n < t; ) try { const t = await e(); return n > 0 && clog.debug(`[重试] 请求成功,共发起 ${n + 1} 次。`), t; } catch (a) { const e = a instanceof Error ? a.message : "object" == typeof a ? JSON.stringify(a) : String(a); if (e.includes("Just a moment") || e.includes("重定向") || e.toLowerCase().includes("404 not found")) throw a; if (n++, n === t) throw clog.debug(`[重试] 达到最大重试次数 (${t}),最终失败:`, a), a; const isRate = /429|too many requests|rate.?limit/i.test(e); await this.sleep((isRate ? 2e3 : 500) * n), clog.debug(`[重试]${isRate ? "(限流退避)" : ""} 请求失败,准备第 ${n + 1} 次重试, 错误信息: ${e}`); } } async pingLocalService(e, t = 3e4, timeout = 2e3) { const n = "jhs_ping_" + e, a = sessionStorage.getItem(n); if (a) { const {result: e, time: n} = JSON.parse(a); if (Date.now() - n < t) return e; } try { const t = new AbortController, a = setTimeout((() => t.abort()), timeout), i = await fetch(e, { signal: t.signal, mode: "no-cors" }); clearTimeout(a); const s = {ok: !0, url: e}; return sessionStorage.setItem(n, JSON.stringify({result: s, time: Date.now()})), s; } catch (i) { const t = {ok: !1, url: e, error: i.message}; return sessionStorage.setItem(n, JSON.stringify({result: t, time: Date.now()})), t; } } } unsafeWindow.utils = window.utils = new J, unsafeWindow.gmHttp = window.gmHttp = new class { constructor() { this._circuitBreakers = new Map(); this._domainStats = new Map(); } _getDomain(e) { try { return new URL(e).hostname; } catch { return "unknown"; } } /** 缩略图源域名: 这些请求的失败不应触发熔断器(尽力而为, 单次失败不禁用整域) */ _isThumbDomain(e) { try { const h = new URL(e).hostname; return /(^|\.)javstore\.net$/.test(h) || /(^|\.)javfree\.me$/.test(h) || /(^|\.)javbest\.net$/.test(h) || /(^|\.)projectjav\.com$/.test(h); } catch { return !1; } } _checkCircuitBreaker(e) { const t = this._circuitBreakers.get(e); if (!t) return null; if ("open" === t.state) { const n = Date.now() - t.openTime; return n < t.cooldownMs ? { state: "open", remaining: Math.ceil((t.cooldownMs - n) / 1e3) } : (t.state = "half-open", t.failCount = 0, t.probing = !1, null); } if ("half-open" === t.state && t.probing) return { state: "half-open", remaining: 0 }; return null; } isDomainCircuitBroken(e) { return this._checkCircuitBreaker(this._getDomain(e)); } /** 判断目标 URL 是否与当前页面同源 */ _isSameOrigin(e) { try { return new URL(e, location.href).origin === location.origin; } catch { return !1; } } /** * 同源请求走原生 fetch。 * GM_xmlhttpRequest 由扩展后台发起, 缺少页面的完整浏览器指纹(UA/TLS/Sec-Fetch-* 等), * 会被 Cloudflare 判定为机器人直接返回 403 挑战页; 原生 fetch 继承当前页面会话, * 与用户正常浏览一致, 因此可正常通过 Cloudflare 校验。 */ async _fetchSameOrigin(method, url, body, headers, timeout, domain, checkRedirect) { const ctrl = new AbortController, timer = setTimeout((() => ctrl.abort()), timeout); let resp, text; try { resp = await fetch(url, { method: method, headers: headers || {}, body: body || void 0, credentials: "same-origin", signal: ctrl.signal }); text = await resp.text(); } catch (err) { this._recordFailure(domain); throw "AbortError" === err.name ? new Error("请求超时: " + url) : new Error(err.message || "网络错误"); } finally { clearTimeout(timer); } if (checkRedirect && resp.url && resp.url !== url) { this._recordFailure(domain); throw new Error("请求被重定向了,URL是:" + resp.url); } if (resp.ok) { this._recordSuccess(domain); if (!text) return text; try { return JSON.parse(text); } catch { return text; } } clog.error("请求失败,状态码:", resp.status, url), this._recordFailure(domain); let e2; try { e2 = JSON.parse(text), e2.status = resp.status; } catch { e2 = new Error(text || `请求发生错误 ${resp.status}`), e2.status = resp.status; } throw e2; } /* domainStats.count 统计请求数(含重试),非独立请求数 */ _recordSuccess(e) { let t = this._circuitBreakers.get(e); t && (t.state = "closed", t.failCount = 0, t.probing = !1); let n = this._domainStats.get(e); n || (n = { count: 0, errors: 0, lastUsed: 0 }, this._domainStats.set(e, n)), n.count++, n.lastUsed = Date.now(); } _recordFailure(e) { let t = this._circuitBreakers.get(e); t || (t = { state: "closed", failCount: 0, openTime: 0, cooldownMs: 6e4, threshold: 3 }, this._circuitBreakers.set(e, t)), t.failCount++, t.failCount >= (t.threshold || 3) && (t.state = "open", t.openTime = Date.now(), clog.warn(`[熔断] ${e} 连续失败 ${t.failCount} 次,已熔断 ${t.cooldownMs / 1e3} 秒`)); let n = this._domainStats.get(e); n || (n = { count: 0, errors: 0, lastUsed: 0 }, this._domainStats.set(e, n)), n.count++, n.errors++, n.lastUsed = Date.now(); } getCircuitBreakerStatus() { const e = {}; return this._circuitBreakers.forEach(((t, n) => { e[n] = { ...t }; })), e; } resetCircuitBreaker(e) { this._circuitBreakers.delete(e); } resetAllCircuitBreakers() { this._circuitBreakers.clear(); } getDomainStats() { const e = {}; return this._domainStats.forEach(((t, n) => { e[n] = { ...t }; })), e; } clearDomainStats() { this._domainStats.clear(); } getUsedDomains() { return [...this._domainStats.keys()]; } async get(e, t = {}, n = {}, a) { return this.gmRequest("GET", e, null, t, n, a, this._isThumbDomain(e)); } /** * 裸请求模式 * 不发送自定义头、30 秒超时、不触发熔断器。 * 用于缩略图源抓取——避免自定义 UA/Accept 等 header 被 Cloudflare 指纹识别为机器人。 */ fetchRaw(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: url, timeout: 30000, onload: (response) => { if (response.status >= 200 && response.status < 300) { resolve(response.responseText); } else { const err = new Error(`HTTP ${response.status}`); err.status = response.status; err.body = response.responseText; reject(err); } }, onerror: (err) => reject(new Error(err.error || "网络错误")), ontimeout: () => reject(new Error("请求超时(30s): " + url)) }); }); } post(e, t = {}, n = {}) { n = { "Content-Type": "application/json", ...n }; let a = JSON.stringify(t); return this.gmRequest("POST", e, a, null, n); } postForm(e, t = {}, n = {}) { n || (n = {}), n["Content-Type"] || (n["Content-Type"] = "application/x-www-form-urlencoded"); let a = ""; return t && Object.keys(t).length > 0 && (a = Object.entries(t).map((([e, t]) => `${e}=${encodeURIComponent(t)}`)).join("&")), this.gmRequest("POST", e, a, null, n); } postFileFormData(e, t = {}, n = {}) { n || (n = {}); const a = `----WebKitFormBoundary${Math.random().toString(36).substring(2)}`; n["Content-Type"] = `multipart/form-data; boundary=${a}`; let i = ""; return t && Object.keys(t).length > 0 && (i = Object.entries(t).map((([e, t]) => `--${a}\r\nContent-Disposition: form-data; name="${e}"\r\n\r\n${t}\r\n`)).join("")), i += `--${a}--`, this.gmRequest("POST", e, i, null, n); } /* 注意: 分块下载未使用 AbortController, 某块失败时其他飞行中的请求不会被取消 */ async downloadFileInChunks(e, t = {}, n, a) { if (!n) throw new Error("请提供文件名 (filename) 用于保存。"); const i = await storageManager.getSetting("httpTimeout", 5e3), s = await storageManager.getSetting("httpRetryCount", 3); let o, r; clog.log(`[${n}] 正在获取文件大小...`); try { const a = await utils.retry((() => new Promise(((n, a) => { GM_xmlhttpRequest({ method: "GET", url: e, headers: { ...t, Range: "bytes=0-0" }, timeout: i, onload: n, onerror: e => a(new Error("网络错误:无法获取文件大小")), ontimeout: () => a(new Error("超时:获取文件大小")) }); }))), s); if (206 !== a.status && 200 !== a.status) throw new Error(`请求文件大小失败,状态码: ${a.status}`); { const e = a.responseHeaders.match(/content-range:\s*bytes\s*\d+-\d+\/(\d+)/i), t = a.responseHeaders.match(/content-type:\s*([^\s;]+)/i); if (e && e[1]) o = parseInt(e[1], 10); else { if (!a.responseHeaders.match(/content-length:\s*(\d+)/i) || 200 !== a.status) throw new Error("无法从响应头中获取文件总大小,服务器可能不支持 Range 请求。"); { const e = a.responseHeaders.match(/content-length:\s*(\d+)/i); o = parseInt(e[1], 10), clog.warn(`[${n}] 服务器返回 200 状态码,可能不支持 Range 请求。将尝试完整下载。`); } } t && t[1] && (r = t[1]), clog.log(`[${n}] 文件总大小:${(o / 1024 / 1024).toFixed(2)} MB, MIME 类型: ${r || "未知"}`); } } catch (u) { throw clog.error(`[${n}] 获取文件大小失败:`, u.message), u; } if (!o || o <= 0) throw new Error("获取到的文件大小无效或服务器拒绝提供大小信息。"); const l = 1048576, c = Math.ceil(o / l), d = [], h = new Array(c); clog.log(`[${n}] 文件将被分为 ${c} 块进行下载 (每块约 1.00 MB)`); for (let f = 0; f < c; f++) { const a = f * l, r = `bytes=${a}-${Math.min(a + l - 1, o - 1)}`, g = await utils.retry((() => new Promise(((a, s) => { const o = { ...t, Range: r, Accept: "application/octet-stream" }; GM_xmlhttpRequest({ method: "GET", url: e, headers: o, timeout: i, responseType: "arraybuffer", onload: e => { 206 === e.status || 200 === e.status ? e.response instanceof ArrayBuffer ? (h[f] = e.response, clog.log(`[${n}] 成功下载第 ${f + 1}/${c} 块 (${r})`), a()) : s(new Error(`第 ${f + 1} 块响应不是 ArrayBuffer。`)) : s(new Error(`第 ${f + 1} 块请求失败,状态码: ${e.status}`)); }, onerror: e => s(new Error(`第 ${f + 1} 块网络错误: ${e.error}`)), ontimeout: () => s(new Error(`第 ${f + 1} 块超时。`)) }); }))), s); d.push(g); } try { await Promise.all(d), clog.log(`[${n}] 所有分块下载完成,开始合并...`); } catch (u) { throw clog.error(`[${n}] 分块下载过程中发生错误:`, u.message), u; } const g = new Blob(h); g.size !== o && clog.warn(`[${n}] 警告:合并后的 Blob 大小 (${g.size}) 与预期文件大小 (${o}) 不匹配!`); const p = await g.text(); let m; m = a ? a(p) : p, utils.download(m, n), clog.log(`[${n}] 文件合并完成,已触发浏览器下载。`); } async gmRequest(e, t, n = {}, a = {}, i = {}, s = !1, l = !1) { if (a && Object.keys(a).length) { const e = new URLSearchParams(a).toString(); t += (t.includes("?") ? "&" : "?") + e; } // 缓存网络配置(单次会话内不变), 避免每个请求都 await IndexedDB if (!this._httpCfg) { const [m, r, b, k] = await Promise.all([storageManager.getSetting("httpTimeout", 5e3), storageManager.getSetting("httpRetryCount", 3), storageManager.getSetting("circuitBreakerThreshold", 3), storageManager.getSetting("circuitBreakerCooldown", 6e4)]); this._httpCfg = { timeout: m, retry: r, threshold: b, cooldown: k }; } const { timeout: m, retry: r, threshold: b, cooldown: k } = this._httpCfg; const o = this._getDomain(t); let u = this._circuitBreakers.get(o); u || (u = { state: "closed", failCount: 0, openTime: 0, cooldownMs: k, threshold: b, probing: !1 }, this._circuitBreakers.set(o, u)); // 尽力而为请求(缩略图等): 不被熔断器长期禁用, 单次失败不牵连后续番号 if (!l) { const w = this._checkCircuitBreaker(o); if (w) { const e = new Error(`站点 ${o} 已熔断,${w.remaining}秒后重试`); throw e._circuitBroken = !0, e; } } "half-open" === u.state && (u.probing = !0); // 同源请求优先走原生 fetch,避免 GM_xmlhttpRequest 被 Cloudflare 拦截 if (this._isSameOrigin(t)) { return await utils.retry((() => this._fetchSameOrigin(e, t, n, i, m, o, s)), r); } return n || (n = void 0), await utils.retry((() => new Promise(((a, r) => { GM_xmlhttpRequest({ method: e, url: t, headers: i, timeout: m, data: n, onload: e => { try { if (s && e.finalUrl !== t && r("请求被重定向了,URL是:" + e.finalUrl), e.status >= 200 && e.status < 300) { this._recordSuccess(o); if (e.responseText) try { a(JSON.parse(e.responseText)); } catch (n) { a(e.responseText); } else a(e.responseText || e); } else { clog.error("请求失败,状态码:", e.status, t), l || this._recordFailure(o); if (e.responseText) { try { const t = JSON.parse(e.responseText); t.status = e.status, r(t); } catch { const t = new Error(e.responseText || `请求发生错误 ${e.status}`); t.status = e.status, r(t); } } else { const t = new Error(`请求发生错误 ${e.status}`); t.status = e.status, r(t); } } } catch (n) { l || this._recordFailure(o), r(n); } }, onerror: e => { clog.error("网络错误:", t), l || this._recordFailure(o), r(new Error(e.error || "网络错误")); }, ontimeout: () => { l || this._recordFailure(o), r(new Error("请求超时: " + t)); } }); }))), r); } }, unsafeWindow.storageManager = window.storageManager = new z; const G = new BroadcastChannel("channel-refresh"); window.refresh = function() { G.postMessage({ type: "refresh" }); }; window.cleanCache_filter_actor_actress_car_list = function() { G.postMessage({ type: "cleanCache_filter_actor_actress_car_list" }); }; window.clean_cacheSettingObj = function() { G.postMessage({ type: "clean_cacheSettingObj" }); }; document.head.insertAdjacentHTML("beforeend", '\n <style>\n .loading-container {\n position: fixed;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background-color: rgba(0, 0, 0, 0.1);\n z-index: 99999999;\n }\n \n .loading-animation {\n position: relative;\n width: 60px;\n height: 12px;\n background: linear-gradient(90deg, #4facfe 0%, #00f2fe 100%);\n border-radius: 6px;\n animation: loading-animate 1.8s ease-in-out infinite;\n box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);\n }\n \n .loading-animation:before,\n .loading-animation:after {\n position: absolute;\n display: block;\n content: "";\n animation: loading-animate 1.8s ease-in-out infinite;\n height: 12px;\n border-radius: 6px;\n box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);\n }\n \n .loading-animation:before {\n top: -20px;\n left: 10px;\n width: 40px;\n background: linear-gradient(90deg, #ff758c 0%, #ff7eb3 100%);\n }\n \n .loading-animation:after {\n bottom: -20px;\n width: 35px;\n background: linear-gradient(90deg, #ff9a9e 0%, #fad0c4 100%);\n }\n \n @keyframes loading-animate {\n 0% {\n transform: translateX(40px);\n }\n 50% {\n transform: translateX(-30px);\n }\n 100% {\n transform: translateX(40px);\n }\n }\n </style>\n '); unsafeWindow.loading = window.loading = function() { const e = document.createElement("div"); e.className = "loading-container"; const t = document.createElement("div"); return t.className = "loading-animation", e.appendChild(t), document.body.appendChild(e), { close: () => { e && e.parentNode && e.parentNode.removeChild(e); } }; }, function() { const e = (e, t, n, a, i) => { let s; "object" == typeof n ? s = n : (s = "object" == typeof a ? a : i || {}, s.gravity = n || "top", s.position = "string" == typeof a ? a : "center"), s.gravity && "center" !== s.gravity || (s.offset = { y: "calc(50vh - 150px)" }); const o = "#60A5FA", r = "#93C5FD", l = "#10B981", c = "#6EE7B7", d = "#EF4444", h = "#FCA5A5", g = { borderRadius: "12px", color: "white", padding: "12px 16px", boxShadow: "0 4px 6px rgba(0,0,0,0.1)", minWidth: "150px", textAlign: "center", zIndex: 999999999 }, p = { text: e, duration: 2500, close: !1, gravity: "top", position: "center", style: { info: { ...g, background: `linear-gradient(to right, ${o}, ${r})` }, success: { ...g, background: `linear-gradient(to right, ${l}, ${c})` }, error: { ...g, background: `linear-gradient(to right, ${d}, ${h})` } }[t], stopOnFocus: !0, oldestFirst: !1, ...s }; -1 === p.duration && (p.close = !0); const m = Toastify(p); return m.showToast(), m.closeShow = () => { m.toastElement.remove(); }, m; }; unsafeWindow.show = window.show = { ok: (t, n = "center", a, i) => e(t, "success", n, a, i), error: (t, n = "center", a, i) => e(t, "error", n, a, i), info: (t, n = "center", a, i) => e(t, "info", n, a, i) }; }(), function() { function e(e = 10) { setTimeout((() => { const e = document.querySelectorAll(".layui-layer-shade").length; document.documentElement.style.overflow = e > 0 ? "hidden" : ""; }), e); } document.head.insertAdjacentHTML("beforeend", "\n <style>\n .viewer-canvas {\n overflow: auto !important;\n }\n \n .viewer-close {\n background: rgba(255,0,0,0.6) !important;\n }\n .viewer-close:hover {\n background: rgba(255,0,0,0.8) !important;\n }\n .viewer-toolbar {\n transform: translateY(-2px);\n }\n </style>\n "), /** * 轻量图片查看器: 拿到 URL 立刻贴 <img> 显示, 浏览器边下边渲染。 * 与 showImageViewer(Viewer.js) 的区别: Viewer.js 要先知道图片固有尺寸才能布局, * 所以整张图下载完之前画面是空的; 这里直接铺一张按宽度缩放的 img, 长图能从上往下逐段出来。 * 缩放用 CSS 宽度倍数(不改 src), 底部工具条支持 缩小/放大/适宽/新窗口/切源/关闭。 */ window.showFastImageViewer = function(url, title = "", opts = null) { // url 允许传 Promise: 弹层先出来显示"正在查找预览图…", 解析出地址再填 src, // 免得点完按钮几秒钟没有任何反馈。 const pending = url && "function" == typeof url.then ? url : null; if (!pending && (!url || "string" != typeof url && !(url instanceof String))) { clog.error("showFastImageViewer: 无效的图片参数", url); return void show.error("预览图无效, 无法打开预览"); } let curUrl = pending ? "" : String(url), scale = .75; $("#jhs-fast-viewer").remove(); if (!document.getElementById("jhs-fv-style")) { const st = document.createElement("style"); st.id = "jhs-fv-style"; st.textContent = ` #jhs-fast-viewer{position:fixed;inset:0;z-index:2147483645;background:rgba(0,0,0,.9);display:flex;flex-direction:column} #jhs-fast-viewer .jhs-fv-scroll{flex:1;overflow:auto;text-align:center;padding:14px 0 76px} #jhs-fast-viewer .jhs-fv-img{display:inline-block;vertical-align:top;background:rgba(255,255,255,.04)} /* 地址还没解析出来(或加载失败后清掉了 src)时, 不要留浏览器默认的裂图占位图标 */ #jhs-fast-viewer .jhs-fv-img:not([src]){display:none} #jhs-fast-viewer .jhs-fv-tip{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);color:#cbd5e1;font-size:13px;pointer-events:none} #jhs-fast-viewer .jhs-fv-bar{position:absolute;left:50%;bottom:18px;transform:translateX(-50%);display:flex;gap:6px;align-items:center;padding:7px 10px;border-radius:999px;background:rgba(30,30,30,.92);box-shadow:0 4px 16px rgba(0,0,0,.45)} #jhs-fast-viewer .jhs-fv-btn{border:0;background:rgba(255,255,255,.12);color:#fff;font-size:12px;line-height:1;padding:8px 13px;border-radius:16px;cursor:pointer;white-space:nowrap} #jhs-fast-viewer .jhs-fv-btn:hover{background:rgba(255,255,255,.22)} #jhs-fast-viewer .jhs-fv-btn:disabled{opacity:.4;cursor:not-allowed} #jhs-fast-viewer .jhs-fv-lvl{min-width:46px;text-align:center;color:#9fb0c6;font-size:12px;font-variant-numeric:tabular-nums;user-select:none} #jhs-fast-viewer .jhs-fv-dot{width:9px;height:9px;border-radius:50%;display:inline-block;margin-right:5px} `; document.head.appendChild(st); } const srcBtns = opts && Array.isArray(opts.sources) ? opts.sources.map((s => `<button type="button" class="jhs-fv-btn jhs-fv-src" data-src="${s.key}"><span class="jhs-fv-dot" style="background:${curUrl.toLowerCase().includes(s.key) ? "#4caf50" : "#ffc107"}"></span>${s.name}</button>`)).join("") : ""; const $ov = $(`<div id="jhs-fast-viewer"> <div class="jhs-fv-scroll"><img class="jhs-fv-img" alt="${title}"></div> <div class="jhs-fv-tip">加载中…</div> <div class="jhs-fv-bar"> <button type="button" class="jhs-fv-btn jhs-fv-out">−</button> <span class="jhs-fv-lvl">75%</span> <button type="button" class="jhs-fv-btn jhs-fv-in">+</button> <button type="button" class="jhs-fv-btn jhs-fv-fit">适宽</button> ${srcBtns} <button type="button" class="jhs-fv-btn jhs-fv-open">新窗口</button> <button type="button" class="jhs-fv-btn jhs-fv-close">关闭</button> </div> </div>`); const $img = $ov.find(".jhs-fv-img"), $tip = $ov.find(".jhs-fv-tip"), $lvl = $ov.find(".jhs-fv-lvl"); const baseW = () => Math.min(1000, .96 * window.innerWidth); const applyScale = () => { $img.css("width", Math.round(baseW() * scale) + "px"); $lvl.text(Math.round(100 * scale) + "%"); $ov.find(".jhs-fv-in").prop("disabled", scale >= 4), $ov.find(".jhs-fv-out").prop("disabled", scale <= .3); }; const close = () => { $ov.remove(), $(document).off("keydown.jhsFv"), $(window).off("resize.jhsFv"), document.documentElement.style.overflow = "", document.body.style.overflow = "", document.documentElement.style.paddingRight = ""; }; // 关键: 先把 img 挂进文档再设 src, 让浏览器一边下载一边渲染 $("body").append($ov); // 锁背景滚动时补上滚动条宽度: 直接 overflow:hidden 会让竖滚动条消失, 页面宽度突然多出 ~15px, // 详情页的预览图格子跟着重排, 而 .screen-container 的高度是 syncScreenTileHeight 写死的像素值, // 对不上就看着被拉长, 要等下一次重新同步才恢复。 const sbw = window.innerWidth - document.documentElement.clientWidth; document.documentElement.style.overflow = "hidden"; sbw > 0 && (document.documentElement.style.paddingRight = sbw + "px"); applyScale(); // 加载失败时把 src 摘掉, 免得裂图图标压在提示文字上; curUrl 仍留着给 新窗口/切源 用 $img.on("load", (() => $tip.remove())).on("error", (() => { $img.removeAttr("src"), $tip.text("图片加载失败, 可点下方按钮换源"); })); const refreshDots = key => $ov.find(".jhs-fv-src").each((function() { $(this).find(".jhs-fv-dot").css("background", $(this).data("src") === key ? "#4caf50" : "#ffc107"); })); if (pending) { $tip.text("正在查找预览图…"); pending.then((u => { if (!$ov.parent().length) return; // 已经被关掉了, 不用管 if (!u) return void $tip.text("没找到预览图, 可点下方按钮手动换源"); curUrl = String(u), $img.attr("src", curUrl); (opts?.sources || []).forEach((s => curUrl.toLowerCase().includes(s.key) && refreshDots(s.key))); }), (err => { $ov.parent().length && $tip.text(`查找预览图失败: ${err && err.message || err}`); })); } else $img.attr("src", curUrl); $ov.on("click", ".jhs-fv-out", (() => { scale = Math.max(.3, scale - .25), applyScale(); })); $ov.on("click", ".jhs-fv-in", (() => { scale = Math.min(4, scale + .25), applyScale(); })); $ov.on("click", ".jhs-fv-fit", (() => { scale = 1, applyScale(); })); $ov.on("click", ".jhs-fv-open", (() => curUrl && window.open(curUrl, "_blank"))); $ov.on("click", ".jhs-fv-close", close); // 点击图片以外的空白区域关闭(点在图上不关) $ov.on("click", ".jhs-fv-scroll", (ev => { ev.target === ev.currentTarget && close(); })); $(document).on("keydown.jhsFv", (ev => { "Escape" === ev.key && close(); })); $(window).on("resize.jhsFv", applyScale); $ov.on("click", ".jhs-fv-src", (async ev => { const $btn = $(ev.currentTarget), key = $btn.data("src"); if ("function" != typeof opts?.fetchSource) return; $ov.find(".jhs-fv-src").prop("disabled", !0); $tip.length || $ov.append('<div class="jhs-fv-tip">切换中…</div>'); try { const u = await opts.fetchSource(key); if (!u) { $btn.find(".jhs-fv-dot").css("background", "#f44336"); return void show.error(`${key} 未找到缩略图`); } if (u === curUrl) return void show.ok("已是当前来源的图片"); curUrl = u, $ov.find(".jhs-fv-img").attr("src", u); $ov.find(".jhs-fv-src").each((function() { $(this).find(".jhs-fv-dot").css("background", $(this).data("src") === key ? "#4caf50" : "#ffc107"); })); } catch (err) { show.error(`切换来源失败: ${err && err.message || err}`); } finally { $ov.find(".jhs-fv-tip").remove(), $ov.find(".jhs-fv-src").prop("disabled", !1); } })); }, window.showImageViewer = function(t, n = "", opts = null) { // 防御: 入参为空 / 非字符串 / 非元素时直接提示, 避免 Viewer.js 抛 "The first argument is required and must be an element" if (t == null || ("string" != typeof t && !(t instanceof String) && (!t || !t.nodeType && (!t.jquery || !t.length)))) { clog.error("showImageViewer: 无效的图片参数", t); show.error("预览图无效, 无法打开预览"); return; } let a = null, i = !1, curUrl = "string" == typeof t || t instanceof String ? String(t) : ""; "string" == typeof t || t instanceof String ? (a = $('<div class="temporary-container" style="display:none;">').append(`<img src="${t}" alt="${n}">`).appendTo("body"), i = !0) : a = $(t); // 在放大预览界面底部注入自定义工具条(切换来源 / 新窗口 / 下载) const buildExtraBar = () => { if (!opts || !Array.isArray(opts.sources) || !opts.sources.length) return; // 工具条挂在 body 上(fixed), 与查看器容器解耦: 换源重建查看器时不会被清掉 let $bar = $("#jhs-viewer-bar"); if ($bar.length) { $bar.show(); return; } // 绿=当前显示的来源(URL含该源域名), 黄=可加载(未试), 红=点击后未找到 const dotColor = s => curUrl && curUrl.toLowerCase().includes(s.key) ? "#4caf50" : "#ffc107"; const srcBtns = opts.sources.map((s => `<button type="button" class="jhs-vb-src" data-src="${s.key}" style="display:inline-flex;align-items:center;gap:6px;border:0;background:rgba(255,255,255,0.12);color:#fff;font-size:11px;line-height:1;padding:8px 14px;border-radius:16px;cursor:pointer;white-space:nowrap;"><span class="jhs-vb-dot" style="width:10px;height:10px;border-radius:50%;background:${dotColor(s)};display:inline-block;"></span>${s.name}</button>`)).join(""); $bar = $(`<div id="jhs-viewer-bar" class="jhs-viewer-bar" style="position:fixed;left:50%;bottom:46px;transform:translateX(-50%);z-index:2147483646;display:flex;gap:8px;align-items:center;background:rgba(60,60,60,0.92);padding:8px 12px;border-radius:26px;box-shadow:0 2px 12px rgba(0,0,0,0.4);">${srcBtns}<button type="button" class="jhs-vb-open" style="border:0;background:rgba(255,255,255,0.12);color:#fff;font-size:11px;line-height:1;padding:8px 14px;border-radius:16px;cursor:pointer;white-space:nowrap;">新窗口</button><button type="button" class="jhs-vb-dl" style="border:0;background:rgba(255,255,255,0.12);color:#fff;font-size:11px;line-height:1;padding:8px 14px;border-radius:16px;cursor:pointer;white-space:nowrap;">下载</button></div>`); $("body").append($bar); $bar.on("click", (e => e.stopPropagation())); $bar.on("click", ".jhs-vb-open", (e => { e.preventDefault(), e.stopPropagation(); curUrl && window.open(curUrl, "_blank"); })); $bar.on("click", ".jhs-vb-dl", (e => { e.preventDefault(), e.stopPropagation(); if (!curUrl) return; const name = (opts.carNum || "screenshot").toUpperCase(); if (typeof GM_xmlhttpRequest !== "undefined") { GM_xmlhttpRequest({ method: "GET", url: curUrl, responseType: "arraybuffer", headers: { Referer: window.location.href }, onload: (res) => { if (!res.response || res.response.byteLength < 100) { show.error && show.error("图片下载失败: 响应为空"); return void (window.open(curUrl, "_blank")); } const arr = new Uint8Array(res.response); let ext = "jpg", mime = "image/jpeg"; if (arr[0] === 0x89 && arr[1] === 0x50) { ext = "png"; mime = "image/png"; } else if (arr[0] === 0x47 && arr[1] === 0x49) { ext = "gif"; mime = "image/gif"; } else if (arr[0] === 0x52 && arr[1] === 0x49 && arr[8] === 0x57 && arr[9] === 0x45) { ext = "webp"; mime = "image/webp"; } else if (arr[0] === 0x3C || arr[0] === 0x0A) { show.error && show.error("图片下载失败: 服务器返回了非图片内容"); return; } const fileName = name + "." + ext; const blob = new Blob([res.response], { type: mime }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url, a.download = fileName, document.body.appendChild(a), a.click(), a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1e3); show.ok && show.ok("缩略图已保存: " + fileName); }, onerror: () => show.error && show.error("图片下载失败: 网络错误") }); } else { const link = document.createElement("a"); link.href = curUrl, link.download = name + ".jpg", link.target = "_blank", document.body.appendChild(link), link.click(), link.remove(); } })); $bar.on("click", ".jhs-vb-src", (async e => { e.preventDefault(), e.stopPropagation(); const key = $(e.currentTarget).data("src"); const $btn = $(e.currentTarget); const old = $btn.text(); $bar.find(".jhs-vb-src").prop("disabled", !0).css("opacity", .5); $btn.css("opacity", 1).append('<span class="jhs-vb-load" style="margin-left:4px;">…</span>'); try { const url = "function" == typeof opts.fetchSource ? await opts.fetchSource(key) : null; if (!url) { $(e.currentTarget).find(".jhs-vb-dot").css("background", "#f44336"); return void show.error(`${key} 未找到缩略图`); } if (url === curUrl) return void show.ok("已是当前来源的图片"); // 换源: 直接替换查看器画布里的图片(不重建查看器, 画面与工具条都不被刷掉) curUrl = url; const refreshDots = activeKey => $("#jhs-viewer-bar").find(".jhs-vb-src").each((function() { const k = $(this).data("src"); $(this).find(".jhs-vb-dot").css("background", k === activeKey ? "#4caf50" : "#ffc107"); })); const $cvImg = $(".viewer-canvas").find("img").last(); // 换源: 销毁重建查看器。不能只改 src——Viewer.js 会用旧图算好的固定像素 // width/height 硬套新图, 比例不同就被拉伸变形。重建可让新图按自身比例重新布局。 a.find("img").attr("src", url).attr("alt", n); try { o && o.destroy(); } catch (er) {} document.documentElement.style.overflow = "", document.body.style.overflow = ""; window.showImageViewer(url, n, opts); refreshDots(key); } catch (err) { show.error(`切换来源失败: ${err && err.message || err}`); } finally { $bar.find(".jhs-vb-load").remove(), $bar.find(".jhs-vb-src").prop("disabled", !1).css("opacity", 1); } })); }; const s = { zIndex: 999999990, navbar: !1, zoomOnWheel: !1, zoomRatio: .1, toggleOnDblclick: !1, toolbar: { zoomIn: 1, zoomOut: 1, reset: 1, rotateLeft: 0, rotateRight: 0, flipHorizontal: 0, flipVertical: 0 }, title: !1, keyboard: !1, viewed() { // 固定 50% 缩放显示, 水平居中、顶部对齐(长截图顶部被切可接受) o.zoomTo(0.5); let e = (o.viewerData.width - o.imageData.width) / 2; o.moveTo(e, 0); }, shown() { document.documentElement.style.overflow = "hidden", document.body.style.overflow = "hidden", buildExtraBar(), o.handleKeydown = function(t) { "Escape" !== t.key && " " !== t.key || (t.preventDefault(), t.stopPropagation(), o.destroy(), document.removeEventListener("keydown", o.handleKeydown), document.documentElement.style.overflow = "", document.body.style.overflow = "", e()); }, document.addEventListener("keydown", o.handleKeydown); }, hidden() { o && o.handleKeydown && document.removeEventListener("keydown", o.handleKeydown), $("#jhs-viewer-bar").remove(), o.destroy(), a && a.remove(), document.documentElement.style.overflow = "", document.body.style.overflow = "", e(); } }, o = new Viewer(a[0], s); o.show(); }; }(), window.ImageHoverPreview = class { constructor(e = {}) { this.config = { selector: ".hover-preview", dataAttribute: "data-full", maxWidth: 1e3, maxHeight: 1e3, offsetX: 20, offsetY: 20, zIndex: 9999999999, transition: .2, autoAdjustPosition: !0, ...e }, this.preview = null, this.currentTarget = null, this.timer = null, this.imgElement = null, this.boundElements = new WeakSet, this.init(); } init() { if (utils.isMobileMode()) return; this.injectStyles(), this.createPreviewElement(); // 延迟绑定: 确保 DOM 中已有目标元素时选择器能正确匹配 if ("loading" === document.readyState) { document.addEventListener("DOMContentLoaded", () => this.bindEvents()); } else { this.bindEvents(); } } injectStyles() { const e = `\n <style>\n .image-hover-preview {\n position: fixed;\n display: none;\n z-index: ${this.config.zIndex};\n border-radius: 4px;\n box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);\n overflow: hidden;\n pointer-events: none;\n opacity: 0;\n transition: opacity ${this.config.transition}s ease;\n background-color: #fff;\n }\n \n .image-hover-preview.active {\n opacity: 1;\n }\n \n .image-hover-preview img {\n max-width: ${this.config.maxWidth}px;\n max-height: ${this.config.maxHeight}px;\n display: block;\n object-fit: contain;\n }\n \n .image-hover-preview::after {\n content: '';\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0, 0, 0, 0.03);\n pointer-events: none;\n }\n \n .image-hover-preview.loading::before {\n content: '加载中...';\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n color: #666;\n font-size: 14px;\n }\n </style>\n `; document.head.insertAdjacentHTML("beforeend", e); } createPreviewElement() { this.preview = document.createElement("div"), this.preview.className = "image-hover-preview", document.body.appendChild(this.preview); } bindEvents() { if (this._delegated) return; this._delegated = !0; const matchTarget = e => { const t = e.target; if (!t || !t.closest) return null; return t.matches && t.matches(this.config.selector) ? t : t.closest(this.config.selector); }; this._onOver = e => { const t = matchTarget(e); if (!t || this.currentTarget === t) return; this.handleMouseEnter(e, t); }; this._onOut = e => { if (!this.currentTarget) return; const rt = e.relatedTarget; if (rt && this.currentTarget.contains && this.currentTarget.contains(rt)) return; this.handleMouseLeave(e); }; this._onMove = e => { this.currentTarget && this.handleMouseMove(e); }; document.addEventListener("mouseover", this._onOver, !0); document.addEventListener("mouseout", this._onOut, !0); document.addEventListener("mousemove", this._onMove, !0); } handleMouseEnter(e, target) { clearTimeout(this.timer), this.currentTarget = target || e.currentTarget || e.target; const t = this.currentTarget.getAttribute(this.config.dataAttribute) || this.currentTarget.src; if (!t) return; this.preview.innerHTML = "", this.preview.classList.add("loading"), this.preview.style.display = "block", this.preview.classList.remove("active"); const n = new Image; n.onload = () => { this.preview.classList.remove("loading"), this.preview.innerHTML = `<img src="${escapeHtml(t)}" alt="预览图">`, this.imgElement = this.preview.querySelector("img"); const {width: a, height: i} = this.calculateImageSize(n); this.preview.style.width = `${a}px`, this.preview.style.height = `${i}px`, this.preview.offsetHeight, this.preview.classList.add("active"), this.handleMouseMove(e); }, n.onerror = () => { this.preview.classList.remove("loading"), this.preview.innerHTML = '<div style="padding:10px;color:#f00;">图片加载失败</div>'; }, n.src = t; } calculateImageSize(e) { let t = e.naturalWidth, n = e.naturalHeight; if (t > this.config.maxWidth || n > this.config.maxHeight) { const e = Math.min(this.config.maxWidth / t, this.config.maxHeight / n); t *= e, n *= e; } return { width: t, height: n }; } handleMouseMove(e) { if (!this.currentTarget || !this.preview.classList.contains("active")) return; if (!this.currentTarget.isConnected) return void this.handleMouseLeave(); let {offsetX: t, offsetY: n} = this.config, a = e.clientX + t, i = e.clientY + n; if (this.config.autoAdjustPosition) { const s = this.preview.offsetWidth, o = this.preview.offsetHeight; a + s > window.innerWidth && (a = e.clientX - s - t), i + o > window.innerHeight && (i = e.clientY - o - n), a = Math.max(0, a), i = Math.max(0, i); } this.preview.style.left = `${a}px`, this.preview.style.top = `${i}px`; } handleMouseLeave() { this.preview.classList.remove("active"), this.preview.style.display = "none", this.currentTarget = null, this.imgElement = null, this._lastSrc = null; } /** 保存当前悬浮预览图片 */ saveCurrentImage() { if (!this.currentTarget) return; const src = this.currentTarget.getAttribute(this.config.dataAttribute) || this.currentTarget.src; if (!src) return; // 用当前番号命名(大写), 若取不到则用 URL 尾段 let carNum = ""; try { if (window.isDetailPage) { // JavDB 详情页: data-clipboard-text 即番号 const $clip = $("a[data-clipboard-text]").first(); if ($clip.length) carNum = ($clip.attr("data-clipboard-text") || "").trim(); // JavBus 详情页: 番号是 URL 最后一段 if (!carNum) { const seg = location.href.split("?")[0].split("#")[0].split("/").filter(Boolean).pop() || ""; if (/^[A-Za-z0-9]+-?\d+/.test(seg) || /^[A-Za-z]{2,}/.test(seg)) carNum = seg; } // JavBus 识别码 label 兜底 if (!carNum) { const $id = $("span.header:contains('識別碼'), span.header:contains('识别码')").first().parent(); if ($id.length) carNum = $id.text().replace(/.*[::]/, "").trim(); } } else { // 列表页: 从 .item / .movie-box 提取 const $item = $(this.currentTarget).closest(".item, .movie-box"); if ($item.length) { const $title = $item.find(".video-title strong, date"); if ($title.length) carNum = $title.first().text().trim(); if (!carNum) { const m = $item.text().match(/[A-Za-z]{2,6}-?\d{2,6}/); if (m) carNum = m[0]; } } } } catch (e) {} carNum = carNum ? carNum.toUpperCase().replace(/[\\/:*?"<>|]/g, "_") : ""; // 使用 GM_xmlhttpRequest 获取 blob 并触发下载 if (typeof GM_xmlhttpRequest !== "undefined") { GM_xmlhttpRequest({ method: "GET", url: src, responseType: "arraybuffer", headers: { Referer: window.location.href }, onload: (res) => { if (!res.response || res.response.byteLength < 100) { show.error && show.error("图片下载失败: 响应为空或被拒绝"); return; } const arr = new Uint8Array(res.response); // 根据文件头魔数判断真实格式 let ext = "jpg", mime = "image/jpeg"; if (arr[0] === 0x89 && arr[1] === 0x50) { ext = "png"; mime = "image/png"; } else if (arr[0] === 0x47 && arr[1] === 0x49) { ext = "gif"; mime = "image/gif"; } else if (arr[0] === 0x52 && arr[1] === 0x49 && arr[8] === 0x57 && arr[9] === 0x45) { ext = "webp"; mime = "image/webp"; } else if (arr[0] === 0x3C || arr[0] === 0x0A) { // 很可能是 HTML 错误页 show.error && show.error("图片下载失败: 服务器返回了非图片内容"); return; } const name = (carNum || src.split("/").pop().split("?")[0].replace(/\.[^.]+$/, "") || "image").toUpperCase() + "." + ext; const blob = new Blob([res.response], { type: mime }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = name; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1e3); show.ok && show.ok("图片已保存: " + name); }, onerror: () => { show.error && show.error("图片下载失败: 网络错误"); } }); } else { this._fallbackSave(src, (carNum || "image") + ".jpg"); } } _fallbackSave(src, name) { const img = new Image(); img.crossOrigin = "anonymous"; img.onload = () => { const canvas = document.createElement("canvas"); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; canvas.getContext("2d").drawImage(img, 0, 0); canvas.toBlob((blob) => { if (!blob) return; const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = name; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1e3); }); }; img.onerror = () => show.error && show.error("图片下载失败"); img.src = src; } destroy() { document.removeEventListener("mouseover", this._onOver, !0); document.removeEventListener("mouseout", this._onOut, !0); document.removeEventListener("mousemove", this._onMove, !0); this._delegated = !1; this.preview && this.preview.parentNode && this.preview.parentNode.removeChild(this.preview); } }, async function() { document.head.insertAdjacentHTML("beforeend", "\n <style>\n .console-logger-container {\n position: fixed;\n bottom: 0;\n right: 0;\n z-index: 99999999;\n font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;\n display: flex;\n flex-direction: column; \n align-items: flex-end;\n width: fit-content;\n }\n\n .console-logger-toggle {\n width: 40px;\n height: 30px;\n background: #2c3e50;\n border-radius: 120px 10px 0 0;\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: pointer;\n box-shadow: -2px 0 5px rgba(0, 0, 0, 0.1);\n transition: all 0.3s ease;\n color: white;\n font-size: 16px;\n }\n\n .console-logger-toggle:hover {\n background: #34495e;\n }\n\n .console-logger-toggle::after {\n content: '▼';\n transition: transform 0.3s ease;\n }\n\n .console-logger-toggle.collapsed::after {\n content: '▲';\n }\n\n .console-logger-window {\n width: 400px;\n height: 400px;\n background: white;\n border-radius: 10px 0 10px 10px;\n box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n transform: translateY(0);\n opacity: 1;\n /* 简化过渡属性 */\n transition: width 0.3s ease, height 0.3s ease, opacity 0.3s ease, transform 0.3s ease;\n }\n\n .console-logger-window.maximized {\n width: 600px !important;\n height: 85vh !important;\n border-radius: 10px 0 0 10px; /* 调整圆角以匹配右下角 */\n }\n\n .console-logger-window.collapsed {\n height: 0 !important;\n min-height: 0 !important; \n opacity: 0;\n }\n\n .console-logger-header {\n background: #2c3e50;\n color: white;\n padding: 12px 15px;\n display: flex;\n justify-content: space-between;\n align-items: center;\n flex-shrink: 0;\n }\n\n .console-logger-title {\n font-weight: 600;\n font-size: 16px;\n }\n\n .console-logger-controls {\n display: flex;\n gap: 10px;\n }\n\n .console-logger-controls button {\n background: transparent;\n border: 1px solid rgba(255, 255, 255, 0.3);\n padding: 5px 10px;\n font-size: 12px;\n color: white;\n border-radius: 4px;\n cursor: pointer;\n transition: background 0.3s;\n }\n\n .console-logger-controls button:hover {\n background: rgba(255, 255, 255, 0.1);\n }\n\n /* 新增的按钮样式 */\n .console-logger-maximize-toggle {\n line-height: 1;\n font-size: 14px !important; /* 使箭头看起来更大 */\n padding: 5px 8px !important;\n }\n .console-logger-maximize-toggle::before {\n content: '⇱'; /* Unicode symbol for maximized */\n }\n .console-logger-maximize-toggle.active::before {\n content: '⇲'; /* Unicode symbol for minimized */\n }\n\n\n .console-logger-filters {\n display: flex;\n align-items: center;\n gap: 5px;\n padding: 10px;\n background: #f8f9fa;\n border-bottom: 1px solid #e9ecef;\n flex-shrink: 0;\n overflow-x: hidden; \n }\n\n /* 新增: 过滤器按钮组的容器,负责滚动 */\n .console-logger-filter-group {\n display: flex;\n gap: 5px;\n overflow-x: auto; /* 允许过滤器按钮滚动 */\n flex-grow: 1; /* 占据剩余空间 */\n padding-right: 10px; /* 避免滚动条影响按钮 */\n }\n\n .console-logger-filter {\n padding: 5px 10px;\n font-size: 12px;\n border-radius: 15px;\n background: #ecf0f1;\n color: #7f8c8d;\n border: 1px solid #ddd;\n cursor: pointer;\n transition: all 0.3s;\n white-space: nowrap;\n flex-shrink: 0; /* 确保不被压缩 */\n }\n\n .console-logger-filter.active {\n background: #3498db;\n color: white;\n border-color: #3498db;\n }\n\n /* 新增: 滚动到底部按钮的样式 (位于 filtersContainer 内部右侧) */\n .console-logger-scroll-to-bottom {\n background: #3498db;\n border: none;\n padding: 5px 10px;\n font-size: 12px;\n color: white;\n border-radius: 4px;\n cursor: pointer;\n transition: background 0.3s;\n line-height: 1;\n height: fit-content;\n white-space: nowrap;\n margin-left: auto; /* 将按钮推到最右侧 */\n flex-shrink: 0; /* 确保不被压缩 */\n }\n\n .console-logger-scroll-to-bottom:hover {\n background: #2980b9;\n }\n\n\n .console-logger-content {\n flex: 1;\n overflow-y: auto;\n padding: 10px;\n background: #ffffff;\n word-wrap: break-word;\n text-align: left;\n }\n\n .console-logger-entry {\n padding: 8px 10px;\n margin-bottom: 3px;\n border-radius: 4px;\n font-size: 12px;\n line-height: 1.4;\n /*animation: consoleFadeIn 0.3s ease;*/\n border-left: 3px solid transparent;\n }\n\n @keyframes consoleFadeIn {\n from { opacity: 0; transform: translateY(5px); }\n to { opacity: 1; transform: translateY(0); }\n }\n\n .console-logger-timestamp {\n color: #7f8c8d;\n font-size: 11px;\n margin-right: 2px;\n }\n\n @media (max-width: 768px) {\n .console-logger-container {\n right: 10px;\n bottom: 10px;\n }\n\n .console-logger-window {\n width: calc(100vw - 20px);\n height: 300px;\n }\n }\n \n .console-logger-message[data-type=\"json\"] {\n white-space: pre-wrap; \n }\n </style>\n "); const e = { base: { label: "信息", background: "#e8f4fd", borderLeftColor: "#3498db" }, warn: { label: "警告", background: "#fef9e7", borderLeftColor: "#f39c12" }, error: { label: "错误", background: "#fdedec", borderLeftColor: "#e74c3c" }, debug: { label: "调试", background: "#f4f6f6", borderLeftColor: "#95a5a6" } }, t = { base: [ "base", "warn", "error" ], warn: [ "warn" ], error: [ "error" ], debug: [ "base", "warn", "error", "debug" ] }, n = await storageManager.getSetting("clogMsgCount", 2e3), a = "jhs_clog_maximize", i = "jhs_clog_expand", s = "jhs_clog_filter"; class o { constructor() { const t = localStorage.getItem(s); this.currentFilter = t && e[t] ? t : "base", this.logs = [], this.isInitialized = !1, this.userScrolledUp = !1; } tryInitialize() { return "loading" !== document.readyState && (this.isInitialized || (this.init(), this.isInitialized = !0), !0); } init() { this.createContainer(), this.bindEvents(), this.checkInitialMaximizeState(), this.checkInitialCollapseState(); } createContainer() { this.container = document.createElement("div"), this.container.className = "console-logger-container", this.container.style.display = "none", this.toggleBtn = document.createElement("div"), this.toggleBtn.className = "console-logger-toggle collapsed", this.container.appendChild(this.toggleBtn), this.window = document.createElement("div"), this.window.className = "console-logger-window collapsed"; const t = document.createElement("div"); t.className = "console-logger-header"; const n = document.createElement("div"); n.className = "console-logger-title", n.textContent = "JHS Pro"; const a = document.createElement("div"); a.className = "console-logger-controls", this.maximizeBtn = document.createElement("button"), this.maximizeBtn.textContent = "", this.maximizeBtn.classList.add("console-logger-maximize-toggle"), a.appendChild(this.maximizeBtn); const i = document.createElement("button"); i.textContent = "清空", i.addEventListener("click", (() => this.clear())), a.appendChild(i), t.appendChild(n), t.appendChild(a), this.filtersContainer = document.createElement("div"), this.filtersContainer.className = "console-logger-filters", this.filterButtonGroup = document.createElement("div"), this.filterButtonGroup.className = "console-logger-filter-group", this.filtersContainer.appendChild(this.filterButtonGroup), this.scrollToBottomBtn = document.createElement("button"), this.scrollToBottomBtn.className = "console-logger-scroll-to-bottom", this.scrollToBottomBtn.textContent = "到底部", this.filtersContainer.appendChild(this.scrollToBottomBtn), this.content = document.createElement("div"), this.content.className = "console-logger-content jhs-scrollbar", this.window.appendChild(t), this.window.appendChild(this.filtersContainer), this.window.appendChild(this.content), this.container.appendChild(this.window), document.body.appendChild(this.container), Object.keys(e).forEach((t => { const n = document.createElement("div"); n.className = "console-logger-filter", t === this.currentFilter && n.classList.add("active"), n.textContent = e[t].label, n.dataset.type = t, n.addEventListener("click", (() => this.setFilter(t))), this.filterButtonGroup.appendChild(n); })); } bindEvents() { this.toggleBtn.addEventListener("click", (() => { this.toggleExpandCollapsed(); })), this.maximizeBtn.addEventListener("click", (() => this.toggleMaximize())), this.scrollToBottomBtn.addEventListener("click", (() => { this.content.scrollTop = this.content.scrollHeight, this.userScrolledUp = !1; })), this.content.addEventListener("scroll", (() => { const e = this.content.scrollHeight - this.content.clientHeight <= this.content.scrollTop + 5; this.userScrolledUp = !e; })), this.content.addEventListener("wheel", (e => { const t = 0 === this.content.scrollTop, n = this.content.scrollHeight - this.content.clientHeight <= this.content.scrollTop + 1; (t && e.deltaY < 0 || n && e.deltaY > 0) && (e.preventDefault(), e.stopPropagation()); }), { passive: !1 }); } toggleExpandCollapsed() { const e = this.window.classList.toggle("collapsed"); this.toggleBtn.classList.toggle("collapsed"), e ? localStorage.setItem(i, "no") : (localStorage.setItem(i, "yes"), this.reRenderAllLogs()); } checkInitialCollapseState() { const e = localStorage.getItem(i); e && "no" !== e ? (this.window.classList.toggle("collapsed"), this.toggleBtn.classList.toggle("collapsed"), setTimeout((() => { this.content.scrollTop = this.content.scrollHeight; }), 0)) : (this.window.classList.add("collapsed"), this.toggleBtn.classList.add("collapsed")); } checkInitialMaximizeState() { "maximized" === localStorage.getItem(a) && (this.window.classList.add("maximized"), this.maximizeBtn.classList.add("active")); } toggleMaximize() { const e = this.window.classList.toggle("maximized"); this.maximizeBtn.classList.toggle("active", e), e ? localStorage.setItem(a, "maximized") : localStorage.setItem(a, "minimized"), this.window.classList.contains("collapsed") || (this.content.scrollTop = this.content.scrollHeight); } addLog(t, a = "base", ...i) { const s = this.tryInitialize(); let o, r = []; e[a] ? (o = a, r = i) : (o = "base", r = [ a, ...i ]), o = e[o] ? o : "base"; const l = [ t, ...r ]; let c = "msg"; const d = []; l.forEach((e => { if ("[object Error]" === Object.prototype.toString.call(e)) d.push(String(e)); else if ("object" == typeof e && null !== e) try { d.push("<br/>" + JSON.stringify(e, null, 2)), c = "json"; } catch (t) { d.push(String(e)), c = "msg"; } else d.push(String(e)); })); let h = d.join(" "); h = h.replace(/(?:(?:https?|ftp):\/\/|www\.|(?:\/\/))[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|]/gi, (e => { const t = e.startsWith("http") || e.startsWith("ftp"), n = e.startsWith("//"), a = e.startsWith("www."); let i = e; return n ? i = `http:${e}` : !t && a && (i = `http://${e}`), `<a href="${escapeHtml(i)}" target="_blank">${escapeHtml(e)}</a>`; })); const g = { message: h, messageType: c, type: o, timestamp: new Date, id: Date.now() + Math.random() }; if (this.logs.push(g), this.logs.length > n) { const e = this.logs[0]; if (s) { const t = this.content.querySelector(`.console-logger-entry[data-id="${e.id}"]`); t && (this.logs.shift(), this.content.removeChild(t)); } } s && this.renderLog(g); } log(...e) { const [t, ...n] = e; setTimeout((() => { this.addLog(t, "base", ...n); }), 0); } error(...e) { const [t, ...n] = e; console.error(...e), setTimeout((() => { this.addLog(t, "error", ...n); }), 0); } warn(...e) { const [t, ...n] = e; setTimeout((() => { this.addLog(t, "warn", ...n); }), 0); } debug(...e) { const [t, ...n] = e; setTimeout((() => { this.addLog(t, "debug", ...n); }), 0); } renderLog(e) { if ("none" === this.container.style.display) return; if (this.window.classList.contains("collapsed")) return; if (!(t[this.currentFilter] || []).includes(e.type)) return; const n = this._createLogElement(e); this.content.appendChild(n), this.window.classList.contains("collapsed") || this.userScrolledUp || (this.content.scrollTop = this.content.scrollHeight); } reRenderAllLogs() { "none" !== this.container.style.display && (this.window.classList.contains("collapsed") || setTimeout((() => { if (this.content.innerHTML = "", 0 === this.logs.length) return; const e = t[this.currentFilter] || [], n = document.createDocumentFragment(); this.logs.forEach((t => { if (e.includes(t.type)) { const e = this._createLogElement(t); n.appendChild(e); } })), this.content.appendChild(n), this.content.scrollTop = this.content.scrollHeight; }), 0)); } _createLogElement(t) { const n = document.createElement("div"); n.className = "console-logger-entry", n.dataset.type = t.type, n.dataset.id = t.id; const a = e[t.type] || e.base; n.style.borderLeft = "3px solid " + a.borderLeftColor, n.style.background = a.background; const i = (t.timestamp instanceof Date ? t.timestamp : new Date(t.timestamp)).toTimeString().split(" ")[0]; return n.innerHTML = `\n <span class="console-logger-timestamp">[${i}]</span>\n <span class="console-logger-message" data-type="${t.messageType}">${t.message}</span>\n `, n; } setFilter(e) { if (this.currentFilter === e) return; this.currentFilter = e, localStorage.setItem(s, e); this.filterButtonGroup.querySelectorAll(".console-logger-filter").forEach((t => { t.dataset.type === e ? t.classList.add("active") : t.classList.remove("active"); })), this.reRenderAllLogs(); } clear() { this.logs = [], this.content.innerHTML = ""; } show() { (this.isInitialized && this.container || this.tryInitialize() && this.container) && (this.container.style.display = "", this.reRenderAllLogs()); } hide() { this.isInitialized && this.container && (this.container.style.display = "none"); } lowZIndex() { this.isInitialized && this.container && (this.container.style.zIndex = "12345678"); } highZIndex() { this.isInitialized && this.container && (this.container.style.zIndex = "999999999"); } } try { unsafeWindow.parent.clog && "function" == typeof unsafeWindow.parent.clog.log ? window.clog = unsafeWindow.clog = unsafeWindow.parent.clog : window.clog = unsafeWindow.clog = new o; } catch (r) { console.error("创建日志控制台出现异常", r), window.clog = unsafeWindow.clog = new o; } !function() { const e = window.clog || console; window.addEventListener("error", (function(t) { const n = t.filename, a = t.message; n.includes("javdb") || n.includes("javbus") || e.error(`[全局 Error 异常捕获] ${a} 来源: ${n}`); })), window.addEventListener("unhandledrejection", (function(t) { const n = t.reason, a = (null == n ? void 0 : n.message) ?? ""; if (a.includes("play()")) return; if (a.includes("The element has no supported sources")) return show.error("播放失败, 请检查是否已对节点分流?"), void e.error("播放失败, 请检查是否已对节点分流?"); if (a.includes("<span>1005</span>") && a.includes("fc2ppvdb")) return; const i = `[全局 Promise 异常捕获] ${n.message || n}`; e.error(i, n), t.preventDefault(); })); }(), document.addEventListener("mousedown", (e => { const t = window.clog; if (!t.isInitialized || !t.container) return; const n = e.target, a = [ ".console-logger-container", ".layui-layer-shade", ".loading-container" ].join(","); n.closest(a) ? t.highZIndex() : t.lowZIndex(); })); }(), function() { function e(e, t, n) { const a = function(e) { const t = document.createElement("div"); t.classList.add("js-tooltip"); const n = document.createElement("div"); return n.innerHTML = escapeHtml(e), t.appendChild(n), document.body.appendChild(t), t; }(t); a.style.display = "block"; const i = e.getBoundingClientRect(), s = a.getBoundingClientRect(); a.style.display = "none"; const o = window.innerWidth, r = window.innerHeight; let l, c, d = n; const h = e => e >= 8 && e + s.height <= r - 8, g = e => e >= 8 && e + s.width <= o - 8, p = i.left + i.width / 2 - s.width / 2, m = i.top + i.height / 2 - s.height / 2; switch (n) { case "top": c = i.top - s.height - 0, c < 8 && h(i.bottom + 0) && (c = i.bottom + 0, d = "bottom"); break; case "bottom": c = i.bottom + 0, c + s.height > r - 8 && h(i.top - s.height - 0) && (c = i.top - s.height - 0, d = "top"); break; case "left": l = i.left - s.width - 0, l < 8 && g(i.right + 0) && (l = i.right + 0, d = "right"); break; case "right": l = i.right + 0, l + s.width > o - 8 && g(i.left - s.width - 0) && (l = i.left - s.width - 0, d = "left"); } const u = "left" === d || "right" === d; "top" === d || "bottom" === d ? (l = p, l < 8 ? l = 8 : l + s.width > o - 8 && (l = o - s.width - 8)) : u && (c = m, c < 8 ? c = 8 : c + s.height > r - 8 && (c = r - s.height - 8)), a.style.left = `${l}px`, a.style.top = `${c}px`, a.classList.add("is-active"), e.tooltipElement = a; } document.head.insertAdjacentHTML("beforeend", "\n <style>\n .js-tooltip {\n /* 通用样式 */\n position: fixed;\n padding: 8px 12px; \n border-radius: 6px; \n white-space: normal;\n max-width: 600px; \n \n pointer-events: none;\n font-size: 14px;\n line-height: 1.5;\n z-index: 9999999999;\n \n background: #F0FDF4; \n color: #166534; \n border: none; \n box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); \n \n display: none; \n }\n .js-tooltip.is-active {\n display: block !important;\n }\n\n </style>\n "); const t = "[data-tip-top], [data-tip-bottom], [data-tip-left], [data-tip-right], [data-tip]"; document.addEventListener("mouseover", (n => { const a = n.target.closest(t); if (a && !a.tooltipElement) { let t, n = "top"; if (a.hasAttribute("data-tip-bottom") ? (t = a.getAttribute("data-tip-bottom"), n = "bottom") : a.hasAttribute("data-tip-left") ? (t = a.getAttribute("data-tip-left"), n = "left") : a.hasAttribute("data-tip-right") ? (t = a.getAttribute("data-tip-right"), n = "right") : a.hasAttribute("data-tip-top") ? (t = a.getAttribute("data-tip-top"), n = "top") : a.hasAttribute("data-tip") && (t = a.getAttribute("data-tip"), n = "top"), !t) return; a.hoverTimeout = setTimeout((() => { a.matches(":hover") && !a.tooltipElement && e(a, t, n); }), 50); } })), document.addEventListener("mouseout", (e => { const n = e.target.closest(t); var a; n && (n.hoverTimeout && (clearTimeout(n.hoverTimeout), n.hoverTimeout = null), n.contains(e.relatedTarget) || n.tooltipElement && ((a = n.tooltipElement) && a.parentNode && a.remove(), n.tooltipElement = null)); })); }(); class Y { constructor() { this.plugins = new Map; this._errorLog = []; this._lastTimings = []; } register(e) { if ("function" != typeof e) throw new Error("插件必须是一个类"); const t = new e; t.pluginManager = this; const n = t.getName(); if (this.plugins.has(n)) throw new Error(`插件"${n}"已注册`); this.plugins.set(n, t); } getBean(e) { return this.plugins.get(e); } _addError(e, t, n) { this._errorLog.push({ time: new Date().toISOString(), plugin: e, phase: t, message: n?.message || String(n), stack: n?.stack || "" }); this._errorLog.length > 200 && this._errorLog.shift(); } getErrorLog() { return [...this._errorLog]; } clearErrorLog() { this._errorLog = []; } getTimings() { return [...this._lastTimings]; } getPluginNames() { return Array.from(this.plugins.keys()); } async _getDisabledPlugins() { try { const e = await storageManager.getSetting("disabledPlugins", DEFAULT_DISABLED_PLUGINS); return JSON.parse(e); } catch (e) { return []; } } async processCss() { const t = await this._getDisabledPlugins(); const m = utils.isMobileMode(); const e = (await Promise.allSettled(Array.from(this.plugins).map((async ([e, n]) => { try { if (t.includes(e)) return { name: e, status: "disabled" }; if (m && "function" == typeof n.shouldSkipOnMobile && n.shouldSkipOnMobile()) return { name: e, status: "skipped" }; if ("function" == typeof n.initCss) { const t = await n.initCss(); return t && utils.insertStyle(t), { name: e, status: "fulfilled" }; } return { name: e, status: "skipped" }; } catch (a) { return console.error(`插件 ${e} 加载 CSS 失败`, a), this._addError(e, "initCss", a), { name: e, status: "rejected", error: a }; } })))).filter((e => "rejected" === e.status)); e.length && console.error("以下插件的 CSS 加载失败:", e.map((e => e.reason.name))); } async processPlugins() { const t = await this._getDisabledPlugins(); const a = []; const m = utils.isMobileMode(); const e = (await Promise.allSettled(Array.from(this.plugins).map((async ([e, n]) => { if (t.includes(e)) return a.push({ name: e, elapsed: 0, status: "disabled" }), { name: e, status: "disabled" }; if (m && "function" == typeof n.shouldSkipOnMobile && n.shouldSkipOnMobile()) return a.push({ name: e, elapsed: 0, status: "skipped-mobile" }), { name: e, status: "skipped" }; const i = performance.now(); try { if ("function" == typeof n.handle) await n.handle(); const s = performance.now() - i; return a.push({ name: e, elapsed: s, status: "ok" }), { name: e, status: "fulfilled" }; } catch (s) { const o = performance.now() - i; return clog.error(`插件 ${e} 执行失败`, s), this._addError(e, "handle", s), a.push({ name: e, elapsed: o, status: "error", error: s.message }), { name: e, status: "rejected", error: s }; } })))).filter((e => "rejected" === e.status)); this._lastTimings = a; e.length && console.error("以下插件执行失败:", e.map((e => e.reason.name))); } } /** 快捷键管理器: 纯静态类, 全局 keydown/keyup 分发 */ const _HotkeyManager = class _HotkeyManager { constructor() { if (new.target === _HotkeyManager) throw new Error("HotkeyManager cannot be instantiated."); } static registerHotkey(hotkeyString, callback, keyupCallback = null) { if (Array.isArray(hotkeyString)) { let id_list = []; hotkeyString.forEach((hotkey => { if (!this.isHotkeyFormat(hotkey)) throw new Error("快捷键格式错误"); let id = this.recordHotkey(hotkey, callback, keyupCallback); id_list.push(id); })); return id_list; } if (!this.isHotkeyFormat(hotkeyString)) throw new Error("快捷键格式错误"); return this.recordHotkey(hotkeyString, callback, keyupCallback); } static recordHotkey(hotkeyString, callback, keyupCallback) { let id = Math.random().toString(36).substr(2); this.registerHotKeyMap.set(id, { hotkeyString: hotkeyString, callback: callback, keyupCallback: keyupCallback }); return id; } static unregisterHotkey(id) { this.registerHotKeyMap.has(id) && this.registerHotKeyMap.delete(id); } static isHotkeyFormat(hotkeyString) { return !!hotkeyString && hotkeyString.toLowerCase().split("+").map((k => k.trim())).every((k => [ "ctrl", "shift", "alt" ].includes(k) || 1 === k.length)); } static judgeHotkey(hotkeyString, event) { const keyList = hotkeyString.toLowerCase().split("+").map((k => k.trim())), ctrl = keyList.includes("ctrl"), shift = keyList.includes("shift"), alt = keyList.includes("alt"), key = keyList.find((k => "ctrl" !== k && "shift" !== k && "alt" !== k)); return (this.isMac ? event.metaKey : event.ctrlKey) === ctrl && event.shiftKey === shift && event.altKey === alt && event.key.toLowerCase() === key; } }; i(_HotkeyManager, "isMac", 0 === navigator.platform.indexOf("Mac")); i(_HotkeyManager, "registerHotKeyMap", new Map); i(_HotkeyManager, "handleKeydown", (event => { for (const [id, data] of _HotkeyManager.registerHotKeyMap) { let hotkeyString = data.hotkeyString, callback = data.callback; _HotkeyManager.judgeHotkey(hotkeyString, event) && callback(event); } })); i(_HotkeyManager, "handleKeyup", (event => { for (const [id, data] of _HotkeyManager.registerHotKeyMap) { let hotkeyString = data.hotkeyString, keyupCallback = data.keyupCallback; keyupCallback && _HotkeyManager.judgeHotkey(hotkeyString, event) && keyupCallback(event); } })); let HotkeyManager = _HotkeyManager; document.addEventListener("keydown", (event => { HotkeyManager.handleKeydown(event); })); document.addEventListener("keyup", (event => { HotkeyManager.handleKeyup(event); })); class X { constructor() { i(this, "pluginManager", null), i(this, "settingSvg", '<svg t="1760926954860" class="jhs-icon icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4947" width="200" height="200"><path d="M511.099222 365.825763c-80.7786 0-146.26579 65.482515-146.26579 146.259556 0 80.7786 65.48719 146.259556 146.26579 146.259556 80.777041 0 146.259556-65.480957 146.259556-146.259556C657.358779 431.308278 591.876263 365.825763 511.099222 365.825763L511.099222 365.825763zM511.099222 585.215097c-40.391637 0-73.136012-32.742816-73.136012-73.129778 0-40.391637 32.742816-73.129778 73.136012-73.129778 40.386962 0 73.129778 32.738141 73.129778 73.129778C584.229 552.472281 551.486184 585.215097 511.099222 585.215097L511.099222 585.215097zM511.099222 585.215097M900.893017 568.24369l-26.451395-15.268032c3.065451-27.021784 3.138697-54.472139 0.077922-81.822754l26.373473-15.225955c69.953678-40.391637 93.920921-129.844512 53.533959-199.799749-40.390079-69.95212-129.839837-93.925596-199.799749-53.533959l-26.373473 15.225955c-22.153219-16.330888-45.963059-29.99217-70.896534-40.843585l0-30.545416c0-80.777041-65.48719-146.259556-146.26579-146.259556-80.7786 0-146.259556 65.482515-146.259556 146.259556l0 30.515806c-12.377127 5.421811-24.587501 11.55583-36.562551 18.473743-11.97505 6.917913-23.396854 14.420242-34.277879 22.432179l-26.431136-15.258682c-69.958353-40.391637-159.406553-16.424395-199.79819 53.533959C27.378272 326.082437 51.343956 415.535311 121.299193 455.922273l26.449837 15.275825c-3.063892 27.020226-3.137139 54.465905-0.077922 81.822754l-26.373473 15.224397c-69.953678 40.391637-93.920921 129.841395-53.533959 199.799749 40.391637 69.95212 129.839837 93.920921 199.79819 53.533959l26.375032-15.224397c22.153219 16.32933 45.963059 29.984378 70.896534 40.843585l0 30.537624c0 80.7786 65.48719 146.26579 146.26579 146.26579 80.777041 0 146.259556-65.48719 146.259556-146.26579l0-30.515806c12.377127-5.415577 24.587501-11.55583 36.567226-18.467509 11.97505-6.917913 23.398412-14.420242 34.277879-22.432179l26.423343 15.258682c69.959912 40.391637 159.408111 16.418162 199.799749-53.533959C994.813938 698.085085 970.848254 608.635327 900.893017 568.24369L900.893017 568.24369zM891.096666 731.474653c-20.198936 34.982294-64.923035 46.962019-99.900654 26.770875l-63.331869-36.567226 0 0 0 0-7.988562-4.611422c-18.134004 18.450366-39.024886 34.787489-62.516805 48.353705-23.49971 13.559983-48.091888 23.482568-73.129778 29.964118l0 9.222846 0 0 0 65.828489 0 7.301289c0 40.391637-32.742816 73.136012-73.136012 73.136012-40.386962 0-73.129778-32.742816-73.129778-73.136012l0-7.402588 0-65.72719 0 0 0-9.300768c-50.682014-13.090892-97.855981-39.682547-135.652816-78.232109l-7.983886 4.606747 0 0-63.331869 36.567226c-34.977618 20.191144-79.706394 8.206743-99.900654-26.770875-20.192702-34.977618-8.206743-79.701718 26.770875-99.899095l6.341291-3.657657 0 0 64.972905-37.516316c-14.487254-52.005129-13.929333-106.151555 0.073247-156.593569l-8.057133-4.650384 0 0-63.331869-36.567226c-34.982294-20.192702-46.963578-64.923035-26.770875-99.900654 20.192702-34.97606 64.923035-46.962019 99.900654-26.763083l6.324148 3.649866 0 0 64.996282 37.528784c18.132445-18.450366 39.024886-34.790606 62.516805-48.353705 23.493477-13.559983 48.085654-23.485685 73.129778-29.964118l0-9.229079L437.960093 153.739276l0-7.309082c0-40.385404 32.742816-73.129778 73.129778-73.129778 40.391637 0 73.129778 32.744375 73.129778 73.129778l0 7.404147 0 65.72719 0 9.307001c50.686689 13.086217 97.862215 39.684106 135.657491 78.232109l48.487732-27.997368 22.828023-13.176607c34.977618-20.192702 79.701718-8.212977 99.89442 26.763083 20.198936 34.982294 8.212977 79.706394-26.764641 99.900654l-30.822819 17.79738-32.50905 18.769847 0 0 0 0-7.983886 4.605189c14.488813 52.009805 13.929333 106.159347-0.077922 156.599803l64.979139 37.511641 0 0 6.414537 3.701294C899.303409 651.772936 911.289368 696.498594 891.096666 731.474653L891.096666 731.474653zM891.096666 731.474653M197.330785 324.240361c-1.932465 3.232203-3.824411 6.497135-5.649343 9.785442L197.330785 324.240361 197.330785 324.240361zM197.330785 324.240361M830.515443 690.133926l-5.655577 9.804144C826.793889 696.699632 828.685835 693.433143 830.515443 690.133926L830.515443 690.133926zM830.515443 690.133926M505.297151 146.430195l11.304921 0C512.835324 146.369416 509.067017 146.374091 505.297151 146.430195L505.297151 146.430195zM505.297151 146.430195M516.898176 877.740444l-11.31583 0C509.350653 877.796547 513.125193 877.796547 516.898176 877.740444L516.898176 877.740444zM516.898176 877.740444" fill="#272636" p-id="4948"></path></svg>'), i(this, "editSvg", '<svg t="1760920692801" class="jhs-icon icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3545" width="200" height="200"><path d="M1013.929675 128.26571a143.759824 143.759824 0 0 1 10.44409 53.858738 84.576649 84.576649 0 0 1-5.836403 30.308339 92.870485 92.870485 0 0 1-18.635533 29.284408 1314.726599 1314.726599 0 0 1-24.983901 24.574329c-7.372299 7.06512-13.82306 13.311095-19.249891 18.737926-6.143582 6.143582-12.082378 11.672806-17.406817 16.382886L720.266444 82.598415c9.317766-8.601015 20.478607-18.942712 33.277737-31.02509s23.448006-21.604931 31.946628-28.67005a102.085858 102.085858 0 0 1 68.193763-22.731255c11.263234 0.307179 22.116896 2.047861 32.560985 5.222045 10.546483 3.071791 19.659463 6.655547 27.441334 10.546483 16.280493 8.601015 34.301667 23.550399 54.063524 45.052936 19.864249 21.502538 35.120812 43.82422 46.076867 67.272226z m-907.20231 570.943576l32.560986-33.38013c17.099637-17.509209 38.397389-39.216533 64.098041-64.917186l84.986221-85.395793 94.303987-94.815953 250.350976-251.477299L850.817567 389.163169 600.46659 640.640468l-93.177663 94.815953c-31.02509 30.410732-58.978389 58.364031-83.859898 83.655111-24.779115 25.29108-45.360116 46.17926-61.743001 62.562146a504.797674 504.797674 0 0 1-55.804206 50.274981c-10.239304 7.884264-20.581 14.130239-31.537055 18.737926a507.152714 507.152714 0 0 1-47.715156 19.86425 1609.311367 1609.311367 0 0 1-131.063087 42.185931c-20.478607 5.426831-35.837563 8.908194-45.974474 10.546483-20.88818 2.35504-34.813633-0.819144-41.981145-9.42016-6.860333-8.601015-8.805801-22.93604-5.73401-43.312254a396.261054 396.261054 0 0 1 11.058448-47.305584c5.836403-20.683394 12.082378-42.185931 18.635532-64.40522 6.553154-22.219289 13.003916-42.697897 19.249891-61.435822 6.143582-18.635533 11.263234-31.537055 15.15417-38.602176 4.607687-10.853662 9.829732-20.785787 15.666135-29.796373a192.49891 192.49891 0 0 1 25.086294-29.796374z" fill="#FF9500" p-id="3546"></path></svg>'), i(this, "deleteSvg", '<svg t="1760921450746" class="jhs-icon icon" viewBox="0 0 1194 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4530" width="200" height="200"><path d="M761.086847 36.028779s309.754321-147.538628 424.952209 231.50509c2.047962 6.570546 71.337359 253.862013-220.838618 415.139055-12.970429 7.167869-267.515096 145.746661-370.339877 341.327076 0 0-90.963666-205.649563-393.379455-351.566888-6.399883-3.071944-304.549083-156.583796-163.751664-487.2444 3.669266-8.533177 163.666333-336.20717 466.423449-99.411511l24.575549 27.391498L387.931021 324.279495l237.648977 159.570408-109.139333 145.746661L625.579998 849.069874l-30.719437-205.820227 166.226286-169.81022-216.486698-168.103585L761.086847 36.028779z" fill="#F4382E" p-id="4531"></path></svg>'), i(this, "checkSvg", '<svg t="1760921633527" class="jhs-icon icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5603" width="200" height="200"><path d="M924.928 544A413.76 413.76 0 0 1 544 924.736v3.264h-64v-3.2A413.696 413.696 0 0 1 99.072 544H96v-64h3.072A413.696 413.696 0 0 1 480 99.2V96h64v3.2a413.76 413.76 0 0 1 380.928 380.8h3.072v64h-3.072z m-64-64A350.016 350.016 0 0 0 544 163.2V288h-64V163.2A350.016 350.016 0 0 0 163.072 480H288v64H163.072A350.016 350.016 0 0 0 480 860.8V736h64v124.8a350.016 350.016 0 0 0 316.928-316.8H736v-64h124.928zM512 544a32 32 0 1 1 32-32 32 32 0 0 1-32 32z" fill="#333333" p-id="5604"></path></svg>'), i(this, "actressSvg", '<svg t="1760926744637" class="jhs-icon icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1948" width="200" height="200"><path d="M265.950168 668.467036V209.809493A209.809493 209.809493 0 0 1 475.759661 0h40.949536A209.809493 209.809493 0 0 1 726.564189 209.809493v440.435" p-id="1949"></path><path d="M916.558657 825.861124a193.463804 193.463804 0 0 0-137.442564-155.83573l-186.001889-45.795231-10.487631-124.293214H424.106373L412.231008 624.025416l-170.623063 44.44162a193.452429 193.452429 0 0 0-133.666108 154.698244L76.410695 1023.192384h871.189985z" fill="#FFE7D9" p-id="1950"></path><path d="M668.472724 265.682859c68.431223-29.187919 96.140409 100.349111 5.20969 151.774902z" fill="#FFCFB5" p-id="1951"></path><path d="M676.378259 334.421203c1.137487-99.814492-38.674561-172.158671-38.674561-172.15867l-59.740822 11.920865a493.805894 493.805894 0 0 1-80.761583 9.099896 493.669396 493.669396 0 0 1-80.761583-9.099896l-59.683948-11.88674s-39.812048 72.344179-38.776934 172.15867l-1.080613 92.05683c5.209691 56.271486 92.4777 121.381247 195.022161 119.163147 61.196805 0.034125 165.59537-51.573665 165.59537-119.197272z" fill="#FFE7D9" p-id="1952"></path><path d="M322.198905 274.703131c-68.419848-29.187919-96.140409 100.349111-5.209691 151.774902z" fill="#FFCFB5" p-id="1953"></path><path d="M297.390311 812.461526H742.034014a38.458438 38.458438 0 0 1 38.458438 38.458439V1020.325917H258.931873V850.90859a38.458438 38.458438 0 0 1 38.458438-38.447064z" fill="#FFD527" p-id="1954"></path><path d="M690.539973 92.284327c-20.645391 84.287793-275.613121 235.323328-424.589805 117.525166l104.955934-95.548915 139.399042-64.529643z" p-id="1955"></path><path d="M285.321573 383.708519h33.624119v177.118114h-33.624119zM675.855015 383.708519h33.624118v177.118114h-33.624118z" fill="#FFD527" p-id="1956"></path></svg>'), i(this, "newSvg", '<svg t="1760926857487" class="jhs-icon icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3954" width="200" height="200"><path d="M508.330667 733.994667c-11.008-7.338667-13.44-17.109333-7.338667-29.333334 28.117333-37.888 41.557333-98.986667 40.341333-183.317333v-165.013333c0-14.656 7.338667-23.210667 21.994667-25.664 37.888-1.216 82.496-5.504 133.845333-12.842667 13.44-2.432 21.376 3.072 23.829334 16.512 1.216 12.224-4.266667 19.562667-16.512 21.994667a1787.093333 1787.093333 0 0 1-113.664 11.008c-6.101333 0-9.173333 3.669333-9.173334 10.986666v84.330667h135.68c12.224 1.237333 18.944 7.957333 20.16 20.181333-1.216 10.986667-7.936 17.109333-20.16 18.346667h-36.672v223.658667c-1.216 12.202667-7.936 18.944-20.16 20.16-11.008-1.216-17.109333-7.957333-18.346666-20.16V501.162667h-60.48v18.346666c1.216 92.885333-13.44 161.92-44.010667 207.146667-6.101333 12.224-15.893333 14.677333-29.333333 7.338667z m-131.989334-282.325334c-1.237333 0-2.453333 0.618667-3.669333 1.834667h45.824a522.666667 522.666667 0 0 0 16.512-31.168c7.317333-12.224 12.224-20.778667 14.656-25.664 6.122667-11.008 15.274667-14.677333 27.52-11.008 9.770667 6.122667 12.202667 14.058667 7.317333 23.829333-4.906667 9.792-13.44 24.448-25.664 44.010667h49.493334c9.770667 1.216 15.274667 6.72 16.512 16.490667-1.237333 11.008-6.741333 17.109333-16.512 18.346666h-82.496a12.437333 12.437333 0 0 1 3.669333 9.173334v38.485333h69.653333c9.792 1.216 15.296 6.72 16.512 16.490667-1.216 11.008-6.72 17.130667-16.512 18.346666h-69.653333v108.16c0 34.218667-15.274667 51.946667-45.845333 53.162667h-16.490667a195.157333 195.157333 0 0 1-20.16 1.834667c-12.224 0-19.562667-6.72-22.016-20.16 1.237333-12.224 7.338667-18.944 18.346667-20.16 2.432 0 6.101333 0.597333 10.986666 1.834666h11.008c15.893333 0 23.829333-8.554667 23.829334-25.685333v-98.986667H314.026667c-11.008-1.216-17.109333-7.338667-18.346667-18.346666 1.237333-9.770667 7.338667-15.274667 18.346667-16.490667h75.157333V497.493333c0-3.669333 1.216-6.72 3.669333-9.173333h-89.813333c-11.029333-1.216-17.130667-7.317333-18.346667-18.325333 1.216-9.770667 7.317333-15.274667 18.346667-16.490667h56.810667c-3.669333-1.216-6.72-4.266667-9.173334-9.173333-1.216-1.216-3.050667-4.266667-5.482666-9.173334a758.336 758.336 0 0 0-14.677334-23.829333c-4.885333-9.770667-3.050667-17.706667 5.504-23.829333 11.008-3.669333 19.562667-1.216 25.664 7.338666 2.453333 2.432 6.122667 7.338667 11.008 14.656 6.101333 8.554667 9.770667 14.08 10.986667 16.512 4.906667 9.770667 2.453333 18.346667-7.317333 25.664z m-60.501333-71.509333c-9.792-1.216-15.274667-7.317333-16.512-18.346667 1.237333-9.749333 6.72-15.253333 16.512-16.490666h75.157333c-3.669333-12.202667-7.338667-21.973333-10.986666-29.333334-1.237333-12.202667 3.648-19.541333 14.656-21.973333 12.224-2.453333 21.397333 1.216 27.52 10.986667 0 1.216 0.597333 3.669333 1.813333 7.338666 4.906667 15.872 9.173333 26.88 12.842667 32.981334h60.48c11.008 1.237333 17.130667 6.741333 18.346666 16.512-1.216 11.008-7.338667 17.109333-18.346666 18.346666h-181.482667z m-14.677333 311.68c-8.533333-6.122667-10.986667-14.08-7.338667-23.829333a1659.648 1659.648 0 0 0 33.002667-66.005334c4.906667-9.792 12.224-12.842667 22.016-9.173333 9.770667 4.906667 13.44 12.224 10.986666 21.994667-3.669333 6.122667-9.173333 17.728-16.490666 34.837333-8.554667 15.893333-14.677333 27.52-18.346667 34.837333-4.885333 8.554667-12.821333 11.008-23.829333 7.338667z m201.664-25.664c-9.770667 4.885333-18.346667 2.432-25.664-7.338667a1138.56 1138.56 0 0 1-27.498667-44.010666c-4.885333-8.533333-3.050667-16.490667 5.504-23.829334 9.770667-3.669333 18.346667-1.216 25.664 7.338667l14.677333 21.994667c6.101333 9.770667 10.389333 17.109333 12.821334 21.994666 4.906667 8.554667 3.050667 16.512-5.504 23.850667z" fill="#333333" p-id="3955"></path><path d="M675.328 117.717333A425.429333 425.429333 0 0 0 512 85.333333C276.352 85.333333 85.333333 276.352 85.333333 512s191.018667 426.666667 426.666667 426.666667 426.666667-191.018667 426.666667-426.666667c0-56.746667-11.093333-112-32.384-163.328a21.333333 21.333333 0 0 0-39.402667 16.341333A382.762667 382.762667 0 0 1 896 512c0 212.074667-171.925333 384-384 384S128 724.074667 128 512 299.925333 128 512 128c51.114667 0 100.8 9.984 146.986667 29.12a21.333333 21.333333 0 0 0 16.341333-39.402667z" fill="#333333" p-id="3956"></path></svg>'), i(this, "refreshSvg", '<svg t="1760926993643" class="jhs-icon icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5942" width="200" height="200"><path d="M511.966722 0a511.966722 511.966722 0 1 0 179.828311 32.445891l-22.46254 59.964102A447.970882 447.970882 0 1 1 511.966722 63.99584a31.99792 31.99792 0 0 0 0-63.99584z" fill="#333333" p-id="5943"></path><path d="M649.2378 9.151405A30.909991 30.909991 0 0 1 671.316364 0h193.267438a31.99792 31.99792 0 0 1 31.357962 31.99792c0 17.662852-13.759106 31.99792-31.357962 31.99792H703.954243v160.629559a31.99792 31.99792 0 0 1-31.99792 31.357962 31.485953 31.485953 0 0 1-31.99792-31.357962V31.357962c0-8.511447 3.647763-16.318939 9.343392-21.950573z" fill="#333333" p-id="5944"></path></svg>'), i(this, "blacklistSvg", '<svg t="1761386375897" class="jhs-icon icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1936" width="200" height="200"><path d="M513.199827 65.667605c-246.537999 0-446.399933 199.861934-446.399933 446.399933 0 246.553349 199.861934 446.399933 446.399933 446.399933 246.553349 0 446.399933-199.846584 446.399933-446.399933C959.599759 265.529539 759.753175 65.667605 513.199827 65.667605zM513.199827 894.697075c-211.320916 0-382.629537-171.322947-382.629537-382.628514 0-94.183056 34.029024-180.417069 90.461291-247.080352l165.389818 165.389818c4.320399 39.651069 26.816762 73.840752 58.981323 94.068446-72.189136 27.369348-123.517151 97.156784-123.517151 178.936345l337.541643 0 100.846826 100.846826C693.608709 860.664981 607.375719 894.697075 513.199827 894.697075zM805.362956 759.14175 697.264982 651.0448c-16.556071-58.332547-60.10082-105.306394-116.275213-126.601396 35.888372-22.570042 59.752896-62.511729 59.752896-108.032482 0-70.436212-57.108672-127.542838-127.542838-127.542838-48.218188 0-90.184999 26.765597-111.865787 66.245773L266.120498 219.900316c66.663282-56.432267 152.897296-90.461291 247.079328-90.461291 211.304544 0 382.628514 171.308621 382.628514 382.629537C895.82834 606.244454 861.796246 692.476421 805.362956 759.14175z" fill="#272636" p-id="1937"></path></svg>'), i(this, "copySvg", '<svg t="1749017229420" class="jhs-icon icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9184" width="200" height="200"><path d="M512 74.666667C270.933333 74.666667 74.666667 270.933333 74.666667 512S270.933333 949.333333 512 949.333333 949.333333 753.066667 949.333333 512 753.066667 74.666667 512 74.666667z m0 810.666666c-204.8 0-373.333333-168.533333-373.333333-373.333333S307.2 138.666667 512 138.666667 885.333333 307.2 885.333333 512 716.8 885.333333 512 885.333333z" fill="#666666" p-id="9185"></path><path d="M512 512m-42.666667 0a42.666667 42.666667 0 1 0 85.333334 0 42.666667 42.666667 0 1 0-85.333334 0Z" fill="#666666" p-id="9186"></path><path d="M341.333333 512m-42.666666 0a42.666667 42.666667 0 1 0 85.333333 0 42.666667 42.666667 0 1 0-85.333333 0Z" fill="#666666" p-id="9187"></path><path d="M682.666667 512m-42.666667 0a42.666667 42.666667 0 1 0 85.333333 0 42.666667 42.666667 0 1 0-85.333333 0Z" fill="#666666" p-id="9188"></path></svg>'), i(this, "titleSvg", '<svg t="1747553289744" class="jhs-icon icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7507" width="200" height="200"><path d="M959.8 150.8c0-2.3-1.9-4.2-4.2-4.2H253.3c-2.3 0-4.2 1.9-4.2 4.2v115.9c0 2.3 1.9 4.2 4.2 4.2h702.3c2.3 0 4.2-1.9 4.2-4.2V150.8z" fill="" p-id="7508"></path><path d="M126.4 208.8m-62.2 0a62.2 62.2 0 1 0 124.4 0 62.2 62.2 0 1 0-124.4 0Z" fill="" p-id="7509"></path><path d="M851.5 453.7c0-2.1-1.8-3.9-3.9-3.9H252.9c-2.1 0-3.9 1.7-3.9 3.9v116.6c0 2.1 1.7 3.9 3.9 3.9h594.7c2.1 0 3.9-1.7 3.9-3.9V453.7z" fill="" p-id="7510"></path><path d="M126.4 512m-62.2 0a62.2 62.2 0 1 0 124.4 0 62.2 62.2 0 1 0-124.4 0Z" fill="" p-id="7511"></path><path d="M851.5 756.9c0-2.1-1.8-3.9-3.9-3.9H252.9c-2.1 0-3.9 1.8-3.9 3.9v116.6c0 2.1 1.7 3.9 3.9 3.9h594.7c2.1 0 3.9-1.7 3.9-3.9V756.9z" fill="" p-id="7512"></path><path d="M126.4 815.2m-62.2 0a62.2 62.2 0 1 0 124.4 0 62.2 62.2 0 1 0-124.4 0Z" fill="" p-id="7513"></path></svg>'), i(this, "carNumSvg", '<svg t="1747552574854" class="jhs-icon icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3539" width="200" height="200"><path d="M920.337035 447.804932c-6.067182-6.067182-10.918677-11.643178-16.985859-17.71036l48.536436-30.334889-42.469254-109.207238-121.340579 12.134365c-6.067182-6.067182-6.067182-12.134365-12.134365-18.201547-12.134365-12.134365-18.201547-24.267706-24.267706-30.334889-24.26873-36.402071-30.334889-42.469254-54.603619-42.469254H339.116511c-18.201547 0-24.267706 6.067182-54.603619 42.469254-6.067182 6.067182-12.134365 18.201547-24.267706 30.334889 0 0-6.067182 6.067182-12.134365 18.201547l-115.27442-12.134365-48.536436 109.207238 51.090608 24.378223c-6.067182 6.067182-30.334889 34.660404-30.334889 34.660405l-15.542998 22.280446-12.282744 17.018605c-6.067182 12.134365-5.064342 10.868535-5.064342 29.070082v224.480635c0 36.402071 18.201547 60.670801 54.603618 60.670801h115.273397c36.402071 0 54.603619-24.267706 54.603619-54.603619v-18.201547h424.693562v18.201547c0 30.334889 18.201547 54.603619 54.603618 54.603619h115.273397c36.402071 0 60.670801-24.267706 60.670801-60.670801V539.300786c0-42.469254 0.685615-46.662763-11.44875-64.863287-4.731768-6.744611-11.94403-16.196891-20.101827-26.632567z m-35.186383-78.381161l-30.334889 18.201547-12.134365-12.134365c-6.067182-8.899694-12.134365-12.134365-12.134365-18.201547l42.469254-6.067183 12.134365 18.201548z m-533.899776-97.072873h339.755054l78.871325 103.140055H272.378527l78.872349-103.140055zM175.305655 357.290429h36.402071c-6.067182 6.067182-6.067182 12.134365-12.134365 18.201547l-18.201547 6.067183-18.201547-12.134365 12.135388-12.134365z m667.375743 394.35765h-54.603619V678.843936H242.043638v72.804143H132.837424V527.167444c0-12.134365-0.041956-20.662599 1.216711-23.556508 1.258667-2.89391 9.955746-16.924461 21.193695-29.173437l35.722596-38.276768h639.576607l21.917172 20.938891c6.067182 6.067182 21.847587 21.366633 25.712615 28.732392 7.621585 9.996678 6.973832 10.999518 13.041014 23.133883v242.682182h-48.536436zM242.043638 533.234627h133.474944v60.670801H242.043638v-60.670801z m412.559197 0h133.474944v60.670801H654.602835v-60.670801z" p-id="3540"></path></svg>'), i(this, "downSvg", '<svg t="1747552626242" class="jhs-icon icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4551" width="200" height="200"><path d="M641.6 660l-8.64-64 32-4.32a211.2 211.2 0 0 0-26.72-420.32 215.36 215.36 0 0 0-213.12 192 94.56 94.56 0 0 0 0 11.52v41.28h-64V384v-7.04a153.12 153.12 0 0 1 0-19.52A279.84 279.84 0 0 1 636.16 108H640A275.2 275.2 0 0 1 673.28 656z" fill="#333333" p-id="4552"></path><path d="M490.4 446.24l-7.52-39.84a182.4 182.4 0 0 1 107.52-162.88l29.12-13.28L646.08 288l-29.12 13.28a117.92 117.92 0 0 0-70.08 101.28l6.24 30.4zM392.96 652.32h-78.72A202.24 202.24 0 0 1 256 256l30.72-9.12 18.24 61.28-30.72 9.12a138.24 138.24 0 0 0 39.68 270.72h78.72zM479.2 512h64v320h-64z" fill="#333333" p-id="4553"></path><path d="M510.4 908l-156.32-147.68 43.84-46.4 112.48 106.08 112.8-106.08 43.84 46.56-156.64 147.52z" fill="#333333" p-id="4554"></path></svg>'), i(this, "handleSvg", '<svg t="1749106236917" class="jhs-icon icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2628" width="200" height="200"><path d="M838 989.48a32 32 0 0 1-22.5-9.22L519.3 687.6 207.48 980.8a32 32 0 0 1-54-23.32V136.52A98.54 98.54 0 0 1 252 38.1h519.6A98.52 98.52 0 0 1 870 136.52v820.96a32 32 0 0 1-32 32zM252 102.1a34.46 34.46 0 0 0-34.42 34.42v746.96L498 619.84a32 32 0 0 1 44.42 0.56L806 880.88V136.52a34.46 34.46 0 0 0-34.4-34.42z" p-id="2629"></path><path d="M648 604.92a28 28 0 0 1-16.46-5.34l-112.84-82-112.84 82a28 28 0 0 1-43.08-31.32l43.1-132.64-112.84-82a28 28 0 0 1 16.46-50.66h139.48L492 170.34a28 28 0 0 1 53.26 0l43.1 132.64h139.48a28 28 0 0 1 16.46 50.66l-112.84 82 43.1 132.64A28 28 0 0 1 648 604.92z m-129.3-150a27.86 27.86 0 0 1 16.46 5.36l59.58 43.28-22.76-70a28 28 0 0 1 10.02-31.28l59.58-43.3H568a28 28 0 0 1-26.64-19.34l-22.76-70-22.76 70a28 28 0 0 1-26.62 19.34h-73.64l59.58 43.3a28 28 0 0 1 10.16 31.3l-22.76 70 59.58-43.28a28 28 0 0 1 16.46-5.32z" p-id="2630"></path></svg>'), i(this, "siteSvg", '<svg t="1749107903569" class="jhs-icon icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="12439" width="200" height="200"><path d="M882.758621 133.674884C882.758621 59.84828 822.91034 0 749.083736 0 675.25715 0 615.40887 59.84828 615.40887 133.674884 615.40887 163.358402 625.152318 191.656395 642.813352 214.773283L670.872117 193.336726 648.314739 166.170836 253.911693 493.666092 276.469054 520.831982 302.371681 496.834595C277.256669 469.725608 241.995388 453.990153 204.295574 453.990153 130.46897 453.990153 70.62069 513.838433 70.62069 587.66502 70.62069 661.491624 130.46897 721.339904 204.295574 721.339904 255.555319 721.339904 301.619094 692.208675 324.036714 647.136344L276.646223 663.002394 706.082022 877.440106 721.856794 845.849335 690.37312 829.861888C680.932829 848.452414 675.940882 869.068818 675.940882 890.325116 675.940882 964.15172 735.789162 1024 809.615766 1024 883.442353 1024 943.290633 964.15172 943.290633 890.325116 943.290633 874.050807 940.36533 858.125365 934.723584 843.16446L868.645076 868.0826C871.294817 875.109252 872.669943 882.595452 872.669943 890.325116 872.669943 925.14899 844.439623 953.37931 809.615766 953.37931 774.791892 953.37931 746.561571 925.14899 746.561571 890.325116 746.561571 880.245089 748.902894 870.575616 753.340487 861.836782L769.436089 830.140063 737.631567 814.258564 308.195769 599.820853 276.554929 584.02108 260.805279 615.686903C250.212352 636.984797 228.494795 650.719214 204.295574 650.719214 169.4717 650.719214 141.241379 622.488894 141.241379 587.66502 141.241379 552.841163 169.4717 524.610842 204.295574 524.610842 222.12269 524.610842 238.680594 531.99985 250.566444 544.829369L273.29589 569.363385 299.026432 547.997855 693.429478 220.502616 719.514606 198.84265 698.930882 171.900169C690.596687 160.991373 686.029559 147.727007 686.029559 133.674884 686.029559 98.85101 714.25988 70.62069 749.083736 70.62069 783.90761 70.62069 812.137931 98.85101 812.137931 133.674884 812.137931 148.208022 807.249885 161.899255 798.379608 172.996785L853.543883 217.089695C872.331935 193.584128 882.758621 164.379366 882.758621 133.674884ZM749.083736 196.729062C729.149334 196.729062 710.818745 187.460449 698.930882 171.900169L642.813352 214.773283C667.922573 247.639305 706.904064 267.349751 749.083736 267.349751 790.225902 267.349751 828.357809 248.599782 853.543883 217.089695L798.379608 172.996785C786.455411 187.915034 768.530291 196.729062 749.083736 196.729062ZM337.970441 587.66502C337.970441 553.551854 325.093782 521.360666 302.371681 496.834595L250.566444 544.829369C261.309069 556.424898 267.349751 571.526356 267.349751 587.66502 267.349751 597.565263 265.091478 607.069184 260.805279 615.686903L324.036714 647.136344C333.156105 628.801148 337.970441 608.540036 337.970441 587.66502ZM809.615766 756.650249C758.753986 756.650249 712.986006 785.330865 690.37312 829.861888L753.340487 861.836782C764.027215 840.791658 785.603302 827.270938 809.615766 827.270938 836.08553 827.270938 859.461862 843.730308 868.645076 868.0826L934.723584 843.16446C915.252259 791.529949 865.714547 756.650249 809.615766 756.650249Z" fill="#389BFF" p-id="12440"></path></svg>'), i(this, "videoSvg", '<svg t="1749003664455" class="jhs-icon icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1952" width="200" height="200"><path d="M825.6 153.6H198.4C124.5 153.6 64 214.1 64 288v448c0 73.9 60.5 134.4 134.4 134.4h627.2c73.9 0 134.4-60.5 134.4-134.4V288c0-73.9-60.5-134.4-134.4-134.4z m-138.2 44.8l112 112H706l-112-112h93.4z m-156.8 0l112 112H526.7l-112-112h115.9z m-179.2 0l112 112H347.5l-112-112h115.9zM108.8 288c0-41.4 28.4-76.1 66.7-86.3l108.7 108.7H108.8V288z m806.4 448c0 49.4-40.2 89.6-89.6 89.6H198.4c-49.4 0-89.6-40.2-89.6-89.6V355.2h806.4V736z m0-425.6h-52.5l-112-112h74.9c49.4 0 89.6 40.2 89.6 89.6v22.4z" p-id="1953"></path><path d="M454 687.2l149.3-77.6c27.5-13.8 27.5-53 0-66.8L468 472.2c-31.2-15.6-68 7.1-68 42v139.6c0 27.8 29.2 45.8 54 33.4zM444.8 512l134.4 67.2-134.4 67.2V512z" p-id="1954"></path></svg>'), i(this, "screenSvg", '<svg t="1750691468062" class="jhs-icon icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2693" width="200" height="200"><path d="M288 160a64 64 0 0 0-64 64v576a64 64 0 0 0 64 64h448a64 64 0 0 0 64-64v-576a64 64 0 0 0-64-64h-448m0-64h448a128 128 0 0 1 128 128v576a128 128 0 0 1-128 128h-448a128 128 0 0 1-128-128v-576a128 128 0 0 1 128-128z" fill="#4078FD" p-id="2694"></path><path d="M416 352m-64 0a64 64 0 1 0 128 0 64 64 0 1 0-128 0Z" fill="#FE9C23" p-id="2695"></path><path d="M352 732.448a32 32 0 0 1-32-32v-160a32 32 0 0 1 44.224-29.568l130.112 53.632 153.952-169.984a32 32 0 0 1 55.712 21.472v284.448a32 32 0 0 1-32 32z m0-32h320z" fill="#4078FD" opacity=".2" p-id="2696"></path><path d="M672 416l-169.088 186.656-150.912-62.208v160h320V416m0-32a32 32 0 0 1 32 32v284.448a32 32 0 0 1-32 32h-320a32 32 0 0 1-32-32v-160a32 32 0 0 1 44.192-29.6l130.112 53.632 153.984-169.984a32 32 0 0 1 23.712-10.496z" fill="#4078FD" p-id="2697"></path></svg>'), i(this, "recoveryVideoSvg", '<svg t="1749003779161" class="jhs-icon icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8204" width="200" height="200"><path d="M938.666667 553.92V768c0 64.8-52.533333 117.333333-117.333334 117.333333H202.666667c-64.8 0-117.333333-52.533333-117.333334-117.333333V256c0-64.8 52.533333-117.333333 117.333334-117.333333h618.666666c64.8 0 117.333333 52.533333 117.333334 117.333333v297.92z m-64-74.624V256a53.333333 53.333333 0 0 0-53.333334-53.333333H202.666667a53.333333 53.333333 0 0 0-53.333334 53.333333v344.48A290.090667 290.090667 0 0 1 192 597.333333a286.88 286.88 0 0 1 183.296 65.845334C427.029333 528.384 556.906667 437.333333 704 437.333333c65.706667 0 126.997333 16.778667 170.666667 41.962667z m0 82.24c-5.333333-8.32-21.130667-21.653333-43.648-32.917333C796.768 511.488 753.045333 501.333333 704 501.333333c-121.770667 0-229.130667 76.266667-270.432 188.693334-2.730667 7.445333-7.402667 20.32-13.994667 38.581333-7.68 21.301333-34.453333 28.106667-51.370666 13.056-16.437333-14.634667-28.554667-25.066667-36.138667-31.146667A222.890667 222.890667 0 0 0 192 661.333333c-14.464 0-28.725333 1.365333-42.666667 4.053334V768a53.333333 53.333333 0 0 0 53.333334 53.333333h618.666666a53.333333 53.333333 0 0 0 53.333334-53.333333V561.525333zM320 480a96 96 0 1 1 0-192 96 96 0 0 1 0 192z m0-64a32 32 0 1 0 0-64 32 32 0 0 0 0 64z" fill="#000000" p-id="8205"></path></svg>'); } getName() { throw new Error(`${this.constructor.name} 未显示getName()`); } getBean(e) { return this.pluginManager.getBean(e); } async initCss() { return ""; } async handle() {} shouldSkipOnMobile() { return false; } /** 提取 JavDB 详情页番号: 兼容 繁体(複製番號)/简体(复制番号)/英文 界面 */ findJavDbCarNum() { let e = $("a[data-clipboard-text]").filter((function() { const t = ($(this).attr("title") || "").toLowerCase(); return t.includes("番") || t.includes("copy") || t.includes("code"); })).first().attr("data-clipboard-text"); if (e && e.trim()) return e.trim(); e = $("a[data-clipboard-text]").first().attr("data-clipboard-text"); if (e && e.trim()) return e.trim(); e = $(".title.is-4 strong").not(".current-title").first().text().trim(); if (e) return e.split(/\s+/)[0]; const t = window.location.href.split("?")[0].split("#")[0].match(/\/v\/([^/]+)$/); return t ? "" : ""; } getPageInfo() { let e, t, n, a, i, s = window.location.href; return r && (e = this.findJavDbCarNum(), t = s.split("?")[0].split("#")[0], n = $(".female").prev().map(((e, t) => $(t).text())).get().join(" "), a = $(".male").prev().map(((e, t) => $(t).text())).get().join(" "), i = $('strong:contains("日期:")').parent(".panel-block").find(".value").text().trim()), l && (t = s.split("?")[0], e = t.split("/").filter(Boolean).pop().replace(/_\d{4}-\d{2}-\d{2}$/, ""), n = $('span[onmouseover*="star_"] a').map(((e, t) => $(t).text())).get().join(" "), a = "", i = ($('span.header:contains("發行日期:")').parent("p").text().trim() || $('span.header:contains("发行日期:")').parent("p").text().trim()).replace("發行日期:", "").replace("发行日期:", "").trim()), { carNum: e, url: t, actress: n, actors: a, publishTime: i }; } getActressId() { const e = o.match(/\/actors\/([^/?]+)/); return e && e.length > 1 ? e[1] : null; } /** 详情页: 记录已浏览番号(供列表页显示"已浏览"标签), JavDb / JavBus 通用 */ async recordVisited() { try { if (!window.isDetailPage) return; // 已浏览记录跟随「首页鉴定筛选功能」开关(两者已合并为同一个开关) if (await storageManager.getSetting("enableQuickFilter", _) !== _) return; const {carNum: carNum} = this.getPageInfo(); if (!carNum) return; jhsAddVisitedCarNum(carNum); clog.debug("已记录浏览番号", carNum); try { new BroadcastChannel("channel-visited").postMessage({ type: "visited", carNum: carNum }); } catch (e) {} } catch (e) { clog.debug("记录已浏览番号失败", e); } } getActressPageInfo() { let e = window.location.href; if (!e.includes("/actors/") && !e.includes("/star/")) throw new Error("接口调用错误, 非演员详情页"); let t = [], n = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); n.length && n.text().trim().split(",").forEach((e => { t.push(e.trim()); })); let a = $(".section-meta:not(:contains('影片'))"); a.length && a.text().trim().split(",").forEach((e => { t.push(e.trim()); })); let i = $(".section-meta:contains('男優')").length > 0 ? B : P, s = D; t.some((e => e.includes("無碼"))) && (s = A), e.includes("uncensored") && (s = A); let o = null, c = null; const d = new URL(e); if (r) { c = d.pathname.split("/").filter((e => "" !== e.trim())).pop(); const e = d.searchParams; e.delete("sort_type"), e.delete("page"), o = d.toString(); } else if (l) { const t = "/star/", n = e.split(t); if (n.length < 2) throw new Error("提取演员url失败"); const a = n[0]; c = n[1].split("/")[0], o = a + t + c; } return { starId: c, name: t[0], allName: t, role: i, movieType: s, blacklistUrl: o }; } getSelector(e) { const t = e || (r ? T : l ? I : null), n = { javdb: { boxSelector: ".movie-list", itemSelector: ".movie-list .item", coverImgSelector: ".cover img", requestDomItemSelector: ".movie-list .item", nextPageSelector: ".pagination-next" }, javbus: { boxSelector: ".masonry", itemSelector: ".masonry .item", coverImgSelector: ".masonry .movie-box .photo-frame img", requestDomItemSelector: "#waterfall .item", nextPageSelector: "#next" } }; if (!t || !n[t]) throw new Error("类型错误: 无法确定选择器类型 (JavDb 或 JavBus)"); return n[t]; } parseMovieId(e) { return e.split("/").pop().split(/[?#]/)[0]; } getBoxCarInfo(e) { var t, n, a; const i = e.find("a"), s = i.attr("href"); let o = null, r = null, l = null; const c = e.find(".video-title"); if (c.length > 0) { const n = c.find("strong"); if (n.length > 0 && (o = n.text().trim()), r = null == (t = i.attr("title")) ? void 0 : t.trim(), !r) { const e = c.text().trim(); r = o && e.includes(o) ? e.replace(o, "").trim() : e; } l = e.find(".meta").text().trim(); } if (!o) { const t = e.find("img"); t.length > 0 && (r = (null == (n = t.attr("title")) ? void 0 : n.trim()) || (null == (a = t.attr("data-title")) ? void 0 : a.trim())); const i = e.find("date").map(((e, t) => $(t).text().trim())).get(), s = e => /^\d{4}-\d{1,2}-\d{1,2}$/.test(e); l = i.find(s) || null, o = i.find((e => !s(e))) || null; } if (!o) { const t = "提取番号信息失败: carNum 为空"; throw console.error("Error in getBoxCarInfo:", t, "Box Element:", e.get(0)), show.error(t), new Error(t); } return { carNum: o, url: s || "", title: r || "", publishTime: l || "" }; } getBoxCarInfoList(e = null) { if (e || (e = $(this.getSelector().itemSelector)), 0 === e.length) return clog.error("获取当前列表页所有item的番号信息失败!"), []; const t = []; return e.each(((e, n) => { const a = $(n); try { const e = this.getBoxCarInfo(a); t.push(e); } catch (i) { clog.error("[getBoxCarInfoList] 提取单个 boxCar 信息失败:", i.message, "元素索引:", e); } })), t; } checkDuplicateCarNumbers(e, t) { if (!e || 0 === e.length || !t || 0 === t.length) return !1; const n = new Set(e.map((e => e.carNum)).filter((e => e))); if (0 === n.size) return !1; let a = 0; for (let i = 0; i < t.length; i++) { const e = t[i] ? t[i].carNum : null; if (e && n.has(e)) { if (a++, a >= 2) return clog.warn("警告: 检测到连续番号信息重复, 该类别可能已被限制页码。"), !0; } else a = 0; } return !1; } } class Q extends X { getName() { return "DetailPagePlugin"; } constructor() { super(); } handle() { if (!window.isDetailPage) return; this.recordVisited(); $(".video-meta-panel a").each((function() { const e = $(this).attr("href"); e && (e.startsWith("http://") || e.startsWith("https://") || e.startsWith("/")) && $(this).attr("target", "_blank"); })); this.addCopyCarNumBtn(); } /** JavDB 详情页: 番号后追加"复制番号"按钮 */ addCopyCarNumBtn() { if ($(".jav-avid-copy").length) return; const anchor = $("a[data-clipboard-text]").filter((function() { const t = ($(this).attr("title") || "").toLowerCase(); return t.includes("番") || t.includes("copy") || t.includes("code"); })).first(); if (!anchor.length) return; const code = String(anchor.attr("data-clipboard-text") || "").trim(); if (!code) return; anchor.hide(); const btn = document.createElement("button"); btn.type = "button"; btn.className = "jav-avid-copy"; btn.textContent = "复制番号"; btn.title = `复制番号:${code}`; btn.style.cssText = 'display:inline-block;min-width:62px;margin-left:8px;padding:2px 8px;font-size:12px;font-family:-apple-system,BlinkMacSystemFont,"Microsoft YaHei","PingFang SC","Noto Sans CJK SC","Segoe UI",sans-serif;font-weight:600;text-align:center;background:#e8f4fd;border:1px solid #90c5e8;border-radius:4px;cursor:pointer;color:#1a6fa8;vertical-align:middle;white-space:nowrap;box-sizing:border-box;'; btn.addEventListener("click", (e => { e.preventDefault(), e.stopPropagation(); const done = () => { btn.textContent = "已复制"; setTimeout((() => { btn.textContent = "复制番号"; }), 900); }; navigator.clipboard && navigator.clipboard.writeText ? navigator.clipboard.writeText(code).then(done).catch((() => { alert("复制失败,请手动复制: " + code); })) : done(); })); anchor[0].parentNode.insertBefore(btn, anchor[0].nextSibling); } } const Z = (e, t) => { if (!e || 0 === e.length) return null; const n = new Set(e); if (n.has(t)) return t; const a = L.map((e => e.quality)).reverse(); for (const i of a) if (n.has(i)) return i; return e[0]; }, ee = "jhs_dmm_video"; /** 负缓存有效期: 确认「查不到预告片源」后, 这段时间内不再重复整套 DMM + jav-pack 请求 */ const JHS_DMM_NONE_TTL = 2 * 60 * 60 * 1e3; class te { constructor(e, t = !0) { this.carNum = e, this.showErrorMessages = t; } _readCacheMap() { try { return localStorage.getItem(ee) ? JSON.parse(localStorage.getItem(ee)) : {}; } catch (e) { return {}; } } _checkCache() { const e = this._readCacheMap(), v = e[this.carNum]; // 负缓存条目(__jhsNone)不是画质表, 交给 _isNoSourceCached 判定 return v && !v.__jhsNone ? (clog.debug("缓存中存在预览视频信息", v), v) : null; } /** 负缓存命中: 上次已确认 DMM 与 jav-pack 都没有源, 且未过期 -> 跳过整套解析 */ _isNoSourceCached() { try { const map = this._readCacheMap(), v = map[this.carNum]; if (!v || !v.__jhsNone) return !1; if (Date.now() - (v.t || 0) < JHS_DMM_NONE_TTL) return clog.debug("负缓存命中, 跳过 DMM 解析:", this.carNum), !0; delete map[this.carNum], localStorage.setItem(ee, JSON.stringify(map)); return !1; } catch (e) { return !1; } } /** 记录「该番号查不到源」, 避免每次点击都把 3 个关键词 + jav-pack 兜底整套重跑 */ _markNoSource() { try { const map = this._readCacheMap(); map[this.carNum] = { __jhsNone: 1, t: Date.now() }, localStorage.setItem(ee, JSON.stringify(map)), clog.debug("写入负缓存(无预告片源):", this.carNum); } catch (e) {} } /** jav-pack 兜底; 仍然没有就写负缓存, 返回值语义与原来完全一致(null) */ async _fallbackOrMarkNone() { const e = await this._fetchFromJavPackApi(); return e || (this._markNoSource(), null); } _updateCache(e) { const t = localStorage.getItem(ee) ? JSON.parse(localStorage.getItem(ee)) : {}; t[this.carNum] = e, clog.debug("成功解析出预览视频并已缓存:", e), localStorage.setItem(ee, JSON.stringify(t)); } async _searchContentIds() { const e = this.carNum, t = e.replace(/-/g, ""), n = [ { keyword: e.replace("-", "00"), name: "00-替换关键词" }, { keyword: e, name: "原始番号关键词" }, { keyword: t, name: "无连字符关键词" } ], a = e.toLowerCase(); for (const o of n) { const {keyword: e, name: n} = o, i = e.toLowerCase(); clog.debug(`--- 尝试使用 ${n} (${e}) 进行 API 搜索 ---`); const r = `https://api.dmm.com/affiliate/v3/ItemList?${new URLSearchParams({ api_id: "UrwskPfkqQ0DuVry2gYL", affiliate_id: "10278-996", output: "json", site: "FANZA", sort: "match", keyword: e }).toString()}`; let l; try { l = await gmHttp.get(r); } catch (s) { clog.error(`API 请求失败,跳过 ${n}:`, s); continue; } if (!l || !l.result || !l.result.result_count) { clog.debug("API 返回无结果,尝试下一个关键词。"); continue; } const c = []; for (const s of l.result.items) { if (c.length >= 2) break; const e = s.content_id || "", o = s.maker_product || ""; (e.includes(i.replace("-", "")) || a === o.toLowerCase() || e.includes(t.toLowerCase())) && (c.push({ serviceCode: s.service_code, floorCode: s.floor_code, contentId: e, pageUrl: s.URL }), clog.debug(`[${n}] cid|makerProduct 匹配成功:`, e, o)); } if (c.length > 0) { clog.debug(`--- 成功通过 ${n} 找到 Content IDs ---`); const t = $("#fanzaBtn"); let a = `https://www.dmm.co.jp/search/=/searchstr=${e}`, i = "single"; c.length > 1 ? (t.attr("href", a), t.css("backgroundColor", "#7bc73b"), i = "multiple") : (a = c[0].pageUrl, t.attr("href", a), t.css("backgroundColor", "#7bc73b")); const s = "jhs_other_site_dmm", o = localStorage.getItem(s) ? JSON.parse(localStorage.getItem(s)) : {}; return o[this.carNum] = { type: i, url: a }, localStorage.setItem(s, JSON.stringify(o)), c; } clog.debug(`[${n}] API 返回结果数 ${l.result.result_count},但无精确匹配的 Content ID。`); } clog.warn("所有关键词尝试均未找到匹配的Content ID, 解析Dmm视频失败"); const i = $("#fanzaBtn"); return i.attr("href", `https://www.dmm.co.jp/search/=/searchstr=${this.carNum}`), i.attr("title", "未查询到, 点击前往搜索页"), i.css("backgroundColor", "#de3333"), null; } async _extractTrailerLinks({contentId: e, serviceCode: t, floorCode: n}) { const a = `https://www.dmm.co.jp/service/digitalapi/-/html5_player/=/cid=${e}/mtype=AhRVShI_/service=${t}/floor=${n}/mode=/`, i = await gmHttp.get(a, null, { "accept-language": "ja-JP,ja;q=0.9", Cookie: "age_check_done=1" }); if ("string" != typeof i) throw clog.error(i), new Error("解析播放页内容失败, 非文本内容"); if (i.includes("このサービスはお住まいの地域からは")) throw new Error("节点不可用,请将DMM域名分流到日本ip"); const s = i.match(/const\s+args\s+=\s+(.*);/); if (!s) throw new Error("未在脚本中找到 const args = ... 变量"); let o; try { ({bitrates: o} = JSON.parse(s[1])); } catch (d) { throw new Error(`解析播放器脚本 JSON 失败: ${d.message}`); } const r = {}, l = L.map((e => e.quality)).join("|"), c = new RegExp(`(${l})\\.mp4$`); if (!Array.isArray(o)) throw clog.error("解析画质链接失败: bitrates 字段不是一个数组或不存在"), new Error("解析画质链接失败: bitrates 字段不是一个数组或不存在"); clog.debug("原始数据返回:", o); for (const h of o) { const e = null == h ? void 0 : h.src; if (!e || "string" != typeof e || !e.endsWith(".mp4")) continue; const t = e.match(c); let n = ""; t && t[1] && (n = t[1]), n && !r[n] && (r[n] = e); } if (0 === Object.keys(r).length) throw new Error("未找到匹配要求的预览画质视频"); return r; } async fetchVideo() { const e = this._checkCache(); if (e) return e; // 上次已确认无源且未过期: 直接返回 null, 让上层照原逻辑走 javxy 兜底 if (this._isNoSourceCached()) return null; let t; try { const e = this.carNum.toLowerCase(); if (e.startsWith("heyzo") || /^(n\d+|\d+(-\d+)*)$/.test(e) || /^n\d+$/.test(e)) throw new Error("无码番号类型, 取消dmm解析"); if (this.carNum.includes("VR-")) throw new Error("VR类型, 取消dmm解析"); t = await this._searchContentIds(); } catch (n) { clog.error("DMM API 搜索失败:", n); const e = $("#fanzaBtn"); e.attr("href", `https://www.dmm.co.jp/search/=/searchstr=${this.carNum}`), e.attr("title", "未查询到, 点击前往搜索页"), e.css("backgroundColor", "#de3333"); // DMM 失败后尝试 jav-pack-api 兜底; 仍无源则写负缓存 return await this._fallbackOrMarkNone(); } if (!t || 0 === t.length) { // DMM 未找到 content_id, 尝试 jav-pack-api 兜底; 仍无源则写负缓存 return await this._fallbackOrMarkNone(); } try { const e = await Promise.any(t.map((e => this._extractTrailerLinks(e)))); return this._updateCache(e), e; } catch (a) { const e = a.errors || [ a ]; if (e.some((e => e.message.includes("节点不可用")))) this.showErrorMessages && show.error("节点不可用,请将DMM域名分流到日本ip"); else { const t = e[0].message || e[0]; clog.error(`解析失败: ${t}`, e), this.showErrorMessages && show.error(`解析失败: ${t}`); } const t = $("#fanzaBtn"); t.attr("href", `https://www.dmm.co.jp/search/=/searchstr=${this.carNum}`), t.attr("title", "未查询到, 点击前往搜索页"), t.css("backgroundColor", "#de3333"); // DMM 提取视频链接失败, 尝试 jav-pack-api 兜底; 仍无源则写负缓存 return await this._fallbackOrMarkNone(); } } /** jav-pack-api 预告片兜底: 返回格式与 DMM 一致 {quality: url} 或 null */ async _fetchFromJavPackApi() { try { const apiBase = await storageManager.getSetting("javPackApiUrl", "https://javp.cc.cd"); const url = `${apiBase}/trailers/${encodeURIComponent(this.carNum)}`; clog.debug("[jav-pack-api] 尝试兜底获取预告片:", url); const resp = await gmHttp.get(url); if (!resp || "string" == typeof resp && resp.includes("Just a moment")) { clog.debug("[jav-pack-api] 被 Cloudflare 拦截或无响应"); return null; } const data = "string" == typeof resp ? JSON.parse(resp) : resp; if (data && data.trailer) { // 将单一 trailer URL 包装成 {quality: url} 格式, 用 "hhb" 作为画质 key (DMM 高画质后缀) const result = { hhb: data.trailer }; this._updateCache(result); clog.log("[jav-pack-api] 兜底成功:", data.trailer); return result; } clog.debug("[jav-pack-api] 返回 404 或无 trailer 字段"); return null; } catch (err) { clog.debug("[jav-pack-api] 兜底失败:", err); return null; } } } const ne = async (e, t = !0) => new te(e, t).fetchVideo(); /** * 预告片模块: javxy 聚合接口解析 + hls.js 播放。 * 相比 pro.js 原有的 DMM 解析(ne), javxy 覆盖 FC2/MGStage/HeyDouga/DUGA/JavTrailers 等多来源, * 且返回的地址可能是 m3u8, 因此需要 hls.js + GM_xmlhttpRequest 自定义 loader 绕过跨域限制。 */ const JhsTrailer = { /** 画质从高到低, 用于自动挑选最佳画质 */ QUALITY_ORDER: [ "4k", "hhb", "1080p", "hmb", "720p", "mhb", "540p", "mmb", "480p", "396p", "360p", "240p" ], SOURCE_LABELS: { "Tokyo-Hot": "Tokyo-Hot", FC2: "FC2", Direct: "Direct", DMM: "DMM", MGStage: "MGStage", DUGA: "DUGA", MYWIFE: "MyWife", JavTrailers: "JavTrailers", JavDB: "JavDB", AVWikiDB: "AVWikiDB", JAVDatabase: "JAVDatabase", HEYZO: "Heyzo", HeyDouga: "HeyDouga", PACO: "Paco", "10MU": "10mu", Caribbean: "加勒比", "1Pondo": "一本道" }, normalize(code) { return String(code || "").trim().toUpperCase().replace(/[_\s]+/g, "-"); }, cacheKey(code) { return `jhs_trailer_javxy_${this.normalize(code)}`; }, _token() { return [ 118, 119, 112, 71, 97, 110, 28, 84, 124, 65, 76, 102, 65, 16, 77, 109, 64, 82, 85, 83, 67, 92, 125, 108, 83, 65, 124, 107, 84, 104, 71, 84, 17, 124, 118, 125, 104, 8, 125, 96, 112, 103, 29, 18, 82, 83, 87, 84 ].map((n => String.fromCharCode(n ^ 37))).join(""); }, _tokenHeader() { return [ String.fromCharCode(88), String.fromCharCode(74, 97, 118, 120, 121), String.fromCharCode(84, 111, 107, 101, 110) ].join("-"); }, _hosts() { return [ String.fromCharCode(106, 97, 118, 120, 121, 46, 99, 99, 46, 99, 100), String.fromCharCode(119, 111, 114, 107, 101, 114, 46, 106, 97, 118, 120, 121, 46, 99, 99, 46, 99, 100) ]; }, _request(url) { return new Promise((resolve => { GM_xmlhttpRequest({ method: "GET", url: url, timeout: 8e3, headers: { Accept: "application/json,text/plain,*/*", [this._tokenHeader()]: this._token() }, onload: resp => resolve(resp), onerror: () => resolve(null), ontimeout: () => resolve(null) }); })); }, sortQualityKeys(map) { const rank = new Map(this.QUALITY_ORDER.map(((q, idx) => [ q, idx ]))); const has = k => null != rank.get(k) ? rank.get(k) : 99; return Object.keys(map || {}).filter((k => map[k])).sort(((a, b) => has(a) - has(b))); }, selectHighestQuality(map) { return this.sortQualityKeys(map)[0] || null; }, isDynamic(result) { return "hls" === String((result && result.type) || "").toLowerCase() || /\.m3u8(?:[?#].*)?$/i.test(String((result && result.url) || "")); }, /** 调用 javxy 聚合接口解析预告片; 主节点失败自动切换 worker 节点。 * opts.skip / opts.source / opts.prefer: 播放失败后切源用(如跳过需要日本 IP 的 DMM) */ async resolve(code, opts = {}) { const id = this.normalize(code); if (!id) return null; const params = new URLSearchParams({ client: "laosiji-new" }); Array.isArray(opts.skip) && opts.skip.length && params.set("skip", opts.skip.join(",")); Array.isArray(opts.source) && opts.source.length && params.set("source", opts.source.join(",")); Array.isArray(opts.prefer) && opts.prefer.length && params.set("prefer", opts.prefer.join(",")); opts.playbackFallback && params.set("purpose", "playback-fallback"); for (const host of this._hosts()) { const apiUrl = `https://${host}/trailers/${encodeURIComponent(id)}?${params}`; clog.debug("[JhsTrailer] 请求", apiUrl); const resp = await this._request(apiUrl); if (!resp) continue; // 5xx / 网络层失败 / 限流 => 换节点; 4xx 明确无结果 => 直接结束 if (resp.status >= 500 || 0 === resp.status || [ 401, 403, 429 ].includes(resp.status)) continue; if (resp.status < 200 || resp.status >= 400) return null; if (!resp.responseText) return null; let data; try { data = JSON.parse(resp.responseText); } catch (err) { continue; } const trailer = String((data && data.trailer) || "").trim(); if (!trailer) return null; const qualities = data && "object" == typeof data.qualities && data.qualities ? data.qualities : {}; const quality = data && data.quality && qualities[data.quality] ? data.quality : this.selectHighestQuality(qualities); const rawSource = String((data && data.source) || "").trim(); const source = this.SOURCE_LABELS[data && data.source] || rawSource || "预告片"; return { url: qualities[quality] || trailer, type: String((data && data.type) || "").trim() || "video", source: source, rawSource: rawSource, requiresJP: Boolean(data && data.requiresJP), fallbackSources: Array.isArray(data && data.fallback) ? data.fallback.filter(Boolean) : [], quality: quality, qualities: qualities, urls: Array.isArray(data && data.urls) && data.urls.length ? data.urls : this.sortQualityKeys(qualities).map((k => qualities[k])) }; } return null; }, /** hls.js 按需加载(不用 @require, 避免 CDN 失败拖垮整个脚本) */ _getHls() { let HlsClass = null; try { HlsClass = window.Hls || null; } catch (e) {} if (!HlsClass) try { HlsClass = unsafeWindow.Hls || null; } catch (e) {} if (!HlsClass) try { HlsClass = "undefined" != typeof Hls ? Hls : null; } catch (e) {} return HlsClass && HlsClass.isSupported && HlsClass.isSupported() ? HlsClass : null; }, HLS_URL: "https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js", _hlsPromise: null, /** 兜底方案: JavDB 的 CSP 会拦掉外链 <script>, 所以再用 GM_xmlhttpRequest 取源码本地执行 */ _loadHlsByGm() { return new Promise((resolve => { GM_xmlhttpRequest({ method: "GET", url: this.HLS_URL, timeout: 2e4, onload: r => { if (r.status >= 200 && r.status < 300 && r.responseText) try { Function(`${r.responseText}\n//# sourceURL=${this.HLS_URL}`).call(unsafeWindow || globalThis); } catch (e) { clog.error("[JhsTrailer] hls.js 本地执行失败", e); } resolve(this._getHls()); }, onerror: () => resolve(this._getHls()), ontimeout: () => resolve(this._getHls()) }); })); }, _loadHls() { const ready = this._getHls(); if (ready) return Promise.resolve(ready); if (this._hlsPromise) return this._hlsPromise; // 沙箱里 <script> 注入的库挂在页面 window 上, 所以 _getHls 也会去 unsafeWindow 找 return this._hlsPromise = new Promise((resolve => { const el = document.createElement("script"); el.src = this.HLS_URL, el.async = !0; el.onload = () => resolve(this._getHls()), el.onerror = () => resolve(null); document.head.appendChild(el); setTimeout((() => resolve(this._getHls())), 8e3); })).then((cls => cls || this._loadHlsByGm())).then((cls => (cls || (this._hlsPromise = null), cls || clog.warn("[JhsTrailer] hls.js 加载失败, m3u8 预告片无法播放"), cls))); }, /** * hls.js 默认用 XHR 拉分片, javxy 的 ts/m4s 分片跨域会被拦, * 因此换成走 GM_xmlhttpRequest 的 loader(用 x-user-defined 把二进制读成字符串再还原)。 */ _createLoader() { const toArrayBuffer = value => { const text = String(value || ""), bytes = new Uint8Array(text.length); for (let i = 0; i < text.length; i += 1) bytes[i] = 255 & text.charCodeAt(i); return bytes.buffer; }; return class { constructor(config) { this.config = config, this.context = null, this.callbacks = null, this.loader = null, this.stats = this.createStats(); } createStats() { return { aborted: !1, loaded: 0, retry: 0, total: 0, chunkCount: 0, bwEstimate: 0, trequest: 0, tfirst: 0, tload: 0, loading: { start: 0, first: 0, end: 0 }, parsing: { start: 0, end: 0 }, buffering: { start: 0, first: 0, end: 0 } }; } destroy() { this.abort(); } abort() { this.stats && (this.stats.aborted = !0); try { this.loader && this.loader.abort && this.loader.abort(); } catch (e) {} this.loader = null; } load(context, config, callbacks) { this.context = context, this.callbacks = callbacks; const url = context.url; const wantsBinary = "arraybuffer" === context.responseType || /\.(?:ts|m4s|mp4|key)(?:[?#]|$)/i.test(url); const startedAt = performance.now(), stats = this.stats = this.createStats(); stats.trequest = startedAt, stats.tfirst = startedAt, stats.tload = startedAt, stats.loading.start = startedAt, this.loader = GM_xmlhttpRequest({ method: "GET", url: url, responseType: "text", overrideMimeType: wantsBinary ? "text/plain; charset=x-user-defined" : void 0, timeout: (config && config.timeout) || 2e4, headers: { Accept: wantsBinary ? "*/*" : "application/vnd.apple.mpegurl, application/x-mpegURL, */*" }, onprogress: evt => { stats.loaded = Number((evt && evt.loaded) || stats.loaded || 0), stats.total = Number((evt && evt.total) || stats.total || stats.loaded || 0), !stats.loading.first && stats.loaded > 0 && (stats.loading.first = performance.now()); }, onload: resp => { const status = Number(resp.status || 0); const meta = { code: status, text: resp.statusText || "", url: resp.finalUrl || url }; if (stats.tfirst = stats.tfirst || performance.now(), stats.tload = performance.now(), stats.loading.first = stats.loading.first || stats.tload, stats.loading.end = stats.tload, status < 200 || status >= 300) return void (callbacks.onError && callbacks.onError(meta, context, null, stats)); const raw = null != resp.responseText ? resp.responseText : null != resp.response ? resp.response : ""; const data = wantsBinary ? toArrayBuffer(raw) : raw; stats.loaded = (data && (data.byteLength || data.length)) || stats.loaded || 0, stats.total = stats.total || stats.loaded, stats.bwEstimate = stats.loading.end > stats.loading.first ? Math.round(8e3 * stats.total / (stats.loading.end - stats.loading.first)) : 0, callbacks.onSuccess && callbacks.onSuccess({ data: data, url: meta.url }, stats, context, meta); }, onerror: () => callbacks.onError && callbacks.onError({ code: 0, text: "network error", url: url }, context, null, stats), ontimeout: () => { stats.tload = performance.now(), stats.loading.end = stats.tload, callbacks.onTimeout && callbacks.onTimeout(stats, context, null); } }); } }; }, _ensureStyle() { if ($("#jhs-trailer-style").length) return; $("<style>").attr("id", "jhs-trailer-style").text(` .jhs-trailer-overlay{position:fixed;inset:0;z-index:10000040;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(15,23,42,.72);backdrop-filter:blur(4px);box-sizing:border-box} .jhs-trailer-modal{position:relative;width:min(1080px,94vw);display:flex;flex-direction:column;overflow:hidden;border-radius:10px;background:#000;box-shadow:0 24px 70px rgba(15,23,42,.45)} .jhs-trailer-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;background:#111827;color:#e5e7eb;font-size:13px;font-weight:700} .jhs-trailer-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .jhs-trailer-close{width:28px;height:28px;border:0;border-radius:6px;background:#1f2937;color:#e5e7eb;font-size:20px;line-height:1;cursor:pointer} .jhs-trailer-close:hover{background:#374151} /* 先按 16:9 把画面撑开, 之后无论换源/换画质都不会再改变弹层高度, 视觉上不闪 */ .jhs-trailer-screen{position:relative;width:100%;aspect-ratio:16/9;max-height:78vh;background:#000;overflow:hidden} .jhs-trailer-screen video{position:absolute;inset:0;width:100%;height:100%;display:block;background:#000;outline:none} .jhs-trailer-status{position:absolute;left:12px;top:12px;z-index:3;max-width:calc(100% - 24px);display:inline-flex;align-items:center;gap:7px;padding:6px 11px;border-radius:6px;background:rgba(2,6,23,.66);color:#e2e8f0;font-size:12px;line-height:1.4;letter-spacing:.2px;backdrop-filter:blur(6px);box-shadow:0 4px 14px rgba(0,0,0,.28);pointer-events:none;transition:opacity .18s ease} .jhs-trailer-status[hidden]{display:none} .jhs-trailer-status.is-error{color:#fca5a5} .jhs-trailer-spinner{width:11px;height:11px;flex:0 0 11px;border:2px solid rgba(226,232,240,.28);border-top-color:#e2e8f0;border-radius:50%;animation:jhsTrailerSpin .7s linear infinite} @keyframes jhsTrailerSpin{to{transform:rotate(360deg)}} .jhs-trailer-empty{position:absolute;inset:0;z-index:4;display:flex;align-items:center;justify-content:center;padding:16px;color:#cbd5e1;background:#000;font-size:15px;text-align:center} .jhs-trailer-empty[hidden]{display:none} `).appendTo("head"); }, /** * 只负责搭壳并立刻显示(标准 16:9 黑屏), 解析/播放由调用方随后填充。 * 返回一个控制器, 状态提示固定在画面左上角。 */ _openShell(code) { this._ensureStyle(), $(".jhs-trailer-overlay").remove(); const savedOverflow = document.documentElement.style.overflow; document.documentElement.style.overflow = "hidden"; const $overlay = $(` <div class="jhs-trailer-overlay"> <div class="jhs-trailer-modal"> <div class="jhs-trailer-head"> <span class="jhs-trailer-title">预告片 ${escapeHtml(this.normalize(code))}</span> <button type="button" class="jhs-trailer-close" title="关闭">×</button> </div> <div class="jhs-trailer-screen"> <video controls autoplay loop playsinline preload="auto"></video> <div class="jhs-trailer-status" hidden></div> <div class="jhs-trailer-empty" hidden></div> </div> </div> </div> `); const video = $overlay.find("video")[0]; const $status = $overlay.find(".jhs-trailer-status"), $empty = $overlay.find(".jhs-trailer-empty"), $title = $overlay.find(".jhs-trailer-title"); let closed = !1, onClose = null; const close = () => { closed = !0; try { onClose && onClose(); } catch (e) {} try { video.pause(), video.removeAttribute("src"), video.load(); } catch (e) {} $overlay.remove(), document.documentElement.style.overflow = savedOverflow, document.removeEventListener("keydown", onKeydown, !0); }; const onKeydown = e => { "Escape" === e.key && (e.stopPropagation(), close()); }; $overlay.on("click", (e => { e.target === $overlay[0] && close(); })), $overlay.find(".jhs-trailer-close").on("click", close), document.addEventListener("keydown", onKeydown, !0), $("body").append($overlay); return { video: video, closed: () => closed, close: close, setOnClose: fn => { onClose = fn; }, setTitle: text => $title.text(text), /** loading=true 时带转圈; error=true 时红字 */ setStatus: (text, opts = {}) => { $status.removeClass("is-error"), opts.error && $status.addClass("is-error"), $status.html(`${!1 === opts.loading ? "" : '<span class="jhs-trailer-spinner"></span>'}<span>${escapeHtml(text)}</span>`).prop("hidden", !1); }, hideStatus: () => $status.prop("hidden", !0), showEmpty: text => { $status.prop("hidden", !0), $empty.text(text).prop("hidden", !1), $(video).hide(); } }; }, /** 在已打开的壳里播放: 画质逐个兜底, 整个来源都失败就按 javxy 的 fallback 列表换源 */ /** 带 sessionStorage 缓存的解析(动态 m3u8 不缓存, 因为地址通常带时效 token) */ async resolveCached(code) { const id = this.normalize(code), key = this.cacheKey(id); try { const cached = sessionStorage.getItem(key); if (cached) { const parsed = JSON.parse(cached); if (parsed && parsed.url && !this.isDynamic(parsed)) return parsed; } } catch (e) {} const res = await this.resolve(id); return res && res.url && !this.isDynamic(res) && sessionStorage.setItem(key, JSON.stringify(res)), res; }, /** * 通用播放核心: 把番号解析出的预告片挂到任意一个 <video> 上。 * 弹层播放器、封面原位内嵌、详情页预览视频都复用这个方法, 差别只在回调里怎么展示状态。 * opts.first 已解析好的结果(可省一次请求) * opts.onStatus (text, {loading,error}) => void * opts.onReady 真正播出画面时回调 * opts.onFail ({reason,detail}) => void 所有画质+所有来源都失败 * opts.onSource (result) => void 每次确定/切换来源时回调 * opts.isAborted () => boolean 外部已取消(弹层关掉/切走)则停止后续动作 * 返回 { destroy() } */ async bindVideo(video, code, opts = {}) { const id = this.normalize(code), noop = () => {}; const onStatus = opts.onStatus || noop, onReady = opts.onReady || noop, onFail = opts.onFail || noop; const aborted = () => "function" == typeof opts.isAborted && !!opts.isAborted(); const failedSources = new Set; let current = null, urls = [], urlIndex = 0, hls = null, lastErr = null, switching = !1, settled = !1; const destroyHls = () => { if (hls) { try { hls.destroy(); } catch (e) {} hls = null; } }; const label = () => current && current.source || "预告片"; const useResult = res => { current = res, lastErr = null, urls = [ ...new Set([ res.url, ...(Array.isArray(res.urls) ? res.urls : []) ].filter(Boolean)) ], urlIndex = 0, "function" == typeof opts.onSource && opts.onSource(res); }; const attachSrc = async src => { if (!src || aborted()) return; onStatus(`正在加载: ${label()}`, { loading: !0 }); if (!/\.m3u8(?:[?#].*)?$/i.test(src)) return video.src = src, void video.load(); let HlsClass = this._getHls(); if (!HlsClass && (onStatus("正在加载播放组件 hls.js", { loading: !0 }), HlsClass = await this._loadHls(), aborted())) return; if (!HlsClass) { if (!video.canPlayType("application/vnd.apple.mpegurl")) return settled = !0, onStatus("hls.js 加载失败, 无法播放 m3u8", { loading: !1, error: !0 }), void onFail({ reason: "no-hls" }); return video.src = src, void video.load(); } destroyHls(), onStatus(`正在加载: ${label()}`, { loading: !0 }), clog.debug("[JhsTrailer] 使用 hls.js 播放", src); const inst = new HlsClass({ enableWorker: !1, loader: this._createLoader(), maxBufferLength: 6, maxMaxBufferLength: 12, manifestLoadingMaxRetry: 1, levelLoadingMaxRetry: 1, fragLoadingMaxRetry: 1 }); inst.on(HlsClass.Events.MANIFEST_PARSED, (() => { inst.startLoad(0), video.play().catch((() => {})); })), inst.on(HlsClass.Events.ERROR, ((e, data) => { data && data.fatal && (clog.warn("[JhsTrailer] hls 播放失败", data.type, data.details), lastErr = data, tryNext()); })), inst.loadSource(src), inst.attachMedia(video), hls = inst; }; /** 当前来源整个播不了时, 按 javxy 给出的 fallback 列表换源 */ const trySwitchSource = async () => { if (switching) return !1; const failed = String(current && (current.rawSource || current.source) || "").trim(); if (!failed || failedSources.has(failed)) return !1; failedSources.add(failed), switching = !0, onStatus(`${failed} 播不了, 正在换下一个接口`, { loading: !0 }); try { const skip = [ ...failedSources ]; const prefer = (current && current.fallbackSources || []).filter((s => !skip.includes(s))); const next = await this.resolve(id, { skip: skip, source: prefer.length ? prefer : [ "JavTrailers", "JavDB" ], playbackFallback: !0 }); if (aborted()) return !0; if (!next || !next.url) return !1; return clog.log("[JhsTrailer] 已切换来源", next.source, next.url), destroyHls(), useResult(next), await attachSrc(urls[urlIndex]), !0; } catch (e) { return clog.error("[JhsTrailer] 切换来源失败", e), !1; } finally { switching = !1; } }; const tryNext = () => { if (switching || settled || aborted()) return; if (urlIndex < urls.length - 1) return urlIndex += 1, void attachSrc(urls[urlIndex]); trySwitchSource().then((ok => { if (ok || aborted()) return; settled = !0, destroyHls(); const detail = lastErr ? lastErr.details || lastErr.type || "" : ""; onStatus(this._getHls() ? `所有接口都播不了${detail ? " [" + detail + "]" : ""}${current && current.requiresJP ? " · DMM 源需要日本 IP" : ""}` : "hls.js 未加载成功, 无法播放 m3u8", { loading: !1, error: !0 }), onFail({ reason: "all-failed", detail: detail }); })); }; const onVideoError = () => tryNext(); const onPlaying = () => { settled = !0, onReady(current); }; video.addEventListener("error", onVideoError), video.addEventListener("playing", onPlaying); const controller = { destroy: () => { settled = !0, destroyHls(), video.removeEventListener("error", onVideoError), video.removeEventListener("playing", onPlaying); }, source: () => current }; let first = opts.first; if (!first && (onStatus("正在查询预告片来源", { loading: !0 }), first = await this.resolveCached(id), aborted())) return controller; if (!first || !first.url) return settled = !0, onStatus("无预告片视频", { loading: !1, error: !0 }), onFail({ reason: "not-found" }), controller; return useResult(first), await attachSrc(urls[urlIndex]), controller; }, /** 弹层适配层: 把 bindVideo 的回调接到壳的标题/状态条上 */ async _playInto(ui, code, first) { let ctl = null; ui.setOnClose((() => ctl && ctl.destroy())); ctl = await this.bindVideo(ui.video, code, { first: first, isAborted: () => ui.closed(), onStatus: (text, o) => ui.setStatus(text, o || {}), onReady: () => ui.hideStatus(), onSource: res => ui.setTitle(`预告片 ${this.normalize(code)} · ${res.source || ""}${res.quality ? " · " + res.quality : ""}`) }); }, /** 入口: 先秒开播放器壳, 再解析来源(视觉上更快); 无结果直接在壳里显示「无预告片视频」 */ async show(code) { const id = this.normalize(code); if (!id) return void show.error("番号为空, 无法查询预告片"); const ui = this._openShell(id); try { ui.setStatus("正在查询预告片来源"); const result = await this.resolveCached(id); if (ui.closed()) return; if (!result || !result.url) return void ui.showEmpty("无预告片视频"); clog.log("[JhsTrailer] 预告片来源", result.source, result.quality, "requiresJP:", result.requiresJP, result.url), await this._playInto(ui, id, result); } catch (err) { clog.error("[JhsTrailer] 预告片解析失败", err), ui.closed() || ui.setStatus("预告片解析失败: " + (err && err.message || err), { loading: !1, error: !0 }); } } }; class ae extends X { getName() { return "PreviewVideoPlugin"; } async initCss() { return "\n .video-control-btn {\n min-width:120px;\n padding: 7px 12px;\n font-size: 12px;\n background: rgba(0,0,0,0.7);\n color: white;\n border: none;\n border-radius: 4px;\n cursor: pointer;\n }\n .video-control-btn.active {\n background-color: #1890ff;\n color: white;\n font-weight: bold;\n border: 2px solid #096dd9;\n }\n "; } async handle() { if (!isDetailPage) return; let e = await storageManager.getSetting(); let t = $(".preview-video-container"); t.on("click", (e => { utils.loopDetector((() => $(".fancybox-content #preview-video").length > 0), (() => { this.handleVideo().then(); })); })); // 不再受「第三方视频资源」控制: 关闭时 initDmm 内部会跳过 DMM 解析, 但仍负责给 // 原站没有预告片的番号创建播放入口(点开后由 javxy 提供片源) o.includes("autoPlay=1") || this.initDmm().then(); let n = window.location.href; (n.includes("gallery-1") || n.includes("gallery-2")) && utils.loopDetector((() => $(".fancybox-content #preview-video").length > 0), (() => { $(".fancybox-content #preview-video").length > 0 && this.handleVideo().then(); })), n.includes("autoPlay=1") && t.length > 0 && t[0].click(); } async initDmm() { try { const carNum = this.getPageInfo().carNum; // 「第三方视频资源」关闭时不解析 DMM, 也不去覆盖原站预告片源 const useOtherSite = await storageManager.getSetting("enableLoadOtherSite", C) === _; const dmmMap = useOtherSite ? await ne(carNum, !1) : (clog.debug("第三方视频资源已关闭, 保留原站预告片源, 跳过DMM解析"), null); const $video = $("#preview-video"), el = $video.length ? $video[0] : null; // 原站已有播放元素: 只有解析到 DMM 时才换源, 否则保持原样 if (el) { if (!dmmMap) return; const want = await storageManager.getSetting("videoQuality"); clog.debug("解析其它画质预览视频", "设置-期望画质", want); const src = dmmMap[Z(Object.keys(dmmMap), want)]; clog.log("切换其它画质预览视频: ", src); const hidden = utils.isHidden($video), at = el.currentTime; return $video.attr("src", src), void (hidden || (clog.debug("播放器已手动打开, 变更进度条"), el.currentTime = at, el.play())); } if (!$(".preview-images").length || $(".preview-video-container").length) return; // 原站没有预告片: 关着 DMM 时先用 javxy 探一下, 有片源才创建入口, 避免出现点开是空的假入口 if (!dmmMap) { const probe = await JhsTrailer.resolveCached(carNum); if (!probe || !probe.url) return void clog.debug("原站无预告片且 javxy 也没有片源, 不创建入口:", carNum); clog.log("原站无预告片, 将使用 javxy 片源:", probe.source); } clog.debug("JavDB没有视频播放元素, 开始创建..."); const cover = $(".column-video-cover img").attr("src"); $(".preview-images").prepend(`\n <a class="preview-video-container" data-fancybox="gallery" href="#preview-video">\n <span>預告片</span>\n <img src="${cover}" class="video-cover" style="width: 100%; height: auto;" alt="">\n </a>\n `), $(".preview-video-container").on("click", (e => { utils.loopDetector((() => $(".fancybox-content #preview-video").length > 0), (async () => { await this.handleVideo(); })); })); } catch (e) { clog.error("预加载dmm失败:", e); } } async handleVideo() { if (await storageManager.getSetting("enableLoadPreviewVideo", _) === C) return; const e = $("#preview-video"); if (!e.length) return; const t = e.parent(); t.css("position", "relative"); const n = e[0], a = localStorage.getItem("jhs_videoMuted"); a && (n.muted = "yes" === a), n.addEventListener("volumechange", (function() { localStorage.setItem("jhs_videoMuted", n.muted ? "yes" : "no"); })), n.play(); let i = this.getPageInfo().carNum; // 仅当「第三方视频资源」开启时才解析并使用 DMM 视频源; 关闭时保留原站预告片, 不覆盖 src, 不显示画质切换 const useOtherSite = await storageManager.getSetting("enableLoadOtherSite", C) === _; const s = useOtherSite ? await ne(i) : null; // 原站自带的预告片地址: DMM 源播不动时要能退回来, 不然会出现「开了开关反而看不了」 const originSrc = e.attr("src") || ""; let recovered = !1; /** DMM 播不动 -> 先退回原站源, 再用 javxy 原位顶上(javxy 会自动换 JavTrailers/JavDB) */ const recoverFromDmm = () => { if (recovered) return; recovered = !0, clog.warn("DMM 预览视频播不动, 回退原站源并尝试 javxy:", i), originSrc && (e.attr("src", originSrc), n.load(), n.play().catch((() => {}))), JhsTrailer.bindVideo(n, i, { isAborted: () => !document.getElementById("preview-video"), onSource: res => clog.log("[预览视频] 改用", res.source, res.quality), onFail: () => { clog.warn("javxy 也没有可用预览视频, 保留原站源"), originSrc && e.attr("src") !== originSrc && (e.attr("src", originSrc), n.load(), n.play().catch((() => {}))); } }).catch((err => clog.error("javxy 预览视频降级失败", err))); }; let o = $("<div></div>").attr("id", "video-bottom-toolbar").css({ display: "flex", gap: "5px", "align-items": "center", "flex-wrap": "wrap" }), r = $("<div></div>").css({ display: "flex", gap: "5px", "align-items": "center" }), l = null; if (s) { let t = await storageManager.getSetting("videoQuality"); l = Z(Object.keys(s), t); let a = s[l]; // DMM 地址可能因地域限制 403, 挂一次性 error 监听做回退, 避免顶掉原站可播的源 n.addEventListener("error", recoverFromDmm, { once: !0 }); e.attr("src") !== a && (e.attr("src", a), n.load(), n.play()); L.forEach((e2 => { let t2 = s[e2.quality]; if (t2) { const n2 = l === e2.quality; let a2 = $(`\n <button class="video-control-btn${n2 ? " active" : ""}" \n id="${e2.id}" \n data-quality="${e2.quality}"\n data-video-src="${t2}"\n style="min-width: 40px; border: 1px solid #ccc; background-color: ${n2 ? "#007bff" : "#fff"}; color: ${n2 ? "white" : "black"};">\n ${e2.text}\n </button>\n `); r.append(a2); } })); } else if (!originSrc) { // 原站没有预告片, 且没有 DMM 源(开关关掉、或 DMM 解析失败) -> 用 javxy 顶上 JhsTrailer.bindVideo(n, i, { isAborted: () => !document.getElementById("preview-video"), onSource: res => clog.log("[预览视频] 使用", res.source, res.quality) }).catch((err => clog.error("javxy 预览视频解析失败", err))); } o.append(r); let c = $("<div></div>").css({ display: "flex", gap: "5px", "align-items": "center", "margin-left": "auto" }), d = $(`<button class="menu-btn" id="video-filterBtn" style="min-width: 120px; background-color:#de3333;">屏蔽</button>`); c.append(d); let h = $(`<button class="menu-btn" id="video-favoriteBtn" style="min-width: 120px; background-color:#25b1dc;">收藏</button>`); c.append(h); let g = $(`<button class="menu-btn" id="speed-btn" style="min-width: 120px; background-color:#76b45d;">快进</button>`); c.append(g), o.append(c), t.append(o), o.on("click", ".video-control-btn", (async t => { const a = $(t.currentTarget), i = a.data("video-src"); if (!a.hasClass("active")) try { const t = n.currentTime; e.attr("src", i), n.load(), n.currentTime = t, await n.play(), o.find(".video-control-btn").removeClass("active").css({ "background-color": "#fff", color: "black" }), a.addClass("active").css({ "background-color": "#007bff", color: "white" }); } catch (s) { console.error("切换画质失败:", s); } })), $("#speed-btn").on("click", (() => { // 原来这里调 DetailPageButtonPlugin.speedVideo(), 但那个方法整个脚本里都没有定义, // 点一下必抛 TypeError。直接对当前 video 快进 10 秒。 try { if (!n || !Number.isFinite(n.currentTime)) return; const dur = Number.isFinite(n.duration) && n.duration > 0 ? n.duration : Infinity; n.currentTime = Math.min(dur, n.currentTime + 10); } catch (err) { clog.error("快进失败", err); } })), utils.rightClick(document.body, "#speed-btn", (e => { this.getBean("DetailPageButtonPlugin").filterOne(e); })), $("#video-filterBtn").on("click", (e => { this.getBean("DetailPageButtonPlugin").filterOne(e); })), $("#video-favoriteBtn").on("click", (e => { this.getBean("DetailPageButtonPlugin").favoriteOne(e); })); } } class oe extends X { getName() { return "JavTrailersPlugin"; } constructor() { super(), this.hasBand = !1; } handle() { let e = window.location.href; if (!e.includes("handle=1")) return; if ($("h1:contains('Page not found')").length) { clog.log("番号无法匹配, 跳搜索"); let t = e.split("?")[0].split("video/")[1].toLowerCase().replace("00", "-"); return void (window.location.href = "/search/" + encodeURIComponent(t) + window.location.search); } let t = $(".videos-list .video-link").toArray(); if (t.length) { const n = e.split("?")[0].split("search/")[1].toLowerCase(), a = t.find((e => $(e).find(".vid-title").text().toLowerCase().includes(n))); if (a) return void (window.location.href = $(a).attr("href") + window.location.search); } this.handlePlayJavTrailers(), $("#videoPlayerContainer").on("click", (() => { this.handlePlayJavTrailers(); })), window.addEventListener("message", (e => { let t = document.getElementById("vjs_video_3_html5_api"); t && (t.currentTime += 5); })); } handlePlayJavTrailers() { this.hasBand || (utils.loopDetector((() => 0 !== $("#vjs_video_3_html5_api").length), (() => { setTimeout((() => { this.hasBand = !0; let e = document.getElementById("vjs_video_3_html5_api"); clog.debug(e), e.play(), e.currentTime = 5, e.addEventListener("timeupdate", (function() { e.currentTime >= 14 && e.currentTime < 16 && (e.currentTime += 2); })), $("#vjs_video_3_html5_api").css({ position: "fixed", width: "100vw", height: "100vh", objectFit: "cover", zIndex: "999999999" }), $(".vjs-control-bar").css({ position: "fixed", bottom: "20px", zIndex: "999999999" }); }), 100); })), utils.loopDetector((() => $("#vjs_video_3 canvas").length > 0), (() => { 0 !== $("#vjs_video_3 canvas").length && $("#vjs_video_3 canvas").css({ position: "fixed", width: "100vw", height: "100vh", objectFit: "cover", top: "0", right: "0", zIndex: "999999998" }); }))); } } class re extends X { getName() { return "SubTitleCatPlugin"; } handle() { $(".t-banner-inner").hide(), $("#navbar").hide(); let e = new URLSearchParams(window.location.search).get("search").toLowerCase(), t = $(".sub-table tr td a").toArray(), n = 0; t.forEach((t => { let a = $(t); a.text().toLowerCase().includes(e) ? n++ : a.parent().parent().hide(); })), 0 === n && show.error("该番号无字幕!"); const a = $(".sec-title"), i = a.html().replace(/^\d+/, n); a.html(i); } } class le extends X { getName() { return "Fc2Plugin"; } async initCss() { return "\n <style>\n /* 弹层样式 */\n .movie-detail-layer .layui-layer-title {\n font-size: 18px;\n color: #333;\n background: #f8f8f8;\n }\n \n \n /* 容器样式 */\n .movie-detail-container {\n margin: 40px;\n height: 100%;\n background: #fff;\n }\n \n .movie-poster-container {\n flex: 0 0 60%;\n padding: 15px;\n }\n \n .right-box {\n flex: 1;\n padding: 20px;\n overflow-y: auto;\n }\n \n /* 预告片iframe */\n .movie-trailer {\n width: 100%;\n height: 100%;\n min-height: 400px;\n background: #000;\n border-radius: 4px;\n }\n \n /* 电影信息样式 */\n .movie-title {\n font-size: 24px;\n margin-bottom: 15px;\n color: #333;\n }\n \n .movie-meta {\n margin-bottom: 20px;\n color: #666;\n }\n \n .movie-meta span {\n margin-right: 15px;\n }\n \n /* 演员列表 */\n .actor-list {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n margin-top: 10px;\n }\n \n .actor-tag {\n padding: 4px 12px;\n background: #f0f0f0;\n border-radius: 15px;\n font-size: 12px;\n color: #555;\n }\n \n /* 图片列表 */\n .image-list {\n display: flex;\n flex-wrap: wrap;\n gap: 10px;\n margin-top: 10px;\n }\n \n .movie-image-thumb {\n width: 120px;\n height: 80px;\n object-fit: cover;\n border-radius: 4px;\n cursor: pointer;\n transition: transform 0.3s;\n }\n \n .movie-image-thumb:hover {\n transform: scale(1.05);\n }\n \n /* 加载中和错误状态 */\n .search-loading, .movie-error {\n padding: 40px;\n text-align: center;\n color: #999;\n }\n \n .movie-error {\n color: #f56c6c;\n }\n \n .fancybox-container{\n z-index:99999999\n }\n \n \n /* 错误提示样式 */\n .movie-not-found, .movie-error {\n text-align: center;\n padding: 30px;\n color: #666;\n }\n \n .movie-not-found h3, .movie-error h3 {\n color: #f56c6c;\n margin: 15px 0;\n }\n \n .icon-warning, .icon-error {\n font-size: 50px;\n color: #e6a23c;\n }\n \n .icon-error {\n color: #f56c6c;\n }\n \n .fc2-movie-panel-info .panel-block {\n padding: 0 !important;\n }\n </style>\n "; } handle() { let e = "/advanced_search?type=3&score_min=0&d=1"; if ($('.navbar-item:contains("FC2")').attr("href", e), $('.tabs a:contains("FC2")').attr("href", e), o.includes("advanced_search?type=3")) { $("h2.section-title").contents().first().replaceWith("Fc2PPV"), $(".section .container > .box").remove(); } if (o.includes("collection_codes?movieId")) { $("section").html(""); const e = new URLSearchParams(window.location.search); let t = e.get("movieId"), n = e.get("carNum"), a = e.get("url"); t && n && a && this.openFc2Dialog(t, n, a); } } async openFc2Dialog(e, t, n) { if (n.includes("123av")) return void this.getBean("Fc2By123AvPlugin").open123AvFc2Dialog(t, n); // 那一行按钮受「插件管理 > 状态管理 > FC2页弹窗按钮」控制, 关掉就整行不输出 // 缩略图开关的初始文字要按已存的状态来: 开着显示「关缩略图」(点它是去关), 反之显示「开缩略图」 const thumbOn = await storageManager.getSetting("enableFc2LongImg", _) === _; const bar = await Fc2ActionBarPlugin.isEnabled() ? `<div style="margin: 30px 0; display:flex; gap:8px; align-items:center; justify-content:flex-end; flex-wrap:wrap"> <a id="fc2ThumbToggleBtn" class="menu-btn" style="background: linear-gradient(to right, rgb(245,140,1), rgb(84,161,29))"> <span>${thumbOn ? "关缩略图" : "开缩略图"}</span> </a> <a id="xunLeiSubtitleBtn" class="menu-btn" style="background:linear-gradient(to left, #375f7c, #2196F3)"> <span>字幕搜索</span> </a> </div>` : ""; let i = `\n <div class="movie-detail-container">\n \x3c!--<div class="movie-poster-container">\n <iframe class="movie-trailer" frameborder="0" allowfullscreen scrolling="no"></iframe>\n </div>--\x3e\n \x3c!-- <div class="right-box">--\x3e\n <div class="movie-info-container">\n <div class="search-loading">加载中...</div>\n </div>\n \n ${bar}\n <div class="message video-panel" style="margin-top:20px">\n <div id="magnets-content" class="magnet-links" style="margin: 0 0.75rem">\n <div class="search-loading">加载中...</div>\n </div>\n </div>\n <div id="reviews-content">\n </div>\n <div id="related-content">\n </div>\n <span id="data-actress" style="display: none"></span>\n \x3c!--</div>--\x3e\n </div>\n `; layer.open({ type: 1, title: t, content: i, area: utils.getResponsiveArea([ "70%", "90%" ]), skin: "movie-detail-layer", scrollbar: !1, success: (i, s) => { this.loadData(e, t), $("#xunLeiSubtitleBtn").on("click", (() => this.getBean("DetailPageButtonPlugin").searchXunLeiSubtitle(t))), $("#fc2ThumbToggleBtn").on("click", (async () => { // 只管 FC2 弹层的缩略图, 用独立的 enableFc2LongImg, // 不动全局的 enableLoadScreenShot(那个还管详情页) const $span = $("#fc2ThumbToggleBtn span"); const turnOn = "开缩略图" === $span.text(); await storageManager.saveSettingItem("enableFc2LongImg", turnOn ? _ : C); $span.text(turnOn ? "关缩略图" : "开缩略图"); turnOn ? this.handleLongImg(t.replace("FC2-", "")) : $(".movie-gallery .image-list .screen-container").remove(); })), this.getBean("DetailQuickIconPlugin")?.mountFc2QuickIcons($("#xunLeiSubtitleBtn"), { carNum: t, url: n, names: () => $("#data-actress").text(), publishTime: () => $("#data-releaseDate").text() }), utils.setupEscClose(s); }, end() { window.location.href.includes("collection_codes?movieId") && utils.closePage(); } }); } loadData(e, t) { let n = t.replace("FC2-", ""); this.handleMovieDetail(e), this.handleLongImg(n), this.handleMagnets(e, t); } handleMovieDetail(e) { V(e).then((e => { const t = e.actors || [], n = e.imgList || []; let a = ""; if (t.length > 0) { let e = ""; for (let n = 0; n < t.length; n++) { let i = t[n]; a += `<span class="actor-tag"><a href="/actors/${escapeHtml(i.id)}" target="_blank">${escapeHtml(i.name)}</a></span>`, 0 === i.gender && (e += i.name + " "); } $("#data-actress").text(e); } else a = '<span class="no-data">暂无演员信息</span>'; let i = ""; i = Array.isArray(n) && n.length > 0 ? n.map(((e, t) => `\n <a href="${e}" data-fancybox="movie-gallery" data-caption="剧照 ${t + 1}">\n <img src="${e}" class="movie-image-thumb" loading="lazy" alt=""/>\n </a>\n `)).join("") : '<div class="no-data">暂无剧照</div>', $(".movie-info-container").html(`\n <h3 class="movie-title"><strong class="current-title">${e.title || "无标题"}</strong></h3>\n <div class="movie-meta">\n <span><strong>番号: </strong>${e.carNum || "未知"}</span>\n <span><strong>年份: </strong>${e.releaseDate || "未知"}</span>\n <span><strong>评分: </strong>${e.score || "无"}</span>\n <span><strong>时长: </strong>${e.duration + " m" || "无"}</span>\n </div>\n <div class="movie-meta">\n <span>\n <strong>站点: </strong>\n <a href="https://fc2ppvdb.com/articles/${e.carNum.replace("FC2-", "")}" target="_blank">fc2ppvdb</a>\n <a style="margin-left: 5px;" href="https://adult.contents.fc2.com/article/${e.carNum.replace("FC2-", "")}/" target="_blank">fc2电子市场</a>\n </span>\n </div>\n <div class="movie-actors">\n <div class="actor-list"><strong>主演: </strong>${a}</div>\n </div>\n <div class="movie-gallery" style="margin-top:10px">\n <strong>剧照: </strong>\n <div class="image-list">${i}</div>\n </div>\n <div id="data-releaseDate" style="display: none">${e.releaseDate || ""}</div>\n `), this.getBean("TranslatePlugin").translate(e.carNum, !1).then(); })).catch((e => { console.error(e), $(".movie-info-container").html(`\n <div class="movie-error">加载失败: ${escapeHtml(e.message)}</div>\n `); })); } handleLongImg(e) { utils.loopDetector((() => $(".movie-gallery .image-list").length > 0), (async () => { // 受弹层里「开缩略图/关缩略图」按钮控制(enableFc2LongImg), 关掉就不去抓图 if (await storageManager.getSetting("enableFc2LongImg", _) !== _) return; $(".movie-gallery .image-list").prepend(' <a class="tile-item screen-container" style="overflow:hidden;max-height: 150px;max-width:150px; text-align:center;"><div style="margin-top: 52px;color: #000;cursor: auto;font-size:15px">正在加载缩略图</div></a> '); const t = this.getBean("ScreenShotPlugin"), n = await t.getScreenshot(e); n && await t.addImg("缩略图", n); })); } handleMagnets(e, carNum) { const fetchMagnets = async () => { let t = `${U}/v1/movies/${e}/magnets`, n = { jdSignature: await O() }; return (await gmHttp.get(t, null, n)).data.magnets || []; }; // 「插件管理 > 外部搜索 > 磁力表格面板」开着时换成移植过来的聚合表格, // JavDB 这个接口当默认数据源(它带文件数和高清/字幕标记), 引擎下拉里还能切到其它磁力站。 MagnetTablePlugin.isEnabled().then((useTable => { if (useTable) { const engine = { id: "javdb", name: "JavDB", targetPage: "", fetch: async () => (await fetchMagnets()).map((m => ({ title: m.name, magnet: `magnet:?xt=urn:btih:${m.hash}`, size: (m.size / 1024).toFixed(2) + " GB", date: m.created_at, hd: m.hd, cnsub: m.cnsub }))) }; // 弹层模板给 #magnets-content 留了 0 0.75rem 的左右边距, 表格要铺满就得去掉 return void $("#magnets-content").css("margin", "0").empty() .append(this.getBean("MagnetTablePlugin").createMagnetTable(carNum || e, [ engine ])); } fetchMagnets().then((e => { let t = ""; if (e.length > 0) for (let n = 0; n < e.length; n++) { let a = e[n], i = ""; n % 2 == 0 && (i = "odd"), t += `\n <div class="item columns is-desktop ${i}">\n <div class="magnet-name column is-four-fifths">\n <a href="magnet:?xt=urn:btih:${a.hash}" title="右鍵點擊並選擇「複製鏈接地址」">\n <span class="name">${a.name}</span>\n <br>\n <span class="meta">\n ${(a.size / 1024).toFixed(2)}GB, ${a.files_count}個文件 \n </span>\n <br>\n <div class="tags">\n ${a.hd ? '<span class="tag is-primary is-small is-light">高清</span>' : ""}\n ${a.cnsub ? '<span class="tag is-warning is-small is-light">字幕</span>' : ""}\n </div>\n </a>\n </div>\n <div class="buttons column">\n <button class="button is-info is-small copy-to-clipboard" data-clipboard-text="magnet:?xt=urn:btih:${a.hash}" type="button"> 複製 </button>\n </div>\n <div class="date column"><span class="time">${a.created_at}</span></div>\n </div>\n `; } else t = '<span class="no-data">暂无磁力信息</span>'; $("#magnets-content").html(t); })).catch((e => { console.error(e), $("#magnets-content").html(`\n <div class="movie-error">加载失败: ${escapeHtml(e.message)}</div>\n `); })); })); } async openFc2Page(e, t, n) { const a = this.getBean("OtherSitePlugin"); let i = await a.getJavDbUrl(); window.open(`${i}/users/collection_codes?movieId=${e}&carNum=${t}&url=${n}`); } } const NMP_PANEL_CLASS = "jhs-nmp-panel"; const NMP_SETTING_KEY = "enableNativeMagnetPanel"; const NMP_SORT_KEY = "nmpMagnetSort"; /** 解析磁链体积字符串 -> 字节数, 支持 TiB/GiB/MiB/KiB 与 TB/GB/MB/KB/B */ function nmpParseSize(value) { const m = String(value || "").replace(/,/g, "").match(/([\d.]+)\s*(TiB|GiB|MiB|KiB|TB|GB|MB|KB|B)?/i); if (!m) return 0; const num = parseFloat(m[1]); const unit = (m[2] || "B").toUpperCase(); const mult = { TIB: 1099511627776, TB: 1099511627776, GIB: 1073741824, GB: 1073741824, MIB: 1048576, MB: 1048576, KIB: 1024, KB: 1024, B: 1 }; return Number.isFinite(num) ? num * (mult[unit] || 1) : 0; } /** 解析日期字符串 -> 时间戳, 兼容 10/13 位秒级毫秒级数字 */ function nmpParseTimestamp(value) { // 0 / "0" 是「没有时间」的哨兵值(聚合条目的 timestamp 缺省就是 0), 必须当空处理: // Date.parse("0") 会返回 2000 年的有效时间戳, 一旦放过去就会把真正的 date 字段挡掉 if (null == value || "" === value || 0 === Number(value)) return 0; const text = String(value).trim(); if (!text) return 0; if (/^\d{10,13}$/.test(text)) { const n = Number(text); return 10 === text.length ? 1e3 * n : n; } const normalized = text.replace(/\//g, "-").replace(/(\d)\s+(\d{1,2}:\d{2})/, "$1T$2"); // 只有长得像日期的才交给 Date.parse: 它对 "3天前"「昨天」这类文本会给出莫名其妙的结果 if (!/\d{4}-\d{1,2}/.test(normalized)) return 0; const ts = Date.parse(normalized); return Number.isFinite(ts) ? ts : 0; } function nmpItemTimestamp(item) { return nmpParseTimestamp(item && item.timestamp) || nmpParseTimestamp(item && item.date); } /** 破解番号识别: UN/UC 独立出现 (非紧邻数字或"字幕") 才算破解 */ function nmpHasCrackedCode(text) { const re = /\b(?:UN|UC)\b/g; let m; while (m = re.exec(text)) { const before = text.slice(0, m.index), after = text.slice(m.index + m[0].length); const adjNum = /\d$/.test(before) || /^\d/.test(after); const adjSub = /字幕$/.test(before) || /^字幕/.test(after); if (!adjNum && !adjSub) return !0; } return !1; } /** 质量分类: 返回 { isChinese, is4K, isCracked } */ function nmpClassifyQuality(title) { const text = String(title || ""); const hasCJK = /[\u4e00-\u9fff]/.test(text); const hasJP = /[\u3040-\u309f\u30a0-\u30ff]/.test(text); const isChinese = /(?:[^A-Za-z]|^)FHDC(?:[^A-Za-z]|$)/i.test(text) || /[-_]CH?(?:[^A-Za-z]|$)/.test(text) || /(?:中字|中文|字幕|中文字幕|繁體中字|繁体中字|繁體中文|繁体中文|繁體字幕|繁体字幕|繁中|繁字|自提|征用|徵用|漢化|汉化|內嵌|内嵌|內封|内封|雙語|双语)/.test(text) || hasCJK && !hasJP; const is4K = /(?:[^A-Za-z0-9]|^)(?:4K(?:UHD)?|2160P)(?:[^A-Za-z0-9]|$)/i.test(text); const isCracked = /(?:破解|破坏|破壞|破壊|无码|無碼)/.test(text) || /\b(?:uncensored|mosaic)\b/i.test(text) || nmpHasCrackedCode(text); return { isChinese, is4K, isCracked }; } /** 质量标签数组: ['4K','中字','破解'] 的子集 */ function nmpQualityTags(title) { const { isChinese, is4K, isCracked } = nmpClassifyQuality(title); return [ is4K ? "4K" : "", isChinese ? "中字" : "", isCracked ? "破解" : "" ].filter(Boolean); } /** 标签种类 -> CSS data-kind 值 */ function nmpTagKind(value) { if ("4K" === value) return "four-k"; if ("中字" === value) return "subtitle"; if ("破解" === value) return "cracked"; return ""; } /** 标签优先分档: 中字+破解(0) > 破解(1) > 中字(2) > 无标签(3)。 * qualityTags 是从标题猜的, nativeTags 是站内自带的(JavDB 的「含字幕」等), 两边都算上更准。 */ function nmpTagRank(item) { const text = [ ...(item && item.qualityTags || []), ...(item && item.nativeTags || []) ].join(" "); const hasSub = /字幕|中字/.test(text); const hasCrack = /破解|无码|無碼|uncensored/i.test(text); return hasSub && hasCrack ? 0 : hasCrack ? 1 : hasSub ? 2 : 3; } /** 排序: size(体积优先) / newest(最新) / oldest(最早) / tag(标签优先), 稳定排序 */ function nmpSortData(data, mode) { return [ ...data ].map(((item, index) => ({ item, index }))).sort(((a, b) => { const sizeDelta = nmpParseSize(b.item && b.item.size) - nmpParseSize(a.item && a.item.size); if ("size" === mode) return sizeDelta || a.index - b.index; const aTime = nmpItemTimestamp(a.item), bTime = nmpItemTimestamp(b.item); if ("tag" === mode) { // 先按标签分档, 同档内(含「无标签」那档)再按时间新->旧, 没日期的沉到本档末尾 const rankDelta = nmpTagRank(a.item) - nmpTagRank(b.item); if (rankDelta) return rankDelta; if (!aTime && !bTime) return sizeDelta || a.index - b.index; if (!aTime) return 1; if (!bTime) return -1; return bTime - aTime || sizeDelta || a.index - b.index; } if (!aTime && !bTime) return sizeDelta || a.index - b.index; if (!aTime) return 1; if (!bTime) return -1; const timeDelta = "oldest" === mode ? aTime - bTime : bTime - aTime; return timeDelta || sizeDelta || a.index - b.index; })).map((e => e.item)); } /** 取节点纯文本, 可先移除指定选择器的子节点 */ function nmpCleanText(node, removeSelectors = []) { if (!node) return ""; const copy = node.cloneNode(!0); removeSelectors.forEach((sel => copy.querySelectorAll(sel).forEach((el => el.remove())))); return (copy.textContent || "").replace(/\s+/g, " ").trim(); } /** 抓取原生站内标签 (高清/字幕等) */ function nmpParseNativeTags(node) { return [ ...node.querySelectorAll(".tags .tag, .btn-mini-new") ].map((t => nmpCleanText(t))).filter(Boolean); } /** JavBus: 从 #magnet-table 解析磁链条目 */ function nmpParseJavbusItems(container) { return [ ...container.querySelectorAll("tr") ].map((row => { const cells = [ ...row.querySelectorAll(":scope > td") ]; const magnetLink = row.querySelector('a[href^="magnet:"]'); if (!magnetLink || cells.length < 2) return null; const title = nmpCleanText(cells[0], [ ".mag-btn-group", ".btn-mini-new" ]) || nmpCleanText(magnetLink); return { title, maglink: magnetLink.href, size: nmpCleanText(cells[1]), files: "", date: nmpCleanText(cells[2]), qualityTags: nmpQualityTags(title), nativeTags: nmpParseNativeTags(cells[0]) }; })).filter(Boolean); } /** JavDB: 从 #magnets-content 解析磁链条目 */ function nmpParseJavdbItems(container) { return [ ...container.querySelectorAll(".item") ].map((row => { const magnetLink = row.querySelector('.magnet-name a[href^="magnet:"]'); if (!magnetLink) return null; const metaText = nmpCleanText(row.querySelector(".magnet-name .meta")); const [ size = "", files = "" ] = metaText.split(/[,,]/).map((p => p.trim())); const title = nmpCleanText(row.querySelector(".magnet-name .name")) || nmpCleanText(magnetLink); return { title, maglink: magnetLink.href, size, files, date: nmpCleanText(row.querySelector(".date .time, .date")), qualityTags: nmpQualityTags(title), nativeTags: nmpParseNativeTags(row) }; })).filter(Boolean); } function nmpBuildNativeItems(site, container) { if ("javbus" === site) return nmpParseJavbusItems(container); if ("javdb" === site) return nmpParseJavdbItems(container); return []; } function nmpCreateButton(label, className, onClick) { const btn = document.createElement("button"); btn.type = "button", btn.className = className, btn.textContent = label; btn.addEventListener("click", onClick); return btn; } function nmpCreateTagGroup(values, withKinds = !1) { if (!values || !values.length) return null; const tags = document.createElement("div"); tags.className = "jhs-nmp-tags"; values.forEach((value => { const tag = document.createElement("span"); tag.className = "jhs-nmp-tag"; const kind = withKinds ? nmpTagKind(value) : ""; if (kind) tag.dataset.kind = kind; tag.textContent = String(value || "").trim(); tags.appendChild(tag); })); return tags; } function nmpCreateMeta(value, label) { const meta = document.createElement("span"); meta.className = "jhs-nmp-meta", meta.title = label, meta.dataset.label = label; meta.textContent = value || "—"; return meta; } /** WhatsLink 预览: 调用 API 获取资源信息 + 截图, 以 overlay 弹窗展示。 * 抽成独立函数是为了让短评面板的「预览」按钮也能直接复用同一套弹窗。 */ async function nmpPreviewMagnet(maglink) { nmpShowWhatslinkModal({ _loading: !0 }, maglink); try { const url = `https://whatslink.info/api/v1/link?url=${encodeURIComponent(maglink)}`; const r = await nmpGmFetch(url, { timeout: 2e4 }); if (!r.loadstuts) throw new Error("WhatsLink 请求失败"); const data = JSON.parse(r.responseText || "{}"); document.querySelector(".jhs-nmp-whatslink-overlay")?.remove(); nmpShowWhatslinkModal(data, maglink); } catch (e) { document.querySelector(".jhs-nmp-whatslink-overlay")?.remove(); nmpShowWhatslinkModal({ error: e.message || "查询失败", name: "查询失败", type: "-", file_type: "-", size: 0, count: "-", screenshots: [] }, maglink); } } /** 每行的操作按钮: 复制 / 预览 / 115离线 */ function nmpAppendActions(actions, item) { const copyBtn = nmpCreateButton("复制", "jhs-nmp-action jhs-nmp-action-copy", (() => { // 优先 GM_setClipboard (油猴原生, 不受 https 限制), 回退 navigator.clipboard if ("function" == typeof GM_setClipboard) { GM_setClipboard(item.maglink); copyBtn.textContent = "已复制"; setTimeout((() => { copyBtn.textContent = "复制"; }), 900); } else { const write = navigator.clipboard && navigator.clipboard.writeText ? navigator.clipboard.writeText(item.maglink) : Promise.reject(); write.then((() => { copyBtn.textContent = "已复制"; setTimeout((() => { copyBtn.textContent = "复制"; }), 900); })).catch((() => show.error("复制失败, 请手动复制"))); } })); const checkBtn = nmpCreateButton("预览", "jhs-nmp-action jhs-nmp-action-check", (() => nmpPreviewMagnet(item.maglink))); const offlineBtn = nmpCreateButton("115离线", "jhs-nmp-action jhs-nmp-action-offline", (async () => { // 115 离线: 获取签名 → 提交离线任务 const maglink = item.maglink.substring(0, 60); const offlineUrl = "https://115.com/?tab=offline&mode=wangpan"; const loginUrl = "https://115.com/?mode=login"; try { const tokenR = await nmpGmFetch(`https://115.com/?ct=offline&ac=space&_=${Date.now()}`, { headers: { Accept: "application/json, text/plain, */*", Referer: offlineUrl } }); if (!tokenR.loadstuts) { show.error("115 错误: 无法获取签名, 请检查跨源权限或登录状态"); return; } if (tokenR.responseText.includes("html")) { show.error("115 未登录, 请先登录"); window.open(loginUrl, "_blank"); return; } const json = nmpParseJson(tokenR.responseText); if (!json?.sign || !json?.time) { show.error("115 签名返回异常, 请确认已登录"); return; } const r = await new Promise((resolve => { GM_xmlhttpRequest({ method: "POST", url: "https://115.com/web/lixian/?ct=lixian&ac=add_task_url", timeout: 2e4, headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", Accept: "application/json, text/javascript, */*; q=0.01", Origin: "https://115.com", Referer: offlineUrl, "X-Requested-With": "XMLHttpRequest" }, data: `url=${encodeURIComponent(maglink)}&uid=&sign=${json.sign}&time=${json.time}`, onload(r) { resolve(r); }, onerror() { resolve(null); }, ontimeout() { resolve(null); } }); })); if (!r) { show.error("115 离线推送失败"); return; } const res = nmpParseJson(r.responseText); if (!res) { show.error("115 返回异常, 可能登录已失效"); return; } if (res.state) show.ok("115 离线成功: 任务已添加"); else { const msg = "911" === String(res.errcode) ? "账号使用异常, 请手工验证" : (res.error_msg || res.msg || res.error || "未知错误"); show.error("115 离线失败: " + msg); } } catch (e) { show.error("115 离线出错: " + (e.message || e)); } })); actions.append(copyBtn, checkBtn, offlineBtn); } /** 取当前最高的 layui 弹层 z-index 再往上叠。 * layui 的 .layui-layer 从约 19891000 起, 写死的 z-index 会被压在弹层底下看不见。 */ function nmpTopZIndex(base) { let zi = base; try { document.querySelectorAll(".layui-layer").forEach((el => { const z = parseInt(getComputedStyle(el).zIndex, 10); Number.isFinite(z) && z + 10 > zi && (zi = z + 10); })); } catch (e) { /* 忽略 */ } return zi; } /** WhatsLink 预览弹窗 */ function nmpShowWhatslinkModal(payload, magnet) { document.querySelector(".jhs-nmp-whatslink-overlay")?.remove(); const overlay = document.createElement("div"); overlay.className = "jhs-nmp-whatslink-overlay"; overlay.style.cssText = `position:fixed;inset:0;z-index:${nmpTopZIndex(10000040)};display:flex;align-items:center;justify-content:center;padding:22px;background:rgba(15,23,42,.66);backdrop-filter:blur(8px);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif`; if (payload._loading) { overlay.innerHTML = '<div style="padding:28px;text-align:center;color:#475569;font-size:14px;background:#fff;border-radius:12px;box-shadow:0 22px 60px rgba(15,23,42,.26)">正在获取预览...</div>'; document.body.appendChild(overlay); overlay.addEventListener("click", (e => { if (e.target === overlay) overlay.remove(); })); return; } const shots = Array.isArray(payload?.screenshots) ? payload.screenshots.map((s => s?.screenshot)).filter(Boolean) : []; const size = nmpFormatBytes(payload?.size); let index = 0; const modal = document.createElement("div"); modal.style.cssText = "width:min(700px,94vw);max-height:90vh;overflow:auto;background:#fff;border-radius:12px;padding:20px;box-shadow:0 30px 80px rgba(2,8,23,.38)"; modal.innerHTML = ` <div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:14px"> <div> <div style="color:#db2777;font-size:12px;font-weight:800;margin-bottom:4px">磁力预览</div> <h2 style="margin:0;font-size:18px;color:#111827;word-break:break-word">${escapeHtml(payload?.name || "未知资源")}</h2> </div> <button class="nmp-wl-close" type="button" style="width:32px;height:32px;border:0;border-radius:8px;color:#64748b;background:transparent;cursor:pointer;font-size:25px;line-height:1">×</button> </div> <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:7px;margin-bottom:14px"> <div style="padding:8px 10px;border:1px solid #e2e8f0;border-radius:11px;background:#fff"><b style="display:block;font-size:13px;color:#172033">${size}</b><span style="font-size:11px;color:#64748b">大小</span></div> <div style="padding:8px 10px;border:1px solid #e2e8f0;border-radius:11px;background:#fff"><b style="display:block;font-size:13px;color:#172033">${payload?.count ?? "-"}</b><span style="font-size:11px;color:#64748b">文件数</span></div> <div style="padding:8px 10px;border:1px solid #e2e8f0;border-radius:11px;background:#fff"><b style="display:block;font-size:13px;color:#172033">${shots.length}</b><span style="font-size:11px;color:#64748b">截图</span></div> </div> <div class="nmp-wl-shots" style="display:${shots.length ? "grid" : "none"};grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px;margin-bottom:14px"></div> ${shots.length ? "" : '<div style="padding:16px;text-align:center;color:#64748b;font-size:13px;border:1px dashed #e2e8f0;border-radius:8px;margin-bottom:14px">暂无截图, 可通过名称/大小/文件数量做基础判断</div>'} <div style="padding:9px;border-radius:8px;background:#f6f8fb;word-break:break-all;font-family:monospace;font-size:12px;color:#334155">${escapeHtml(magnet)}</div> `; const shotsBox = modal.querySelector(".nmp-wl-shots"); // 截图点开: 在当前页盖一层看大图 (原来是 window.open 开新标签页) // 参数是截图下标而不是 url —— 大图里要能用左右方向键翻到前后那张 const openShot = (startIdx => { // 按钮样式走 <style> 注入(只注入一次), 内联 style 写不了 hover/active/disabled if (!document.getElementById("jhs-nmp-shot-style")) { const st = document.createElement("style"); st.id = "jhs-nmp-shot-style"; st.textContent = ` .jhs-nmp-shot-bar{display:flex;align-items:center;gap:3px;padding:6px;border:1px solid rgba(255,255,255,.14);border-radius:999px;background:rgba(15,23,42,.72);backdrop-filter:blur(10px);box-shadow:0 12px 32px rgba(2,8,23,.45)} .jhs-nmp-shot-btn{display:inline-flex;align-items:center;justify-content:center;gap:5px;min-width:72px;height:34px;padding:0 15px;border:0;border-radius:999px;background:transparent;color:#e8eef7;font-size:13px;font-weight:700;cursor:pointer;transition:background .15s,transform .1s,opacity .15s} .jhs-nmp-shot-btn:hover{background:rgba(255,255,255,.14)} .jhs-nmp-shot-btn:active{transform:scale(.95)} .jhs-nmp-shot-btn:disabled{opacity:.32;cursor:not-allowed;background:transparent} .jhs-nmp-shot-btn i{font-style:normal;font-size:16px;font-weight:800;line-height:1} .jhs-nmp-shot-close{margin-left:3px} .jhs-nmp-shot-level{min-width:50px;text-align:center;color:#9fb0c6;font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;user-select:none} `; document.head.appendChild(st); } const viewer = document.createElement("div"); viewer.className = "jhs-nmp-shot-viewer"; // overflow:auto + box 用 margin:auto 居中: 放大到超出视口时可滚动, 且不会像 align-items:center 那样切掉顶部 // z-index 要盯着预览弹层再往上叠: 那层现在会按 layui 弹层动态抬高, 写死 10000050 会被它盖住 const overlayZ = parseInt(getComputedStyle(document.querySelector(".jhs-nmp-whatslink-overlay") || document.body).zIndex, 10); viewer.style.cssText = `position:fixed;inset:0;z-index:${Number.isFinite(overlayZ) ? overlayZ + 10 : nmpTopZIndex(10000050)};display:flex;overflow:auto;padding:20px;background:rgba(2,8,23,.9);cursor:zoom-out`; const onKey = (e => { if ("Escape" === e.key) return void close(); // 左右方向键翻上/下一张; 只有多于一张时才响应, 到头循环 if (shots.length > 1 && ("ArrowLeft" === e.key || "ArrowRight" === e.key)) { e.preventDefault(), goTo(idx + ("ArrowRight" === e.key ? 1 : -1)); } }); const close = () => { viewer.remove(), document.removeEventListener("keydown", onKey); }; const box = document.createElement("div"); box.style.cssText = "margin:auto;display:flex;flex-direction:column;align-items:center;gap:10px;cursor:default"; const big = document.createElement("img"); let idx = startIdx; big.src = shots[idx], big.alt = "截图"; big.style.cssText = "height:auto;object-fit:contain;border-radius:8px;box-shadow:0 30px 80px rgba(2,8,23,.5)"; // 基准尺寸对齐预览弹窗(width:min(700px,94vw)); 给定 width 小图也会放大到基准 // 默认 1.5 倍打开: 预览截图分辨率偏低, 基准尺寸看细节不够 let scale = 1.5; const mkBtn = ((icon, text, cls, fn) => { const b = document.createElement("button"); b.type = "button", b.className = "jhs-nmp-shot-btn" + (cls ? " " + cls : ""); b.innerHTML = `<i>${icon}</i>${escapeHtml(text)}`; b.addEventListener("click", (e => { e.stopPropagation(), fn(); })); return b; }); const zoomIn = mkBtn("+", "放大", "", (() => { scale = Math.min(4, scale + .25), applyScale(); })); const zoomOut = mkBtn("−", "缩小", "", (() => { scale = Math.max(.5, scale - .25), applyScale(); })); const level = document.createElement("span"); level.className = "jhs-nmp-shot-level"; const applyScale = () => { big.style.width = `calc(min(700px,94vw) * ${scale})`; big.style.maxHeight = scale <= 1 ? "90vh" : "none"; // 放大后放开高度限制, 否则缩放没效果 level.textContent = Math.round(100 * scale) + "%"; zoomIn.disabled = scale >= 4, zoomOut.disabled = scale <= .5; }; applyScale(); // 左右翻图: 到头循环, 缩放比例保持不变(连着看细节时不想每张都重新放大一次) const counter = document.createElement("span"); counter.className = "jhs-nmp-shot-level"; counter.title = "← → 切换截图"; shots.length > 1 || (counter.style.display = "none"); const goTo = (next => { idx = (next + shots.length) % shots.length; big.src = shots[idx], counter.textContent = `${idx + 1}/${shots.length}`; }); goTo(idx); const bar = document.createElement("div"); bar.className = "jhs-nmp-shot-bar"; bar.append(zoomOut, level, zoomIn, counter, mkBtn("×", "关闭", "jhs-nmp-shot-close", (() => close()))); box.append(big, bar); viewer.appendChild(box); // 只有点遮罩空白处才关闭, 点图片/按钮不关闭(否则缩放时容易误关) viewer.addEventListener("click", (e => { if (e.target === viewer) close(); })); document.addEventListener("keydown", onKey); document.body.appendChild(viewer); }); shots.forEach(((url, i) => { const img = document.createElement("img"); img.src = url, img.alt = "截图", img.style.cssText = "width:100%;border-radius:8px;cursor:zoom-in;border:2px solid #e2e8f0"; img.addEventListener("click", (() => openShot(i))); shotsBox.appendChild(img); })); overlay.appendChild(modal); document.body.appendChild(overlay); modal.querySelector(".nmp-wl-close").addEventListener("click", (() => overlay.remove())); overlay.addEventListener("click", (e => { if (e.target === overlay) overlay.remove(); })); } function nmpRenderRows(state, opts = {}) { const rows = state.panel.querySelector(".jhs-nmp-rows"); const count = state.panel.querySelector(".jhs-nmp-count"); const source = state.panel.querySelector(".jhs-nmp-source"); // opts.tab: 聚合还在路上时先拿站内条目占位, 这时要按 native 渲染而不是看 state.activeTab const isAggregate = "aggregate" === (opts.tab || state.activeTab); const items = isAggregate ? nmpSortData(state.aggregateItems || [], state.sortMode) : nmpSortData(state.nativeItems, state.sortMode); rows.replaceChildren(); count.textContent = `${items.length} 条`; source.textContent = isAggregate ? `聚合:${nmpEngineLabels()[state.engineKey] || state.engineKey}` : "站内原始条目"; if (!items.length) { const empty = document.createElement("div"); empty.className = "jhs-nmp-empty"; empty.textContent = isAggregate ? "没有搜索到可用磁力" : "站内暂时没有可用磁力"; rows.appendChild(empty); return; } items.forEach(((item, index) => { const row = document.createElement("article"); row.className = "jhs-nmp-row"; const rowIndex = document.createElement("span"); rowIndex.className = "jhs-nmp-index"; rowIndex.textContent = String(index + 1).padStart(2, "0"); const name = document.createElement("div"); name.className = "jhs-nmp-name"; const titleLine = document.createElement("div"); titleLine.className = "jhs-nmp-title-line"; const title = document.createElement("a"); title.href = item.maglink; title.target = "_blank"; title.rel = "noopener noreferrer"; title.title = item.title; title.textContent = String(item.title || "").trim(); const qTags = nmpCreateTagGroup(item.qualityTags, !0); if (qTags) titleLine.appendChild(qTags); titleLine.appendChild(title); const nTags = nmpCreateTagGroup(item.nativeTags); if (nTags) titleLine.appendChild(nTags); name.appendChild(titleLine); const metadata = document.createElement("div"); metadata.className = "jhs-nmp-metadata"; metadata.append(nmpCreateMeta(item.size, "大小"), nmpCreateMeta(item.files, "文件"), nmpCreateMeta(item.date, "日期")); const actions = document.createElement("div"); actions.className = "jhs-nmp-actions"; nmpAppendActions(actions, item); row.append(rowIndex, name, metadata, actions); rows.appendChild(row); })); } /** 切换 Tab 时同步按钮选中态与聚合控制栏可见性 */ function nmpSyncTabControls(state) { const nativeBtn = state.panel.querySelector('[data-nmp-tab="native"]'); const aggBtn = state.panel.querySelector('[data-nmp-tab="aggregate"]'); const controls = state.panel.querySelector(".jhs-nmp-controls"); const isAggregate = "aggregate" === state.activeTab; nativeBtn && nativeBtn.setAttribute("aria-selected", String(!isAggregate)); aggBtn && aggBtn.setAttribute("aria-selected", String(isAggregate)); if (controls) controls.hidden = !isAggregate; } /** 拉取聚合搜索结果 (带 6 小时缓存)。opts.quiet: 静默加载, 保留站内条目且不显示进度 */ async function nmpLoadAggregate(state, opts = {}) { const rows = state.panel.querySelector(".jhs-nmp-rows"); const refresh = state.panel.querySelector(".jhs-nmp-refresh"); const source = state.panel.querySelector(".jhs-nmp-source"); const label = nmpEngineLabels()[state.engineKey] || state.engineKey; const seq = ++state.loadSeq; // JavDB 引擎打的就是 jdforrepam.com/api, 和站内 #magnets-content 是同一份数据源。 // 在 JavDB 详情页选 JavDB 引擎再发一次请求纯属白等, 直接把站内条目当聚合结果复用。 // 点「刷新」时 forceRefresh 为真, 那时仍走真实请求, 留个手动兜底。 if ("javdb" === state.site && state.engineKey === NMP_ENGINE_CFG.javdbSearchUrl && state.nativeItems.length && !state.forceRefresh) { state.aggregateItems = state.nativeItems.slice(); state.aggregateUrl = location.href; nmpRenderRows(state); return; } // quiet 用于首次打开/切页签: 站内条目本身就是可用结果, 静默换掉即可, 不必提示「在等」。 // 用户主动换引擎或点刷新时不 quiet —— 那是明确的「我要看新结果」, 旧条目留着反而误导, // 该清空列表并把进度显示出来。 const useNative = opts.quiet && state.nativeItems.length > 0; let empty = null; if (useNative) nmpRenderRows(state, { tab: "native" }); else { empty = document.createElement("div"); empty.className = "jhs-nmp-empty"; empty.textContent = `正在搜索聚合磁力(${label})…`; rows.replaceChildren(empty); if (source) source.textContent = `聚合:${label}`; } if (refresh) refresh.disabled = !0; try { const engines = nmpGetEngines(); const search = engines[state.engineKey] || Object.values(engines)[0]; const key = `nmp:${state.engineKey}:${state.avid}`; const result = state.forceRefresh ? await search(state.avid) : await storageManager.cachedRequest(key, 216e5, (() => search(state.avid))); if (seq !== state.loadSeq) return; // 期间换过引擎/点过刷新, 这份结果已经过期 state.forceRefresh = !1; state.aggregateItems = nmpNormalizeAggregateItems(result?.data || [], { nativeItems: state.nativeItems }); state.aggregateUrl = result?.url || ""; nmpRenderRows(state); } catch (err) { if (seq !== state.loadSeq) return; clog.error("[磁力面板] 聚合搜索出错:", err); // 静默加载时失败也别弹提示条: 只在底部来源那行说明一句, 列表继续显示站内条目 if (empty) empty.textContent = "聚合搜索失败,请刷新重试"; else if (source) source.textContent = "站内原始条目(聚合搜索失败)"; } finally { if (refresh && seq === state.loadSeq) refresh.disabled = !1; } } function nmpActivateTab(state, tab) { state.activeTab = tab; nmpSyncTabControls(state); // 首次打开/切页签走静默加载: 站内条目先占位显示, 不清空列表也不弹「正在搜索」 if ("aggregate" === tab && null === state.aggregateItems) return void nmpLoadAggregate(state, { quiet: !0 }); nmpRenderRows(state); } function nmpCreatePanel(site, avid, nativeItems, sortMode, engineKey, defaultTab) { const panel = document.createElement("section"); panel.className = `${NMP_PANEL_CLASS} jhs-nmp-${site}`; panel.dataset.site = site; const state = { panel, site, avid, nativeItems, sortMode: sortMode || "size", engineKey: engineKey || NMP_DEFAULT_ENGINE_KEY, aggregateItems: null, aggregateUrl: "", forceRefresh: !1, loadSeq: 0, activeTab: nativeItems.length ? ("aggregate" === defaultTab ? "aggregate" : "native") : "aggregate" }; const head = document.createElement("header"); head.className = "jhs-nmp-head"; const heading = document.createElement("div"); const title = document.createElement("span"); title.className = "jhs-nmp-title", title.textContent = "磁力资源"; const count = document.createElement("span"); count.className = "jhs-nmp-count"; heading.append(title, count); // Tab 组: 只留「默认」下拉 (站内磁力 / 聚合搜索 两个 Tab 按钮已合并进下拉) const tabs = document.createElement("div"); tabs.className = "jhs-nmp-tabs"; if (nativeItems.length) { const defaultTabSelect = document.createElement("select"); defaultTabSelect.className = "jhs-nmp-default-tab"; defaultTabSelect.setAttribute("aria-label", "默认显示的磁力页签"); defaultTabSelect.title = "切换/设置默认显示的页签, 数值会持久化到下一次打开"; defaultTabSelect.add(new Option("默认:站内磁力", "native")); defaultTabSelect.add(new Option("默认:聚合搜索", "aggregate")); defaultTabSelect.value = state.activeTab; defaultTabSelect.addEventListener("change", (() => { storageManager.saveSettingItem("nmpDefaultTab", defaultTabSelect.value); nmpActivateTab(state, defaultTabSelect.value); })); tabs.appendChild(defaultTabSelect); } const tools = document.createElement("div"); tools.className = "jhs-nmp-tab-tools"; const sortSelect = document.createElement("select"); sortSelect.className = "jhs-nmp-select"; sortSelect.setAttribute("aria-label", "磁力排序"); [ [ "size", "大小优先" ], [ "tag", "标签优先" ], [ "newest", "最新收录" ], [ "oldest", "最早收录" ] ].forEach((([ value, label ]) => sortSelect.add(new Option(label, value)))); sortSelect.value = state.sortMode; sortSelect.addEventListener("change", (() => { state.sortMode = sortSelect.value; storageManager.saveSettingItem(NMP_SORT_KEY, sortSelect.value); nmpRenderRows(state); })); tools.append(tabs, sortSelect); head.append(heading, tools); // 聚合控制栏: 引擎选择 + 刷新 + 原网页 const controls = document.createElement("div"); controls.className = "jhs-nmp-controls"; const engineSelect = document.createElement("select"); engineSelect.className = "jhs-nmp-select"; engineSelect.dataset.nmpEngine = "1"; engineSelect.setAttribute("aria-label", "磁力引擎"); const engines = nmpGetEngines(), labels = nmpEngineLabels(); Object.keys(engines).forEach((key => engineSelect.add(new Option(labels[key] || key, key, !1, key === state.engineKey)))); engineSelect.addEventListener("change", (() => { // 只改当前面板的内存状态, 不落盘: 换番号时按默认引擎重新开始 state.engineKey = engineSelect.value; state.aggregateItems = null; nmpLoadAggregate(state); })); const refresh = nmpCreateButton("刷新", "jhs-nmp-refresh", (() => { if ("aggregate" === state.activeTab) { state.aggregateItems = null, state.forceRefresh = !0; nmpLoadAggregate(state); } else nmpRenderRows(state); })); const openSrc = nmpCreateButton("原网页", "jhs-nmp-refresh", (() => { state.aggregateUrl ? window.open(state.aggregateUrl, "_blank", "noopener") : show.info("暂无原网页地址"); })); controls.append(engineSelect, refresh, openSrc); controls.hidden = "aggregate" !== state.activeTab; const rows = document.createElement("div"); rows.className = "jhs-nmp-rows"; const foot = document.createElement("footer"); foot.className = "jhs-nmp-foot"; const source = document.createElement("span"); source.className = "jhs-nmp-source"; const hint = document.createElement("span"); hint.textContent = nativeItems.length ? "聚合结果不会覆盖站内磁力" : "聚合搜索沿用已配置的磁力引擎"; foot.append(source, hint); panel.append(head, controls, rows, foot); panel._jhsNmpState = state; nmpActivateTab(state, state.activeTab); return panel; } function nmpHideNativeSource(source) { source.dataset.jhsNmpDisplay = source.style.display || ""; source.dataset.jhsNmpHidden = "1"; source.style.display = "none"; } function nmpHideNativeParent(parent) { if (!parent || "1" === parent.dataset.jhsNmpParentHidden) return; parent.dataset.jhsNmpParentDisplay = parent.style.display || ""; parent.dataset.jhsNmpParentHidden = "1"; parent.style.display = "none"; } /** 恢复原生磁链区并移除面板 */ function nmpRemove() { document.querySelectorAll(`.${NMP_PANEL_CLASS}`).forEach((p => p.remove())); document.querySelectorAll('[data-jhs-nmp-hidden="1"]').forEach((el => { el.style.display = el.dataset.jhsNmpDisplay || ""; delete el.dataset.jhsNmpDisplay, delete el.dataset.jhsNmpHidden; })); document.querySelectorAll('[data-jhs-nmp-parent-hidden="1"]').forEach((el => { el.style.display = el.dataset.jhsNmpParentDisplay || ""; delete el.dataset.jhsNmpParentDisplay, delete el.dataset.jhsNmpParentHidden; })); } const NMP_ENGINE_CFG = { javdbSearchUrl: "javdb.com", ciligouUrl: "clg55.top", u3c3Url: "www.u3c3.com", btsearchUrl: "btsearch.love", sukebeiUrl: "sukebei.nyaa.si", u9a9Url: "u9a9.com", sokittyUrl: "w1.sokitty.me" }; const NMP_DEFAULT_ENGINE_KEY = "sukebei.nyaa.si"; function nmpGmFetch(url, opts = {}) { return new Promise((resolve => { GM_xmlhttpRequest({ method: "GET", timeout: 2e4, ...opts, url, onload(r) { r.loadstuts = !0, resolve(r); }, onerror(r) { r.loadstuts = !1, resolve(r); }, onabort(r) { r.loadstuts = !1, resolve(r); }, ontimeout(r) { r.loadstuts = !1, r.finalUrl = url, resolve(r); } }); })); } function nmpParseHTML(str) { return new DOMParser().parseFromString(str, "text/html"); } const NMP_JAVDB_API_BASE = "https://jdforrepam.com/api"; const NMP_JAVDB_SIGN_SALT = "71cf27bb3c0bcdf207b64abecddc970098c7421ee7203b9cdae54478478a199e7d5a6e1a57691123c1a931c057842fb73ba3b3c83bcd69c17ccf174081e3d8aa"; let nmpJavdbSignCache = { ts: 0, sign: "" }; function nmpBuildJavdbSignature() { const curr = Math.floor(Date.now() / 1e3); if (nmpJavdbSignCache.sign && curr - nmpJavdbSignCache.ts <= 20) return nmpJavdbSignCache.sign; nmpJavdbSignCache = { ts: curr, sign: `${curr}.lpw6vgqzsp.${md5(`${curr}${NMP_JAVDB_SIGN_SALT}`)}` }; return nmpJavdbSignCache.sign; } function nmpParseJson(text) { try { return JSON.parse(text || "{}"); } catch { return null; } } function nmpFormatJavdbSize(size) { const mb = Number(size); if (!Number.isFinite(mb) || mb <= 0) return ""; return mb >= 1024 ? `${(mb / 1024).toFixed(mb >= 10240 ? 1 : 2)} GB` : `${Math.round(mb)} MB`; } function nmpFormatBytes(bytes) { const num = Number(bytes) || 0; if (!num) return "-"; const units = ["B","KB","MB","GB","TB"]; let value = num, index = 0; for (; value >= 1024 && index < units.length - 1;) value /= 1024, index += 1; return `${value.toFixed(index >= 3 ? 2 : 1)} ${units[index]}`; } function nmpNormalizeFileCount(...values) { for (const value of values) { if (Array.isArray(value)) return value.length ? String(value.length) : ""; if (value && "object" == typeof value) { const c = Object.keys(value).length; if (c) return String(c); continue; } const text = String(value ?? "").trim(); if (!text) continue; const num = parseInt(text.replace(/,/g, ""), 10); if (Number.isFinite(num) && num > 0) return String(num); } return ""; } function nmpCompactJavdbNumber(v) { return String(v || "").toUpperCase().replace(/[^A-Z0-9]/g, ""); } async function nmpFindJavdbMovie(kw, { limit = 5, fallbackFirst = !1 } = {}) { const headers = { accept: "application/json", jdSignature: nmpBuildJavdbSignature() }; const params = new URLSearchParams({ q: kw, page: "1", type: "movie", limit: String(limit), movie_type: "all", from_recent: "false", movie_filter_by: "all", movie_sort_by: "relevance" }); const r = await nmpGmFetch(`${NMP_JAVDB_API_BASE}/v2/search?${params.toString()}`, { headers, timeout: 2e4 }); if (!r.loadstuts || r.status < 200 || r.status >= 400) return null; const json = nmpParseJson(r.responseText); const movies = Array.isArray(json?.data?.movies) ? json.data.movies : []; const ckw = nmpCompactJavdbNumber(kw); return movies.find((m => nmpCompactJavdbNumber(m?.number) === ckw)) || (fallbackFirst ? movies[0] : null) || null; } async function nmpSearchJavDB(kw) { const webBase = "https://" + NMP_ENGINE_CFG.javdbSearchUrl; const movie = await nmpFindJavdbMovie(kw, { limit: 5, fallbackFirst: !0 }); if (!movie?.id) return { url: webBase, data: [] }; const detailUrl = `${webBase}/v/${movie.id}`; const r2 = await nmpGmFetch(`${NMP_JAVDB_API_BASE}/v1/movies/${encodeURIComponent(movie.id)}/magnets`, { headers: { accept: "application/json", jdSignature: nmpBuildJavdbSignature() } }); if (!r2.loadstuts || r2.status < 200 || r2.status >= 400) return { url: detailUrl, data: [] }; const magnets = Array.isArray(nmpParseJson(r2.responseText)?.data?.magnets) ? nmpParseJson(r2.responseText).data.magnets : []; return { url: detailUrl, data: magnets.map((item => { const hash = String(item?.hash || "").trim(); if (!hash) return null; const name = String(item?.name || movie.number || kw).trim(); const title = [name, item?.cnsub ? "-CH" : "", item?.hd ? "HD" : ""].filter(Boolean).join(" "); return { title, maglink: `magnet:?xt=urn:btih:${hash}`, size: nmpFormatJavdbSize(item?.size), date: item?.created_at || "", src: detailUrl, files: nmpNormalizeFileCount(item?.files_count, item?.file_count, item?.fileCount, item?.files) }; })).filter(Boolean) }; } function nmpTitleMatchesKeyword(title, keyword) { const rawTitle = String(title || "").toUpperCase(); const ckw = String(keyword || "").toUpperCase().replace(/[^A-Z0-9]/g, ""); if (!rawTitle || !ckw) return !1; const code = ckw.match(/^([A-Z]{2,15})0*(\d{2,10})$/); if (code) { const n = code[2].replace(/^0+(?=\d)/, ""); return new RegExp(`(?:^|[^A-Z0-9])${code[1]}[\\s._-]*0*${n}(?!\\d)`, "i").test(rawTitle); } return rawTitle.replace(/[^A-Z0-9]/g, "").includes(ckw); } async function nmpSearchCiligou(kw) { const base = "https://" + NMP_ENGINE_CFG.ciligouUrl; const encoded = btoa(unescape(encodeURIComponent(kw))).replace(/=+$/, ""); const searchUrl = `${base}/search?word=${encoded}`; const r = await nmpGmFetch(searchUrl, { headers: { Referer: base + "/", Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" } }); if (!r.loadstuts) return { url: searchUrl, data: [] }; const doc = nmpParseHTML(r.responseText), data = []; doc.querySelectorAll("#Search_list_wrapper li").forEach((li => { const titleA = li.querySelector("a.SearchListTitle_result_title"); if (!titleA) return; const href = titleA.getAttribute("href") || "", hash = href.split("/").pop(); if (!hash) return; const infoText = li.querySelector(".Search_list_info")?.textContent || ""; const sizeMatch = infoText.match(/文件大小[::]\s*([\d.,]+\s*(?:TiB|GiB|MiB|KiB|TB|GB|MB|KB|B))/i); const dateMatch = infoText.match(/创建时间[::]\s*(\d{4}[-/]\d{1,2}[-/]\d{1,2}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?)/); data.push({ title: titleA.textContent.trim(), maglink: `magnet:?xt=urn:btih:${hash}`, size: sizeMatch ? sizeMatch[1] : "", date: dateMatch ? dateMatch[1] : "", src: base + href }); })); return { url: searchUrl, data }; } async function nmpSearchU3C3(kw) { const base = "https://" + NMP_ENGINE_CFG.u3c3Url; const home = await nmpGmFetch(base + "/", { headers: { Referer: base + "/" } }); if (!home.loadstuts) return { url: base, data: [] }; const homeDoc = nmpParseHTML(home.responseText); const searchScript = [...homeDoc.scripts].map((s => s.textContent || "")).find((t => t.includes("function search21"))) || ""; const token = searchScript.match(/^\s*var\s+nmefafej\s*=\s*["']([^"']+)["'];?/m)?.[1] || ""; if (!token) return { url: base, data: [] }; const searchUrl = `${base}/?search2=${encodeURIComponent(token)}&search=${encodeURIComponent(kw)}`; const r = await nmpGmFetch(searchUrl, { headers: { Referer: base + "/" } }); if (!r.loadstuts) return { url: searchUrl, data: [] }; const doc = nmpParseHTML(r.responseText); const nkw = String(kw || "").toUpperCase().replace(/[-_\s]/g, ""); const data = [...doc.querySelectorAll("table.torrent-list tbody tr.default, table.torrent-list tbody tr.success")].map((row => { const titleA = row.querySelector('td:nth-child(2) a[href*="/view?id="]'); const magnetA = row.querySelector('td:nth-child(3) a[href^="magnet:"]'); const title = titleA?.getAttribute("title")?.trim() || titleA?.textContent?.trim() || ""; if (!title || !magnetA?.href) return null; if (nkw && !title.toUpperCase().replace(/[-_\s]/g, "").includes(nkw)) return null; const href = titleA.getAttribute("href") || ""; return { title, maglink: magnetA.href, size: row.querySelector("td:nth-child(4)")?.textContent?.trim() || "", date: row.querySelector("td:nth-child(5)")?.textContent?.trim() || "", src: href ? new URL(href, base).href : searchUrl }; })).filter(Boolean); return { url: r.finalUrl || searchUrl, data }; } function nmpPickBTSearchItems(json) { return Array.isArray(json?.data) ? json.data : Array.isArray(json?.data?.data) ? json.data.data : []; } function nmpCleanBTSearchText(v) { const raw = String(v || "").trim(); if (!raw) return ""; if (!/[<&]/.test(raw)) return raw.replace(/\s+/g, " "); const doc = nmpParseHTML(`<body>${raw}</body>`); return (doc.body?.textContent || raw.replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim(); } function nmpNormalizeBTSearchItem(item, base, searchUrl, kw) { const title = nmpCleanBTSearchText(item?.name), hash = String(item?.hash || "").replace(/^magnet:\?xt=urn:btih:/i, "").replace(/[^a-z0-9]/gi, ""); if (!/^[a-f0-9]{32,40}$/i.test(hash)) return null; const src = item?.id ? `${base}/torrent/${encodeURIComponent(item.id)}?keyword=${encodeURIComponent(kw)}` : searchUrl; const date = item?.created_at || item?.created_time || item?.create_time || item?.createTime || item?.date || item?.added_at || item?.add_time || ""; return { title: title || `magnet:?xt=urn:btih:${hash}`, maglink: `magnet:?xt=urn:btih:${hash}`, size: nmpFormatBytes(item?.size), date, src, files: nmpNormalizeFileCount(item?.files_count, item?.file_count, item?.fileCount, item?.filesCount, item?.files_num, item?.file_num, item?.num_files, item?.count, item?.files, item?.fileList) }; } function nmpRandomNonce(len = 8) { const c = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let n = ""; for (let i = 0; i < len; i++) n += c.charAt(Math.floor(Math.random() * c.length)); return n; } function nmpBuildBTSearchHeaders(params, referer) { const ts = Math.floor(Date.now() / 1e3).toString(), nonce = nmpRandomNonce(); const parts = [`timestamp=${ts}`, `nonce=${nonce}`]; Object.keys(params).forEach((k => parts.push(`${k}=${params[k]}`))); return { Accept: "application/json, text/plain, */*", Referer: referer, "x-timestamp": ts, "x-nonce": nonce, "x-sign": md5(`${parts.sort().join("&")}&key=long2ice`).toUpperCase() }; } async function nmpSearchBTSearch(kw) { const base = "https://" + NMP_ENGINE_CFG.btsearchUrl, searchUrl = `${base}/search?keyword=${encodeURIComponent(kw)}`; const params = { keyword: kw, limit: "10", offset: "0", mode: "", time: "", sort: "size", sort_type: "desc", size: "" }; const r = await nmpGmFetch(`${base}/api/search?${new URLSearchParams(params).toString()}`, { headers: nmpBuildBTSearchHeaders(params, searchUrl) }); if (!r.loadstuts || r.status < 200 || r.status >= 400) return { url: searchUrl, data: [] }; return { url: searchUrl, data: nmpPickBTSearchItems(nmpParseJson(r.responseText)).map((item => nmpNormalizeBTSearchItem(item, base, searchUrl, kw))).filter(Boolean) }; } async function nmpSearchSukebei(kw) { const base = "https://" + NMP_ENGINE_CFG.sukebeiUrl; const r = await nmpGmFetch(`${base}/?f=0&c=0_0&q=${kw}`); if (!r.loadstuts) return { url: base, data: [] }; const doc = nmpParseHTML(r.responseText); return { url: r.finalUrl || base, data: [...doc.querySelectorAll("tr.default, tr.success")].map((el => { const dc = el.querySelector("td:nth-child(5)"); return { title: el.querySelector("td:nth-child(2)>a:nth-child(1)")?.title || "", maglink: el.querySelector("td:nth-child(3)>a:last-child")?.href || "", size: el.querySelector("td:nth-child(4)")?.textContent?.trim() || "", date: dc?.textContent?.trim() || "", timestamp: Number(dc?.dataset?.timestamp) || 0, src: base + (el.querySelector("td:nth-child(2)>a:nth-child(1)")?.getAttribute("href") || "") }; })) }; } async function nmpSearchU9A9(kw) { const base = "https://" + NMP_ENGINE_CFG.u9a9Url, searchUrl = `${base}/?type=2&search=${encodeURIComponent(kw)}`; const r = await nmpGmFetch(searchUrl, { headers: { Referer: base + "/", Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" } }); if (!r.loadstuts) return { url: searchUrl, data: [] }; const doc = nmpParseHTML(r.responseText); return { url: r.finalUrl || searchUrl, data: [...doc.querySelectorAll("table.torrent-list tbody tr.default, table.torrent-list tbody tr.success")].map((el => { const titleA = el.querySelector("td:nth-child(2)>a:nth-child(1)"), magnetA = el.querySelector('td:nth-child(3)>a[href^="magnet:"]'); const href = titleA?.getAttribute("href") || "", src = href ? href.startsWith("http") ? href : new URL(href, base).href : searchUrl; return { title: titleA?.getAttribute("title") || titleA?.textContent?.trim() || "", maglink: magnetA?.href || "", size: el.querySelector("td:nth-child(4)")?.textContent?.trim() || "", date: el.querySelector("td:nth-child(5)")?.textContent?.trim() || "", src }; })).filter((item => item.title && item.maglink && nmpTitleMatchesKeyword(item.title, kw))) }; } async function nmpSearchSokitty(kw) { const base = "https://" + NMP_ENGINE_CFG.sokittyUrl, searchUrl = `${base}/search?key=${encodeURIComponent(kw)}`; const r = await nmpGmFetch(searchUrl, { headers: { Referer: base + "/" } }); if (!r.loadstuts) return { url: searchUrl, data: [] }; const doc = nmpParseHTML(r.responseText), norm = s => s.toUpperCase().replace(/[-_\s]/g, ""), kwN = norm(kw), data = []; doc.querySelectorAll(".panel.search-panel").forEach((panel => { const titleA = panel.querySelector("h3.panel-title > a.list-title"); if (!titleA) return; const href = titleA.getAttribute("href") || ""; if (!href.startsWith("/bt/")) return; const hash = href.replace("/bt/", ""), title = titleA.textContent.trim(); if (!hash || !norm(title).includes(kwN)) return; const info = [...panel.querySelectorAll(".panel-footer .info-item")]; data.push({ title, maglink: `magnet:?xt=urn:btih:${hash}`, size: info[0]?.textContent?.trim() || "", date: info[2]?.textContent?.trim() || "", src: base + href }); })); return { url: searchUrl, data }; } function nmpGetEngines() { return { [NMP_ENGINE_CFG.javdbSearchUrl]: nmpSearchJavDB, [NMP_ENGINE_CFG.ciligouUrl]: nmpSearchCiligou, [NMP_ENGINE_CFG.u3c3Url]: nmpSearchU3C3, [NMP_ENGINE_CFG.btsearchUrl]: nmpSearchBTSearch, [NMP_ENGINE_CFG.sukebeiUrl]: nmpSearchSukebei, [NMP_ENGINE_CFG.u9a9Url]: nmpSearchU9A9, [NMP_ENGINE_CFG.sokittyUrl]: nmpSearchSokitty }; } function nmpEngineLabels() { return { [NMP_ENGINE_CFG.javdbSearchUrl]: "JavDB", [NMP_ENGINE_CFG.ciligouUrl]: "CiliGou", [NMP_ENGINE_CFG.u3c3Url]: "U3C3", [NMP_ENGINE_CFG.btsearchUrl]: "BTSearch", [NMP_ENGINE_CFG.sukebeiUrl]: "Sukebei", [NMP_ENGINE_CFG.u9a9Url]: "U9A9", [NMP_ENGINE_CFG.sokittyUrl]: "SoKitty" }; } function nmpNormalizeAggregateItems(data, { nativeItems = [] } = {}) { const nativeTagMap = new Map(nativeItems.map((item => { const m = String(item?.maglink || "").match(/[?&]xt=urn:btih:([^&]+)/i); return [m ? m[1].toLowerCase() : "", item?.nativeTags || []]; })).filter(([h]) => h)); return data.map((item => { const m = String(item?.maglink || "").match(/[?&]xt=urn:btih:([^&]+)/i); const hash = m ? m[1].toLowerCase() : ""; return { title: String(item?.title || item?.maglink || ""), maglink: item?.maglink || "", src: item?.src || "", size: String(item?.size || ""), files: String(item?.files || ""), date: item?.date || "", timestamp: item?.timestamp || 0, qualityTags: nmpQualityTags(item?.title), nativeTags: nativeTagMap.get(hash) || [] }; })).filter((item => item.title && item.maglink)); } class NativeMagnetPanelPlugin extends X { getName() { return "NativeMagnetPanelPlugin"; } async initCss() { return ` <style> .${NMP_PANEL_CLASS}{width:100%;box-sizing:border-box;margin:14px 0;padding:0;color:#172033;background:#fffdfa;border:1px solid #e6ddd3;border-radius:7px;box-shadow:0 8px 24px rgba(68,49,31,.07)} /* 只有紧跟在 115 匹配框后面时才收窄上间距, 面板作为首个元素时保持 14px, 与短评/相关清单等原生面板对齐 */ #all-match-box ~ .${NMP_PANEL_CLASS}{margin-top:7px} .jhs-nmp-head,.jhs-nmp-tab-tools,.jhs-nmp-tabs,.jhs-nmp-controls,.jhs-nmp-actions,.jhs-nmp-metadata,.jhs-nmp-title-line{display:flex;align-items:center} .jhs-nmp-controls[hidden]{display:none!important} .jhs-nmp-controls{gap:8px;min-height:48px;padding:9px 14px;background:#fffbf6;border-bottom:1px solid #e6ddd3} .jhs-nmp-tabs{flex-wrap:wrap;gap:5px} .jhs-nmp-tab{min-height:30px;padding:3px 8px;border:1px solid transparent;border-radius:4px;color:#475467;background:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:background .16s ease,border-color .16s ease,color .16s ease} .jhs-nmp-tab[aria-selected="true"]{color:#1d4ed8;background:#e8f0ff;border-color:#c9d9ff} .jhs-nmp-tab:not([aria-selected="true"]):hover{color:#1d4ed8;background:#f5f8fc;border-color:#a8c2ff} .jhs-nmp-default-tab{height:30px;padding:2px 24px 2px 8px;border:1px solid #ddd2c4;border-radius:4px;color:#77543b;background:#fffbf6;font-size:11px;cursor:pointer} .jhs-nmp-default-tab:focus-visible{outline:2px solid #a85b2d;outline-offset:2px} html[data-theme="dark"] .jhs-nmp-default-tab{color:#d9e1ea;background:#253141;border-color:#536276} html[data-theme="dark"] .jhs-nmp-controls{background:#1d2632;border-color:#435062} html[data-theme="dark"] .jhs-nmp-tab{color:#d9e1ea;background:#253141;border-color:#536276} html[data-theme="dark"] .jhs-nmp-tab[aria-selected="true"]{color:#dce9ff;background:#244b89;border-color:#4e82d5} .jhs-nmp-head{justify-content:space-between;gap:14px;min-height:55px;padding:10px 14px;border-bottom:1px solid #e6ddd3} .jhs-nmp-title{color:#172033;font-size:16px;font-weight:720} .jhs-nmp-count{margin-left:9px;color:#667085;font-size:12px;font-weight:500} .jhs-nmp-tab-tools{flex-wrap:wrap;justify-content:flex-end;gap:8px} .jhs-nmp-refresh,.jhs-nmp-action{min-height:30px;padding:3px 8px;border:1px solid #cdd6e2;border-radius:4px;color:#475467;background:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:background .16s ease,border-color .16s ease,color .16s ease} .jhs-nmp-refresh{color:#1d4ed8;border-color:#bdd0ff;background:#f6f9ff} .jhs-nmp-refresh:hover,.jhs-nmp-action:hover{color:#1d4ed8;background:#f5f8fc;border-color:#a8c2ff} .jhs-nmp-refresh:active,.jhs-nmp-action:active{background:#e8f0ff} .jhs-nmp-select{height:30px;max-width:150px;padding:2px 24px 2px 8px;border:1px solid #cdd6e2;border-radius:4px;color:#344054;background:#fff;font-size:12px} .jhs-nmp-rows{display:grid} .jhs-nmp-row{display:grid;grid-template-columns:20px minmax(0,1fr) auto;grid-template-areas:"index name actions" "index metadata actions";align-items:center;column-gap:6px;row-gap:6px;min-height:64px;padding:10px 12px;border-bottom:1px solid #eee7df;transition:background .16s ease} .jhs-nmp-row:last-child{border-bottom:0} .jhs-nmp-row:hover{background:#fff9f1} .jhs-nmp-index{grid-area:index;color:#8a98a9;font-size:12px;font-variant-numeric:tabular-nums;text-align:right} .jhs-nmp-name,.jhs-nmp-title-line{min-width:0} .jhs-nmp-name{grid-area:name} .jhs-nmp-title-line{gap:7px} .jhs-nmp-title-line>a{flex:0 1 auto;max-width:100%;min-width:0;overflow:hidden;color:#243b67;font-size:13px;font-weight:650;line-height:1.45;text-decoration:none;text-overflow:ellipsis;white-space:nowrap} .jhs-nmp-title-line>a:hover{color:#1d4ed8;text-decoration:underline} .jhs-nmp-tags{display:flex;flex:0 0 auto;flex-wrap:wrap;gap:5px} .jhs-nmp-tag{display:inline-flex;align-items:center;min-height:20px;padding:1px 6px;border:1px solid #dfe5eb;border-radius:3px;color:#58677a;background:#f3f5f7;font-size:11px;font-weight:700;line-height:1;white-space:nowrap} .jhs-nmp-tag[data-kind="four-k"]{color:#1d4ed8;background:#e8f0ff;border-color:#c9d9ff} .jhs-nmp-tag[data-kind="subtitle"]{color:#a85b00;background:#fff2d9;border-color:#ffe0a5} .jhs-nmp-tag[data-kind="cracked"]{color:#9f1239;background:#ffe4e6;border-color:#fecdd3} .jhs-nmp-metadata{grid-area:metadata;display:flex;flex-wrap:wrap;gap:5px 15px} .jhs-nmp-meta{display:inline-flex;align-items:baseline;gap:4px;color:#344054;font-size:12px;font-weight:650;font-variant-numeric:tabular-nums;white-space:nowrap} .jhs-nmp-meta::before{content:attr(data-label);color:#7b8796;font-size:11px;font-weight:500} .jhs-nmp-actions{grid-area:actions;align-self:center;justify-content:flex-end;gap:6px;min-width:max-content} .jhs-nmp-action-copy{color:#4b3d87;border-color:#d0c7ee;background:#f6f4ff} .jhs-nmp-action-check{color:#176b9e;border-color:#b8d9ed;background:#f0f9ff} .jhs-nmp-action-offline{color:#137553;border-color:#b7ddce;background:#effaf5} .jhs-nmp-action-copy:hover{color:#3d3075;border-color:#b9aae5;background:#eeebff} .jhs-nmp-action-check:hover{color:#115b89;border-color:#91c5e4;background:#e5f5ff} .jhs-nmp-action-offline:hover{color:#0f6548;border-color:#8ac9b3;background:#e3f6ed} .jhs-nmp-empty{padding:22px 14px;color:#64748b;text-align:center;font-size:13px} .jhs-nmp-foot{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:10px 14px;color:#667085;background:#fffbf6;border-top:1px solid #eee7df;font-size:11px} html[data-theme="dark"] .${NMP_PANEL_CLASS}{color:#e5e7eb;background:#222b38;border-color:#435062;box-shadow:none} html[data-theme="dark"] .jhs-nmp-head{border-color:#435062} html[data-theme="dark"] .jhs-nmp-foot{background:#1d2632} html[data-theme="dark"] .jhs-nmp-title,html[data-theme="dark"] .jhs-nmp-meta,html[data-theme="dark"] .jhs-nmp-title-line>a{color:#e5e7eb} html[data-theme="dark"] .jhs-nmp-row{border-color:#374454} html[data-theme="dark"] .jhs-nmp-row:hover{background:#293545} html[data-theme="dark"] .jhs-nmp-count,html[data-theme="dark"] .jhs-nmp-foot,html[data-theme="dark"] .jhs-nmp-empty,html[data-theme="dark"] .jhs-nmp-meta::before,html[data-theme="dark"] .jhs-nmp-index{color:#aeb9c7} html[data-theme="dark"] .jhs-nmp-action,html[data-theme="dark"] .jhs-nmp-select{color:#d9e1ea;background:#253141;border-color:#536276} html[data-theme="dark"] .jhs-nmp-refresh{color:#c8dbff;background:#1f3658;border-color:#4775bc} @media (max-width:760px){ .jhs-nmp-head{align-items:flex-start;flex-direction:column;gap:8px;padding:11px 12px} .jhs-nmp-tab,.jhs-nmp-refresh,.jhs-nmp-action,.jhs-nmp-select{min-height:44px} .jhs-nmp-controls{width:100%;align-items:flex-start;flex-wrap:wrap;min-height:0;padding:9px 12px} .jhs-nmp-row{grid-template-columns:minmax(0,1fr) auto;grid-template-areas:"name index" "metadata metadata" "actions actions";gap:8px;min-height:0;padding:11px 12px} .jhs-nmp-index{align-self:start;padding-top:2px} .jhs-nmp-title-line{align-items:flex-start;flex-wrap:wrap} .jhs-nmp-title-line>a{flex:1 1 100%;overflow:visible;white-space:normal} .jhs-nmp-metadata{gap:10px} .jhs-nmp-actions{justify-content:flex-start;flex-wrap:wrap} .jhs-nmp-foot{padding:10px 12px} } </style> `; } /** 挂载: 面板插在原生磁力列表后面(同一个 tab-panel 内), 从而跟随「磁力」标签显隐 */ mount(site, avid, sortMode, engineKey, defaultTab) { if (document.querySelector(`.${NMP_PANEL_CLASS}[data-site="${site}"]`)) return !0; const source = "javbus" === site ? document.querySelector("#magnet-table") : document.querySelector("#magnets-content"); if (!source) return !1; const nativeItems = nmpBuildNativeItems(site, source); if (!nativeItems.length) return !1; const panel = nmpCreatePanel(site, avid, nativeItems, sortMode, engineKey, defaultTab); const article = source.closest("article.message.video-panel") || source.closest("article.message"); if (article && article.parentNode) { article.insertAdjacentElement("afterend", panel); nmpHideNativeParent(article); } else { source.insertAdjacentElement("afterend", panel); nmpHideNativeSource(source); } // 绑定磁力 tab 的点击接管 (修复第二次点击不显示面板的问题) if (r) jhsBindMagnetTab(); return !0; } /** 原生磁链是异步渲染的, 用 MutationObserver 等待其就绪后再挂载 */ scheduleMount(site, avid, sortMode, engineKey, defaultTab) { const tryMount = () => { try { return this.mount(site, avid, sortMode, engineKey, defaultTab); } catch (e) { return clog.error("[磁力面板] 挂载失败", e), !0; } }; if (tryMount()) return; if ("undefined" == typeof MutationObserver || !document.documentElement) { [ 300, 800, 1600, 3e3, 6e3, 1e4 ].forEach((d => setTimeout(tryMount, d))); return; } let pending = !1; const observer = new MutationObserver((() => { if (pending) return; pending = !0, queueMicrotask((() => { pending = !1, tryMount() && observer.disconnect(); })); })); observer.observe(document.documentElement, { childList: !0, subtree: !0 }); setTimeout((() => observer.disconnect()), 3e4); } async handle() { if (!window.isDetailPage) return; const sortMode = await storageManager.getSetting(NMP_SORT_KEY, "size"); const defaultTab = await storageManager.getSetting("nmpDefaultTab", "native"); const site = r ? "javdb" : l ? "javbus" : ""; if (!site) return; // 引擎不再跨番号记忆: 面板里换引擎只对当前这个番号有效, 打开下一个番号回到默认。 // JavDB 站内的默认就选 JavDB 引擎 —— 它直接复用站内条目, 零等待。 const engineKey = "javdb" === site ? NMP_ENGINE_CFG.javdbSearchUrl : NMP_DEFAULT_ENGINE_KEY; const avid = this.getPageInfo().carNum || ""; // 临时: JavDB api-x.javdb.com 域名挂了(net::ERR_CONNECTION_CLOSED), 快速模式无法工作。 // 回退到 DOM 解析模式(等原生 #magnets-content 渲染后提取), 稍慢 0.5-2s 但稳定可用。 // 待 JavDB API 恢复后, 把下面这行改回 `if (r && avid) { this._fastMountJavdb(...) } else { this.scheduleMount(...) }` 即可。 this.scheduleMount(site, avid, sortMode, engineKey, defaultTab); } /** JavDB 详情页: 直接调 API 拉磁力数据并渲染面板, 不等原生 #magnets-content */ async _fastMountJavdb(avid, sortMode, engineKey, defaultTab) { if (document.querySelector(`.${NMP_PANEL_CLASS}[data-site="javdb"]`)) return; // 先立即创建空面板框架(无数据), 让用户马上看到面板UI const magnetsPane = document.getElementById("magnets"); const emptyPanel = nmpCreatePanel("javdb", avid, [], sortMode, engineKey, defaultTab); emptyPanel.querySelector(".jhs-nmp-rows").innerHTML = '<div class="jhs-nmp-empty" style="padding:20px;text-align:center;color:#999;">正在读取磁力数据...</div>'; if (magnetsPane) { magnetsPane.appendChild(emptyPanel); } else { await new Promise(res => { const t = setInterval(() => { const p = document.getElementById("magnets"); if (p) { clearInterval(t); p.appendChild(emptyPanel); res(); } }, 100); setTimeout(() => { clearInterval(t); res(); }, 5000); }); } if (r) jhsBindMagnetTab(); // 后台异步加载数据, 完成后填充面板 (async () => { try { const movieId = window.location.href.split("/").pop().split(/[?#]/)[0]; if (!movieId) throw new Error("无法获取 movieId"); const r2 = await nmpGmFetch(`${NMP_JAVDB_API_BASE}/v1/movies/${encodeURIComponent(movieId)}/magnets`, { headers: { accept: "application/json", jdSignature: nmpBuildJavdbSignature() }, timeout: 2e4 }); if (!r2.loadstuts || r2.status < 200 || r2.status >= 400) throw new Error("API 请求失败 HTTP " + (r2.status || 0)); const json = nmpParseJson(r2.responseText); const magnets = Array.isArray(json?.data?.magnets) ? json.data.magnets : []; const nativeItems = magnets.map(item => { const hash = String(item?.hash || "").trim(); if (!hash) return null; const name = String(item?.name || avid).trim(); const title = [name, item?.cnsub ? "-CH" : "", item?.hd ? "HD" : ""].filter(Boolean).join(" "); return { title, maglink: `magnet:?xt=urn:btih:${hash}`, size: nmpFormatJavdbSize(item?.size), date: item?.created_at || "", src: window.location.href, files: nmpNormalizeFileCount(item?.files_count, item?.file_count, item?.fileCount, item?.files) }; }).filter(Boolean); if (!nativeItems.length) { emptyPanel.querySelector(".jhs-nmp-rows").innerHTML = '<div class="jhs-nmp-empty" style="padding:20px;text-align:center;color:#999;">暂无磁力资源</div>'; return; } // 用数据重新渲染行 const state = emptyPanel._jhsNmpState; if (state) { state.nativeItems = nativeItems; state.items = nativeItems; nmpRenderRows(state); } else { // fallback: 替换整个面板 const newPanel = nmpCreatePanel("javdb", avid, nativeItems, sortMode, engineKey, defaultTab); emptyPanel.replaceWith(newPanel); } // 隐藏原生磁力列表 const source = document.querySelector("#magnets-content"); if (source) { const article = source.closest("article.message.video-panel") || source.closest("article.message"); if (article) nmpHideNativeParent(article); else nmpHideNativeSource(source); } } catch (e) { clog.error("[磁力面板] API 快速加载失败:", e); emptyPanel.querySelector(".jhs-nmp-rows").innerHTML = `<div class="jhs-nmp-empty" style="padding:20px;text-align:center;color:#c00;">磁力加载失败: ${e.message || e}</div>`; } })(); } } const NRP_SETTING_KEY = "enableNativeReviewPanel"; const NRP_INITIAL_LIMIT = 6; const NRP_PAGE_SIZE = 20; /** 统一切换 JavDB 详情页三 tab (磁力/短评/相关清单) 的显隐与高亮. * 仅用 is-hidden 类, 不用内联 display, 避免与 JavDB 原生逻辑冲突导致再次点击失效 */ function jhsSwitchDetailTab(activeId) { const map = { magnets: "magnetTab", reviews: "reviewTab", lists: "listTab" }; Object.entries(map).forEach((([ paneId, target ]) => { const pane = document.getElementById(paneId); if (pane) { pane.style.display = ""; pane.classList.toggle("is-hidden", paneId !== activeId); } const li = document.querySelector(`[data-movie-tab-target="${target}"]`); if (li) li.classList.toggle("is-active", paneId === activeId); })); jhsSync115MatchVisibility(); } /** 磁力 pane 当前是否可见. * JavDB 原生 tab 用内联 display 切换, 脚本用 is-hidden 类, 两种都要判断 */ function jhsIsMagnetsPaneActive() { const pane = document.getElementById("magnets"); if (!pane) return !0; if (pane.classList.contains("is-hidden")) return !1; return "none" !== getComputedStyle(pane).display; } /** 115匹配框正常情况下挂在「磁力」pane 内部, 显隐自动跟随标签; * 只有回退到 #tabs-container 外部时才需要手动同步 */ function jhsSync115MatchVisibility() { const visible = jhsIsMagnetsPaneActive(); document.querySelectorAll("#all-match-box").forEach((box => { if (document.getElementById("magnets")?.contains(box)) return; box.style.display = visible ? "" : "none"; })); } /** 短评/相关清单未被脚本接管时走的是 JavDB 原生 tab 逻辑, * 不会经过 jhsSwitchDetailTab, 所以这里兜底监听 tab 点击同步显隐 */ function jhsBind115MatchTabSync() { if ("1" === document.body.dataset.jhs115TabSync) return; document.body.dataset.jhs115TabSync = "1"; document.addEventListener("click", (e => { e.target && e.target.closest && e.target.closest("[data-movie-tab-target]") && setTimeout(jhsSync115MatchVisibility, 0); }), !0); } /** 给「磁力」tab 绑定点击 (只需一次): 点磁力时统一切到磁力 pane. * 修复: 因短评/相关清单拦截了各自 tab, JavDB 的 active 状态不同步, * 导致再次点磁力时 JavDB 认为已激活而不处理, 磁力面板保持隐藏 */ function jhsBindMagnetTab() { const link = document.querySelector('[data-movie-tab-target="magnetTab"] a'); if (!link || link.dataset.jhsMagBound === "1") return; link.dataset.jhsMagBound = "1"; link.removeAttribute("data-action"); link.removeAttribute("data-url"); link.addEventListener("click", (e => { e.preventDefault(); e.stopImmediatePropagation(); jhsSwitchDetailTab("magnets"); }), !0); } /** 星级渲染 (1-5 星) */ function nrpRenderStars(score) { const v = Math.max(0, Math.min(5, parseInt(score, 10) || 0)); return `<span class="jhs-nrp-stars">${"★".repeat(v)}${"☆".repeat(5 - v)}</span>`; } /** 「短评磁力预览」开关的渲染期快照。nrpRenderLinkedText 是同步拼 HTML 的, 读设置是异步的, * 所以在面板加载时先把开关读进来放这里。 */ let NRP_MAG_PREVIEW = !1; /** 正文中的 magnet / ed2k / http 链接自动变成可点击 <a> */ function nrpRenderLinkedText(text) { const str = String(text || ""); const re = /((?:magnet:\?|ed2k:\/\/|https?:\/\/)[^\s"'<>]+)/gi; let html = "", last = 0; str.replace(re, ((match, _link, offset) => { html += escapeHtml(str.slice(last, offset)); const safe = escapeHtml(match); html += `<a class="jhs-nrp-link" href="${safe}" target="_blank" rel="noopener noreferrer">${safe}</a>`; // 磁力后面挂一个预览按钮(复用原生磁力面板那套 WhatsLink 弹窗)。 // magnet 和 ed2k 都挂: 实测 WhatsLink 对 ed2k 链接同样能返回文件名/大小/截图。 // http 链接不挂: 那是网页地址不是资源链。上面的正则已排除引号, safe 放进属性里是安全的。 if (NRP_MAG_PREVIEW && /^(?:magnet:\?|ed2k:\/\/)/i.test(match)) { html += `<button type="button" class="jhs-nrp-mag-preview" data-mag="${safe}" title="预览资源内容">预览</button>`; } last = offset + match.length; return match; })); html += escapeHtml(str.slice(last)); return html.replace(/\n/g, "<br>"); } /** 短评正文里「预览」按钮的点击委托(全局只绑一次)。 * 列表会因排序/加载更多整块重绘, 直接绑在按钮上会丢, 所以委托到 document。 */ function nrpBindMagPreview() { if (document.body.dataset.jhsNrpMagBound === "1") return; document.body.dataset.jhsNrpMagBound = "1"; $(document).on("click", ".jhs-nrp-mag-preview", (function(ev) { ev.preventDefault(), ev.stopPropagation(); const mag = this.getAttribute("data-mag"); mag && nmpPreviewMagnet(mag); })); } /** 判断短评是否含磁力/下载资源: magnet / ed2k / thunder 链接, 或裸哈希(40 位 btih / 32 位 ed2k) */ function nrpHasMagnet(item) { const text = String(item?.content || ""); if (/(magnet:\?|ed2k:\/\/|thunder:\/\/)/i.test(text)) return !0; return /(^|[^0-9a-z])(?:[0-9a-f]{40}|[0-9a-f]{32})([^0-9a-z]|$)/i.test(text); } /** 渲染单条短评 */ function nrpRenderItem(item, num) { const date = String(item?.created_at || "").slice(0, 10); const likes = Number(item?.likes_count || 0); return `<div class="jhs-nrp-review"> <div class="jhs-nrp-review-head"> <span><strong>#${num}</strong> ${escapeHtml(item?.username || "匿名")}</span> <span>${nrpRenderStars(item?.score)} ${escapeHtml(date)}${likes ? ` · 點讚:${likes}` : ""}</span> </div> <div class="jhs-nrp-review-content">${nrpRenderLinkedText(item?.content || "")}</div> </div>`; } /** 读取 JavDB App 授权 token, 依次尝试: 本脚本自身登录 -> localStorage 明文 -> GM 存储 */ async function nrpGetJavdbToken() { // 1) YA3 自身登录后的 token (加密存于 localStorage[jhs_appAuthorization]); 解不开则按明文兼容 try { const enc = localStorage.getItem("jhs_appAuthorization"); if (enc) { const dec = await decryptTokenSafe(enc); if (dec) return dec; } } catch (e) { /* 解密失败, 尝试后续来源 */ } // 2) 明文存于 localStorage 的 token try { const t = localStorage.getItem("laosiji_javdb_app_authorization"); if (t) return String(t).replace(/^Bearer\s+/i, ""); } catch (e) {} // 3) GM_setValue 存的 token try { if ("function" === typeof GM_getValue) { const t = GM_getValue("laosiji_javdb_app_authorization", ""); if (t) return String(t).replace(/^Bearer\s+/i, ""); } } catch (e) {} return ""; } /** 生成短评去重键: 优先 id, 兜底 username+created_at+content 前缀 * sort_by=hotly 的榜单会实时抖动, 翻页时页边界条目可能重复出现 */ function nrpReviewKey(item) { if (item && (item.id || item._id)) return String(item.id || item._id); const u = String(item?.username || ""); const t = String(item?.created_at || ""); const c = String(item?.content || "").slice(0, 40); return `${u}|${t}|${c}`; } /** 调用 JavDB API 拉取短评 — 对齐 jhs.json javDbApi.getReviews: * 真分页 page + 固定 limit. API 对 limit 有 50 上限, 放大 limit 无效, 只能靠翻页 */ async function nrpFetchReviews(movieId, page = 1, limit = NRP_PAGE_SIZE) { const params = new URLSearchParams({ page: String(page), limit: String(limit), sort_by: "hotly" }); const url = `${NMP_JAVDB_API_BASE}/v1/movies/${encodeURIComponent(movieId)}/reviews?${params.toString()}`; const headers = { accept: "application/json", "accept-language": "zh-TW", "user-agent": "Dart/3.5 (dart:io)", jdSignature: nmpBuildJavdbSignature() }; const token = await nrpGetJavdbToken(); if (token) headers.authorization = `Bearer ${token}`; const res = await nmpGmFetch(url, { headers, timeout: 2e4 }); if (!res.loadstuts || res.status < 200 || res.status >= 400) throw new Error("短评接口请求失败 HTTP " + (res.status || 0)); const json = nmpParseJson(res.responseText); return Array.isArray(json?.data?.reviews) ? json.data.reviews : []; } class NativeReviewPanelPlugin extends X { getName() { return "NativeReviewPanelPlugin"; } async initCss() { return ` <style> .jhs-nrp-panel{width:100%;box-sizing:border-box;margin:14px 0;color:#172033;background:#f5f5f5;border:1px solid #dddddd;border-radius:7px;overflow:hidden} .jhs-nrp-toolbar{display:flex;align-items:center;justify-content:space-between;gap:14px;flex-wrap:wrap;min-height:55px;padding:10px 14px;border-bottom:1px solid #dddddd;background:#f5f5f5} .jhs-nrp-toolbar-left,.jhs-nrp-toolbar-right{display:flex;align-items:center;gap:8px} .jhs-nrp-title{color:#172033;font-size:16px;font-weight:720} .jhs-nrp-count{margin-left:4px;color:#667085;font-size:12px;font-weight:500} .jhs-nrp-toolbar-label{color:#475467;font-size:13px;font-weight:600} .jhs-nrp-sort,.jhs-nrp-fontsize{height:30px;padding:2px 24px 2px 8px;border:1px solid #cdd6e2;border-radius:4px;color:#344054;background:#fff;font-size:12px;font-weight:600;cursor:pointer} .jhs-nrp-toggle{min-height:30px;padding:0 12px;border:1px solid #cdd6e2;border-radius:4px;background:#fff;color:#344054;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap} .jhs-nrp-toggle:hover{background:#eef2f7;border-color:#a8c2ff} .jhs-nrp-items{--nrp-font-size:15px} .jhs-nrp-items .jhs-nrp-review-content{font-size:var(--nrp-font-size)} .jhs-nrp-loading,.jhs-nrp-empty,.jhs-nrp-error,.jhs-nrp-end{padding:11px 14px;color:#6b7280;text-align:center;font-size:13px;font-weight:600} .jhs-nrp-error{color:#be123c} .jhs-nrp-review{margin:0;padding:11px 14px;border-bottom:1px solid #dddddd;background:#f5f5f5;word-break:break-word} .jhs-nrp-review:last-child{border-bottom:0} .jhs-nrp-review-head{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;color:#334155;font-size:13px} .jhs-nrp-review-head strong{color:#4a72d4;font-weight:700;margin-right:4px} .jhs-nrp-stars{color:#f59e0b;letter-spacing:1px} .jhs-nrp-review-content{margin-top:7px;color:#1f2937;font-size:15px;line-height:1.65;white-space:normal} .jhs-nrp-link{color:#1d4ed8;text-decoration:underline} .jhs-nrp-mag-preview{display:inline-block;margin-left:6px;padding:0 7px;height:20px;line-height:18px;border:1px solid #bfdbfe;border-radius:3px;background:#eff6ff;color:#1d4ed8;font-size:12px;font-weight:500;cursor:pointer;vertical-align:middle} .jhs-nrp-mag-preview:hover{background:#dbeafe} html[data-theme="dark"] .jhs-nrp-mag-preview{background:#1e293b;border-color:#3b4a61;color:#c8dbff} html[data-theme="dark"] .jhs-nrp-mag-preview:hover{background:#26364b} .jhs-nrp-footer{padding:12px 14px;border-top:1px solid #dddddd;background:#f5f5f5} .jhs-nrp-more{width:100%;min-height:34px;padding:6px 12px;border:0;border-radius:0;background:transparent;color:#1d4ed8;font-size:13px;font-weight:800;cursor:pointer} .jhs-nrp-more:hover{background:transparent;text-decoration:underline} .jhs-nrp-more:disabled{opacity:.6;cursor:wait} html[data-theme="dark"] .jhs-nrp-panel{background:#222b38;border-color:#435062} html[data-theme="dark"] .jhs-nrp-review{background:#222b38;border-color:#374454} html[data-theme="dark"] .jhs-nrp-review-head{color:#aeb9c7} html[data-theme="dark"] .jhs-nrp-review-content{color:#e5e7eb} html[data-theme="dark"] .jhs-nrp-footer{background:#1d2632;border-color:#435062} html[data-theme="dark"] .jhs-nrp-more{color:#c8dbff;background:transparent;border:0} html[data-theme="dark"] .jhs-nrp-toolbar{background:#1d2632;border-color:#435062} html[data-theme="dark"] .jhs-nrp-title{color:#e5e7eb} html[data-theme="dark"] .jhs-nrp-count,html[data-theme="dark"] .jhs-nrp-toolbar-label{color:#aeb9c7} html[data-theme="dark"] .jhs-nrp-sort,html[data-theme="dark"] .jhs-nrp-fontsize,html[data-theme="dark"] .jhs-nrp-toggle{color:#e5e7eb;background:#253141;border-color:#536276} </style> `; } /** 接管 JavDB 原生「短评」tab */ mount(movieId) { const pane = document.getElementById("reviews"); const tabLink = document.querySelector('[data-movie-tab-target="reviewTab"] a'); if (!pane || !tabLink) return !1; if (tabLink.dataset.jhsNrpBound === "1") return !0; tabLink.dataset.jhsNrpBound = "1"; // 关键 1: 摘掉 JavDB 原生 Stimulus/Turbo 的加载指令 (VIP 限制的源头) tabLink.removeAttribute("data-action"); tabLink.removeAttribute("data-url"); pane.removeAttribute("src"); pane.removeAttribute("data-url"); pane.removeAttribute("data-src"); // 关键 2: 捕获阶段拦截点击, 阻断 JavDB 自己的处理链, 由脚本渲染 const onTabClick = (e => { e.preventDefault(); e.stopImmediatePropagation(); this._activateTab(tabLink, pane, movieId); }); tabLink.addEventListener("click", onTabClick, !0); // 关键 3: 若「短评」本来就是激活态, 立即渲染 const li = tabLink.closest("li"); if (li && li.classList.contains("is-active")) this._activateTab(tabLink, pane, movieId); return !0; } /** 切到短评 tab: 直接用 _loadEnhancedReviews 渲染短评区 * (评论增强 ReviewPlugin 已移除, 数据直接走 R() API) */ _activateTab(tabLink, pane, movieId) { jhsBindMagnetTab(); jhsSwitchDetailTab("reviews"); if (pane.querySelector(".jhs-nrp-review-wrap")) return; pane.innerHTML = '<div class="jhs-nrp-review-wrap"></div>'; this._loadEnhancedReviews(pane.querySelector(".jhs-nrp-review-wrap"), movieId); } /** 在短评 pane 里渲染评论区, UI 风格与「相关清单面板」(jhs-nlp-*) 保持一致: * 同一套 jhs-nrp-* 配色 — 面板外框 + toolbar 标题栏 + 卡片列表 + footer 加载更多. * 数据走 R() (与「评论增强」同一个 reviews 接口), 分页对齐 jhs.json: page 递增, * 本页不满一页即到底 */ async _loadEnhancedReviews(wrap, movieId) { const $wrap = $(wrap); const limit = await storageManager.getSetting("reviewCount", 20); // 默认排序方式来自 设置-基础设置「短评排序方式」, 面板上的下拉框仍可临时改 const defSortMode = await storageManager.getSetting("reviewSortMode", "time"); // 「短评磁力预览」: 正文里的 magnet 后面挂预览按钮, 渲染是同步的所以先取快照 NRP_MAG_PREVIEW = await storageManager.getSetting("enableReviewMagnetPreview", _) === _; nrpBindMagPreview(); $wrap.html('<section class="jhs-nrp-panel"><div class="jhs-nrp-loading">正在读取短评...</div></section>'); let data = null; try { data = await R(movieId, 1, limit); } catch (e) { clog.error("[短评面板] 加载失败:", e); $wrap.html(`<section class="jhs-nrp-panel"><div class="jhs-nrp-error">短评读取失败: ${escapeHtml(e.message || String(e))}</div></section>`); return; } if (!data || !data.length) { $wrap.html('<section class="jhs-nrp-panel"><div class="jhs-nrp-empty">暂无短评</div></section>'); return; } // 关键词过滤 (与「评论增强」displayReviews 的过滤规则一致) const keywords = await storageManager.getReviewFilterKeywordList(); const filterRe = keywords && keywords.length ? new RegExp(keywords.map((k => String(k).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))).join("|")) : null; // 数量优先取原生「短评」tab 上的总数, 取不到则用已加载数 const nativeTabText = document.querySelector('[data-movie-tab-target="reviewTab"] a')?.textContent || ""; const countText = (nativeTabText.match(/(\d+)/) || [])[1] || String(data.length); $wrap.html(` <section class="jhs-nrp-panel"> <header class="jhs-nrp-toolbar"> <div class="jhs-nrp-toolbar-left"> <span class="jhs-nrp-title">短评</span> <span class="jhs-nrp-count">${escapeHtml(countText)} 条</span> </div> <div class="jhs-nrp-toolbar-right"> <span class="jhs-nrp-toolbar-label">排序</span> <select class="jhs-nrp-sort"> <option value="time"${"time" === defSortMode ? " selected" : ""}>时间</option> <option value="score"${"score" === defSortMode ? " selected" : ""}>星级</option> <option value="likes"${"likes" === defSortMode ? " selected" : ""}>点赞</option> <option value="magnet"${"magnet" === defSortMode ? " selected" : ""}>磁力</option> </select> <button type="button" class="jhs-nrp-toggle">收起短评</button> </div> </header> <div class="jhs-nrp-items"></div> <div class="jhs-nrp-footer"></div> </section>`); const $items = $wrap.find(".jhs-nrp-items"); const $footer = $wrap.find(".jhs-nrp-footer"); // 累积到全量列表, 排序时重绘 (排序按当前已加载的数据本地排序) const all = []; let sortMode = defSortMode; const sortList = (arr => { const list = arr.slice(); if (sortMode === "score") { return list.sort(((a, b) => (Number(b?.score) || 0) - (Number(a?.score) || 0))); } if (sortMode === "likes") { return list.sort(((a, b) => (Number(b?.likes_count) || 0) - (Number(a?.likes_count) || 0))); } // time / magnet: 最新在前 (magnet 只额外做过滤, 排序仍按时间) return list.sort(((a, b) => (Date.parse(b?.created_at) || 0) - (Date.parse(a?.created_at) || 0))); }); const renderAll = () => { let sorted = sortList(all).filter((it => !(filterRe && filterRe.test(String(it?.content || ""))))); // 磁力模式: 仅保留含磁力的条目 if (sortMode === "magnet") sorted = sorted.filter(nrpHasMagnet); const html = sorted.map(((it, i) => nrpRenderItem(it, i + 1))).join(""); $items.html(html || `<div class="jhs-nrp-empty">${sortMode === "magnet" ? "已加载的短评中没有磁力" : "暂无短评"}</div>`); }; const addBatch = (list => { if (!list || !list.length) return; all.push(...list); renderAll(); }); addBatch(data); // 排序变更 $wrap.find(".jhs-nrp-sort").on("change", (function() { sortMode = this.value; renderAll(); })); // 收起/展开 $wrap.find(".jhs-nrp-toggle").on("click", (function() { const willCollapse = $items.is(":visible"); $items.toggle(!willCollapse); $footer.toggle(!willCollapse); $(this).text(willCollapse ? "展开短评" : "收起短评"); })); // 加载更多: page 递增, 本页不满一页即到底 let page = 1; const renderFooter = (hasMore => { if (!hasMore) { $footer.html('<div class="jhs-nrp-end">已加载全部短评</div>'); return; } $footer.html('<button type="button" class="jhs-nrp-more">加载更多短评</button>'); $footer.find(".jhs-nrp-more").on("click", (async () => { const $btn = $footer.find(".jhs-nrp-more"); $btn.text("加载中...").prop("disabled", !0); page++; let next = null; try { next = await R(movieId, page, limit); } catch (e) { $btn.text("加载失败, 点击重试").prop("disabled", !1); return; } addBatch(next); renderFooter((next || []).length >= limit); })); }); renderFooter(data.length >= limit); } /** 原生 pane 是异步渲染的, 用 MutationObserver 等它出现 */ scheduleMount(movieId) { const tryMount = () => { try { return this.mount(movieId); } catch (e) { return clog.error("[短评面板] 挂载失败", e), !0; } }; if (tryMount()) return; if ("undefined" == typeof MutationObserver || !document.documentElement) { [ 300, 800, 1600, 3e3, 6e3, 1e4 ].forEach((d => setTimeout(tryMount, d))); return; } let pending = !1; const observer = new MutationObserver((() => { if (pending) return; pending = !0, queueMicrotask((() => { pending = !1, tryMount() && observer.disconnect(); })); })); observer.observe(document.documentElement, { childList: !0, subtree: !0 }); setTimeout((() => observer.disconnect()), 3e4); } async _load(panel, movieId) { try { // 「短评磁力预览」同样生效: 渲染是同步的, 先把开关读成快照 NRP_MAG_PREVIEW = await storageManager.getSetting("enableReviewMagnetPreview", _) === _; nrpBindMagPreview(); const reviews = await nrpFetchReviews(movieId, 1, NRP_PAGE_SIZE); const seen = new Set(); for (const rv of reviews) seen.add(nrpReviewKey(rv)); panel._jhsAllReviews = reviews; panel._jhsSeenKeys = seen; panel._jhsPage = 1; // jhs.json 手法: 本页取满一页就认为还有下一页, 完全以 API 返回为准 panel._jhsHasMore = reviews.length >= NRP_PAGE_SIZE; panel._jhsSortMode = "time"; panel._jhsFontSize = "中"; panel._jhsCollapsed = !1; this._render(panel, panel._jhsAllReviews, movieId, 0); } catch (e) { clog.error("[短评面板] 加载失败:", e); panel.innerHTML = `<div class="jhs-nrp-error">短评读取失败: ${escapeHtml(e.message || String(e))}</div>`; } } /** 排序短评 */ _sortReviews(reviews, mode) { const arr = [ ...reviews ]; if ("score" === mode) return arr.sort(((a, b) => (Number(b.score) || 0) - (Number(a.score) || 0))); if ("likes" === mode) return arr.sort(((a, b) => (Number(b.likes_count) || 0) - (Number(a.likes_count) || 0))); // 默认 time: 最新在前 return arr.sort(((a, b) => { const ta = Date.parse(a.created_at) || 0, tb = Date.parse(b.created_at) || 0; return tb - ta; })); } _render(panel, reviews, movieId, offset) { if (!reviews.length) { panel.innerHTML = '<div class="jhs-nrp-empty">暂无短评</div>'; return; } const sorted = this._sortReviews(reviews, panel._jhsSortMode || "time"); const items = sorted.map(((item, i) => nrpRenderItem(item, offset + i + 1))).join(""); const fontSize = panel._jhsFontSize || "中"; const collapsed = panel._jhsCollapsed || !1; // 数量优先取原生「短评」tab 上的总数, 保持与按钮同步; 取不到则用已加载数 const nativeTabText = document.querySelector('[data-movie-tab-target="reviewTab"] a')?.textContent || ""; const nativeCount = (nativeTabText.match(/(\d+)/) || [])[1]; const countText = nativeCount || String(sorted.length); panel.innerHTML = ` <header class="jhs-nrp-toolbar"> <div class="jhs-nrp-toolbar-left"> <span class="jhs-nrp-title">短评</span> <span class="jhs-nrp-count">${countText} 条</span> </div> <div class="jhs-nrp-toolbar-right"> <span class="jhs-nrp-toolbar-label">排序</span> <select class="jhs-nrp-sort"> <option value="time"${panel._jhsSortMode === "time" ? " selected" : ""}>时间</option> <option value="score"${panel._jhsSortMode === "score" ? " selected" : ""}>评分</option> <option value="likes"${panel._jhsSortMode === "likes" ? " selected" : ""}>点赞</option> </select> <span class="jhs-nrp-toolbar-label">字号</span> <select class="jhs-nrp-fontsize"> <option value="大"${"大" === fontSize ? " selected" : ""}>大</option> <option value="中"${"中" === fontSize ? " selected" : ""}>中</option> <option value="小"${"小" === fontSize ? " selected" : ""}>小</option> </select> <button type="button" class="jhs-nrp-toggle">${collapsed ? "展开短评" : "收起短评"}</button> </div> </header> <div class="jhs-nrp-items" data-fontsize="${fontSize}" style="${collapsed ? "display:none" : ""}">${items}</div> <div class="jhs-nrp-footer" style="${collapsed ? "display:none" : ""}"></div>`; this._applyFontSize(panel, fontSize); this._bindToolbar(panel, movieId, offset); if (!collapsed) this._renderFooter(panel, movieId, offset + sorted.length, panel._jhsHasMore !== false); } _applyFontSize(panel, size) { const map = { "大": "17px", "中": "15px", "小": "13px" }; const items = panel.querySelector(".jhs-nrp-items"); if (items) items.style.setProperty("--nrp-font-size", map[size] || "15px"); } _bindToolbar(panel, movieId, offset) { // 排序 panel.querySelector(".jhs-nrp-sort")?.addEventListener("change", (e => { panel._jhsSortMode = e.target.value; this._render(panel, panel._jhsAllReviews || [], movieId, 0); })); // 字号 panel.querySelector(".jhs-nrp-fontsize")?.addEventListener("change", (e => { panel._jhsFontSize = e.target.value; this._applyFontSize(panel, e.target.value); const items = panel.querySelector(".jhs-nrp-items"); if (items) items.dataset.fontsize = e.target.value; })); // 收起/展开 panel.querySelector(".jhs-nrp-toggle")?.addEventListener("click", (() => { panel._jhsCollapsed = !panel._jhsCollapsed; const items = panel.querySelector(".jhs-nrp-items"); const footer = panel.querySelector(".jhs-nrp-footer"); const btn = panel.querySelector(".jhs-nrp-toggle"); if (items) items.style.display = panel._jhsCollapsed ? "none" : ""; if (footer) footer.style.display = panel._jhsCollapsed ? "none" : ""; if (btn) btn.textContent = panel._jhsCollapsed ? "展开短评" : "收起短评"; })); } _renderFooter(panel, movieId, shown, hasMore) { const footer = panel.querySelector(".jhs-nrp-footer"); if (!footer) return; if (!hasMore) { footer.innerHTML = '<div class="jhs-nrp-end">已加载全部短评</div>'; return; } footer.innerHTML = '<button type="button" class="jhs-nrp-more">加载更多短评</button>'; const btn = footer.querySelector(".jhs-nrp-more"); btn.addEventListener("click", (async () => { btn.textContent = "加载中...", btn.disabled = !0; try { // API 对 limit 有 50 上限, 靠递增 page 翻页; hotly 排序会抖动, 需去重 const nextPage = (panel._jhsPage || 1) + 1; const next = await nrpFetchReviews(movieId, nextPage, NRP_PAGE_SIZE); const seen = panel._jhsSeenKeys || new Set(); const fresh = []; for (const rv of next) { const k = nrpReviewKey(rv); if (seen.has(k)) continue; seen.add(k), fresh.push(rv); } if (fresh.length) panel._jhsAllReviews = (panel._jhsAllReviews || []).concat(fresh); panel._jhsSeenKeys = seen; panel._jhsPage = nextPage; // jhs.json 手法: 本页不满一页 => 到底了. 另外若本页全是重复(fresh 为空), // 说明已经在原地打转, 也停止, 避免按钮点了没反应 panel._jhsHasMore = next.length >= NRP_PAGE_SIZE && fresh.length > 0; this._render(panel, panel._jhsAllReviews, movieId, 0); } catch (err) { btn.textContent = "加载失败, 点击重试", btn.disabled = !1; } })); } async handle() { if (!r || !window.isDetailPage) return; const movieId = window.location.href.split("/").pop().split(/[?#]/)[0]; if (!movieId) return; this.scheduleMount(movieId); } } const NLP_PAGE_SIZE = 20; /** 渲染单条清单 */ function nlpRenderItem(item, num) { const href = `/lists/${encodeURIComponent(item?.id || "")}`; const date = String(item?.created_at || "").slice(0, 10); const desc = item?.description ? `<div class="jhs-nlp-list-desc">${nrpRenderLinkedText(item.description)}</div>` : ""; return `<div class="jhs-nlp-list"> <div class="jhs-nlp-list-head"> <span><strong>#${num}</strong> <a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(item?.name || "未命名清单")}</a></span> <span>${escapeHtml(date)}</span> </div> ${desc} <div class="jhs-nlp-list-meta"> <span>影片:${escapeHtml(String(item?.movies_count ?? "-"))}</span> <span>收藏:${escapeHtml(String(item?.collections_count ?? "-"))}</span> <span>浏览:${escapeHtml(String(item?.views_count ?? "-"))}</span> </div> </div>`; } /** 调用 JavDB API 获取相关清单 */ async function nlpFetchLists(movieId, page = 1, limit = NLP_PAGE_SIZE) { const params = new URLSearchParams({ movie_id: movieId, page: String(page), limit: String(limit) }); const url = `${NMP_JAVDB_API_BASE}/v1/lists/related?${params.toString()}`; const headers = { accept: "application/json", "accept-language": "zh-TW", "user-agent": "Dart/3.5 (dart:io)", jdSignature: nmpBuildJavdbSignature() }; const token = await nrpGetJavdbToken(); if (token) headers.authorization = `Bearer ${token}`; const res = await nmpGmFetch(url, { headers, timeout: 2e4 }); if (!res.loadstuts || res.status < 200 || res.status >= 400) throw new Error("相关清单接口请求失败"); const json = nmpParseJson(res.responseText); return Array.isArray(json?.data?.lists) ? json.data.lists : []; } class NativeListPanelPlugin extends X { getName() { return "NativeListPanelPlugin"; } async initCss() { return ` <style> .jhs-nlp-panel{width:100%;box-sizing:border-box;margin:14px 0;color:#172033;background:#f5f5f5;border:1px solid #dddddd;border-radius:7px;overflow:hidden} .jhs-nlp-toolbar{display:flex;align-items:center;justify-content:space-between;gap:14px;flex-wrap:wrap;min-height:55px;padding:10px 14px;border-bottom:1px solid #dddddd;background:#f5f5f5} .jhs-nlp-toolbar-left,.jhs-nlp-toolbar-right{display:flex;align-items:center;gap:8px} .jhs-nlp-titletext{color:#172033;font-size:16px;font-weight:720} .jhs-nlp-count{margin-left:4px;color:#667085;font-size:12px;font-weight:500} .jhs-nlp-toolbar-label{color:#475467;font-size:13px;font-weight:600} .jhs-nlp-sort{height:30px;padding:2px 24px 2px 8px;border:1px solid #cdd6e2;border-radius:4px;color:#344054;background:#fff;font-size:12px;font-weight:600;cursor:pointer} .jhs-nlp-toggle{min-height:30px;padding:0 12px;border:1px solid #cdd6e2;border-radius:4px;background:#fff;color:#344054;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap} .jhs-nlp-toggle:hover{background:#eef2f7;border-color:#a8c2ff} .jhs-nlp-loading,.jhs-nlp-empty,.jhs-nlp-error,.jhs-nlp-end{padding:22px 14px;color:#64748b;text-align:center;font-size:13px;font-weight:600} .jhs-nlp-error{color:#be123c} .jhs-nlp-list{margin:0;padding:11px 14px;border-bottom:1px solid #dddddd;background:#f5f5f5;word-break:break-word} .jhs-nlp-list:last-child{border-bottom:0} .jhs-nlp-list-head{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;color:#334155;font-size:13px} .jhs-nlp-list-head strong{color:#4a72d4;font-weight:700;margin-right:6px} .jhs-nlp-list-head a{color:#243b67;font-size:14px;font-weight:650;text-decoration:none} .jhs-nlp-list-head a:hover{color:#1d4ed8;text-decoration:underline} .jhs-nlp-list-desc{margin-top:7px;color:#1f2937;font-size:14px;line-height:1.6} .jhs-nlp-list-meta{display:flex;gap:14px;flex-wrap:wrap;margin-top:7px;color:#64748b;font-size:12px;font-variant-numeric:tabular-nums} .jhs-nlp-footer{padding:12px 14px;border-top:1px solid #dddddd;background:#f5f5f5} .jhs-nlp-more{width:100%;min-height:34px;padding:6px 12px;border:0;border-radius:0;background:transparent;color:#1d4ed8;font-size:13px;font-weight:800;cursor:pointer} .jhs-nlp-more:hover{background:transparent;text-decoration:underline} .jhs-nlp-more:disabled{opacity:.6;cursor:wait} html[data-theme="dark"] .jhs-nlp-panel{background:#222b38;border-color:#435062} html[data-theme="dark"] .jhs-nlp-list{background:#222b38;border-color:#374454} html[data-theme="dark"] .jhs-nlp-list-head{color:#aeb9c7} html[data-theme="dark"] .jhs-nlp-list-head a,.jhs-nlp-list-desc{color:#e5e7eb} html[data-theme="dark"] .jhs-nlp-list-meta{color:#aeb9c7} html[data-theme="dark"] .jhs-nlp-footer{background:#1d2632;border-color:#435062} html[data-theme="dark"] .jhs-nlp-more{color:#c8dbff;background:transparent;border:0} html[data-theme="dark"] .jhs-nlp-toolbar{background:#1d2632;border-color:#435062} html[data-theme="dark"] .jhs-nlp-titletext{color:#e5e7eb} html[data-theme="dark"] .jhs-nlp-count,html[data-theme="dark"] .jhs-nlp-toolbar-label{color:#aeb9c7} html[data-theme="dark"] .jhs-nlp-sort,html[data-theme="dark"] .jhs-nlp-toggle{color:#e5e7eb;background:#253141;border-color:#536276} </style> `; } /** 接管 JavDB 原生「相关清单」tab (与短评相同手法) */ mount(movieId) { const pane = document.getElementById("lists"); const tabLink = document.querySelector('[data-movie-tab-target="listTab"] a'); if (!pane || !tabLink) return !1; if (tabLink.dataset.jhsNlpBound === "1") return !0; tabLink.dataset.jhsNlpBound = "1"; tabLink.removeAttribute("data-action"); tabLink.removeAttribute("data-url"); pane.removeAttribute("src"); pane.removeAttribute("data-url"); pane.removeAttribute("data-src"); const onTabClick = (e => { e.preventDefault(); e.stopImmediatePropagation(); this._activateTab(tabLink, pane, movieId); }); tabLink.addEventListener("click", onTabClick, !0); const li = tabLink.closest("li"); if (li && li.classList.contains("is-active")) this._activateTab(tabLink, pane, movieId); return !0; } _activateTab(tabLink, pane, movieId) { jhsBindMagnetTab(); jhsSwitchDetailTab("lists"); if (pane.querySelector(".jhs-nlp-panel")) return; pane.innerHTML = ""; const panel = document.createElement("section"); panel.className = "jhs-nlp-panel"; panel.innerHTML = '<div class="jhs-nlp-loading">正在读取相关清单...</div>'; pane.appendChild(panel); this._load(panel, movieId); } scheduleMount(movieId) { const tryMount = () => { try { return this.mount(movieId); } catch (e) { return clog.error("[相关清单面板] 挂载失败", e), !0; } }; if (tryMount()) return; if ("undefined" == typeof MutationObserver || !document.documentElement) { [ 300, 800, 1600, 3e3, 6e3, 1e4 ].forEach((d => setTimeout(tryMount, d))); return; } let pending = !1; const observer = new MutationObserver((() => { if (pending) return; pending = !0, queueMicrotask((() => { pending = !1, tryMount() && observer.disconnect(); })); })); observer.observe(document.documentElement, { childList: !0, subtree: !0 }); setTimeout((() => observer.disconnect()), 3e4); } async _load(panel, movieId) { try { const lists = await nlpFetchLists(movieId, 1, NLP_PAGE_SIZE); panel._jhsAllLists = lists; panel._jhsPage = 1; panel._jhsHasMore = lists.length >= NLP_PAGE_SIZE; panel._jhsSortMode = panel._jhsSortMode || "time"; panel._jhsCollapsed = panel._jhsCollapsed || !1; this._render(panel, movieId); } catch (e) { clog.error("[相关清单面板] 加载失败:", e); panel.innerHTML = `<div class="jhs-nlp-error">相关清单读取失败: ${escapeHtml(e.message || String(e))}</div>`; } } /** 排序清单: time(最新) / movies(影片数) / collections(收藏) / views(浏览), 均由高到低 */ _sortLists(lists, mode) { const arr = [ ...lists ]; const num = (v) => Number(v) || 0; if ("movies" === mode) return arr.sort(((a, b) => num(b.movies_count) - num(a.movies_count))); if ("collections" === mode) return arr.sort(((a, b) => num(b.collections_count) - num(a.collections_count))); if ("views" === mode) return arr.sort(((a, b) => num(b.views_count) - num(a.views_count))); return arr.sort(((a, b) => (Date.parse(b.created_at) || 0) - (Date.parse(a.created_at) || 0))); } _render(panel, movieId) { const all = panel._jhsAllLists || []; if (!all.length) { panel.innerHTML = '<div class="jhs-nlp-empty">暂无相关清单</div>'; return; } const sorted = this._sortLists(all, panel._jhsSortMode || "time"); const items = sorted.map(((item, i) => nlpRenderItem(item, i + 1))).join(""); const collapsed = panel._jhsCollapsed || !1; const nativeTabText = document.querySelector('[data-movie-tab-target="listTab"] a')?.textContent || ""; const nativeCount = (nativeTabText.match(/(\d+)/) || [])[1]; const countText = nativeCount || String(sorted.length); panel.innerHTML = ` <header class="jhs-nlp-toolbar"> <div class="jhs-nlp-toolbar-left"> <span class="jhs-nlp-titletext">相关清单</span> <span class="jhs-nlp-count">${countText} 个</span> </div> <div class="jhs-nlp-toolbar-right"> <span class="jhs-nlp-toolbar-label">排序</span> <select class="jhs-nlp-sort"> <option value="time"${panel._jhsSortMode === "time" ? " selected" : ""}>时间</option> <option value="movies"${panel._jhsSortMode === "movies" ? " selected" : ""}>影片数</option> <option value="collections"${panel._jhsSortMode === "collections" ? " selected" : ""}>收藏数</option> <option value="views"${panel._jhsSortMode === "views" ? " selected" : ""}>浏览数</option> </select> <button type="button" class="jhs-nlp-toggle">${collapsed ? "展开相关清单" : "收起相关清单"}</button> </div> </header> <div class="jhs-nlp-items" style="${collapsed ? "display:none" : ""}">${items}</div> <div class="jhs-nlp-footer" style="${collapsed ? "display:none" : ""}"></div>`; this._bindToolbar(panel, movieId); if (!collapsed) this._renderFooter(panel, movieId, panel._jhsPage || 1, all.length, panel._jhsHasMore); } _bindToolbar(panel, movieId) { panel.querySelector(".jhs-nlp-sort")?.addEventListener("change", (e => { panel._jhsSortMode = e.target.value; this._render(panel, movieId); })); panel.querySelector(".jhs-nlp-toggle")?.addEventListener("click", (() => { panel._jhsCollapsed = !panel._jhsCollapsed; const items = panel.querySelector(".jhs-nlp-items"); const footer = panel.querySelector(".jhs-nlp-footer"); const btn = panel.querySelector(".jhs-nlp-toggle"); if (items) items.style.display = panel._jhsCollapsed ? "none" : ""; if (footer) footer.style.display = panel._jhsCollapsed ? "none" : ""; if (btn) btn.textContent = panel._jhsCollapsed ? "展开相关清单" : "收起相关清单"; })); } _renderFooter(panel, movieId, page, shown, hasMore) { const footer = panel.querySelector(".jhs-nlp-footer"); if (!footer) return; if (!hasMore) { footer.innerHTML = '<div class="jhs-nlp-end">已加载全部清单</div>'; return; } footer.innerHTML = '<button type="button" class="jhs-nlp-more">加载更多清单</button>'; const btn = footer.querySelector(".jhs-nlp-more"); btn.addEventListener("click", (async () => { btn.textContent = "加载中...", btn.disabled = !0; try { const nextPage = page + 1; const next = await nlpFetchLists(movieId, nextPage, NLP_PAGE_SIZE); // 累积到全量缓存后按当前排序重绘, 保证排序一致 panel._jhsAllLists = (panel._jhsAllLists || []).concat(next); panel._jhsPage = nextPage; panel._jhsHasMore = next.length >= NLP_PAGE_SIZE; this._render(panel, movieId); } catch (err) { btn.textContent = "加载失败, 点击重试", btn.disabled = !1; } })); } async handle() { if (!r || !window.isDetailPage) return; const movieId = window.location.href.split("/").pop().split(/[?#]/)[0]; if (!movieId) return; this.scheduleMount(movieId); } } class de extends X { getName() { return "FoldCategoryPlugin"; } async initCss() { const e = await storageManager.getSetting(); return `\n <style>\n #tags a.tag, .tags a.tag {\n position:relative;\n }\n .highlight-btn {\n position: absolute;\n top: -10px;\n right: -10px;\n background-color: #4CAF50;\n color: white;\n border: none;\n border-radius: 50%;\n width: 24px;\n height: 24px;\n font-size: 14px;\n line-height: 24px;\n text-align: center;\n cursor: pointer;\n box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);\n display: none;\n z-index: 999;\n }\n /* 当父元素被高亮时,按钮变为其他颜色 */\n .highlighted .highlight-btn {\n background-color: #FF5722;\n }\n /* 高亮状态下的标签样式 */\n .highlighted {\n /* 浅黄色 */\n border: ${e.highlightedTagNumber || 1}px solid ${e.highlightedTagColor || "#ce2222"};\n }\n </style>\n `; } async handle() { window.isListPage && (o.includes("advanced_search") || (this.highlightTag(), utils.loopDetector((() => $("#waitCheckBtn").length), (() => { this.createFoldBtn(); }), 1, 1e4, !0), $("#tags .tag-category .tag-expand").each(((e, t) => { $(t).parent().hasClass("collapse") && t.click(); })))); } highlightTag() { (async () => { const e = await storageManager.getHighlightedTags(); e && e.forEach((e => { $(`#tags a.tag:contains(${e})`).addClass("highlighted"), $(`.tags a.tag:contains(${e})`).addClass("highlighted"); })); })().then(), $("#tags a.tag, .tags a.tag").hover((function() { const e = $(this), t = $('<button class="highlight-btn" title="高亮显示">★</button>'); e.append(t), t.fadeIn(0); }), (function() { $(this).find(".highlight-btn").fadeOut(0, (function() { $(this).remove(); })); })), $(document).on("click", ".highlight-btn", (async function(e) { e.stopPropagation(), e.preventDefault(); const t = $(this).closest("a.tag"), n = t.clone(); n.find(".highlight-btn").remove(); const a = n.text().trim().replace(/\s*\(\d+\)$/, ""); let i = await storageManager.getHighlightedTags(); i.includes(a) ? (i = i.filter((e => e !== a)), t.removeClass("highlighted")) : (i.push(a), t.addClass("highlighted")), await storageManager.setHighlightedTags(i); })); } async createFoldBtn() { let t = $("#tags"), n = $("#tags dl div.tag.is-info").map((function() { return $(this).text().replaceAll("\n", "").replaceAll(" ", ""); })).get().join(" "); if (!n) return; $(".tabs").append(`\n <div style="display: flex;align-items: center;flex-grow:1;justify-content: flex-end;">\n <div>已选分类: <span id="jhs-check-tag">${n}</span></div>\n <a class="menu-btn main-tab-btn" id="foldCategoryBtn" style="background-color:#d23e60 !important;">\n <span></span>\n <i style="margin-left: 10px"></i>\n </a>\n\n </div>\n `); let a = $("h2.section-title"); if (a.length > 0 && (a.append('\n <div id="foldCategoryBtn">\n <a class="menu-btn" style="background-color:#d23e60 !important;margin-left: 20px;border-bottom:none !important;border-radius:3px;">\n <span></span>\n <i style="margin-left: 10px"></i>\n </a>\n </div>\n '), t = $("section > div > div.box")), !t) return; let i = $("#foldCategoryBtn"), s = localStorage.getItem("jhs_foldCategory") === _, [o, r] = s ? [ "展开", "icon-angle-double-down" ] : [ "折叠", "icon-angle-double-up" ]; i.find("span").text(o).end().find("i").attr("class", r), window.location.href.includes("noFold=1") || t[s ? "hide" : "show"](), i.on("click", (async e => { e.preventDefault(), s = !s, localStorage.setItem("jhs_foldCategory", s ? _ : C); const [n, a] = s ? [ "展开", "icon-angle-double-down" ] : [ "折叠", "icon-angle-double-up" ]; i.find("span").text(n).end().find("i").attr("class", a), t[s ? "hide" : "show"](); })); } } class he extends X { constructor() { super(...arguments), i(this, "apiUrl", "https://ja.wikipedia.org/wiki/"); // 自建数据库 JAV_info: 优先查这里, 命中即返回(毫秒级), 未命中再回退维基 i(this, "repoBase", "https://cdn.jsdelivr.net/gh/lesspem/JAV_info@main/dist/"); i(this, "repoBaseFallback", "https://raw.githubusercontent.com/lesspem/JAV_info/main/dist/"); i(this, "repoTableUrl", "https://lesspem.github.io/JAV_info/"); i(this, "_repoIdxMem", null); // 内存缓存: 索引 i(this, "_repoShardMem", {}); // 内存缓存: 分片 i(this, "_repoDisabled", !1); // 仓库不可用时置位, 本次会话不再重试 } /** 带 7 天本地缓存地取 JSON (先 jsDelivr, 失败回退 raw.githubusercontent) */ async _repoFetchJson(file) { const ck = "jhs_repo_" + file, TTL = 6048e5; // 7天 try { const raw = localStorage.getItem(ck); if (raw) { const o = JSON.parse(raw); if (o && o.t && Date.now() - o.t < TTL && o.d) return o.d; } } catch (e) {} let txt = ""; for (const base of [ this.repoBase, this.repoBaseFallback ]) { try { txt = await gmHttp.get(base + file); if (txt) break; } catch (e) {} } if (!txt) throw new Error("仓库数据获取失败: " + file); const data = JSON.parse(txt); try { localStorage.setItem(ck, JSON.stringify({ t: Date.now(), d: data })); } catch (e) {} return data; } /** 取索引 { shardCount, actors: {主键→分片号}, aliases: {别名→主键} } */ async _getRepoIndex() { if (this._repoIdxMem) return this._repoIdxMem; return this._repoIdxMem = await this._repoFetchJson("index.json"); } /** 按名字查自建库, 支持中文名/日文名/曾用名反查, 命中返回统一格式, 未命中返回 null */ async _repoLookup(name) { if (this._repoDisabled || !name) return null; let idx; try { idx = await this._getRepoIndex(); } catch (e) { this._repoDisabled = !0, clog.debug("自建库索引不可用, 本次会话回退维基"); return null; } const actors = idx && idx.actors || {}, aliases = idx && idx.aliases || {}; // 1. 主键(通常是日文名)直接命中; 2. 别名表(中文名/曾用名)反查主键 let key = Object.prototype.hasOwnProperty.call(actors, name) ? name : (aliases[name] && Object.prototype.hasOwnProperty.call(actors, aliases[name]) ? aliases[name] : null); if (!key) return null; const sid = String(actors[key]).padStart(2, "0"); let shard = this._repoShardMem[sid]; if (!shard) { try { shard = await this._repoFetchJson("shard-" + sid + ".json"); } catch (e) { return null; } this._repoShardMem[sid] = shard; } const rec = shard[key]; return rec ? this._mapRepoRecord(rec, key) : null; } /** 自建库记录 → 脚本内部统一格式(与 searchInfo 返回结构保持一致) */ _mapRepoRecord(r, key) { // 生日 1993-08-16 → 1993.08.16, 年龄按当天实时算 const b = (r.birth || "").trim(); let birthday = "", age = ""; const m = /^(\d{4})-(\d{2}|\?\?)-(\d{2}|\?\?)$/.exec(b); if (m) { birthday = b.replace(/-/g, "."); const y = parseInt(m[1], 10), now = new Date(); let a = now.getFullYear() - y; if ("??" !== m[2] && "??" !== m[3]) { const mo = parseInt(m[2], 10), d = parseInt(m[3], 10); (now.getMonth() + 1 < mo || now.getMonth() + 1 === mo && now.getDate() < d) && a--; } a > 0 && a < 120 && (age = a + "岁"); } const jaName = r.nameJa || key; return { birthday: birthday, age: age, height: r.height ? r.height + "cm" : "", weight: r.weight || "", threeSizeText: r.threeSize || "", braSize: r.cup || "", // 状态: 自建库有明确的 retired 布尔值, 直接喂给 _computeStatus activeRaw: r.retired ? "" : "現在", retireRaw: r.retired ? (r.retiredAt || "已引退") : "", bodyRetired: !!r.retired, url: this.apiUrl + encodeURIComponent(jaName), _fromRepo: !0 }; } getName() { return "ActressInfoPlugin"; } async handle() { "yes" === await storageManager.getSetting("enableLoadActressInfo", "yes") && this.loadActressInfo(); } loadActressInfo() { this.handleDetailPage().then(), this.handleStarPage().then(); } async initCss() { return "\n <style>\n .info-tag {\n background-color: #ecf5ff;\n display: inline-block;\n height: 24px;\n padding: 0 5px;\n line-height: 22px;\n font-size: 11px;\n color: #409eff;\n border: 1px solid #d9ecff;\n border-radius: 4px;\n box-sizing: border-box;\n white-space: nowrap;\n margin: 2px 3px 2px 0;\n }\n </style>\n "; } /** 定位详情页「演員/演员」所在的 panel-block (兼容 JavDB 改版) */ _findActressBlock() { // JavBus: 演员链接所在的容器 (ul.star-show / p), 兼容传统 star_ 结构 if (l) { const $star = this._findBusStarLinks().first(); if (!$star.length) return null; const $ul = $star.closest("ul"); if ($ul.length) return $ul; const $p = $star.closest("p, li"); return $p.length ? $p : $star.parent(); } let $anchor = null; $(".movie-panel-info .panel-block, .panel-block").each(((_, el) => { const $el = $(el), label = $el.find("strong").first().text().trim(); if (label.includes("演員") || label.includes("演员")) return $anchor = $el, !1; })); return $anchor; } /** JavBus 详情页: 演员链接集合 (限定在 .info 信息栏内, 避免误取"推薦"区) */ _findBusStarLinks() { let $links = $(".info span[onmouseover*='star_'] a"); if (!$links.length) $links = $(".info li.star-name a[href*='/star/']"); if (!$links.length) $links = $(".info a[href*='/star/']"); return $links; } /** 从详情页解析女演员名列表 (多重选择器兼容改版, 排除男演员) */ _parseFemaleNames($anchor) { // JavBus: 信息栏只列女优, 直接取 /star/ 链接文本 if (l) { const busNames = this._findBusStarLinks().map(((_, x) => $(x).text().trim())).get().filter(Boolean); return [ ...new Set(busNames) ]; } // 1) 传统: .female 标记前一个元素即演员名 let names = $(".female").prev().map(((e, t) => $(t).text().trim())).get().filter(Boolean); if (names.length) return [ ...new Set(names) ]; // 2) 新版: 演員 block 内的 /actors/ 链接, 跳过带 male 标记的(男演员) const collect = $scope => { const out = []; $scope.find("a[href*='/actors/']").each(((_, x) => { const $a = $(x), $next = $a.next(); if ($next.length && $next.hasClass("male")) return; const nm = $a.text().trim(); nm && out.push(nm); })); return out; }; if ($anchor && $anchor.length) names = collect($anchor); // 3) 兜底: 整个信息面板 if (!names.length) names = collect($(".movie-panel-info")); return [ ...new Set(names) ]; } /** 详情页女优信息块: 只渲染有内容的字段。 * 三项全空(自建库命中但缺字段 / wiki 词条没有 infobox)时返回 "", 按无数据处理, * 否则会插出三个空的 .info-tag 圆角块 */ _infoBlockHtml(primary, data) { if (!data) return ""; const pair = (a, b) => [ a, b ].map((x => String(null == x ? "" : x).trim())) .filter((x => x && "undefined" !== x && "null" !== x)).join(" "); const tags = [ pair(data.birthday, data.age), pair(data.height, data.weight), pair(data.threeSizeText, data.braSize) ].filter(Boolean); if (!tags.length) return ""; const inner = tags.map((t => `<span class="info-tag">${escapeHtml(t)}</span>`)).join(""); const href = data.url ? escapeHtml(data.url) : ""; return `<div class="panel-block actress-info"><strong>${escapeHtml(primary)}:</strong>${href ? `<a href="${href}" style="margin-left: 5px" target="_blank">${inner}</a>` : `<span style="margin-left: 5px">${inner}</span>`}</div>`; } async handleDetailPage() { if ($(".actress-info").length > 0) return; const $anchor = this._findActressBlock(); const names = this._parseFemaleNames($anchor); if (!names.length) return void clog.debug("加载女优信息: 未解析到演员名, 跳过 (页面结构可能已改版)"); const cacheKey = "jhs_actress_info"; const cache = localStorage.getItem(cacheKey) ? JSON.parse(localStorage.getItem(cacheKey)) : {}; // 在演员栏每个女演员名字后面追加状态徽章(仅一次, 跳过男优) // 仅在已取到 wiki 数据时才显示徽章, 避免先显示"现役"再改成"引退"的误导 const actorSelector = l ? "a[href*='/star/']" : "a[href*='/actors/']"; const appendBadgeToActorLinks = () => { if (!$anchor || !$anchor.length) return; // 让演员链接的直接父容器变为 flex 居中, 确保徽章与文字垂直对齐 $anchor.find(actorSelector).parent().css({ "display": "flex", "align-items": "center", "flex-wrap": "wrap", "column-gap": "4px", "row-gap": "4px" }); $anchor.find(actorSelector).each(((_, el) => { const $a = $(el); if ($a.next(".jhs-actress-status").length) return; // 已追加过 // 跳过男优: 后面紧跟 .male 图标的是男演员 (JavDB); JavBus 信息栏本身只列女优 if (!l) { const $next = $a.next(); if ($next.length && ($next.hasClass("male") || $next.find(".male").length)) return; } // 支持逗号分隔别名, 从任一别名缓存里取数据 const rawText = ($a.text() || "").trim(); const aliases = rawText.split(/[,,]/).map(a => a.trim()).filter(Boolean); let data = null; for (const alias of aliases) { if (cache[alias]) { data = cache[alias]; break; } } if (!data) return; // 尚无 wiki 数据 → 暂不显示徽章 const status = this._computeStatus(data.activeRaw, data.retireRaw, data.bodyRetired); $a.after(this._statusBadge(status)); })); }; // 非阻塞: 有缓存直接用, 无缓存后台请求不阻塞页面 let html = ""; const uncached = []; for (const rawName of names) { // 支持逗号分隔的别名(如 "新有菜, 新ありな"): 主名 = 第一个, 查询时逐个尝试 const aliases = rawName.split(/[,,]/).map(a => a.trim()).filter(Boolean); const primary = aliases[0] || rawName; // 命中任一别名缓存(优先命中有数据的) let cachedData = undefined, matchedAlias = null; for (const alias of aliases) { if (cache[alias] !== undefined) { if (cache[alias]) { cachedData = cache[alias]; matchedAlias = alias; break; } if (cachedData === undefined) { cachedData = null; matchedAlias = alias; } } } if (matchedAlias !== null) { if (cachedData) { html += this._infoBlockHtml(primary, cachedData); } // 无 wiki 数据 → 隐藏 } else { uncached.push({ primary: primary, aliases: aliases }); html += `<div class="panel-block actress-info" id="jhs-actress-${primary.replace(/[^a-zA-Z0-9]/g, '_')}"><strong>${primary}:</strong> <span style="color:#999;font-size:12px">加载中...</span></div>`; } } // 先插入已有缓存 + 占位, 不阻塞后续插件 const insertHtml = (h) => { if ($anchor && $anchor.length) $anchor.after(h); else { const $legacy = $('strong:contains("演員"), strong:contains("演员")').first().parent(); $legacy.length ? $legacy.after(h) : $(l ? ".info" : ".movie-panel-info").append(h); } }; insertHtml(html); appendBadgeToActorLinks(); // 后台异步加载未缓存的(不阻塞 processPlugins) if (uncached.length) { (async () => { for (const item of uncached) { let data = null; // 逐个别名尝试: 先查自建库(毫秒级), 未命中再查维基 for (const alias of item.aliases) { try { data = await this._repoLookup(alias); } catch (e) {} if (!data) { try { data = await this.searchInfo(alias); } catch (e) { clog.debug("查询演员信息失败:", alias); } } if (data) { cache[alias] = data; break; } cache[alias] = null; } const id = `jhs-actress-${item.primary.replace(/[^a-zA-Z0-9]/g, '_')}`; const $el = $(`#${id}`); if ($el.length) { // 三项字段全为空(自建库/wiki 缺字段)时按"无数据"处理, 否则会渲染出三个空标签 const block = data ? this._infoBlockHtml(item.primary, data) : ""; block ? $el.replaceWith(block) : $el.remove(); } } localStorage.setItem(cacheKey, JSON.stringify(cache)); appendBadgeToActorLinks(); // 异步加载完后再次更新演员栏徽章 })(); } else { localStorage.setItem(cacheKey, JSON.stringify(cache)); } } async handleStarPage() { if ($(".actress-info").length > 0) return; let e = [], t; if (l) { // JavBus 演员页: 名字在 .avatar-box .photo-info .pb10, 无 section-meta t = $(".avatar-box .photo-info .pb10"); if (!t.length) return; t.text().trim().split(/[,,]/).forEach((n => { e.push(n.trim()); })); } else { t = $(".actor-section-name"); t.length && t.text().trim().split(",").forEach((n => { e.push(n.trim()); })); let n = $(".section-meta:not(:contains('影片'))"); if (n.length && n.text().trim().split(",").forEach((m => { e.push(m.trim()); })), !e.length) return; } e = e.filter(Boolean); if (!e.length) return; const a = "jhs_actress_info", i = localStorage.getItem(a) ? JSON.parse(localStorage.getItem(a)) : {}; // 先尝试从缓存命中 let s = null; for (let l = 0; l < e.length; l++) { if (i[e[l]]) { s = i[e[l]]; break; } } if (s) { this._renderStarPage(s, t, e, i, a); return; } // 缓存未命中: 非阻塞, 后台异步查询不阻塞其他插件 (async () => { for (let l = 0; l < e.length; l++) { // 先查自建库, 未命中再查维基 try { s = await this._repoLookup(e[l]); } catch (r) {} if (!s) { try { s = await this.searchInfo(e[l]); } catch (r) { clog.debug("该名称查询失败,尝试其它名称:", e[l]); } } if (s) break; } s && e.forEach((name => { i[name] = s; })); if (s) this._renderStarPage(s, t, e, i, a); localStorage.setItem(a, JSON.stringify(i)); })(); } /** 演员页渲染信息(从 handleStarPage 抽出, 避免重复代码) */ _renderStarPage(s, t, e, i, a) { const status = this._computeStatus(s.activeRaw, s.retireRaw, s.bodyRetired); const $nameEl = t.length ? t : (l ? $(".avatar-box .photo-info .pb10") : $(".actor-section-name")); if ($nameEl.length && !$nameEl.find(".jhs-actress-status").length) { $nameEl.css({ "display": "inline-flex", "align-items": "center" }); $nameEl.append(this._statusBadge(status)); $nameEl.append(this._wikiIcon(s.url)); } // JavBus 演员页本身已有完整资料栏, 只挂徽章 + wiki 图标, 不再重复插入信息块 if (l) { e.forEach((name => { i[name] = s; })); localStorage.setItem(a, JSON.stringify(i)); return; } const row1 = [], row2 = []; if (s.birthday) row1.push(`出生: ${s.birthday}`); if (s.age) row1.push(`年龄: ${s.age}`); if (s.height) row1.push(`身高: ${s.height}`); if (s.threeSizeText) row2.push(`三围: ${s.threeSizeText}`); if (s.braSize) row2.push(`罩杯: ${s.braSize}`); if (s.weight) row2.push(`体重: ${s.weight}`); const spanStyle = 'display:inline-block;width:33%;min-width:200px;'; const makeRow = arr => arr.length ? `<div style="display:flex;margin-bottom:10px;">${arr.map(f => `<span style="${spanStyle}">${f}</span>`).join("")}</div>` : ""; if (row1.length || row2.length) { const o = `\n <div class="actress-info" style="font-size: 15px; font-weight: normal; margin-top: 5px; color: #666;">\n ${makeRow(row1)}${makeRow(row2)}\n </div>\n `; $nameEl.parent().append(o); } e.forEach((name => { i[name] = s; })); localStorage.setItem(a, JSON.stringify(i)); } async searchInfo(e) { "三上悠亞" === e && (e = "三上悠亜"); let t = this.apiUrl + e; const n = await gmHttp.get(t), a = new DOMParser, i = $(a.parseFromString(n, "text/html")); const pick = (sel, fn) => { try { const v = fn(i.find(sel)); return v ? String(v).trim() : ""; } catch { return ""; } }; const birthday = pick('a[title="誕生日"]', $x => $x.parent().parent().find("td").text()); const ageRaw = pick("th:contains('現年齢')", () => i.find("th:contains('現年齢')").parent().find("td").text()); const age = ageRaw && !isNaN(parseInt(ageRaw)) ? parseInt(ageRaw) + "岁" : ""; const heightRaw = pick('tr:has(a[title="身長"]) td', $x => $x.text()); const height = heightRaw ? heightRaw.split(" ")[0] + "cm" : ""; const weightRaw = pick('tr:has(a[title="体重"]) td', $x => $x.text()); const weightParts = weightRaw ? weightRaw.split("/") : []; let weight = (weightParts.length > 1 ? weightParts[1] : weightParts[0] || "").trim(); "― kg" === weight && (weight = ""); // 活動期間: 用于判断女优状态(引退/现役) const activeRaw = pick("th:contains('活動期間')", () => i.find("th:contains('活動期間')").parent().find("td").text()); // 引退字段: 部分演员 wiki 有专门的「引退」th, 若非空/非空占位符即视为已引退 const retireRaw = pick("th:contains('引退'), th:contains('退役')", $x => $x.first().parent().find("td").text()); // 正文关键短语判断: 正文中含「元AV女優」「元セクシー女優」「AV引退」等即为引退 const bodyText = i.find("#mw-content-text").text() || ""; const bodyRetired = /元AV女優|元セクシー女優|AV引退|AV女優を引退|完全引退/.test(bodyText); return { birthday: birthday, age: age, height: height, weight: weight, threeSizeText: pick('a[title="スリーサイズ"]', $x => $x.closest("tr").find("td").text().replace("cm", "")), braSize: pick('th:contains("ブラサイズ")', $x => $x.next("td").contents().first().text()), activeRaw: activeRaw, retireRaw: retireRaw, bodyRetired: bodyRetired, url: t }; } /** * 判断女优状态(引退/现役), 判断依据: * 1) wiki 词条含独立「引退/退役」字段且非空占位符 → 引退 * 2) wiki 正文含「元AV女優/元セクシー女優/AV引退/完全引退」→ 引退 * 3) wiki 活動期間字段中含 引退/退役 关键字 → 引退 * 4) wiki 活動期間解析出结束日期, 距今 > 6 个月 → 引退 (半年以上无作品) * 5) 其余情况 → 现役 */ _computeStatus(activeRaw, retireRaw, bodyRetired) { // 1) 明确的引退字段 const rr = (retireRaw || "").trim(); if (rr && rr !== "―" && rr !== "-" && rr !== "?" && rr !== "?") return "引退"; // 2) 正文关键短语 if (bodyRetired) return "引退"; if (!activeRaw) return "现役"; // 3) 活動期間文本含 引退/退役 if (/引退|退役/.test(activeRaw)) return "引退"; // 显式仍活跃标识 if (/現在|現役|活動中|継続中/.test(activeRaw)) return "现役"; // 4) 半年以上无作品(以活動期間结束日期为准) const years = activeRaw.match(/\d{4}/g); if (!years || years.length < 2) return "现役"; // 仅开始年份 → 现役 const endYear = parseInt(years[years.length - 1]); if (isNaN(endYear)) return "现役"; const mmMatch = activeRaw.match(new RegExp(endYear + "\\D+?(\\d{1,2})月")); const endMonth = mmMatch ? parseInt(mmMatch[1]) - 1 : 11; const endDate = new Date(endYear, endMonth + 1, 0); // 该月末 const halfYearAgo = new Date(); halfYearAgo.setMonth(halfYearAgo.getMonth() - 6); return endDate < halfYearAgo ? "引退" : "现役"; } /** 状态徽章: 引退红色/现役绿色 (小尺寸, flex 容器内自动垂直居中) */ _statusBadge(status) { const bg = status === "引退" ? "#ff4d4f" : "#52c41a"; return `<span class="jhs-actress-status" style="display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;height:16px;background:${bg};color:#fff;padding:0 6px;border-radius:3px;font-size:10px;line-height:1;margin-left:4px;vertical-align:middle;font-weight:normal;">${status}</span>`; } /** wiki 链接图标(与状态徽章同高同行, 使用维基百科官方 apple-touch 图标) */ _wikiIcon(url) { return `<a class="jhs-wiki-icon" href="${url}" target="_blank" title="Wikipedia" style="display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:16px;height:16px;margin-left:6px;text-decoration:none;"><img src="https://ja.wikipedia.org/static/apple-touch/wikipedia.png" alt="W" style="width:16px;height:16px;display:block;border-radius:2px;"></a>`; } } class pe extends X { constructor() { super(), i(this, "$contentBox", $(".section .container")); } getName() { return "HitShowPlugin"; } handle() { $('a[href*="rankings/playback"]').on("click", (e => { e.preventDefault(), e.stopPropagation(), window.location.href = "/advanced_search?handlePlayback=1&period=daily"; })), this.handlePlayback().then(); } hookPage() { let e = $("h2.section-title"); e.contents().first().replaceWith("热播"), e.css("marginBottom", "0"), $(".empty-message").remove(), $(".section .container .box").remove(), $("#sort-toggle-btn").remove(), this.$contentBox.append('<div class="tool-box" style="margin-top: 10px"></div>'), this.$contentBox.append('<div class="movie-list h cols-4 vcols-8" style="margin-top: 10px"></div>'); } async handlePlayback() { if (!window.location.href.includes("handlePlayback=1")) return; let e = new URLSearchParams(window.location.search).get("period"); this.toolBar(e), this.hookPage(); let t = $(".movie-list"); t.html(""); let n = loading(); let a = !1; for (let s = 1; s <= 3 && !a; s++) try { const n = await W(e); let i = this.markDataListHtml(n); t.html(i), this.loadScore(n), a = !0; } catch (i) { s < 3 ? (clog.error(`获取热播数据失败 (第 ${s} 次重试)`, i), await new Promise((e => setTimeout(e, 1e3)))) : clog.error("所有重试尝试均失败,无法获取数据。", i); } finally { (a || 3 === s) && n.close(); } } toolBar(e) { let t = `\n <div class="button-group" style="margin-top:18px">\n <div class="buttons has-addons" id="conditionBox">\n <a style="padding:18px 18px !important;" class="button is-small ${"daily" === e ? "is-info" : ""}" href="/advanced_search?handlePlayback=1&period=daily">日榜</a>\n <a style="padding:18px 18px !important;" class="button is-small ${"weekly" === e ? "is-info" : ""}" href="/advanced_search?handlePlayback=1&period=weekly">周榜</a>\n <a style="padding:18px 18px !important;" class="button is-small ${"monthly" === e ? "is-info" : ""}" href="/advanced_search?handlePlayback=1&period=monthly">月榜</a>\n </div>\n </div>\n `; this.$contentBox.append(t); } getStarRating(e) { let t = ""; const n = Math.floor(e); for (let a = 0; a < n; a++) t += '<i class="icon-star"></i>'; for (let a = 0; a < 5 - n; a++) t += '<i class="icon-star gray"></i>'; return t; } loadScore(e) { if (0 === e.length) return; (async () => { let t = "jhs_score_info"; for (const a of e) try { const e = a.id; if (!$(`#score_${e}`).length) return; if ($(`#${e}`).is(":hidden")) continue; const n = localStorage.getItem(t) ? JSON.parse(localStorage.getItem(t)) : {}, i = n[e]; if (i) { this.appendScoreHtml(e, i); continue; } for (;!document.hasFocus(); ) await new Promise((e => setTimeout(e, 500))); const s = await V(e); let o = s.score, r = s.watchedCount, l = `\n <span class="value">\n <span class="score-stars">${this.getStarRating(o)}</span> \n ${o}分,由${r}人評價\n </span>\n `; this.appendScoreHtml(e, l), n[e] = l, localStorage.setItem(t, JSON.stringify(n)), await new Promise((e => setTimeout(e, 500))); } catch (n) { clog.error(`解析评分数据失败 | 编号: ${a.number}\n`, `错误详情: ${n.message}\n`, n.stack ? `调用栈:\n${n.stack}` : ""); } })(); } appendScoreHtml(e, t) { let n = $(`#score_${e}`); n.length && "" === n.html().trim() && n.slideUp(0, (function() { $(this).html(t).slideDown(500); })); } markDataListHtml(e) { let t = ""; return e.forEach((e => { t += `\n <div class="item" id="${escapeHtml(e.id)}">\n <a href="/v/${escapeHtml(e.id)}" class="box" title="${escapeHtml(e.origin_title)}">\n <div class="cover ">\n <img loading="lazy" src="${e.cover_url.replace(/https:\/\/[^/]+\/rhe951l4q/, "https://c0.jdbstatic.com")}" alt="">\n </div>\n <div class="video-title"><strong>${escapeHtml(e.number)}</strong> ${escapeHtml(e.origin_title)}</div>\n <div class="score" id="score_${escapeHtml(e.id)}">\n </div>\n <div class="meta">\n ${escapeHtml(e.release_date)}\n </div>\n <div class="tags has-addons">\n ${e.has_cnsub ? '<span class="tag is-warning">含中字磁鏈</span>' : e.magnets_count > 0 ? '<span class="tag is-success">含磁鏈</span>' : '<span class="tag is-info">无磁鏈</span>'}\n ${e.new_magnets ? '<span class="tag is-info">今日新種</span>' : ""}\n </div>\n </a>\n </div>\n `; })), t; } } const me = "jhs_appAuthorization"; class ue extends X { constructor() { super(), i(this, "has_cnsub", ""), i(this, "$contentBox", $(".section .container")), i(this, "movies", []); } getName() { return "TOP250Plugin"; } handle() { $('.main-tabs ul li:contains("猜你喜歡")').html('<a href="/rankings/top"><span>Top250</span></a>'), $('a[href*="rankings/top"]').on("click", (e => { e.preventDefault(), e.stopPropagation(); const t = $(e.target), n = (t.is("a") ? t : t.closest("a")).attr("href"); let a = n.includes("?") ? n.split("?")[1] : n; const i = new URLSearchParams(a); this.checkLogin(e, i); })), this.handleTop().then(); } hookPage() { $("h2.section-title").contents().first().replaceWith("Top250"), $(".empty-message").remove(), $(".section .container .box").remove(), $("#sort-toggle-btn").remove(), this.$contentBox.append('<div class="tool-box" style="margin-top: 10px"></div>'), this.$contentBox.append('<div class="movie-list h cols-4 vcols-8" style="margin-top: 10px"></div>'), this.renderPagination(); } renderPagination() { const e = new URLSearchParams(window.location.search); let t = parseInt(e.get("page")) || 1; this.$contentBox.append((e => { const t = e >= 5; let n = ""; for (let a = 1; a <= 5; a++) { n += `<li><a class="pagination-link ${e === a ? "is-current" : ""}" data-page="${a}">${a}</a></li>`; } return `\n <nav class="pagination">\n <a class="pagination-previous ${e <= 1 ? "do-hide" : ""}" data-page="${e - 1}">上一頁</a>\n <a class="pagination-next ${t ? "do-hide" : ""}" data-page="${e + 1}">下一頁</a>\n \n <ul class="pagination-list">\n ${n}\n </ul>\n </nav>\n `; })(t)), this.$contentBox.on("click", ".pagination-link, .pagination-previous, .pagination-next", (t => { t.preventDefault(); const n = parseInt($(t.currentTarget).data("page")); !isNaN(n) && n > 0 && (t => { e.set("page", t), window.history.pushState({}, "", "?" + e.toString()), window.location.reload(); })(n); })); } async handleTop() { if (!window.location.href.includes("handleTop=1")) return; const e = new URLSearchParams(window.location.search); let t = e.get("handleType") || "all", n = e.get("type_value") || ""; this.has_cnsub = e.get("has_cnsub") || ""; let a = e.get("page") || 1; this.toolBar(t, n, a), this.hookPage(); let i = $(".movie-list"); i.html(""); let s = loading(); let o = !1; for (let l = 1; l <= 3 && !o; l++) try { const e = await q(t, n, a, 50); let r = e.success, l = e.message, c = e.action; if (1 === r) { let t = e.data.movies; if (0 === t.length) return show.error("无数据"), void s.close(); this.movies = t; const n = t.filter((e => "1" === this.has_cnsub ? e.has_cnsub : "0" !== this.has_cnsub || !e.has_cnsub)), a = this.getBean("HitShowPlugin"); let r = a.markDataListHtml(n); i.html(r), a.loadScore(n), o = !0; } else console.error(e), i.html(`<h3>${escapeHtml(l)}</h3>`), show.error(l), "JWTVerificationError" === c && (await localStorage.removeItem(me), await this.checkLogin(null, new URLSearchParams(window.location.search))), o = !0; } catch (r) { l < 3 ? (clog.error(`获取Top数据失败 (第 ${l} 次重试):`, r), await new Promise((e => setTimeout(e, 1e3)))) : (clog.error("所有重试尝试均失败,无法获取Top数据。", r), i.html("<h3>无法加载数据,请稍后再试。</h3>")); } finally { (o || 3 === l) && s.close(); } } toolBar(e, t, n) { "5" === n.toString() && $(".pagination-next").remove(), $(".pagination-ellipsis").closest("li").remove(), $(".pagination-list li a").each((function() { parseInt($(this).text()) > 5 && $(this).closest("li").remove(); })); let a = ""; for (let s = (new Date).getFullYear(); s >= 2008; s--) a += `\n <a style="padding:18px 18px !important;" \n class="button is-small ${t === s.toString() ? "is-info" : ""}" \n href="/advanced_search?handleTop=1&handleType=year&type_value=${s}&has_cnsub=${this.has_cnsub}">\n ${s}\n </a>\n `; let i = `\n <div class="button-group">\n <div class="buttons has-addons" id="conditionBox" style="margin-bottom: 0!important;">\n <a style="padding:18px 18px !important;" class="button is-small ${"all" === e ? "is-info" : ""}" href="/advanced_search?handleTop=1&handleType=all&type_value=&has_cnsub=${this.has_cnsub}">全部</a>\n <a style="padding:18px 18px !important;" class="button is-small ${"0" === t ? "is-info" : ""}" href="/advanced_search?handleTop=1&handleType=video_type&type_value=0&has_cnsub=${this.has_cnsub}">有码</a>\n <a style="padding:18px 18px !important;" class="button is-small ${"1" === t ? "is-info" : ""}" href="/advanced_search?handleTop=1&handleType=video_type&type_value=1&has_cnsub=${this.has_cnsub}">无码</a>\n <a style="padding:18px 18px !important;" class="button is-small ${"2" === t ? "is-info" : ""}" href="/advanced_search?handleTop=1&handleType=video_type&type_value=2&has_cnsub=${this.has_cnsub}">欧美</a>\n <a style="padding:18px 18px !important;" class="button is-small ${"3" === t ? "is-info" : ""}" href="/advanced_search?handleTop=1&handleType=video_type&type_value=3&has_cnsub=${this.has_cnsub}">Fc2</a>\n \n <a style="padding:18px 18px !important;margin-left: 50px" class="button is-small ${"1" === this.has_cnsub ? "is-info" : ""}" data-cnsub-value="1">含中字磁鏈</a>\n <a style="padding:18px 18px !important;" class="button is-small ${"0" === this.has_cnsub ? "is-info" : ""}" data-cnsub-value="0">无字幕</a>\n <a style="padding:18px 18px !important;" class="button is-small" data-cnsub-value="">重置</a>\n </div>\n \n <div class="buttons has-addons" id="conditionBox">\n ${a}\n </div>\n </div>\n `; this.$contentBox.append(i), $("a[data-cnsub-value]").on("click", (e => { const t = $(e.currentTarget).data("cnsub-value"); this.has_cnsub = t.toString(), $("a[data-cnsub-value]").removeClass("is-info"), $(e.currentTarget).addClass("is-info"), $(".toolbar a.button").not("[data-cnsub-value]").each(((e, n) => { const a = $(n), i = new URL(a.attr("href"), window.location.origin); i.searchParams.set("has_cnsub", t), a.attr("href", i.toString()); })); const n = this.movies.filter((e => "1" === this.has_cnsub ? e.has_cnsub : "0" !== this.has_cnsub || !e.has_cnsub)), a = this.getBean("HitShowPlugin"); let i = a.markDataListHtml(n); $(".movie-list").html(i), a.loadScore(n); })); } async checkLogin(e, t) { if (!localStorage.getItem(me)) return show.error("该类别依赖移动端接口,请先完成登录"), void this.openLoginDialog(); let n = "all", a = "", i = t.get("t") || ""; /^y\d+$/.test(i) ? (n = "year", a = i.substring(1)) : "" !== i && (n = "video_type", a = i); let s = `/advanced_search?handleTop=1&handleType=${n}&type_value=${a}`; e && (e.ctrlKey || e.metaKey) ? GM_openInTab(window.location.origin + s, { insert: 0 }) : window.location.href = s; } openLoginDialog() { layer.open({ type: 1, title: "JavDB", closeBtn: 1, area: utils.getResponsiveArea([ "360px", "auto" ]), shadeClose: !1, content: '\n <div style="padding: 30px; font-family: \'Helvetica Neue\', Arial, sans-serif;">\n <div style="margin-bottom: 25px;">\n <input type="text" id="username" name="username" \n style="width: 100%; padding: 12px 15px; border: 1px solid #e0e0e0; border-radius: 4px; \n box-sizing: border-box; transition: all 0.3s; font-size: 14px;\n background: #f9f9f9; color: #333;"\n placeholder="用户名 | 邮箱"\n onfocus="this.style.borderColor=\'#4a8bfc\'; this.style.background=\'#fff\'"\n onblur="this.style.borderColor=\'#e0e0e0\'; this.style.background=\'#f9f9f9\'">\n </div>\n \n <div style="margin-bottom: 15px;">\n <input type="password" id="password" name="password" \n style="width: 100%; padding: 12px 15px; border: 1px solid #e0e0e0; border-radius: 4px; \n box-sizing: border-box; transition: all 0.3s; font-size: 14px;\n background: #f9f9f9; color: #333;"\n placeholder="密码"\n onfocus="this.style.borderColor=\'#4a8bfc\'; this.style.background=\'#fff\'"\n onblur="this.style.borderColor=\'#e0e0e0\'; this.style.background=\'#f9f9f9\'">\n </div>\n \n <button id="loginBtn" \n style="width: 100%; padding: 12px; background: #4a8bfc; color: white; \n border: none; border-radius: 4px; font-size: 15px; cursor: pointer;\n transition: background 0.3s;"\n onmouseover="this.style.background=\'#3a7be0\'"\n onmouseout="this.style.background=\'#4a8bfc\'">\n 登录\n </button>\n </div>\n ', success: (e, t) => { $("#loginBtn").click((function() { const e = $("#username").val(), n = $("#password").val(); if (!e || !n) return void show.error("请输入用户名和密码"); let a = loading(); (async (e, t) => { let n = `${U}/v1/sessions?username=${encodeURIComponent(e)}&password=${encodeURIComponent(t)}&device_uuid=04b9534d-5118-53de-9f87-2ddded77111e&device_name=iPhone&device_model=iPhone&platform=ios&system_version=17.4&app_version=official&app_version_number=1.9.29&app_channel=official`, a = { "user-agent": "Dart/3.5 (dart:io)", "accept-language": "zh-TW", "content-type": "multipart/form-data; boundary=--dio-boundary-2210433284", jdsignature: await O() }; return await gmHttp.post(n, null, a); })(e, n).then((async e => { let n = e.success; if (0 === n) show.error(e.message); else { if (1 !== n) throw clog.error("登录失败", e), new Error(e.message); { let n = e.data.token; await localStorage.setItem(me, await encryptData(n)), show.ok("登录成功"), layer.close(t), window.location.href = "/advanced_search?handleTop=1&period=daily"; } } })).catch((e => { clog.error("登录异常:", e), show.error(e.message); })).finally((() => { a.close(); })); })); } }); } } class fe extends X { getName() { return "NavBarPlugin"; } async initCss() { return "\n .highlight-red {\n /* 核心要求:高亮红色文本 */\n color: red !important; \n \n /* 建议:增加字体加粗,效果更明显 */\n font-weight: bold;\n \n /* 建议:增加背景色,效果更突出 */\n /* background-color: yellow; */ \n}\n" /* ---- 导航栏溢出修复 ---- 实测(1478px 窗口): nav.navbar 可用 1472px, 但 .navbar-brand(134) + #navbar-menu-hero(内容 552) + #search-box(446) + #navbar-menu-user(497) = 1629px, 且三块 .navbar-menu 都是 flex-shrink:0, 谁都不让 -> 整行溢出 157px, 最右边的「语言/用户名」被顶出屏幕。 下面两步解决, 选择器全部限定在 nav.navbar #search-box 内部(脚本自己注入的元素), 不碰站点原生导航项, 也不影响页面其它区域: 1. 只让 #search-box 可收缩, 由它吸收溢出; 2. #search-box 内部只允许输入框收缩, select 和三个按钮锁 flex:0 0 auto + nowrap (上一版没锁, 收缩量平摊到按钮上, 把「识图」「搜索」压变形了), 同时把这几个控件的内边距和间距收紧一点, 给输入框多留出可用宽度。 */ + ` nav.navbar #search-box{flex:1 1 auto !important;min-width:0 !important} nav.navbar #search-box > .navbar-start{min-width:0 !important;width:100% !important;gap:4px !important} nav.navbar #search-box #search-keyword{flex:1 1 0% !important;min-width:0 !important} nav.navbar #search-box #search-type, nav.navbar #search-box > .navbar-start > a{flex:0 0 auto !important;white-space:nowrap !important} nav.navbar #search-box #search-type{padding-left:6px !important;padding-right:6px !important} nav.navbar #search-box > .navbar-start > a{padding-left:10px !important;padding-right:10px !important} `; } handle() { if (!r) return; this.margeNav(), this.hookSearch(), this.hookOldSearch(), this.toggleOtherNavItem(), $(window).resize(() => this.toggleOtherNavItem()); this.diagNavBar(); if (window.location.href.includes("/search")) { const e = new URLSearchParams(window.location.search); let t = e.get("q"), n = e.get("f"); $("#search-keyword").val(t), n && $("#search-type").val(n), t && this.highlightKeyword(t); } } /** * 【临时诊断】导航栏右侧溢出问题定位用, 只读不改。 * 打印: 视口宽度 / #search-box 到 nav 的父链 / 这条 flex 行里每个直接子元素的实际占宽与 flex 参数。 * 定位完成后删除本方法及 handle() 里的调用。 */ diagNavBar() { setTimeout((() => { const line = el => { if (!el) return "(null)"; const cs = getComputedStyle(el); const name = el.tagName.toLowerCase() + (el.id ? "#" + el.id : "") + (el.className && "string" == typeof el.className ? "." + el.className.trim().split(/\s+/).join(".") : ""); return `${name} | offsetW=${el.offsetWidth} scrollW=${el.scrollWidth} clientW=${el.clientWidth} | display=${cs.display} flex=${cs.flexGrow}/${cs.flexShrink}/${cs.flexBasis} wrap=${cs.flexWrap} | minW=${cs.minWidth} width=${cs.width} overflowX=${cs.overflowX}`; }; const box = document.getElementById("search-box"); console.log("[JHS诊断] 视口", window.innerWidth, "docClientW", document.documentElement.clientWidth, "body.scrollW", document.body.scrollWidth); console.log("[JHS诊断] #search-box 存在", !!box, "显示", box ? "none" !== getComputedStyle(box).display : "-"); let cur = box, i = 0; while (cur && "body" !== cur.tagName.toLowerCase()) console.log(`[JHS诊断] 父链[${i++}] ` + line(cur)), cur = cur.parentElement; const row = box && box.parentElement; if (row) { console.log("[JHS诊断] === flex 行 = #search-box 的父元素: " + line(row)); Array.from(row.children).forEach(((c, k) => console.log(`[JHS诊断] 同级[${k}] ` + line(c)))); } [ "#navbar-menu-hero", "#navbar-menu-user", ".navbar", ".navbar-brand", "#search-bar-container", ".navbar-search" ].forEach((sel => { const el = document.querySelector(sel); console.log("[JHS诊断] " + sel + " -> " + (el ? line(el) : "不存在")); })); }), 1500); } highlightKeyword(e) { const t = e.trim(); if (!t) return; const n = t.toLowerCase(); $(".video-title strong, .actor-box strong").each((function() { const e = $(this); e.text().toLowerCase().includes(n) && e.addClass("highlight-red"); })); } hookSearch() { $("#navbar-menu-hero").after('\n <div class="navbar-menu" id="search-box">\n <div class="navbar-start" style="display: flex; align-items: center; gap: 5px;">\n <select id="search-type" style="padding: 8px 12px; border: 1px solid #555; border-radius: 4px; background-color: #333; color: #eee; font-size: 14px; outline: none;">\n <option value="all">影片</option>\n <option value="actor">演员</option>\n <option value="series">系列</option>\n <option value="maker">片商</option>\n <option value="director">导演</option>\n <option value="code">番号</option>\n <option value="list">清单</option>\n </select>\n <input id="search-keyword" type="text" placeholder="输入影片番号,演员名等" style="padding: 8px 12px; border: 1px solid #555; border-radius: 4px; flex-grow: 1; font-size: 14px; background-color: #333; color: #eee; outline: none;">\n <a href="/advanced_search?noFold=1" title="高级搜索" style="padding: 6px 12px; background-color: #444; border-radius: 4px; text-decoration: none; color: #ddd; font-size: 14px; border: 1px solid #555;"><span>...</span></a>\n <a id="search-img-btn" style="padding: 6px 16px; background-color: #444; color: #fff; border-radius: 4px; text-decoration: none; font-weight: 500; cursor: pointer; border: 1px solid #555;">识图</a>\n <a id="search-btn" style="padding: 6px 16px; background-color: #444; color: #fff; border-radius: 4px; text-decoration: none; font-weight: 500; cursor: pointer; border: 1px solid #555;">搜索</a>\n </div>\n </div>\n '), $("#search-keyword").on("paste", (e => { const t = e.originalEvent.clipboardData.items; for (let n = 0; n < t.length; n++) if (-1 !== t[n].type.indexOf("image")) { const e = t[n].getAsFile(); $("#search-keyword").blur(); const a = this.getBean("SearchByImagePlugin"); return void a.open((() => { a.handleImageFile(e), a.resetSearchUI(); })); } })).on("keypress", (e => { "Enter" === e.key && setTimeout((() => { $("#search-btn").click(); }), 0); })), $("#search-btn").on("click", (e => { let t = $("#search-keyword").val(), n = $("#search-type option:selected").val(); "" !== t && (window.location.href.includes("/search") ? window.location.href = "/search?q=" + t + "&f=" + n : window.open("/search?q=" + t + "&f=" + n)); })), $("#search-img-btn").on("click", (() => { this.getBean("SearchByImagePlugin").open(); })); } hookOldSearch() { const e = document.querySelector(".search-image"); if (!e) return; const t = e.cloneNode(!0); e.parentNode.replaceChild(t, e), $("#button-search-image").attr("data-tooltip", "以图识图"), $(".search-image").on("click", (e => { this.getBean("SearchByImagePlugin").open(); })); } margeNav() { $('a[href*="/feedbacks/new"]').remove(), $('a[href*="theporndude.com"]').remove(), $('a.navbar-link[href="/makers"]').parent().after('\n <div class="navbar-item has-dropdown is-hoverable">\n <a class="navbar-link">其它</a>\n <div class="navbar-dropdown is-boxed">\n <a class="navbar-item" href="/feedbacks/new" target="_blank" >反饋</a>\n <a class="navbar-item" rel="nofollow noopener" target="_blank" href="https://theporndude.com/zh">ThePornDude</a>\n </div>\n </div>\n '); } toggleOtherNavItem() { const e = $("#search-box"), t = $("#search-bar-container"), n = $(window).width(); // 原逻辑要求宽度 >1600 才显示自定义搜索框, 1024~1600 及 <=1023 会出现"导航栏没加载"的现象 n >= 1280 ? (e.show(), t.hide()) : (e.hide(), t.show()); } } class ve { constructor() { this.queue = Promise.resolve(); } addTask(e) { this.queue = this.queue.then((() => e())).catch((e => { clog.error("执行异步队列任务失败:", e); })); } async waitAllFinished() { return this.queue; } } class be extends X { constructor() { super(...arguments), i(this, "okBackgroundColor", "#7bc73b"), i(this, "errorBackgroundColor", "#de3333"), i(this, "warnBackgroundColor", "#d7a80c"), i(this, "domainErrorBackgroundColor", "#d7780c"), i(this, "siteConfigs", [ { id: "javTrailersBtn", getBaseUrl: async () => await this.getJavTrailersUrl(), itemSelector: ".videos-list .video-link", searchPath: (e, t) => `${e}/search/${t}`, getDetailPageHref: e => e.attr("href"), findCarNumOrTitle: e => e.find("p.card-text").text() }, { id: "123AvBtn", getBaseUrl: async () => await this.getAv123Url() + "/ja", itemSelector: ".box-item", searchPath: (e, t) => `${e}/search?keyword=${t}`, getDetailPageHref: e => e.find(".detail a").attr("href"), findCarNumOrTitle: e => e.find("img").attr("title") }, { id: "jableBtn", getBaseUrl: async () => await this.getjableUrl(), itemSelector: "#list_videos_videos_list_search_result .detail .title a", searchPath: (e, t) => `${e}/search/${t}/`, getDetailPageHref: e => e.attr("href"), findCarNumOrTitle: e => e.text() }, { id: "missAvBtn", getBaseUrl: async () => await this.getMissAvUrl(), itemSelector: ".text-secondary", searchPath: (e, t) => `${e}/search/${t}`, getDetailPageHref: e => e.attr("href"), findCarNumOrTitle: e => e.text() }, { id: "supJavBtn", getBaseUrl: async () => await this.getSupJavUrl(), itemSelector: ".posts post", searchPath: (e, t) => `${e}/?s=${t}`, getDetailPageHref: (e, t, n) => e.attr("href"), findCarNumOrTitle: e => e.attr("title") }, { id: "javDbBtn", getBaseUrl: async () => await this.getJavDbUrl(), itemSelector: ".movie-list .item", searchPath: (e, t) => `${e}/search?q=${t}`, getDetailPageHref: e => e.find("a").attr("href"), findCarNumOrTitle: e => e.find(".video-title").text(), condition: e => l }, { id: "javBusBtn", getBaseUrl: async () => await this.getJavBusUrl(), itemSelector: ".container h3", searchPath: (e, t) => `${e}/${t}`, getDetailPageHref: (e, t, n) => `${t}/${n}`, findCarNumOrTitle: e => e.text(), condition: e => r && e && !e.includes("FC2") }, { id: "fanzaBtn", getBaseUrl: async () => await this.getFanzaUrl(), itemSelector: ".tmb a", searchPath: (e, t) => `${e}/search/=/searchstr=${t}`, getDetailPageHref: e => e.attr("href"), findCarNumOrTitle: e => e.attr("title") || e.text(), condition: e => e && !e.includes("FC2") } ]), i(this, "settingCache", null), i(this, "lastFetchTime", 0), i(this, "CACHE_DURATION", 1e4); } getName() { return "OtherSitePlugin"; } async initCss() { return "\n <style>\n .site-btn {\n position: relative !important;\n min-width: 80px;\n display: inline-block;\n padding: 5px 10px;\n color: white !important;\n background-color:#938585;\n text-decoration: none;\n border-radius: 4px;\n text-align: center;\n margin-bottom: 5px;\n }\n .site-btn:hover {\n color: white;\n transform: translateY(-2px);\n box-shadow: 0 2px 5px rgba(0,0,0,0.1);\n }\n .site-tag {\n position: absolute; \n top: -15px; \n right: 0; \n background-color: #ffc107; \n color: #333; \n font-size: 12px; \n padding: 2px 6px; \n border-radius: 4px;\n }\n </style>\n "; } async handle() { // 详情页(JavDB / JavBus)不再自动渲染「外部网站」按钮栏(#otherSiteBox): // 它和详情页快捷图标栏的「外部跳转」是同一件事, 两处并存属于重复功能。 // FC2 弹层仍保留那一栏 —— 它是 Fc2Plugin / Fc2By123AvPlugin 直接调 loadOtherSite, 不走这里。 } async loadOtherSite(e, t, n = {}) { // 详情页番号栏下方的「外部网站」按钮栏不再受「第三方视频资源」(enableLoadOtherSite) 总开关控制, // 该开关现在只影响 FC2 弹层里的「第三方站点」区块; 这里各站点的显隐由 jhs_hidden_sites 单独控制。 $("#otherSiteBox").remove(); e || (e = this.getPageInfo().carNum); // 读取自定义名称 const _st = await storageManager.getSetting(); const siteNameMap = { javTrailersBtn: _st.siteNameJavTrailer || "JavTrailer", "123AvBtn": _st.siteNameAv123 || "123Av", jableBtn: _st.siteNameJable || "Jable", missAvBtn: _st.siteNameMissAv || "MissAv", supJavBtn: _st.siteNameSupJav || "SupJav", javDbBtn: _st.siteNameJavDb || "JavDb", javBusBtn: _st.siteNameJavBus || "JavBus", fanzaBtn: _st.siteNameFanza || "Fanza" }; const hiddenSites = jhsGetHiddenSites(), i = `\n <div id="otherSiteBox" class="panel-block" style="${r ? "margin-top:8px;font-size:13px" : "margin-top:10px;font-size:13px"}; user-select: none; ">\n <div style="display: flex;gap: 5px;flex-wrap: wrap">\n ${this.siteConfigs.map((e => { if (e.sourceCarNum = t, e.condition && !1 === e.condition(e.sourceCarNum)) return ""; const label = siteNameMap[e.id] || e.id.replace("Btn", ""); const siteKey = JHS_BTN_TO_SITEKEY[e.id] || ""; const isHidden = siteKey && hiddenSites.includes(siteKey); return `<a target="_blank" class="site-btn" style="${isHidden ? "display:none" : ""}" id="${e.id}"><span>${label}</span></a>`; })).join("")}\n </div>\n </div>\n `; // JavBus / JavDB 详情页统一处理: 把 #otherSiteBox 作为信息面板(番号栏)的最后一个子元素, 即整栏最底部。 // - JavDB: 信息面板 = .movie-panel-info, 每行是 .panel-block(番號/演員/想看看过/下载订正/xx人想看) // - JavBus: 信息面板 = .info, 每行是 <p>(識別碼/發行日期/演員 …) // 这里只取容器并 append, 不再锚定演员行, 否则会被插到面板中间。 const findHost = () => { if (l) { const $info = $(".info").first(); if ($info.length) return $info; // JavBus 兜底: 識別碼 header 所在 <p> 的父容器 const $busHeader = $("span.header").filter((function() { return /識別碼|识别码/.test(($(this).text() || "").trim()); })).first(); if ($busHeader.length) { const $p = $busHeader.closest("p"); if ($p.length && $p.parent().length) return $p.parent(); } } else { const $mpi = $(".movie-panel-info").first(); if ($mpi.length) return $mpi; const $vmp = $(".video-meta-panel").first(); if ($vmp.length) return $vmp; // JavDB 兜底: 番號/演員 .panel-block 的父容器(改版兜底) let $blk = $(); $(".panel-block").each((function() { const label = $(this).find("strong").first().text().trim(); if (label.includes("番號") || label.includes("番号") || label.includes("演員") || label.includes("演员")) return $blk = $(this), !1; })); if ($blk.length && $blk.parent().length) return $blk.parent(); } return $(); }; const doInsert = () => { if ($("#otherSiteBox").length) return true; const $host = findHost(); if ($host.length) { $host.append(i); return $("#otherSiteBox").length > 0; } return false; }; // 其它插件(如演员维基信息)会异步往信息面板追加 .panel-block, 之后再压一次底, 保证 box 始终是最后一个。 // 例外: 详情页快捷图标栏(#jhs-quick-icons)固定排在本 box 之后, 这里要让位, 否则两者会来回互顶造成跳动。 const keepAtBottom = () => { const $box = $("#otherSiteBox"); if (!$box.length) return; const $parent = $box.parent(); if (!$parent.length) return; const $icons = $parent.children("#jhs-quick-icons"); if ($icons.length) return void ($icons[0].previousElementSibling !== $box[0] && $icons.before($box)); if ($parent.children().last()[0] !== $box[0]) $parent.append($box); }; // 绑定按钮事件 + 回填各站点搜索链接; 延迟插入时同样要跑一遍, 否则按钮没有 href const wireUp = async () => { $("#javTrailersBtn").off("click.jhsOtherSite").on("click.jhsOtherSite", (async t => { t.preventDefault(); let o = $("#javTrailersBtn").attr("href"), r = o + "?handle=1"; t && (t.ctrlKey || t.metaKey) && (r = o), utils.openPage(r, e, !1, t); })); await Promise.all(this.siteConfigs.map((async t => { t.condition && !1 === t.condition(t.sourceCarNum) || await this.prepareSiteLink(e, t, _st); }))); n.autoDetect && await this.detectOtherSites(e); }; if (doInsert()) { await wireUp(); } else { // DOM 尚未就绪: 轮询等待 movie-panel-info / .panel-block / .info / span.header 任一出现 utils.loopDetector((() => $(".movie-panel-info").length > 0 || $(".panel-block").length > 0 || $(".info").length > 0 || $("span.header").length > 0), (() => { doInsert() && wireUp().catch((e => clog.error("外部网站按钮初始化失败:", e))); }), 100, 5e3, !1); } // 演员维基信息等是异步追加的, 延迟两次压底, 保证 box 稳定停在番号栏最下面 setTimeout(keepAtBottom, 800); setTimeout(keepAtBottom, 2500); } async prepareSiteLink(e, t, settings) { const n = $(`#${t.id}`); if (t.initUrl) return void (n.attr("href", t.initUrl(e)), n.css("backgroundColor", this.warnBackgroundColor), n.attr("title", "点击前往外部搜索页")); try { // 优先使用面板配置的「搜索地址」; 支持两种写法: // 1. 完整 URL (以 http/https 开头) => `搜索地址 + 番号` // 2. 相对路径 (如 search?q= 或 /search?q=) => `域名(原始字段) + / + 路径 + 番号` // 相对路径直接使用「域名」字段的原始值, 不经过 getBaseUrl 附加的额外后缀(如 123Av 会加 /ja), // 以保证按钮跳转与用户在面板中看到的域名一致. // 留空则回退内置 searchPath 规则(此时使用 getBaseUrl). const searchKey = SITE_SEARCH_KEY_MAP[t.id]; const customSearch = searchKey && settings ? String(settings[searchKey] || "").trim() : ""; let i; if (customSearch) { if (/^https?:\/\//i.test(customSearch)) { i = `${customSearch}${e}`; } else { const domainKey = SITE_DOMAIN_KEY_MAP[t.id]; let base = domainKey && settings ? String(settings[domainKey] || "").trim() : ""; if (!base) base = await t.getBaseUrl(); base = base.replace(/\/+$/, ""); const sep = customSearch.startsWith("/") ? "" : "/"; i = `${base}${sep}${customSearch}${e}`; } } else { const a = await t.getBaseUrl(); i = t.searchPath(a, e); } n.attr("href", i), n.attr("title", "点击前往外部搜索页"), n.css("backgroundColor", this.warnBackgroundColor); } catch (a) { n.attr("title", "外部站点地址未配置或不可用"), n.css("backgroundColor", this.domainErrorBackgroundColor); } } async detectOtherSites(e) { const t = $("#detectOtherSiteBtn"), n = t.text(); return t.text("检测中").css("backgroundColor", "#938585"), await Promise.all(this.siteConfigs.map((async t => { t.condition && !1 === t.condition(t.sourceCarNum) || await this.handleSite(e, t); }))), t.text(n).css("backgroundColor", "#1677ff"); } async handleSite(e, t) { const n = $(`#${t.id}`); n.removeAttr("href").find(".site-tag").remove(); if (t.initUrl && (n.attr("href", t.initUrl(e)), n.css("backgroundColor", this.warnBackgroundColor)), t.noHandle && !0 === t.noHandle) { const t = "jhs_other_site_dmm", a = (localStorage.getItem(t) ? JSON.parse(localStorage.getItem(t)) : {})[e]; a && ("single" === a.type ? (n.attr("href", a.url), n.css("backgroundColor", this.okBackgroundColor)) : "multiple" === a.type && (n.attr("href", a.url), n.css("backgroundColor", this.okBackgroundColor))); } else try { if (n.attr("href")) return; if (utils.isHidden(n)) return; const a = "jhs_other_site", i = localStorage.getItem(a) ? JSON.parse(localStorage.getItem(a)) : {}, s = e + "_" + t.id.replace("Btn", ""), o = i[s], m = Date.now(); if (o && o.time && m - o.time < 864e5) return void ("single" === o.type ? (n.attr("href", o.url), n.css("backgroundColor", this.okBackgroundColor)) : "multiple" === o.type && (n.attr("href", o.url), n.append('<span class="site-tag" style="top:-15px">多结果</span>'), n.css("backgroundColor", this.okBackgroundColor))); const r = await t.getBaseUrl(), l = t.searchPath(r, e); n.attr("href", l); /* 预检查仅用于 UI 展示,实际拦截依赖 gmRequest 内部熔断检查 */ const _breaker = gmHttp.isDomainCircuitBroken(l); if (_breaker) { n.attr("title", `站点已熔断,${_breaker.remaining}秒后重试`), n.css("backgroundColor", this.domainErrorBackgroundColor); return; } const c = await storageManager.cachedRequest(`other-site:${t.id}:${e}`, 864e5, (() => gmHttp.get(l, null, t.headers, !0))), d = utils.htmlTo$dom(c), h = []; d.find(t.itemSelector).each(((n, a) => { const i = $(a); if (!t.findCarNumOrTitle(i).toLowerCase().includes(e.toLowerCase())) return; let s = t.getDetailPageHref(i, r, e); if (!s) throw new Error("解析href失败"); s.includes("http") || (s = r + (s.startsWith("/") ? s : "/" + s)), h.push(s); })); let g = "", p = null; if (1 === h.length) { let e = h[0]; n.attr("href", e), n.css("backgroundColor", this.okBackgroundColor), p = { type: "single", url: e, time: m }; } else h.length > 1 ? (n.attr("href", l), g += '<span class="site-tag" style="top:-15px">多结果</span>', n.css("backgroundColor", this.okBackgroundColor), p = { type: "multiple", url: l, time: m }) : (n.attr("href", l), n.attr("title", "未查询到, 点击前往搜索页"), n.css("backgroundColor", this.errorBackgroundColor)); if (p) { const e = localStorage.getItem(a) ? JSON.parse(localStorage.getItem(a)) : {}; e[s] = p, localStorage.setItem(a, JSON.stringify(e)); } g && n.append(g); } catch (a) { const e = String(a), i = t.id.replace("Btn", ""); a._circuitBroken ? (n.attr("title", e), n.css("backgroundColor", this.domainErrorBackgroundColor), clog.warn(`检测第三方资源跳过, ${i} 已熔断`)) : e.includes("Just a moment") ? (n.attr("title", "请求失败:Cloudflare 安全检查。"), n.css("backgroundColor", this.warnBackgroundColor), clog.warn(`检测第三方资源失败, ${i} 需Cloudflare安全检查`)) : e.includes("重定向") ? (n.attr("title", "域名失效"), n.css("backgroundColor", this.domainErrorBackgroundColor), clog.warn(`检测第三方资源失败, ${i} 域名被重定向`)) : e.includes("404 Page Not Found") ? (n.attr("title", "未查询到, 点击前往搜索页"), n.css("backgroundColor", this.errorBackgroundColor)) : (console.error(a), n.attr("title", "请求失败。"), n.css("backgroundColor", this.errorBackgroundColor), clog.warn(`检测第三方资源失败, ${i}`)); } } async getSettingCache() { const e = Date.now(); return (!this.settingCache || e - this.lastFetchTime > this.CACHE_DURATION) && (this.settingCache = await storageManager.getSetting(), this.lastFetchTime = e), this.settingCache; } async getMissAvUrl() { return (await this.getSettingCache()).missAvUrl || "https://missav.live"; } async getjableUrl() { return (await this.getSettingCache()).jableUrl || "https://jable.tv"; } async getAvgleUrl() { return (await this.getSettingCache()).avgleUrl || "https://jav.rs"; } async getJavTrailersUrl() { return (await this.getSettingCache()).javTrailersUrl || "https://javtrailers.com"; } async getAv123Url() { return (await this.getSettingCache()).av123Url || "https://123av.com"; } async getJavDbUrl() { return (await this.getSettingCache()).javDbUrl || "https://javdb.com"; } async getJavBusUrl() { return (await this.getSettingCache()).javBusUrl || "https://www.javbus.com"; } async getSupJavUrl() { return (await this.getSettingCache()).supJavUrl || "https://supjav.com"; } async getFanzaUrl() { return (await this.getSettingCache()).fanzaUrl || "https://www.dmm.co.jp"; } } class we extends X { getName() { return "BusDetailPagePlugin"; } async initCss() { if (!window.isDetailPage) return ""; $("h4:contains('推薦')").hide(); } async handle() { if (window.location.href.includes("/star/")) { const e = $(".avatar-box"); if (e.length > 0) { let t = e.parent(); t.css("position", "initial"), t.insertBefore(t.parent()); } } this.recordVisited(); this.alignBusTopAlert(); $(".genre a").each((function() { const e = $(this).attr("href"); e && (e.startsWith("http://") || e.startsWith("https://") || e.startsWith("/")) && $(this).attr("target", "_blank"); })), this.addCopyCarNumBtn(); } /** JavBus: 顶部「已有磁力/全部影片」提示条与头像信息栏 .avatar-box 左右对齐 */ alignBusTopAlert() { const align = () => { // 对齐目标优先取 .avatar-box (和封面首列左对齐), 回退到 .masonry 减去内边距 const $ref = $(".avatar-box").length ? $(".avatar-box") : $(".masonry"); if (!$ref.length) return; const $bar = $("div.alert-success, div.alert-common, div.alert").filter(((_, el) => /已有磁力|全部影片|當前顯示|当前显示/.test(el.textContent || ""))).first(); if (!$bar.length) return; const ref = $ref[0].getBoundingClientRect(), el = $bar[0]; el.style.setProperty("box-sizing", "border-box", "important"); el.style.setProperty("width", Math.round(ref.width) + "px", "important"); el.style.setProperty("max-width", Math.round(ref.width) + "px", "important"); el.style.setProperty("margin-left", "0", "important"); el.style.setProperty("margin-right", "0", "important"); const b = el.getBoundingClientRect(); el.style.setProperty("margin-left", Math.round(ref.left - b.left) + "px", "important"); }; utils.loopDetector((() => $(".masonry").length > 0 || $(".avatar-box").length > 0), (() => { align(), setTimeout(align, 300), $(window).on("resize", align); }), 1, 1e4, !1); } addCopyCarNumBtn() { let e = null; const t = document.querySelectorAll("span.header"); for (const n of t) if ("識別碼:" === n.textContent.trim()) { e = n; break; } if (e) { const t = e.nextElementSibling; if (t && "SPAN" === t.tagName) { const e = t.textContent.trim(), n = document.createElement("button"); n.type = "button"; n.className = "jav-avid-copy"; n.textContent = "复制番号"; n.title = `复制番号:${e}`; // 复制番号按钮样式 n.style.cssText = 'display:inline-block;margin-left:6px;padding:1px 6px;font-size:12px;font-family:-apple-system,BlinkMacSystemFont,"Microsoft YaHei","PingFang SC","Noto Sans CJK SC","Segoe UI",sans-serif;font-weight:500;text-align:center;background:#e8f4fd;border:1px solid #90c5e8;border-radius:3px;cursor:pointer;color:#1a6fa8;vertical-align:middle;white-space:nowrap;box-sizing:border-box;line-height:1.4;'; n.addEventListener("click", (function(t) { t.preventDefault(), t.stopPropagation(); const n = () => { this.textContent = "已复制", setTimeout((() => { this.textContent = "复制番号"; }), 900); }; navigator.clipboard && navigator.clipboard.writeText && navigator.clipboard.writeText(e).then((() => n())).catch((t => { console.error("无法通过标准API复制:", t), alert("复制失败,请手动复制: " + e); })); })), t.parentNode.insertBefore(n, t.nextSibling); } } } } /* ===================== 字幕搜索(迅雷 + SubTitleCat 双引擎) ===================== */ /** 来源图标(内联 base64, 避免外链被 CSP/防盗链拦掉) */ const JHS_SUB_ICON = { xunlei: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAMKADAAQAAAABAAAAMAAAAADbN2wMAAALVElEQVRoBc1aa6xdRRX+Zu99Hvf03r54VFpRWxGwLaWkqRSKYEVEfhAwgEICoYBVozEBooKEH9fEPwoETNAAmgvhhyBQA4naQqAmvA0tpRduGx4tBeVS2tL2vs9j7z1+38w5595zeyjn3tLatc/ee/bM7JlvrVlrzZrZx6AJLensLVQGO5ZZi4tNml4La7OASZtUPYRZNoBB2ZqwywR4PNM+8PKGztnD4zs0DRmdncGigeuvtTa8IUAw3wQh0rjEKrah2uF7MAiiHGyaCEOPNfFd3R3TutA5Ksw6Aws6d7aH/bn7gjB/hV6wSfnw4WyhJxNm4QSaFB9KppZ+2NN57KBeC3S57BEbhn3ZP4XRlCvSysgRB14YJVBhC4hRWEHMyncMbHmpb2UQFS5Pyo4p5R8xlFJ7aYt1SokxiNouX0jMyjSLfr5jCpL8BhNkTzpS1EaAK1T7hOiPnupgYnCEYKsKT6y0i/KbiIpLojTOLQ3DzBEBPiboSmLRkbdY8uUQ5y3OYsEXAtz84Aj6hw3CKgMED0PMCSpLI7qoS4wJ6WcqYvX/QuVYamIxdxZw/mkRLjgth5PmRMRi8Ou/DmDbDiBPRz6WhNmk2UsiWHONd5Vjiw99WmpdpsyiMMXSEwJctjyHFQtzaM87s3QAnuku4pEXYuSyo3k1ZMJsjL0mMtZkyL/Ll8GIgrpz9c+f9bXkgFucs9DgynMKWH5yln02drp3KMHtTxSJzPBoRgRL7BonpowbwhntFm1kp3cPpRMbqDTcn/lmrbWUJ8Ok9eHMkwNc9602B1x9jyXJUDn3rB3G1h3E00T6Y+pbMUDyoo9pQAvmGVx6RogdfcC/30qxfWfqmalaUMyAQvoqiYnBVkZLI1uqWMz/PPCj7+Rx/uJ8g8R972rXt7dpe9mpTj7z6dKrMkCuyfbgSIA1G4EN22JcuDTCHSvbMMwJ+elNZTy3OUZAtPOPN/QSAT7ca7FxW4qd/UAu80nDDBSpLtMLFj+5IIOrzmlr0HEHvI7eY4jJ7d3/HMFIOWC7XrwHuppF1/cVyXtubKWaxI6bbjnUWVy1ooBEfpkiykWjUundE+OeJ0fw2EsJVY1xyxhtUBvl2OLr8w1+cVGBXmUUTQ24JC6y7qBBs4E1G0dw04NFqtFoP75Ws6sphbOW/epWFtVHQtU0GhmqzFDJYN3rCV7dWsbZCzKY2uZm73pLHW0BVpySw1EdKZ7fzPiJ3epducVCzuLGi7K45dJ2HDtNbpqkC89R4D5LWu+KXBnVdleC93a1Yn8mOSCbMuBCzuDFNy1u6BrAQLF5RH35WQWCzXASSjn0ltK2+PNPC1jJkZNUNRpwtuNuSvqTnFgadcq7ziiweHdXivVvK81KLVDTERj/nkZj205KK42x/KsN2lavunhuhnZRwewZwO9/0IG5x2bkcJy0q/irUtaTJK+jVk7A7GP7rhi/vH8EgyUG8xzsMRrp3tn/YpKmNrB/RUqMvXXkU6y+qQPHzWjQOAdEnRUrKSLqUBRR6gQvSXuo7lZ9qoJne46V6kWqd2PXIJ7p5qw7ai7unU++GLLaIslA+xlQ9e6RM/edq2/pszuJVgYeUJJytQIvd+tOci8L0enUhc8SiGOS9wzd5eqXSljXnU4AvFCMM16f1fwqsPL9IaXrqApcaSlDqgEXJ3pWGQ+X1rPLEzO1KjWjZXsU4bsfxbh3bYlq1LI8Xdu6NOpCPbtJgp23ZS1mTvGdSIICVqOatPVsq7w4JqqgVZfTn6vu6/r3JY97147go31mv4DNVf6US8sMCPC0KYZn4NWDcByQagde6v5Bxit4KTnxTPpQpcaQRkvvZiJLnS/h768kyFL9VLc6vr6hFq4TYmBqwRtY6nS4qhLqkT3X1EZ379cF0iOQ3rs83t3hOUTM+eKdDys4cY7B7v4U+4aMmzDlkcKWvNAEVEg46ebdKqmm7g6XQIkBSZUq4jDX8xxvTtpKedVRPU/D5RQXLsvgojMDDAxZvL/LYtO2BBu2Jtj6oUWRYUyGOnYg02h5BOSF9nLJ3M/JbMYUbrdwFDxoseZVwquUADJPTOgQWpeWK1bd6nvMGyonjm0KHLM4W3/xmADnnhoQeIq3emOsYwy2blOC/+zWrEz33BgIOClQC1sjrtzwcb+lZBJ87SsR129V0PW7w1YFzVbdzzPgeFBxvTPLIJELQvpbuV7NASrTklJVQnJ0ypeyWDwvi6vPTfD0a4xOn6/QW1nHSA2xxMHJu/WjyDXCM92EzjelTjJsbw+1O8MC2YdUjYgYnfP0oYT3+SpPHfDBIqNbtsNVlfNNSuvUaGSYkNowiZkdXPMuz+O3K/NYeiLjLDZaO1KmWlYhcZ3lDLnm1Qq+f1aMOUeFLtpUvlRHJIbUuP+pez7RYFUux0SsjrG+kZjPFlkCzVHHC1zvZnnXemR3X8LJMmZYkeLdHTGDutSF7vuoviMMzRXWjKUJMSAJfTxgGK8P4TdXthOfl663BT46PgRaLlSMOHZcvopKVJlipeLWFTkCkZF+sJtAOZG99UHKFRjBcjW4b9i6RZTecZMnOw44Iup/PE2IAb2sxctTr1nMmzWC684ruAWLxyqwHnwVu+tLkhcQRap7hyq0oxRvfwD0vJ/ShVrs7DNOsqosQ5XqaOE0fhdC5c2IuxLMbsJZs8q1PA3jfU9V+Oowrl6RB9ct3r2ygsBLPWx1ElPzohzjnbXrEzz6fMqR8B2GRCup1lZeE4Th2iUD7ELnBEgdcVcG966p0Hcn+PEFeRzVHlKSUh1J3ENRWgKSSsnw538hw4mqhGzdHbrKrs4Euq9XlVfmgE2OBEiTzD/WW/zsnmE8ubHoJO9CaeLStqDzRrzLGxXJ3MK5AU6dx01/zsCfFYXHLLv5VmKZsC0IQI2JfUPAs2+keP29CgoZi1nTA3os71Vc+MwR0CSmEEG6/UJP4vT8oJkwJtHGFtuhiCZDTgNqyz/DMMCie1sZJ8wu47tnRljOdbT2K+JUfXg3uGhexPIK3uml1OqqNJnOKUC2O2kVqndZ553RJQFpvfB2r8HvHq3wVJjMVRrH101sVKcM55Jvcv9TE95BE+XCmVg8+PjFG9/BpTU3yEtFFO+zbwS45f4iXtxcYZ4AW5S46D/95BDHcyNXI+PnEI3Q5NJEL+/tG6g1ovvBpGvtac7YMxjijtUx/vIvfmGhLego5A2+vSRyofPB9eW9kNAeMtIMqm4eeRb4wxNlt+2ioO107o/OOZoeSjPd5IkxpkkZYRw6knT8ZGXwXI/BHY9VGJanmN4e4Bunhi4CnVzvbNnYSsBPZV1BlJ9cGxN8Syr1xvYAd64uY8ce7lIvCPgJSRtbE2yI1Y0+v4amK+DLq7Vhdbgox40BudC7VrsPFDhjAeMkxSITJWJmaP63IBuW1vPj3ptG3zkkCp1as9bS9edxeZpmG+qpfEydetm4fLaXpZfa2hvg7scrOIWzs76JWU3X+/U5Lq9abgwjIGLORqVXgu7bPzeU2uA2fUSuk5vc6k8E6r3SmBwmmddQb1ydelmTfJYp/u/ZzsiWAd6smR57Q/v19xty/QMZoEe7Tdidj9hy9rQH4srww0GWMf5hIrnTLCe4194x+C/3XUPOSK2QMCbp8MNbzp75gOo7BvA9blMfHa+Kk+GH+BFZnzBbaesg6yjI8AsVv9jXSH0y6duwybTRaxFjHK8SZtX2DDCh/x5smXHnlXFaXJXadDM/h9PS5Z0O3LAamRRpfUky7k8pSrjHcReyKAyhli3J5iQprhLGnj/6/0moctPXZvPvNjP28e82AS7m7tO1/G8IY0jjBFXt16n/5NNSIIITD+MR1PNSriaDsg3CrgTJ4wPTh1/ubfJ3m/8B5M725Ex+J24AAAAASUVORK5CYII=", subcat: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAMKADAAQAAAABAAAAMAAAAADbN2wMAAAVp0lEQVRoBa1ZCZRddXn/3f3dt8zbZksm+zaTBBImMSGERdE0YADBDRUQl3LqoRRoPbS19RwMrVvraXu0Ku0BFRE9irRAWASMBFBiFkkTYEJIBrJMMpn1zfK2++67S3/ffRkIhoioN7nvvnnv3v//W3/f7/uegj/RcfSll/JlpzDP94Iu0/A3mJr20YnxCSiKgqamJgSh8p0g1J9yA2+fpSR653Z3j/8ptlb+mEUOPX333PJ48ZIwVC4NdXO5bufbdNtWavBRd+vQVC1SQJSwTBOaoqLiuqGiaUcN0/w/VTEe9kL7ka4VK/r/UDn+IAX2brpjjQ7nplqlvEENg3SoqQgNE7rVisBKwOffUBX+FwUoWvTCi0jJF1VVYRoGNM2E52FI1bUHNNu+c1Zn9863q8jbUqBny4MLwsGe2/zi+IcT2axRCzT4PhBwFU0EMlJAMksFjEhSsbxGYRUqc8ohH9Ejmm4iZseoSODquvoD09C/3LZo1aun3H+aD95k5VPv3LgxVK9YffcNKA/dqk2ONHvlMtR0CmosAV2LIWSoBDStqukI4nmEFr+j8KKAXBumf31d+Vyh8HRFdMp7nTclLAuOFwzWNfzj/DPXfvf1J07/7i0V+OUvH86qw6/+p+WXrw4rFfjjBZgaoCeb+GJROFresqHoOsLAQ2g3A4lcJPxvKyB/Tx2REpIjPCWk1LDxjU5vhbpBr2p3xXX3ppau84pTz7zZlSY4/bH7oXs70LfnMcMpXO06DupeHb7rRhaNhPMDBG4N9WoZgcfPgxCKV2Gy0h9i5dMu3fBAdM8JpZQo1FR4XKPu1GDA+2TJ9R/pe3lrx2mX4RenVWDrL+7tGB3fu0n1SqtrtRpDIYRKC6sU2q8HcCm4V69BEatT+KBejwSmCFzUi+JeouRULRhWYvFIwYaSomgYhvQg1+bp+R6KpSL3C853S86DfbtPr8SbKrBt26NNzpG992l+dYVT96gm3cpNGDlQuJFfrSGsOBTajRRRfH7rBTz9SBFqRA/IE1Mn3544poTnDdEn0W20ehjwWRrD9+tchl6lQUrlKmKWuVI11R8NDfUkp9Y4+XqKAvfeG2qF/btvD7zaGscJKLvEKZGEGxpMUlXM6vrwSzWYoUYoFCSikhSAkcNYVmERWQzG8VSIvH5trBPQCAEtHfAZsbZPwUVoz5cwrVL4WiSjoJMgVTKZuMCf8L9+suBT7/WpN1PXeP1bf84qdFWdFtV02ZACUwn6GCG9oQhcqgZty88UHbFUOlKKUQXEbdjZPKxkmshkRYp5xNk6wV6el4ARwantie24ZhgFUKREQE9wUegseslEErFYPIpAp+oQpdxP9z+3Zcf0lRf+94mHo8sb8uzxTXfPdQYP79TqTl5T6yCwwKQ1dUOHTisZlSJiRhP1MVHjXqqqw0pnEIsn4dPimh1HLJmkktyYz8nuEtdOpQpXrEprNxTg3pHgUxcJIX5Gb1oxm2FjcW8aj/cwD+AxB6uTQ/Br/qiaTK+ac/ZFB6eUOMkDoVKe+NqX9NDLe7RWA78bMSyWUyR8sjmoxPiQoWXZCWjxBBQrhsC0YFhxKmpBFcFZiRWNz4j3GH52Ig7LsxDSG4HkBYWV2A+InSHzS6BUYTVUaTGd+0R5Ed1E79UqqBRGUC4XEU+k8k7d+TyFv25Kgdc8sHnTj1eNDR98Vq2VaO+AMewz5ukBVlWLFrHtFN2ag+tUEZRKsLLNUBg+iniHgpumHVVjoRWNmKfCFD46BHFoXZqlIZx4Rv5Fu0ux4+es6CHBQAl9hhXVDAjZ1QomRg7DGRtlXSCXstMSorVkS8ea1rlLd8vaJ3ZgXpYKnyX2GgIOEWRSCVlfhOELlUiRvyRhGDb0TAZmLg+bxSxmJ0nU7KgKS8LJ/eI3iXk5KLosiEgx4Ui0sqLT4jxV5pKciuSYHBSeccZy4sJzy7T8IKoD/Qyf4SifxAAxO26Vy+Xro/v5Einwm1/9fFa97mzwCItMs8YpgsvmPIR0lViFd+3eif2vvoKB8UkcPT6IoYEhVEuVyIKmxVwxdRYxPkNLBqwXDPgT1pQ1ZXvZkMgmV0lqWlqReyIUcim6hzqVCCRXKIskv5w+T5VkUWV+kGqIbT440NvbKrJFOTA00HuJprpNbsN08vlJBzdisrrE+Icf24Kte16UgstY1aATN+PMg5kzZ2LN2tVYckYXFsxfiFyuhbJTZGF6URhRZD4/FV5StCSZGTzRVeBUwkYyOSpmFFj2MJMJWAzTultlCNJTXEuei1ta3nMnL6aQd1OBUPG9b14uWsrCEi5R2NBicpXktFNZ5JIZzFq6GJu27mwkKBd6R/cKdHV24ljfUdx33yZM3HkXpk1rw/nnrsW6devQ2dXF5Un0qIwvFudVY5JKmIRSvCJFhM7yO8kJ+UwUY3LwTrqeHiVIGKToIrzkiigrBc/zlctFAW379vPzE4NHvhi4bkKh2w1CgK7SGloIK5FCc/s8pPLTmagxnNV9Fkwm9K+f3YkLzl2Nr3/jX/CuVWdgeUcbWpvISmmEY32D+PWOHdiyZQtGR0YwZ/ZM2HHSZW4svufuPGlh3hsZjV5iRtBY9JLAJo3GTKL3SCsYSp7rMJmdSBGVQCLRoJBAsitq+qvP/u2derFwbBEt0hJwITYnPCWRQiZLFs1t85DMtTFB+YAoR2xe0jk/qrjdnYtgFArY+fQWPLl5M/YOjWGwyI24weK5c1EkWv3gnnvxwgsv4OZbbsay5csZCtKruYwq5gkVkTivMLecigdXFKTwGhOd9AG2wV6BsojHhK5LPlL9SElBK9VS2k2lulCvFifnalTb45qKWJ5JphNVWlpnI5uj5ZnAjExxOg2ooDnXjAQr7sHeXhza8xtse+Zp9JbqMGd0obC3B7WIIw1gwzlnY+VZ3XjgsZ/jti98Cbfc8jdYuWJl1Ik5hMeX9+7F9m3bse/l/egfHJFWMyJyImMiYWN6ezOmt7ZiZsd0LJw5C9MyLTSgHoWiT2NbTELP82fo2YT5geOTDe4hMS8uSjZlYTelo04roBVEcFFNCNc8Wvcd3cuwq6cHF67sRNVKomd4BCMHtvPZxvMuLXho8ChuuPGvUSPduP+hh/BP//xldC5ciJZcDik2Q08+9QwG+gdpjCTMmIlyzaFQCtqb80im0+g7Poat23fDIbVuyacxd/ZcrF6zFhe+ex1y+QiAxClLtWuvfO9PxgrjEaRJqSHOorWtA7FEU+QySSdBiIgCkGVqrLAxwtlDDz2GebNnIJ7J4vlD/ShMTgp60koW3rmqG0nm0lnLlyGezWLzM8/AIbs80teHA72H8Py+A7BjSXz4PechnbNxeHgUJQ4BWBMxJ2XisgtW41OfuAoXXXoRDh45gpcPHkE/IfuZp3cgk03hHStXkAXT66a5Rh0aHDqBx0KBFZb9NIWn9UVwiVOeEcX1yBTJZ6psXlavXh4l9KO/2oFFS5di/blnY1pbM63XjMsvXo8FszuQI0dqTeex58UeQrCEoAWXiEQWIr7Exy85F0nDwdbdL+DIuIOyYuN4TcPBYhUv7HkOw/t70NGawjUfuwKswfSOhWVnLUHXkqWoMdwkZ72am9B9dlkRMhAEhJxlyHd0chuhvJJUcvXYsMjJP7l3SCQycd111+Kmz34O9/z0fnzkA+/H6u7FpBs26UYCY4P9mLF4KR7f8jQeeOhhLCC0XvqetVS+ivuf2Io6ES6fjWPnvqNRbrFEwK+wByB9KKcshhPR7NgA2jhXmhwdYdfn44oPvhdXfvQaGOzDqzVa32KSE3a1v7jq8o3F4hg5jx7FY0t7AzIldATQJHyEUHmMUeE24iXxyrTp7VQ2jSeeeAqbf/EUxkZH4dA7I0MDcGsutu3eg7sf/RnDykMX742T3VpEmFK1zio+jAWzppHnHEeJTHOYnzmsojIaWNSRRZ5JvIgG0Mw4vnXHXShXfdxw/SeRyTST1dKwIgfPmBWr6MT3J9novpvQTCobY4ybDB2amo1JZHEpLoQ7gTxCkkAVFWNhrZawYd15OGPhbGzbthO7X9yHzU9vw8RkiS5mx0ZPZeImFs+ahTVnr8TSpUvgUNhjDJeXmQePbduNT73vncgd7IXRexhHhoroyGWxtCOPxV1nomXWPNx+1z3oOdCPm2++AXOmT0NNujWOcsJARZwJ49Tdr+r9g33fJyN4t3AXXUgWUaPRWBCDKaxwl0YuMI7pa5keSNi5TjnKiXRMxzom3YXnrCTOezh+fADbtz+Ho8f6kSTl3nDZBpz7rgtgxTkzojGseBqvHOnH84cP438e34k/Y0G8eslych2F98cI0+00j4Xbv/ND7HjuRdxy4/W45D3n0guTNGCcS3BwRjlkwBBq4V7dVdUDTrkYZpJNZMbSbZ2wMs0f9b+0pMoOibgbcZvy5CgqPGUS4TM5TVZHnV1ajdWyXCyyV6hi8ZyZmNNGDJ87B93dy+kttomsDwb504xcEuctW4BqfRIv9r6C53oOoDWTRGfnPGSzGYZYDbvJtwxGwuf+/kas6V6CwkgfhSWtiZEvR4CuMJ88L5FO9OrZVMf+Vw8dGk7F/VaLvEPKrMQ5AyeKfwkjnUzQZ4K5TgWlsWGUJwqkDQFhLEZIZdiRYvsMO50eaOlIY/6ZmYiV9rHYDezqgck16qETJT9xDYtntiFhr2K1rkMGKA770WFC+eGBYTY1Ki5+73pcccnFyGViKI0ORTmosjlxNVZuVnJLs1HzwuPpSv1V/f3X3lD497/bvott3MWiQBhRCcF+ASdp1EmeJGQY1w7dGPouvUSJ6JUUMV7qhcbKbZM1NrdTCSprk0WeaKOx5Yf3QZ8oYSkTucJ1Ci6HY9MY62eegWKR9Yd8p236dORa27kOuzmioYwpOWpkHzBBuGSoyMl/ASeWdRrA5P601/au884rkkQooWHpDzSaCrJGLigjDelhfSauSwRyShMoj7PYcEHBYF8qsyABJ3Ixjs5tQqfG8JMEF4SqcPTocBDWwsRb96HLkW3N49jBw3jplVcwGVOxcNlSzJo/n9/P4HDPRoEINjx4jJA5iPHhAYwRyaoTYwy9IodcVJiKC9ljBlIhAghzgOo8yGRp9APtLbN/plNfZmuTz0QLGC58H1nbJxus8wx5CqWyaW1YzAsWFptVWyDJZbkPOLGQKYZ4K3B8VIu8EhhkB6MpjsNKHfPPX4VpXYz1lmYuL6zXQDKVocGqEWrValXSZCmoMq5ht8b4Fd4jrZAQOp0wrDE0WE+GDTP389cUuPLmjUce+fYXNpEOXxNEtIHxIyWGjSrRNCJQwolqTFCXmwnn4c8AFJCUmJhfnJiIvOVReIf3GPxevFcqlpgzYzg6MYzkvJnIz5wW5dXw4GCDZXJNM6ZxqsE5KxXi2I+GIMLUOTSj7iZ7bZlSSLOv0sPClZI00njN+1/2G4OvKSBvMrnWb7Dr+ojlBUZjSkbcpxKigcHc0GkN4Um+16ATIWmBL5ydBU5moR7j1C2zBlTKGCoMM/FqKE6W2RMUcJyQ2trWTi50FE2ZBPfKRQr60slH3CrBtW0KLcMtqfr0QiPq4XNtQS8WLMogOem5iUTqdpFZDgbA68fOn931o2wy/jGHMRYd9IaEkiSt/BN3+qQUEpPSjARMNErCmGfJHx9Djf1xieVfYlqK1uRkFVWOB6ukz0JJZs2djekdLWSjaQ4JBL1YZyyO1VM5JFknypVxzltZ8Rnv0XiFoSQ/lkg/YrEqpzJ5TASx752z/qpPT0kt/d1rR6DmPl+tFtdZMaPFJbWM+lT6Utwp0wONLhWkkCZcfkJyi5zZVCaoQInqkXIwTyYnx4lYruiNPC1ttnMexD/K9IzDGB8bHeNanEhEMMUrw8MlMlVpSssyCBqSB6z8OouexckfO0EZpMn9Fb82nG6ZfdtrAvPNGzwgX+zZ8sh1tlG/QxppGZn7RCSpvjL2FogzuIl0TXUSKiFZLnO/VCxgfHQYtWKZFmS5ZxwHNICQPum+opBgCAoyTU5MkiwaaCdCxcl5dFZyM25EBpHOOMuRTaVciSqtAEWMPXmc45tkUwbV0LpuxUXXfudkBSjSG4+X37Xhe67n/UglEgmEifCEBSriwSlSWPa5tVKZsSgFr4HZChWSQ7A6lUrxTLKby6IpzYGyxC3j2LZNpNnIzJjZgXwzJ3zs/hSVBIy1tSIVnOvXqOAYQzHJNWzORqk7Q096EI4n6/5d3es//r1oo5NeTlHgSkXxrVj8+pHRsR3Cd8b4i4wMVzUhcgJhDIUJKlEWnOb3gkwyPhHyJv9K/L7GGqKZhD4KIN2cSXoudENnzMdsgwrS8kQTySNJ9joFl+Q3oz3A3wbK0dDMYrdms331QuVZM567kTWLgfnG45QQmvr68e9+a+aefc/e36QqK9vZoZEAIs8mJSR01ljcyuXJKNYt8nOd8VktTjAMZK4vvxk4HInTgox1CUH5RVKIoozRFbrUkzrDnJHf1KRky48aOhPXoLCWVPFooGwjx0rvetpzmbb2yzvXXnlsSraTr6dVQG669sOXdizMmT99ft+L50hpX738TLpVQV9/Py1XZcJOsvkIMaO9Ha05jmAyTSxODCs2LJL5EXbxrZBEk3Ef0RSFYcPYkHqjRxNsIY/0kihpEyR4yqxVJyvWYsmt7bPPuLJz7WVvKrzI+DsVkBs2/sfGzEubH/tmcXjw6jFavVYhTtOAZVpOYXzyl16Gi8FJQhyLWshAGftZ9q2JGCfV7Iujn1gZXnrUhJCSC5ciMZMfRgyGVjSyoQKxOIVm0ouSHI1yKtd6d5DM37jhmpvIo09/vKUC8uiWMNR/8Zcf+gyblY1HDx9sLghzZGPiOCVMI5LUQh2TzIXWhIUsFWlmkrYSQvNsD1OJWPTDoMz4Zd7DRpzV1eCQmFNtyQ16QWZCiaTNK1npeG2wKZG69ZO3fvsO2lcg5Hcev5cCUytsffAHC3p7dt36at+xj/a8tN/49fPPM21VtLAwuXRLgSQuQeEStHBIq86fluM8JxP1A02stBLTcsQ4RpFfYSTIqkJF2Fsk0qm6lcz9eELtuO2LX/vaK1N7vtX1bSnQWCxUNt1z+9l7d+2+4dEnN192vDCW1ohCuTSbbZK6cdKHlKVx/sOpBHNCJYxOFsaQZSXNZtJRMbOTcdSpoEM6MlYuT4SG/eDC+Z23/+udP9n2VgL/9vd/gAKvL/GVjZ+bs/mpJy4qTpTelzP1FU263j7GxG5uTnKkwh8jSJWF3L0ko5WhceZGE6ok8i0z2gea2lp3mfHMppbZHY//w1f/69Drq769d3+UAlNbsQZo/3bbLdn+Y4OLbFNduGzRjE94ldKFYyPjEReqO0SeCrbs29fzfT3ZvP/s9esPfAZfKGDjqbg+tebve/1/cLJcFIrDeiAAAAAASUVORK5CYII=" }; const JHS_SUB_SOURCE = { xunlei: { label: "迅雷", color: "#2f7fe8" }, subcat: { label: "SubTitleCat", color: "#e0a33a" } }; const JHS_SUBCAT_HOST = "https://subtitlecat.com"; const JHS_SUB_EXTS = [ "srt", "ass", "ssa", "vtt", "sub" ]; const JhsSubtitle = { /** 文件名里不能出现的字符 */ safeName(text) { return String(text || "").replace(/[\\/:*?"<>|\r\n]+/g, "_").replace(/\s+/g, " ").trim(); }, normExt(ext, fallbackName) { let e = String(ext || "").toLowerCase().replace(/^\./, ""); if (!JHS_SUB_EXTS.includes(e)) e = String(fallbackName || "").split(".").pop().toLowerCase(); return JHS_SUB_EXTS.includes(e) ? e : "srt"; }, /** 番号归一化: 去掉大小写/横杠/空格/下划线, 用来判断结果是否真的对得上 */ normCode(text) { return String(text || "").toUpperCase().replace(/[^A-Z0-9]/g, ""); }, /** * 结果是否命中关键词。 * SubTitleCat 的搜索很松(会返回一堆无关条目), 迅雷偶尔也会带回别的番号, * 所以统一按「归一化后的番号必须出现在文件名里」过滤。 */ isMatch(name, keyword) { const kw = this.normCode(keyword); if (kw.length < 3) return !0; const n = this.normCode(name); // 番号常见写法: ABC-008 / ABC008 / ABC00008(补零)。 // 数字段后面不允许再跟数字, 否则搜 SHKD-808 会把 SHKD-8080 也算命中。 const m = kw.match(/^([A-Z]+)(\d+)$/); if (m) return new RegExp(`${m[1]}0*${m[2].replace(/^0+/, "")}(?!\\d)`).test(n); return n.includes(kw); }, /** 统一去重: 同一来源 + 同名 + 同地址只留一条 */ dedup(list) { const seen = new Set; return list.filter((it => { const key = `${it.source}|${it.name}|${it.url || it.detailUrl || ""}`; return !seen.has(key) && (seen.add(key), !0); })); }, /** * 抓取原始文本。 * 用 fetchRaw 而不是 gmHttp.get: 后者默认 5s 超时 + 走熔断器, * 字幕站响应慢一点就会整源失败(而且熔断后一段时间内直接不发请求, 表现就是"搜不到迅雷")。 */ async raw(url) { return await gmHttp.fetchRaw(url); }, /** 人类可读的文件大小 */ humanSize(bytes) { const n = Number(bytes); if (!n || n < 0) return ""; if (n < 1024) return n + " B"; if (n < 1048576) return Math.round(n / 1024) + " KB"; return (n / 1048576).toFixed(1) + " MB"; }, /** * 取文件大小。迅雷字幕接口只给 url/语言, 没有大小和下载次数; * 而且它的 CDN 走 HTTP/2 分块传输, HEAD 和 GET 都不带 Content-Length, * 所以只能真的把文件读回来量字节数(字幕都是几 KB, 代价可以接受)。 * 下载次数迅雷侧确实没有这个字段, 拿不到。 */ /** 简繁判定: 数两组特征字, 谁多算谁。配合 12KB 采样, 对字幕这种口语文本足够稳 */ detectChineseLang(text) { const sample = String(text || "").slice(0, 12000); if (!/[\u3400-\u9fff]/.test(sample)) return ""; const s = (sample.match(/[为与个么开关东风头后发这边过还进时样气应实话说问题现经体万台农国门书车云]/g) || []).length; const t = (sample.match(/[為與個麼開關東風頭後發這邊過還進時樣氣應實話說問題現經體萬臺農國門書車雲]/g) || []).length; if (!s && !t) return ""; return t > s ? "繁体" : "简体"; }, /** arraybuffer -> 文本: 字幕 srt 有 utf-8 也有 gbk, utf-8 解出一堆替换字符就换 gbk 再试一次 */ decodeSubtitle(buf) { try { const bytes = new Uint8Array(buf); const utf8 = new TextDecoder("utf-8").decode(bytes); if ((utf8.match(/\uFFFD/g) || []).length < 5) return utf8; try { return new TextDecoder("gbk").decode(bytes); } catch (e) { return utf8; } } catch (e) { return ""; } }, /** * 探一条迅雷字幕, 返回 { size, lang }。 * 迅雷接口既不给文件大小、也经常不给语言; 而它的 CDN 走 HTTP/2 分块传输, HEAD 和 GET 都不带 Content-Length, * 所以只能真的把文件读回来量字节数(字幕都是几 KB, 代价可以接受)。 * 既然正文已经在手里, 顺手数一下简繁特征字把语言也定出来 —— 不用多发一个请求。 * 下载次数迅雷侧确实没有这个字段, 拿不到。 */ probeFile(url) { return new Promise((resolve => { const done = (size, lang) => resolve({ size: size || "", lang: lang || "" }); try { GM_xmlhttpRequest({ method: "GET", url: url, responseType: "arraybuffer", timeout: 12000, onload: resp => { if (!resp || resp.status < 200 || resp.status >= 300) return done(""); const buf = resp.response; const len = (resp.responseHeaders || "").match(/content-length:\s*(\d+)/i); const size = len ? this.humanSize(len[1]) : buf && buf.byteLength ? this.humanSize(buf.byteLength) : ""; done(size, buf ? this.detectChineseLang(this.decodeSubtitle(buf)) : ""); }, onerror: () => done(""), ontimeout: () => done("") }); } catch (err) { done(""); } })); }, /** 取字幕原始字节。上传 115 必须原封不动地传, 不能拿解码后的文本再编回去(编码一变字幕就花了) */ fetchBuffer(url) { return new Promise(((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: url, responseType: "arraybuffer", timeout: 3e4, onload: resp => { if (!resp || resp.status < 200 || resp.status >= 300) return reject(new Error(`HTTP ${resp && resp.status || 0}`)); const buf = resp.response; buf && buf.byteLength ? resolve(buf) : reject(new Error("字幕内容为空")); }, onerror: () => reject(new Error("网络错误")), ontimeout: () => reject(new Error("请求超时")) }); })); }, /** * 字幕直传 115 —— 走的是 115 网页版的老通道(和「115离线」「115匹配」同一套 Cookie 会话, 不需要开放平台 token): * 1) uplb 的 sampleinitupload 用 文件名/大小/目标目录 换一套阿里云 OSS 的表单凭证; * 2) 把文件表单式 POST 给 OSS, policy 和 signature 都是 115 服务端算好给的, 本地不做签名。 * 返回 "秒传" 或 "上传"。 */ async push115(buffer, filename, cid) { const init = await new Promise(((resolve, reject) => { GM_xmlhttpRequest({ method: "POST", url: "https://uplb.115.com/3.0/sampleinitupload.php", timeout: 2e4, headers: { "Content-Type": "application/x-www-form-urlencoded", Referer: "https://115.com/" }, data: `filename=${encodeURIComponent(filename)}&filesize=${buffer.byteLength}&target=U_1_${encodeURIComponent(cid)}`, onload: r => { if (!r || r.status < 200 || r.status >= 400) return reject(new Error(`获取上传凭证失败 HTTP ${r && r.status || 0}`)); // 未登录时 115 会回一整张登录页 HTML, 而不是 JSON if (/^\s*</.test(r.responseText || "")) return reject(new Error("115 未登录, 请先登录 115 网页版")); const j = nmpParseJson(r.responseText); j ? resolve(j) : reject(new Error("上传凭证返回异常")); }, onerror: () => reject(new Error("网络错误(取上传凭证)")), ontimeout: () => reject(new Error("取上传凭证超时")) }); })); // 没给 host 有两种情况: 秒传命中(文件 115 端已有), 或者真的失败了 if (!init.host) { if (2 === Number(init.status) || 0 === Number(init.statuscode)) return "秒传"; throw new Error(init.error_msg || init.error || init.message || "115 未返回上传地址"); } const form = new FormData(); form.append("name", filename); form.append("key", init.object); form.append("policy", init.policy); form.append("OSSAccessKeyId", init.accessid); form.append("success_action_status", "200"); form.append("callback", init.callback); form.append("signature", init.signature); // 不手动设 Content-Type: multipart 的 boundary 要交给浏览器自己生成 form.append("file", new File([ buffer ], filename, { type: "application/octet-stream" })); const ok = await new Promise((resolve => { GM_xmlhttpRequest({ method: "POST", url: init.host, data: form, timeout: 12e4, onload: r => resolve(!!r && r.status >= 200 && r.status < 300), onerror: () => resolve(!1), ontimeout: () => resolve(!1) }); })); if (!ok) throw new Error("OSS 拒绝接收文件"); return "上传"; }, /** 迅雷字幕接口 -> 统一条目(带直链) */ async fromXunLei(carNum) { const text = await this.raw(`https://api-shoulei-ssl.xunlei.com/oracle/subtitle?gcid=&cid=&name=${encodeURIComponent(carNum)}`); let res = null; try { res = JSON.parse(text); } catch (err) { throw new Error("迅雷返回内容无法解析"); } const list = res && Array.isArray(res.data) ? res.data : []; // languages 里的「默认」是迅雷自己的占位值(= 它也不知道语言), 当没有处理, // 这样后面 probeFile 数出来的简繁结果才能顶上去 const vague = l => !l || /^(默认|未知|default|unknown)$/i.test(String(l).trim()); return list.filter((it => it && it.url)).map((it => ({ source: "xunlei", name: it.name || `${carNum}.${it.ext || "srt"}`, ext: this.normExt(it.ext, it.name), lang: (it.languages || []).filter((l => !vague(l))).join("/") || "未知语言", // extra_name 基本全是「(网友上传)」, 每条都一样, 不带信息量, 不往标签里放 url: it.url }))); }, /** * SubTitleCat 搜索页 -> 统一条目。 * 真实 .srt 地址在详情页上, 这里只拿列表, 点下载/预览时再解析(否则要为每条多发一次请求)。 */ async fromSubCat(carNum) { const html = await this.raw(`${JHS_SUBCAT_HOST}/index.php?search=${encodeURIComponent(carNum)}`); if ("string" != typeof html || !html) return []; const doc = new DOMParser().parseFromString(html, "text/html"); const out = []; doc.querySelectorAll("table.sub-table tr").forEach((tr => { const link = tr.querySelector("td a[href]"); if (!link) return; const title = (link.textContent || "").trim(); const href = link.getAttribute("href") || ""; if (!title || !href || !/\.html?$/i.test(href)) return; const tds = tr.querySelectorAll("td"); const txt = idx => tds[idx] ? (tds[idx].textContent || "").trim() : ""; // 页面原文是「(… translated from Chinese)」这种带括号的句子, 字符类里放了括号是为了兼容 // 「Chinese (Simplified)」这种带限定词的写法, 代价是会把句子自己的右括号一起吃进来 —— 收尾去掉不成对的括号 const rawFrom = ((txt(0).match(/translated from ([A-Za-z()\s]+)/i) || [])[1] || "").replace(/\s+/g, " ").trim(); let from = rawFrom; while (from.endsWith(")") && (from.split("(").length - 1) < (from.split(")").length - 1)) { from = from.slice(0, -1).trim(); } const size = (txt(2).match(/([\d.]+\s*[KMG]?B)/i) || [])[1] || ""; const downloads = (txt(3).match(/\d+/) || [])[0] || ""; const ext = this.normExt("", title); out.push({ source: "subcat", name: new RegExp(`\\.(${JHS_SUB_EXTS.join("|")})$`, "i").test(title) ? title : `${title}.${ext}`, ext: ext, lang: from ? `译自${from.trim()}` : "多语言", note: [ size, downloads ? downloads + " 次下载" : "" ].filter(Boolean).join(" · "), detailUrl: new URL(href, JHS_SUBCAT_HOST + "/").href }); })); return out; }, /** SubTitleCat 详情页 -> 真实字幕地址(优先简体 -> 繁体 -> 任意已生成的语言) */ async resolveUrl(item) { if (item.url) return item.url; if (!item.detailUrl) throw new Error("缺少字幕地址"); const html = await this.raw(item.detailUrl); if ("string" != typeof html || !html) throw new Error("字幕详情页读取失败"); const doc = new DOMParser().parseFromString(html, "text/html"); const byLang = id => { const el = doc.querySelector(`a[id="download_${id}"][href]`); return el ? el.getAttribute("href") : ""; }; const zhCN = byLang("zh-CN"), zhTW = byLang("zh-TW"); let href = zhCN || zhTW || byLang("zh"); if (href) item.lang = href === zhTW && !zhCN ? "繁体" : "简体"; else { const any = doc.querySelector("a[id^='download_'][href]"); href = any ? any.getAttribute("href") : ""; } if (!href) throw new Error("该字幕还没有生成可下载文件"); // 站点自己的链接里带空格, 必须编码后才能请求 return item.url = new URL(encodeURI(href), JHS_SUBCAT_HOST + "/").href; }, /** * 渐进查询: 两个源照旧同时发出, 但谁先回来就先回调一次 —— * 迅雷是 JSON 直连接口(通常几百毫秒), SubTitleCat 要抓整页搜索 HTML 再解析, 慢得多。 * 原来 await 两个都完成才渲染, 等于让快的那个陪着慢的一起等。 * onUpdate({items, errors, done}) 每个源完成时调一次, 最后一次 done=true。 */ searchStream(carNum, onUpdate) { // 番号过滤: 只保留文件名里确实含该番号的结果(SubTitleCat 搜索很松, 会混入大量无关字幕) const relevant = list => this.dedup(list.filter((it => this.isMatch(it.name, carNum)))); // 迅雷是直链、命中率高, 整组放前面; 每组内部再把中文字幕提到前面 const zhFirst = list => { const w = it => /简|繁|中文|chinese|zh/i.test(it.lang) ? 0 : 1; return list.map(((it, i) => (it._i = i, it))).sort(((a, b) => w(a) - w(b) || a._i - b._i)); }; const groups = { xunlei: [], subcat: [] }; const errors = []; let left = 2; // 顺序固定「迅雷组在前, subcat 组在后」: 后到的 subcat 只往后追加, // 已渲染的迅雷条目下标不变 —— 补文件大小那步是按 data-idx 写回的, 下标一变就会串行。 const emit = () => onUpdate({ items: [ ...zhFirst(groups.xunlei), ...zhFirst(groups.subcat) ], errors: errors.slice(), done: 0 === left }); const run = (key, label, promise) => promise.then((list => { groups[key] = relevant(list || []); })).catch((err => { // 带上原因: 只记 label 的话两个源都挂时面板只显示「没有找到」, 会把"请求失败"看成"没有字幕" const msg = err && err.message ? err.message : String(err || "未知错误"); clog.warn(`[字幕] ${label} 查询失败:`, err), errors.push(`${label}: ${msg}`); })).then((() => { left--, emit(); })); return Promise.all([ run("xunlei", "迅雷", this.fromXunLei(carNum)), run("subcat", "SubTitleCat", this.fromSubCat(carNum)) ]); }, /** 兼容旧用法: 等两个源都完成再一次性返回 */ async searchAll(carNum) { let last = { items: [], errors: [] }; await this.searchStream(carNum, (u => { last = u; })); return last; } }; class ye extends X { getName() { return "DetailPageButtonPlugin"; } constructor() { super(), this.answerCount = 1; } /** 详情页鉴定按钮统一切换逻辑: 首次点击=标记, 再次点击=取消. 屏蔽需要二次确认. */ async toggleStatus(actionType, evt) { try { const info = this.getPageInfo(), carNum = info.carNum; if (!carNum) return void show.error("番号为空!"); const existing = await storageManager.getCar(carNum); const isSame = existing && existing.status === actionType; const doSave = async () => { await storageManager.saveCar({ carNum: carNum, url: info.url, names: info.actress, actionType: actionType, publishTime: info.publishTime }), window.refresh(), show.ok("操作成功"); }; const doRemove = async () => { await storageManager.removeCar(carNum), window.refresh(), show.ok("已取消鉴定"); }; if (isSame) { actionType === d ? utils.q(evt, `是否取消屏蔽${carNum}?`, doRemove) : await doRemove(); } else { actionType === d ? utils.q(evt, `是否屏蔽${carNum}?`, doSave) : await doSave(); } } catch (err) { console.error("鉴定操作失败:", err), show.error("操作失败"); } } async favoriteOne() { try { let e = this.getPageInfo(); await storageManager.saveCar({ carNum: e.carNum, url: e.url, names: e.actress, actionType: h, publishTime: e.publishTime }), window.refresh(), utils.closePage(); } catch (t) { console.error("收藏操作失败:", t), show.error("操作失败"); } } /** 兼容旧调用名, 现在打开的是双引擎(迅雷 + SubTitleCat)字幕面板 */ searchXunLeiSubtitle(carNum) { return this.searchSubtitle(carNum); } /** * 让弹层可以拖着走。 * layui-layer 自带的 move 只认 .layui-layer-title, 这个面板用的是自定义头部(title:false), * 所以自己在指定的 handle 上实现拖动。 * 注意: layer 是 position:fixed, 坐标必须用视口坐标, 不能叠加 pageXOffset/pageYOffset, * 否则每次按下都会按滚动距离整体下移一次(表现就是"点一下往下跳, 最后跳出屏幕")。 */ makeDraggable(layerEl, handleSelector) { const box = layerEl && layerEl.length ? layerEl[0] : layerEl; if (!box) return; const handle = box.querySelector(handleSelector); if (!handle) return; const fixed = "fixed" === window.getComputedStyle(box).position; const offX = () => fixed ? 0 : window.pageXOffset; const offY = () => fixed ? 0 : window.pageYOffset; let startX = 0, startY = 0, baseLeft = 0, baseTop = 0, dragging = !1; const onDown = ev => { // 头部里的按钮/链接照常点击, 不触发拖动 if (ev.target.closest("a,button,input,select,textarea,label")) return; if (0 !== ev.button) return; const rect = box.getBoundingClientRect(); baseLeft = rect.left + offX(), baseTop = rect.top + offY(); startX = ev.clientX, startY = ev.clientY, dragging = !0; document.addEventListener("mousemove", onMove), document.addEventListener("mouseup", onUp), ev.preventDefault(); }; const onMove = ev => { if (!dragging) return; const dx = ev.clientX - startX, dy = ev.clientY - startY; // 只有真的拖了才改位置, 单纯点一下不动 if (Math.abs(dx) < 2 && Math.abs(dy) < 2) return; const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight; const minLeft = offX() - box.offsetWidth + 80, maxLeft = offX() + vw - 80; const minTop = offY(), maxTop = offY() + vh - 44; box.style.left = Math.min(Math.max(baseLeft + dx, minLeft), maxLeft) + "px"; box.style.top = Math.min(Math.max(baseTop + dy, minTop), maxTop) + "px"; }; const onUp = () => { dragging = !1, document.removeEventListener("mousemove", onMove), document.removeEventListener("mouseup", onUp); }; handle.addEventListener("mousedown", onDown); } /** 字幕面板样式(只注入一次) */ ensureSubtitleStyle() { if (document.getElementById("jhs-sub-style")) return; const style = document.createElement("style"); style.id = "jhs-sub-style"; style.textContent = ` /* 圆角: 方角的 .layui-layer 会在圆角内容后面露出白色直角, 所以容器也要一起圆。 这里不给容器加 overflow:hidden —— 会把 layui 的关闭按钮(.layui-layer-setwin)裁掉; 裁剪交给 .jhs-sub-wrap 自己做。用 :has() 定位而不是 skin, 免得覆盖 layui 自带皮肤。 */ .layui-layer:has(.jhs-sub-wrap){border-radius:16px;box-shadow:0 12px 40px rgba(0,0,0,.18)} .layui-layer:has(.jhs-sub-wrap) .layui-layer-content{border-radius:16px} .layui-layer:has(.jhs-sub-wrap) .layui-layer-setwin{z-index:5} .jhs-sub-wrap{display:flex;flex-direction:column;height:100%;background:#fbfbfc;border-radius:16px;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;color:#1f2328} .jhs-sub-head{display:flex;align-items:center;gap:12px;padding:14px 18px;background:#fff;border-bottom:1px solid #ececf0;flex-shrink:0;cursor:move;user-select:none} .jhs-sub-badge{width:40px;height:40px;border-radius:11px;background:linear-gradient(135deg,#e04b43,#c8352e);color:#fff;font-size:19px;font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0} .jhs-sub-htext{flex:1;min-width:0} .jhs-sub-title{font-size:16px;font-weight:700;line-height:1.3} .jhs-sub-sub{font-size:12px;color:#8b8f96;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .jhs-sub-bar{padding:14px 18px;background:#fff;border-bottom:1px solid #ececf0;flex-shrink:0} /* 右侧留出卡片内边距, 让「搜索」和下方「下载」按钮右边缘对齐 */ .jhs-sub-searchrow{display:flex;gap:12px;align-items:center;padding-right:20px} .jhs-sub-input{flex:1;min-width:0;height:44px;padding:0 14px;font-size:15px;color:#1f2328;background:#f3f4f7;border:1px solid #e8e9ee;border-radius:11px;outline:none;box-sizing:border-box} .jhs-sub-input:focus{background:#fff;border-color:#e79a95} .jhs-sub-go{width:92px;height:44px;padding:0;font-size:15px;font-weight:600;color:#fff;background:linear-gradient(135deg,#e04b43,#d2372f);border:none;border-radius:11px;cursor:pointer;flex-shrink:0} .jhs-sub-go:hover{filter:brightness(1.05)} .jhs-sub-go:disabled{opacity:.6;cursor:default} /* 右上角复选框: margin-right 让它右边缘与下方「搜索」按钮对齐(18+20), 同时给 layer 关闭按钮留出位置 */ .jhs-sub-check{display:inline-flex;align-items:center;gap:8px;font-size:13px;color:#4b5058;background:#f3f4f7;border:1px solid #e8e9ee;border-radius:10px;padding:7px 12px;cursor:pointer;user-select:none;flex-shrink:0;align-self:center;margin-right:20px} .jhs-sub-check input{width:15px;height:15px;accent-color:#d6413a;cursor:pointer;margin:0} .jhs-sub-chip{font-size:13px;color:#4b5058;background:#f3f4f7;border:1px solid #e8e9ee;border-radius:10px;padding:7px 12px;cursor:pointer;user-select:none} .jhs-sub-chip.is-on{color:#fff;background:linear-gradient(135deg,#e04b43,#d2372f);border-color:transparent} .jhs-sub-body{flex:1;overflow-y:auto;padding:10px 14px 14px} .jhs-sub-body::-webkit-scrollbar{width:8px} .jhs-sub-body::-webkit-scrollbar-thumb{background:#d8d9de;border-radius:4px} /* 紧凑卡片: 两个按钮改成横排(竖排时 32*2+7 的高度反过来决定了整张卡的高度), 一屏能多显示大约一半的条目 */ .jhs-sub-card{display:flex;gap:10px;align-items:center;background:#f4f5f7;border:1px solid #ebecf0;border-radius:10px;padding:6px 12px;margin-bottom:8px} .jhs-sub-card:hover{background:#eff0f4} .jhs-sub-no{min-width:18px;font-size:13px;font-weight:700;color:#a2a6ad;line-height:18px;text-align:right;flex-shrink:0} .jhs-sub-icon{width:18px;height:18px;border-radius:5px;object-fit:cover;flex-shrink:0;background:#fff} .jhs-sub-main{flex:1;min-width:0} .jhs-sub-name{font-size:13.5px;font-weight:700;line-height:1.3;word-break:break-all} .jhs-sub-tags{display:flex;gap:6px;flex-wrap:wrap;margin-top:4px} .jhs-sub-tag{font-size:11px;line-height:1;padding:3px 7px;border-radius:6px;background:#e9eaee;color:#5b6068;border:1px solid #e2e3e8} .jhs-sub-tag.is-ext{color:#d6413a;background:#fdeceb;border-color:#f6cdca;font-weight:700;letter-spacing:.04em} .jhs-sub-tag.is-src{color:#fff;border:none} .jhs-sub-acts{display:flex;flex-direction:row;gap:6px;flex-shrink:0;align-self:center;margin-right:2px} .jhs-sub-btn{height:26px;padding:0 12px;min-width:54px;font-size:12px;font-weight:600;border-radius:7px;cursor:pointer;border:1px solid transparent} .jhs-sub-btn.is-down{color:#fff;background:linear-gradient(135deg,#e04b43,#d2372f)} .jhs-sub-btn.is-prev{color:#4b5058;background:#eceef1;border-color:#e0e2e7} .jhs-sub-btn.is-115{color:#fff;background:linear-gradient(135deg,#2f7fe8,#1f66c4);min-width:64px} .jhs-sub-btn:hover{filter:brightness(1.05)} .jhs-sub-btn:disabled{opacity:.55;cursor:default;filter:none} .jhs-sub-empty{padding:48px 20px;text-align:center;color:#9a9ea5;font-size:14px;line-height:1.8} .jhs-sub-warn{margin:0 0 11px;padding:9px 12px;border-radius:9px;background:#fff8e6;border:1px solid #f3e0ac;color:#8a6a1b;font-size:12.5px} `; document.head.appendChild(style); } /** 迅雷 + SubTitleCat 双引擎字幕搜索面板 */ searchSubtitle(carNum) { const code = String(carNum || "").trim(); if (!code) return void show.error("番号为空!"); this.ensureSubtitleStyle(); const html = ` <div class="jhs-sub-wrap"> <div class="jhs-sub-head"> <div class="jhs-sub-badge">字</div> <div class="jhs-sub-htext"> <div class="jhs-sub-title">字幕搜索</div> <div class="jhs-sub-sub" id="jhs-sub-count">正在查询 迅雷 / SubTitleCat …</div> </div> <label class="jhs-sub-check"><input type="checkbox" id="jhs-sub-origin"><span>使用原始文件名</span></label> </div> <div class="jhs-sub-bar"> <div class="jhs-sub-searchrow"> <input class="jhs-sub-input" id="jhs-sub-kw" spellcheck="false" placeholder="输入番号或关键词"> <button class="jhs-sub-go" id="jhs-sub-go">搜索</button> </div> </div> <div class="jhs-sub-body" id="jhs-sub-list"></div> </div>`; layer.open({ type: 1, title: !1, closeBtn: 1, shadeClose: !0, scrollbar: !1, anim: -1, area: utils.getResponsiveArea([ "860px", "80%" ]), content: html, success: (layerEl, layerIdx) => { utils.setupEscClose(layerIdx); this.makeDraggable(layerEl, ".jhs-sub-head"); const $list = $("#jhs-sub-list"), $count = $("#jhs-sub-count"), $kw = $("#jhs-sub-kw"), $go = $("#jhs-sub-go"); let items = []; const useOrigin = () => $("#jhs-sub-origin").is(":checked"); /** 下载文件名: 默认「番号.后缀」; 勾选「使用原始文件名」则用来源原名 */ const fileNameOf = it => { if (useOrigin()) return JhsSubtitle.safeName(it.name); const kw = JhsSubtitle.safeName($kw.val() || code); return `${kw}.${it.ext}`; }; /** 卡片上会随状态变化的两处文字: 下载文件名 / 语言标签 */ const refreshCards = () => { $list.find(".jhs-sub-card").each(((idx, el) => { const it = items[$(el).data("idx")]; if (!it) return; $(el).find(".jhs-sub-name").text(fileNameOf(it)); $(el).find(".jhs-sub-lang").text(it.lang); })); }; /** 迅雷接口不给文件大小, 也经常不给语言: 面板显示后补一次(并发限 5, 字幕文件都很小)。 * 一次请求同时拿到两样 —— probeFile 把正文读回来量字节数时顺手数了简繁特征字。 * 渐进渲染下这个函数会被调用两次(迅雷到 / subcat 到), _sizeTried 用来防止同一条被探两遍。 */ const fillXunLeiMeta = async list => { const targets = list.map(((it, idx) => ({ it: it, idx: idx }))).filter((o => "xunlei" === o.it.source && !o.it._sizeTried && o.it.url)); targets.forEach((o => { o.it._sizeTried = !0; })); let cursor = 0; const worker = async () => { while (cursor < targets.length) { const { it: it, idx: idx } = targets[cursor++]; const { size: size, lang: lang } = await JhsSubtitle.probeFile(it.url); const $card = $list.find(`.jhs-sub-card[data-idx="${idx}"]`); if (size) { it.note2 = size; $card.find(".jhs-sub-size").text(size).prop("hidden", !1); } // 只在接口没给出语言时才用检测结果顶上: 接口给的更权威(能区分日/英等, 检测只认简繁)。 // 「默认」是迅雷的占位值, fromXunLei 已经把它换成「未知语言」, 这里再兜一次 if (lang && /^(未知语言|默认|未知)?$/.test(String(it.lang || "").trim())) { it.lang = lang; $card.find(".jhs-sub-lang").text(lang); } } }; await Promise.all(Array.from({ length: Math.min(5, targets.length) }, worker)); }; const render = (list, errors, pending) => { items = list; const kw = $kw.val() || code; // pending = 还有源没回来: 此时不能报"没有结果"(会先闪一下再变), 计数也标成进行中 $count.text(pending ? (list.length ? `已找到 ${list.length} 条 · SubTitleCat 仍在查询…` : `正在查询 迅雷 / SubTitleCat · ${kw}`) : list.length ? `找到 ${list.length} 条 · ${kw}` : `没有结果 · ${kw}`); if (!list.length) { if (pending) return void $list.html('<div class="jhs-sub-empty">正在查询两个来源, 请稍候…</div>'); // 有 errors 说明是请求挂了, 不是真的没字幕 —— 分开提示, 否则排查方向完全跑偏 if (errors && errors.length) { return void $list.html(`<div class="jhs-sub-empty">来源查询失败, 不是没有字幕<br/>${escapeHtml(errors.join(" / "))}<br/>常见原因: 油猴的 @connect 授权被拒、代理/网络不通</div>`); } return void $list.html(`<div class="jhs-sub-empty">迅雷和 SubTitleCat 都没有找到「${escapeHtml(kw)}」的字幕<br/>可以换个关键词, 比如去掉横杠或只留数字</div>`); } const warn = errors && errors.length ? `<div class="jhs-sub-warn">${escapeHtml(errors.join(" / "))} —— 以下只包含其余来源的结果</div>` : ""; const cards = list.map(((it, idx) => { const src = JHS_SUB_SOURCE[it.source] || { label: it.source, color: "#8b8f96" }; return ` <div class="jhs-sub-card" data-idx="${idx}"> <div class="jhs-sub-no">${idx + 1}</div> <img class="jhs-sub-icon" src="${JHS_SUB_ICON[it.source] || ""}" alt="${escapeHtml(src.label)}" title="${escapeHtml(src.label)}"> <div class="jhs-sub-main"> <div class="jhs-sub-name">${escapeHtml(fileNameOf(it))}</div> <div class="jhs-sub-tags"> <span class="jhs-sub-tag is-ext">${escapeHtml(it.ext.toUpperCase())}</span> <span class="jhs-sub-tag jhs-sub-lang">${escapeHtml(it.lang)}</span> ${it.note ? `<span class="jhs-sub-tag">${escapeHtml(it.note)}</span>` : ""} <span class="jhs-sub-tag jhs-sub-size" ${it.note2 ? "" : "hidden"}>${escapeHtml(it.note2 || "")}</span> </div> </div> <div class="jhs-sub-acts"> <button class="jhs-sub-btn is-down" data-act="down">下载</button> <button class="jhs-sub-btn is-prev" data-act="prev">预览</button> <button class="jhs-sub-btn is-115" data-act="push115" title="直接传到 115 里影片所在的目录">115直传</button> </div> </div>`; })).join(""); $list.html(warn + cards), fillXunLeiMeta(list).catch((err => clog.debug("补迅雷文件大小/语言失败", err))); }; // 搜索代次: 渐进渲染下一次搜索会分两次回调, 期间又发起新搜索时, // 旧那次的回调必须丢掉, 否则会把新结果覆盖回旧的 let searchSeq = 0; const doSearch = async () => { const kw = ($kw.val() || "").trim(); if (!kw) return void show.error("请输入番号或关键词"); const seq = ++searchSeq; $go.prop("disabled", !0).text("搜索中"); $count.text(`正在查询 迅雷 / SubTitleCat · ${kw}`); $list.html('<div class="jhs-sub-empty">正在查询两个来源, 请稍候…</div>'); try { // 渐进渲染: 迅雷先回来就先画出来, SubTitleCat 到了再追加(见 searchStream) await JhsSubtitle.searchStream(kw, (({ items: found, errors: errs, done: done }) => { if (seq !== searchSeq) return; render(found, done ? errs : [], !done); })); } catch (err) { clog.error("字幕搜索失败", err), $list.html('<div class="jhs-sub-empty">查询失败, 请稍后重试</div>'), $count.text("查询失败"); } finally { seq === searchSeq && $go.prop("disabled", !1).text("搜索"); } }; $list.on("click", ".jhs-sub-btn", (async ev => { const $btn = $(ev.currentTarget), act = $btn.data("act"); const it = items[$btn.closest(".jhs-sub-card").data("idx")]; if (!it) return; const label = $btn.text(); $btn.prop("disabled", !0).text("..."); try { const url = await JhsSubtitle.resolveUrl(it); const fileName = fileNameOf(it); if ("push115" === act) { $btn.text("直传中"); const done = await this.pushSubtitleTo115(url, it, fileName, $kw.val() || code); show.ok(`已直传 115: ${done.join(" / ")}`); } else if ("prev" === act) await this.previewSubtitle(url, fileName, it); else { // 预览面板里编辑保存过就下载编辑后的版本 const content = null != it.editedContent ? it.editedContent : await gmHttp.fetchRaw(url); utils.download(content, fileName); } // 详情页解析可能补全了语言, 同步一下卡片上的文件名和语言标签 refreshCards(); } catch (err) { clog.warn("字幕操作失败", err), show.error(err && err.message ? err.message : "字幕获取失败"); } finally { $btn.prop("disabled", !1).text(label); } })), $("#jhs-sub-origin").on("change", refreshCards), $go.on("click", doSearch), $kw.on("keydown", (ev => { "Enter" === ev.key && (ev.preventDefault(), doSearch()); })), $kw.val(code), doSearch(); } }); } /** * 字幕直传 115。目录取自「115匹配」搜到的影片所在目录 —— 字幕要和影片同目录播放器才认。 * 文件名直接用番号(如 IPX-253.srt), 不跟影片文件名走: 影片名常带 -U/-C 之类的后缀, * 跟着走会传成 IPX-253-U.srt。匹配到多个目录时每个都传一份(同一目录只传一次)。 */ async pushSubtitleTo115(url, item, fallbackName, keyword) { const matcher = this.getBean("WangPan115MatchPlugin"); if (!matcher) throw new Error("115 匹配插件不可用"); const kw = String(keyword || "").trim(); if (!kw) throw new Error("番号为空"); const matches = await matcher.searchFiles(kw); if (!matches || !matches.length) throw new Error("115 里没搜到这个番号的影片, 请先确认已下载到 115"); const byCid = new Map(); matches.forEach((m => { const cid = null == m.parentId ? "" : String(m.parentId); cid && !byCid.has(cid) && byCid.set(cid, m); })); if (!byCid.size) throw new Error("匹配结果里没有目录信息"); const buffer = await JhsSubtitle.fetchBuffer(url); const ext = item && item.ext || "srt"; const name = JhsSubtitle.safeName(fallbackName || `${kw}.${ext}`); const done = []; for (const cid of byCid.keys()) { const mode = await JhsSubtitle.push115(buffer, name, cid); done.push(`${name}(${mode})`); clearDirFilesCache115(cid); } // 让「115匹配」表格的字幕列重新查一次, 否则刚传完那格还写着「无」 matcher.bindSubCells($(".jhs-match-detail")); return done; } async filterOne(e, t) { e && e.preventDefault(); let n = this.getPageInfo(); t ? (await storageManager.saveCar({ carNum: n.carNum, url: n.url, names: n.actress, actionType: d, publishTime: n.publishTime }), window.refresh(), utils.closePage(), layer.closeAll(), this.answerCount = 1) : utils.q(e, `是否屏蔽${n.carNum}?`, (async () => { await storageManager.saveCar({ carNum: n.carNum, url: n.url, names: n.actress, actionType: d, publishTime: n.publishTime }), window.refresh(), utils.closePage(); }), (() => { this.answerCount = 1; })); } /** * 字幕预览 + 编辑。 * item 传入时, 保存后的内容会写回 item.editedContent, * 这样字幕面板的「下载」按钮拿到的也是编辑后的最新版本。 */ async previewSubtitle(e, t, item) { if (!e) return void console.error("未提供文件URL"); const n = e.split(".").pop().toLowerCase(); if ("ass" !== n && "srt" !== n) return void show.error("仅支持预览ASS和SRT字幕文件"); try { // 已编辑过的优先用本地版本, 避免重新拉取覆盖修改 let text = item && null != item.editedContent ? item.editedContent : await gmHttp.get(e); const title = "ass" === n ? "ASS字幕预览 - " + t : "SRT字幕预览 - " + t; const renderLines = txt => { const lines = txt.split("\n"), pad = String(lines.length).length; return lines.map(((line, idx) => `<span style="color:#AAA;">${String(idx + 1).padStart(pad, " ")}. </span>${escapeHtml(line)}`)).join("\n"); }; const boxCss = "background:#1E1E1E;color:#FFF;font-family:Consolas,Monaco,monospace;font-size:13px;line-height:1.5;box-sizing:border-box;height:100%;width:100%"; const html = ` <div style="position:relative;height:100%"> <button id="jhs-sub-edit-btn" style="position:absolute;top:10px;right:16px;z-index:2;height:28px;padding:0 14px;font-size:13px;font-weight:600;color:#fff;background:linear-gradient(135deg,#e04b43,#d2372f);border:none;border-radius:7px;cursor:pointer">编辑</button> <div id="jhs-sub-edit-view" style="${boxCss};padding:15px 5px;white-space:pre-wrap;overflow:auto">${renderLines(text)}</div> <textarea id="jhs-sub-edit-area" spellcheck="false" style="${boxCss};display:none;padding:15px;white-space:pre;overflow:auto;border:none;outline:none;resize:none"></textarea> </div>`; layer.open({ type: 1, title: title, area: utils.getResponsiveArea([ "860px", "80%" ]), scrollbar: !1, content: html, btn: [ "下载", "关闭" ], // 下载始终取当前(可能已编辑)的内容 btn1: () => (utils.download(text, t), !1), success: layerEl => { const $view = $("#jhs-sub-edit-view", layerEl), $area = $("#jhs-sub-edit-area", layerEl), $btn = $("#jhs-sub-edit-btn", layerEl); let editing = !1; $btn.on("click", (() => { if (editing) { text = $area.val(), item && (item.editedContent = text), $view.html(renderLines(text)).show(), $area.hide(), $btn.text("编辑"), editing = !1, show.info("字幕已保存"); } else { $area.val(text).show(), $view.hide(), $btn.text("保存"), editing = !0, $area.focus(); } })); } }); } catch (a) { show.error(`预览失败: ${a.message}`), console.error("预览字幕文件出错:", a); } } } class xe extends X { constructor() { super(...arguments), i(this, "tableObj", null); } getName() { return "HistoryPlugin"; } async initCss() { return "\n <style>\n /* 下拉菜单容器(相对定位) */\n .sub-btns {\n position: relative;\n display: inline-block;\n }\n \n /* 下拉菜单内容(默认隐藏) */\n .sub-btns-menu {\n display: none;\n position: absolute;\n right: 80px;\n top:-10px;\n background: white;\n padding:10px;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);\n z-index: 100;\n border-radius: 4px;\n overflow: hidden;\n }\n \n \n /* 点击后显示菜单(JS 控制) */\n .sub-btns-menu.show {\n display: flex !important;\n flex-direction: column;\n }\n \n .table-link-param {\n cursor: pointer;\n }\n </style\n "; } handleResize() { $(".navbar-search").is(":hidden") ? ($(".historyBtnBox").show(), $(".miniHistoryBtnBox").hide()) : ($(".historyBtnBox").hide(), $(".miniHistoryBtnBox").show()); } handle() { r && ($(".navbar-end").prepend('<div class="navbar-item has-sub-btns is-hoverable historyBtnBox">\n <a id="historyBtn" class="navbar-link nav-btn" style="color: #fff !important;padding-right:15px !important;">\n 鉴定记录\n </a>\n </div>'), $(".navbar-search").css("margin-left", "0").before('\n <div class="navbar-item miniHistoryBtnBox">\n <a id="miniHistoryBtn" class="navbar-link nav-btn" style="color: #fff !important;padding-left:0 !important;padding-right:0 !important;">\n 鉴定记录\n </a>\n </div>\n '), this.handleResize(), $(window).resize((() => { this.handleResize(); })), $("#historyBtn,#miniHistoryBtn").on("click", (e => this.openHistory()))), l && utils.loopDetector((() => $("#setting-btn").length), (() => { $("#top-right-box").append('\n <a id="historyBtn" class="menu-btn main-tab-btn" style="background-color:#b68625 !important; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; height: 30px; padding: 0 12px; line-height: 1; margin: 0 !important;">\n 鉴定记录\n </a>\n '), $("#historyBtn,#miniHistoryBtn").on("click", (e => this.openHistory())); }), 1, 1e4, !1), this.bindClick(); } openHistory() { let e = `\n <div style="padding: 10px 20px; height: 100%;overflow:hidden;"> \n <div id="filterBox" style="display: flex;gap: 5px;">\n <select id="dataType" style="text-align: center;min-width: 150px;">\n <option value="all" selected>所有</option>\n <option value="filter">${u}</option>\n <option value="favorite">${b}</option>\n <option value="hasDown">${y}</option>\n <option value="hasWatch">${k}</option>\n </select>\n <input id="searchCarNum" type="text" placeholder="搜索番号|演员" style="padding: 4px 5px;">\n <a id="clearSearchbtn" class="a-info" style="margin-left: 0">重置</a>\n </div>\n <div id="allSelectBox" style="margin-top: 8px;display: none">\n <a class="menu-btn multiple-history-deleteBtn" style="background-color:#8c8080; color:white; margin-bottom: 5px;"> <span>移除</span> </a>\n <a class="menu-btn multiple-history-hasWatchBtn" style="background-color:${S};margin-bottom: 5px">${k}</a>\n <a class="menu-btn multiple-history-hasDownBtn" style="background-color:${x};margin-bottom: 5px">${y}</a>\n <a class="menu-btn multiple-history-favoriteBtn" style="background-color:${w};margin-bottom: 5px">${v}</a>\n <a class="menu-btn multiple-history-filterBtn" style="background-color:${f};margin-bottom: 5px">${m}</a>\n </div>\n <div id="table-container" style="height: calc(100% - 50px); overflow-x:hidden;"></div>\n </div>\n `; layer.open({ type: 1, title: "鉴定记录", content: e, scrollbar: !1, shadeClose: !0, area: utils.getResponsiveArea([ "70%", "90%" ]), anim: -1, success: async e => { await this.loadTableData(), $(".layui-layer-content").on("click", "#clearSearchbtn", (async e => { $("#searchCarNum").val(""), $("#dataType").val("all"), await this.reloadTable(), $("#allSelectBox").hide(); })).on("focusout keydown", "#searchCarNum", (async e => { if ("focusout" === e.type || "Enter" === e.key) { if ("Enter" === e.key && e.preventDefault(), "keydown" === e.type && "Enter" !== e.key) return; await this.reloadTable(); } })).on("click", ".table-link-param", (async e => { let t = $(e.currentTarget); $("#searchCarNum").val(t.text()), await this.reloadTable(); })).on("change", "#dataType", (async () => { await this.reloadTable(); })); }, end: () => { this.tableObj && (this.tableObj.destroy(), this.tableObj = null), window.refresh(); } }); } async reloadTable() { this.tableObj.deselectRow(), this.tableObj.setPage(1); } bindClick() { document.addEventListener("click", (function(e) { if (e.target.closest(".sub-btns-toggle")) { const t = e.target.closest(".sub-btns").querySelector(".sub-btns-menu"); document.querySelectorAll(".sub-btns-menu.show").forEach((e => { e !== t && e.classList.remove("show"); })), t.classList.toggle("show"); } else document.querySelectorAll(".sub-btns-menu.show").forEach((e => { e.classList.remove("show"); })); })), $(document).on("click", ".history-deleteBtn, .history-filterBtn, .history-favoriteBtn, .history-hasDownBtn, .history-hasWatchBtn, .history-detailBtn", (e => { e.preventDefault(), e.stopPropagation(); const t = $(e.currentTarget), n = t.closest(".action-btns"), a = n.attr("data-car-num"), i = n.attr("data-href"), s = async e => { try { await storageManager.saveCar({ carNum: a, url: i, names: null, actionType: e }), window.refresh(), await this.reloadTable(); } catch (s) { console.error("历史记录操作失败:", s), show.error("操作失败"); } }; t.hasClass("history-filterBtn") ? utils.q(e, `是否屏蔽${a}?`, (() => s(d))) : t.hasClass("history-favoriteBtn") ? s(h).then() : t.hasClass("history-hasDownBtn") ? s(g).then() : t.hasClass("history-hasWatchBtn") ? s(p).then() : t.hasClass("history-deleteBtn") ? this.handleDelete(e, a) : t.hasClass("history-detailBtn") && this.handleClickDetail(e, { carNum: a, url: i }).then(); })), $(document).on("click", ".multiple-history-deleteBtn, .multiple-history-filterBtn, .multiple-history-favoriteBtn, .multiple-history-hasDownBtn, .multiple-history-hasWatchBtn", (e => { e.preventDefault(), e.stopPropagation(); const t = $(e.currentTarget); let n = this.tableObj.getSelectedData(), a = "", i = ""; t.hasClass("multiple-history-filterBtn") ? (a = "屏蔽", i = d) : t.hasClass("multiple-history-favoriteBtn") ? (a = "收藏", i = h) : t.hasClass("multiple-history-hasDownBtn") ? (a = "已下载", i = g) : t.hasClass("multiple-history-hasWatchBtn") ? (a = "已观看", i = p) : t.hasClass("multiple-history-deleteBtn") && (a = "移除", i = "delete"), utils.q(e, `当前已勾选${n.length}条数据, 是否全标记为 ${a}?`, (async () => { let e = loading(); try { if ("delete" === i) { const e = n.map((e => e.carNum)), t = await storageManager.batchRemoveCars(e); t > 0 ? show.ok(`已成功删除 ${t} 个番号`) : !1 === t && show.error("提供的番号中没有一个存在于列表中。"); } else { const e = JSON.parse(JSON.stringify(n)); e.forEach((e => { e.actionType = i; })), await storageManager.saveCarList(e), show.ok("操作成功"); } this.tableObj.deselectRow(), this.reloadTable().then(); } catch (t) { console.error(t); } finally { e.close(); } })); })); } async getDataList(e, t, n) { let a = await storageManager.getCarList(); this.allCount = a.length, this.filterCount = 0, this.favoriteCount = 0, this.hasDownCount = 0, this.hasWatchCount = 0, this.waitCheckCount = 0, a.forEach((e => { switch (e.status) { case d: this.filterCount++; break; case h: this.favoriteCount++; break; case g: this.hasDownCount++; break; case p: this.hasWatchCount++; break; default: this.waitCheckCount++; } })), $('#dataType option[value="all"]').text(`所有 (${this.allCount})`), $('#dataType option[value="waitCheck"]').text(`待鉴定 (${this.waitCheckCount})`), $('#dataType option[value="filter"]').text(`${u} (${this.filterCount})`), $('#dataType option[value="favorite"]').text(`${b} (${this.favoriteCount})`), $('#dataType option[value="hasDown"]').text(`${y} (${this.hasDownCount})`), $('#dataType option[value="hasWatch"]').text(`${k} (${this.hasWatchCount})`); const i = $("#dataType").val(); let s = "all" === i ? a : "waitCheck" === i ? a.filter((e => "" === e.status || !e.status)) : a.filter((e => e.status === i)); const o = $("#searchCarNum").val().trim(); if (o) { let e = o.toLowerCase().replace("-c", "").replace("-uc", "").replace("-4k", ""); s = s.filter((t => { const n = t.carNum.toLowerCase().includes(e); const a = (t.names ? t.names : "").toLowerCase().includes(e); return n || a; })); } if (n && n.length > 0) { const e = n[0], t = e.field, a = e.dir; s.sort(((e, n) => { const i = e[t], s = n[t], o = null == i || "" === i, r = null == s || "" === s; return o && !r ? 1 : !o && r ? -1 : o && r ? 0 : i < s ? "asc" === a ? -1 : 1 : i > s ? "asc" === a ? 1 : -1 : 0; })); } const r = s.length, l = Math.ceil(r / t), c = (e - 1) * t, m = c + t; return s = s.slice(c, m), { maxPage: l, dataList: s, totalCount: r }; } async loadTableData() { this.tableObj = new Tabulator("#table-container", { layout: "fitColumns", placeholder: "暂无数据", virtualDom: !0, pagination: !0, paginationMode: "remote", sortMode: "remote", ajaxURL: "queryRealm", dataLoader: !1, ajaxRequestFunc: async (e, t, n) => { const a = n.page, i = n.size, s = n.sort; return await this.getDataList(a, i, s); }, dataReceiveParams: { last_page: "maxPage", last_row: "totalCount", data: "dataList" }, paginationSize: 50, paginationSizeSelector: [ 50, 100, 1e3, 99999 ], paginationCounter: (e, t, n, a, i) => `共 ${a} 条记录`, responsiveLayout: "collapse", responsiveLayoutCollapse: !0, columnDefaults: { headerHozAlign: "center", hozAlign: "center" }, selectableRowsPersistence: !1, index: "carNum", columns: [ { formatter: "rowSelection", titleFormatter: "rowSelection", hozAlign: "center", headerSort: !1, responsive: 0, width: 40, titleFormatterParams: { rowRange: "active" }, cellClick: (e, t) => { t.getRow().toggleSelect(); } }, { title: "番号", field: "carNum", width: 120, sorter: "string", responsive: 0, formatter: (e, t, n) => { const a = e.getData().carNum, i = a.indexOf("-"); if (-1 === i) return a; return `<a class="table-link-param">${a.substring(0, i + 1)}</a>${a.substring(i + 1)}`; } }, { title: "演员", field: "names", minWidth: 200, sorter: "string", responsive: 5, headerSort: !0, formatter: (e, t, n) => (e.getData().names || "").split(" ").filter((e => "" !== e.trim())).map((e => `<a class="table-link-param">${e}</a>`)).join(" ") }, { title: "创建时间", field: "createDate", width: 170, sorter: "string", responsive: 4 }, { title: "修改时间", field: "updateDate", width: 170, sorter: "string", responsive: 4 }, { title: "发行时间", field: "publishTime", width: 170, sorter: "string", responsive: 4 }, { title: "来源", field: "url", width: 80, sorter: "string", responsive: 5, hozAlign: "left", formatter: (e, t, n) => { let a = e.getData().url; return a ? a.includes("javdb") ? '<span style="color:#d34f9e">Javdb</span>' : a.includes("javbus") ? '<span style="color:#eaa813">JavBus</span>' : a.includes("123av") ? '<span style="color:#eaa813">123Av</span>' : `<span style="color:#050505">${a}</span>` : ""; } }, { title: "状态", field: "status", width: 100, sorter: "string", responsive: 1, headerSort: !1, formatter: (e, t, n) => { const a = e.getData().status; let i = "", s = ""; switch (a) { case "filter": i = f, s = m; break; case "favorite": i = w, s = v; break; case "hasDown": i = x, s = y; break; case "hasWatch": i = S, s = k; break; default: s = a; } return `<span style="color:${i}">${s}</span>`; } }, { title: "备注", field: "remark", width: 100, sorter: "string", responsive: 6 }, { title: "操作", sorter: "string", minWidth: 150, cssClass: "action-cell-dropdown", responsive: 0, headerSort: !1, formatter: (e, t, n) => { const a = e.getData(); return n((() => { var t; null == (t = e.getElement().querySelector(".history-editBtn")) || t.addEventListener("click", (e => { this.editRecord(a); })); })), `\n <div class="action-btns" style="display: flex; gap: 5px;justify-content:center" data-car-num="${a.carNum}" data-href="${a.url ? a.url : ""}">\n <div class="sub-btns">\n <a class="menu-btn sub-btns-toggle" style="background-color:#c59d36; color:white; margin-bottom: 5px;">\n <span>变更</span>\n </a>\n <div class="sub-btns-menu">\n <a class="menu-btn history-editBtn" style="background-color:#007bff; color:white; margin-bottom: 5px;"> <span>编辑</span> </a>\n <a class="menu-btn history-deleteBtn" style="background-color:#8c8080; color:white; margin-bottom: 5px;"> <span>移除</span> </a>\n <a class="menu-btn history-hasWatchBtn" style="background-color:${S};margin-bottom: 5px">${k}</a>\n <a class="menu-btn history-hasDownBtn" style="background-color:${x};margin-bottom: 5px">${y}</a>\n <a class="menu-btn history-favoriteBtn" style="background-color:${w};margin-bottom: 5px">${v}</a>\n <a class="menu-btn history-filterBtn" style="background-color:${f};margin-bottom: 5px">${m}</a>\n </div>\n </div>\n \n <a class="menu-btn history-detailBtn" style="background-color:#3397de; color:white; margin-bottom: 5px;"> <span>详情页</span> </a>\n \n </div>\n `; } } ], initialSort: [ { column: "updateDate", dir: "desc" } ], locale: "zh-cn", langs: { "zh-cn": { pagination: { first: "首页", first_title: "首页", last: "尾页", last_title: "尾页", prev: "上一页", prev_title: "上一页", next: "下一页", next_title: "下一页", all: "所有", page_size: "每页行数" } } } }), this.tableObj.on("rowSelectionChanged", ((e, t, n, a) => { const i = $("#allSelectBox"), s = $("#filterBox"); e && e.length > 0 ? (s.hide(), i.show()) : (s.show(), i.hide()); })), this.tableObj.on("rowDblClick", (function(e, t) { t.toggleSelect(); })), this.tableObj.on("tableBuilt", (async () => {})); } handleDelete(e, t) { utils.q(e, `是否移除${t}?`, (async () => { await storageManager.removeCar(t), this.getBean("ListPagePlugin").showCarNumBox(t), this.reloadTable(null).then(); })); } async handleClickDetail(e, t) { if (r) if (t.carNum.includes("FC2-")) { const e = this.parseMovieId(t.url); this.getBean("Fc2Plugin").openFc2Dialog(e, t.carNum, t.url); } else { if (!t.url) return void window.open("/search?q=" + t.carNum, "_blank"); utils.openPage(t.url, t.carNum, !1, e); } if (l) { let n = t.url; if (n.includes("javdb")) if (t.carNum.includes("FC2-")) { const e = this.parseMovieId(n); await this.getBean("Fc2Plugin").openFc2Page(e, t.carNum, n); } else window.open(n, "_blank"); else utils.openPage(t.url, t.carNum, !1, e); } } async editRecord(e) { const t = e.carNum, n = e.names || "", a = e.url || "", i = e.status, s = e.remark || "", o = "width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; min-height: 60px; overflow-y: hidden;", r = "width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;", l = [ { value: d, text: m }, { value: h, text: v }, { value: g, text: y }, { value: p, text: k } ]; clog.debug(l); const c = `\n <div style="padding: 20px;">\n <div style="margin-bottom: 15px;">\n <label style="display: block; margin-bottom: 5px; font-weight: bold;">番号:</label>\n <input type="text" id="edit-carNum" value="${t}" style="${r} background-color: #f0f0f0;" readonly>\n </div>\n <div style="margin-bottom: 15px;">\n <label style="display: block; margin-bottom: 5px; font-weight: bold;">演员 (用空格隔开):</label>\n <textarea id="edit-names" style="${o}">${n}</textarea>\n </div>\n <div style="margin-bottom: 15px;">\n <label style="display: block; margin-bottom: 5px; font-weight: bold;">状态:</label>\n <select id="edit-status" style="width: 100%; padding: 10px; border: 1px solid #ddd;">\n <option value="" ${"" === i ? "selected" : ""}>-- 请选择 --</option>\n ${l.map((e => `\n <option value="${e.value}" ${i === e.value ? "selected" : ""}>${e.text}</option>\n `)).join("")}\n </select>\n </div>\n <div style="margin-bottom: 15px;">\n <label style="display: block; margin-bottom: 5px; font-weight: bold;">链接:</label>\n <input type="text" id="edit-url" value="${a}" style="${r}">\n </div>\n \n <div style="margin-bottom: 15px;">\n <label style="display: block; margin-bottom: 5px; font-weight: bold;">备注:</label>\n <textarea id="edit-remark" style="${o}">${s}</textarea>\n </div>\n </div>\n `; layer.open({ type: 1, title: `编辑记录: ${t}`, area: utils.getResponsiveArea([ "500px", "650px" ]), content: c, btn: [ "保存", "取消" ], success: (e, t) => { const n = e => { e.css("height", "auto"), e.css("height", e[0].scrollHeight + 15 + "px"); }, a = $("#edit-names"); a.on("input", (function() { n($(this)); })), n(a); const i = $("#edit-remark"); i.on("input", (function() { n($(this)); })), n(i); }, yes: async t => { const n = $("#edit-names").val().trim(), a = $("#edit-status").val(), i = $("#edit-url").val().trim(), s = $("#edit-remark").val().trim(); if (!a) return show.error("请选择状态"), !1; const o = { ...e, names: n, actionType: a, url: i, remark: s }; await storageManager.updateCarInfo(o), this.tableObj.setData(), layer.close(t); } }); } } class $e extends X { constructor() { super(...arguments), i(this, "floorIndex", 1), i(this, "isInit", !1); } getName() { return "ReviewPlugin"; } async handle() { if (window.isDetailPage) { if (r) { const e = this.parseMovieId(window.location.href); await this.showReview(e); } if (l) { let e = this.getPageInfo().carNum; const t = await (async e => { let t = `${U}/v2/search`, n = { "user-agent": "Dart/3.5 (dart:io)", "accept-language": "zh-TW", host: "jdforrepam.com", jdsignature: await O() }, a = { q: e, page: 1, type: "movie", limit: 1, movie_type: "all", from_recent: "false", movie_filter_by: "all", movie_sort_by: "relevance" }; return (await gmHttp.get(t, a, n)).data.movies; })(e); let n = null; for (let a = 0; a < t.length; a++) { let i = t[a]; if (i.number.toLowerCase() === e.toLowerCase()) { n = i.id; break; } } if (!n) return; this.showReview(n, $("#sample-waterfall")).then(); } } } async showReview(e, t) { const n = await storageManager.getSetting("enableLoadReview", _), a = t || $("#magnets-content"); a.append(`\n <div style="display: flex; align-items: center; margin: 16px 0; color: #666; font-size: 14px;">\n <span style="flex: 1; height: 1px; background: linear-gradient(to right, transparent, #999, transparent);"></span>\n <span style="padding: 0 10px;" data-tip="想要发表评论? 滑上去, 点击上面的按钮-看过">评论区</span>\n <a id="reviewsFold" style="margin-left: 8px; color: #1890ff; text-decoration: none; display: flex; align-items: center;">\n <span class="toggle-text">${n === _ ? "折叠" : "展开"}</span>\n <span class="toggle-icon" style="margin-left: 4px;">${n === _ ? "▲" : "▼"}</span>\n </a>\n <span style="flex: 1; height: 1px; background: linear-gradient(to right, transparent, #999, transparent);"></span>\n </div>\n `), $("#reviewsFold").on("click", (t => { t.preventDefault(), t.stopPropagation(); const n = $("#reviewsFold .toggle-text"), a = $("#reviewsFold .toggle-icon"), i = "展开" === n.text(); n.text(i ? "折叠" : "展开"), a.text(i ? "▲" : "▼"), i ? ($("#reviewsContainer").show(), $("#reviewsFooter").show(), this.isInit || (this.fetchAndDisplayReviews(e), this.isInit = !0), storageManager.saveSettingItem("enableLoadReview", _)) : ($("#reviewsContainer").hide(), $("#reviewsFooter").hide(), storageManager.saveSettingItem("enableLoadReview", C)); })), a.append('<div id="reviewsContainer"></div>'), a.append('<div id="reviewsFooter"></div>'), n === _ && await this.fetchAndDisplayReviews(e); } async fetchAndDisplayReviews(e) { const t = $("#reviewsContainer"), n = $("#reviewsFooter"); t.append('<div id="reviewsLoading" style="margin-top:15px;background-color:#ffffff;padding:10px;margin-left: -10px;">获取评论中...</div>'); const a = await storageManager.getSetting("reviewCount", 20); let i = null; try { i = await R(e, 1, a); } catch (o) { o.toString().includes("簽名已過期") && show.error("生成签名失败, 请检查系统时间及时区是否正确!"), clog.error("获取评论失败:", o), console.error("获取评论失败:", o); } finally { $("#reviewsLoading").remove(); } if (!i) return t.append('\n <div style="margin-top:15px;background-color:#ffffff;padding:10px;margin-left: -10px;">\n 获取评论失败\n <a id="retryFetchReviews" href="javascript:;" style="margin-left: 10px; color: #1890ff; text-decoration: none;">重试</a>\n </div>\n '), void $("#retryFetchReviews").on("click", (async () => { $("#retryFetchReviews").parent().remove(), await this.fetchAndDisplayReviews(e); })); if (0 === i.length) return void t.append('<div style="margin-top:15px;background-color:#ffffff;padding:10px;margin-left: -10px;">无评论</div>'); const s = await storageManager.getReviewFilterKeywordList(); if (this.displayReviews(i, t, s), i.length === a && R(e, 2, a).catch((() => {})), i.length === a) { n.html('\n <button id="loadMoreReviews" style="width:100%; background-color: #e1f5fe; border:none; padding:10px; margin-top:10px; cursor:pointer; color:#0277bd; font-weight:bold; border-radius:4px;">\n 加载更多评论\n </button>\n <div id="reviewsEnd" style="display:none; text-align:center; padding:10px; color:#666; margin-top:10px;">已加载全部评论</div>\n '); let i = 1, r = $("#loadMoreReviews"); r.on("click", (async () => { let n; r.text("加载中...").prop("disabled", !0), i++; try { n = await R(e, i, a); } catch (o) { console.error("加载更多评论失败:", o); } finally { r.text("加载失败, 请点击重试").prop("disabled", !1); } n && (this.displayReviews(n, t, s), n.length < a ? (r.remove(), $("#reviewsEnd").show()) : r.text("加载更多评论").prop("disabled", !1)); })); } else n.html('<div style="text-align:center; padding:10px; color:#666; margin-top:10px;">已加载全部评论</div>'); } displayReviews(e, t, n) { if (!e.length) return; const o = [], c = n.length > 0 ? new RegExp(n.map((e => e.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))).join("|")) : null; e.forEach((e => { if (c && c.test(e.content)) return; const a = Array(e.score).fill('<i class="icon-star"></i>').join(""), i = e.content.replace(/ed2k:\/\/\|file\|[^|]+\|\d+\|[a-fA-F0-9]{32}\|\/|magnet:\?[^\s"'<>`\u4e00-\u9fa5,。?!()【】]+|https?:\/\/[^\s"'<>`\u4e00-\u9fa5,。?!()【】]+/g, (e => e.startsWith("ed2k://") ? `\n <span style="word-break: break-all;background: #e0f2fe;color: #0369a1;">${e}</span>\n\n ` : e.startsWith("magnet:") ? `\n <a href="${e}" class="a-primary" style="padding:0; word-break: break-all; white-space: pre-wrap;" target="_blank" rel="noopener noreferrer">${e}</a>\n\n ` : e.startsWith("http://") || e.startsWith("https://") ? `\n <a href="${e}" class="a-primary" style="padding:0; word-break: break-all; white-space: pre-wrap;" target="_blank" rel="noopener noreferrer">${e}</a>\n ` : e)), s = `\n <div class="item columns is-desktop" style="display:block;margin-top:6px;background-color:#ffffff;padding:10px;margin-left: -10px;word-break: break-word;position:relative;">\n <span style="position:absolute;top:5px;right:10px;color:#999;font-size:12px;">#${this.floorIndex++}楼</span>\n ${e.username} <span class="score-stars">${a}</span> \n <span class="time">${utils.formatDate(e.created_at)}</span> \n 点赞:${e.likes_count}\n <p class="review-content" style="margin-top: 5px;"> ${i} </p>\n </div>\n `; o.push(s); })), o.length && t.append(o.join("")), this.rightClickFilter(); } async rightClickFilter() { await storageManager.getSetting("enableTitleSelectFilter", _) === _ && utils.rightClick(document.body, ".review-content", (async e => { const t = window.getSelection().toString(); t && (e.preventDefault(), await utils.q(e, `是否将 '${t}' 加入评论区关键词?`, (async () => { await storageManager.saveReviewFilterKeyword(t), show.ok("操作成功, 刷新页面后生效"); }))); })); } } class ke extends X { getName() { return "FilterTitleKeywordPlugin"; } async handle() { if (!isDetailPage && !isFc2Page) return; if (await storageManager.getSetting("enableTitleSelectFilter", _) !== _) return; let e; r ? e = ".title strong, .current-title" : l && (e = "h3"), utils.rightClick(document.body, e, (e => { const t = window.getSelection().toString(); if (t) { e.preventDefault(); let n = { clientX: e.clientX, clientY: e.clientY + 80 }; utils.q(n, `是否屏蔽标题关键词 ${t}?`, (async () => { await storageManager.saveTitleFilterKeyword(t), window.refresh(), utils.closePage(); })); } })); } } class Se extends X { getName() { return "BlacklistPlugin"; } async addBlacklist(e) { let t = { clientX: e.clientX, clientY: e.clientY + 80 }; const n = $("#addBlacklistBtn span").text().includes("已加入"); let a, i; if (o.includes("/tags")) { const e = new URL(o); e.searchParams.delete("page"); const t = $("#jhs-check-tag").text().trim(); a = { starId: "no-" + t, name: "虚拟演员-" + t, allName: [ "虚拟演员" ], role: "虚拟演员", movieType: t, blacklistUrl: e.toString() }, i = `是否将分类 <span style="color: #f40">${t}</span> 加入到黑名单中?`, n && (i = `分类 <span style="color: #f40">${t}</span> 已在黑名单中, 是否从当前页开始追加屏蔽?`); } else a = this.getActressPageInfo(), i = `是否将该演员 <span style="color: #f40">${a.name}</span> 加入到黑名单中?`, n && (i = `演员 <span style="color: #f40">${a.name}</span> 已在黑名单中, 是否从当前页开始追加屏蔽?`); const {starId: s, name: r, allName: c, role: d, movieType: h, blacklistUrl: g} = a; if (o.includes("page") && !o.includes("page=1") && (i += "<br/> 注意: 当前页面非第一页, 屏蔽数据将从此页面开始"), l) { const e = o.split("/star/")[1].split("/"); if (e.length > 1) { parseInt(e[1]) > 1 && (i += "<br/> 注意: 当前页面非第一页, 屏蔽数据将从此页面开始"); } } utils.q(t, i, (async () => { const e = this.getBean("TaskPlugin"); navigator.locks.request(e.singleTaskKey, { ifAvailable: !0 }, (async e => { if (clog.debug("获取锁", e), e) { this.loadObj = loading(); try { await storageManager.addBlacklistItem({ starId: s, name: r, allName: c, role: d, movieType: h, url: g }), await this.filterActorVideo(r, s); const e = show.ok(`屏蔽结束,是否跳转到最后一页: ${this.lastPageLink}`, { duration: -1, close: !0, onClick: () => { e.closeShow(), window.location.href = this.lastPageLink; } }); } catch (t) { clog.error(t); const e = show.error("发生错误, 是否填转到解析失败的那一页? (点击并跳转)", { duration: -1, close: !0, onClick: () => { e.closeShow(), window.location.href = this.nextPageLink; } }); } finally { this.loadObj.close(); } } else show.error("当前有定时任务在后台执行中, 无法发起此操作"); })).catch((e => { console.error("锁任务出现错误:", e), clog.error("锁任务出现错误:", e); })); })); } async resetBtnTip() { const e = this.getBean("TaskPlugin"), t = localStorage.getItem(e.lastCheckBlacklistTimeKey) || "无", n = await storageManager.getSetting("checkBlacklist_intervalTime", 12); this.checkBlacklist_ruleTime = await storageManager.getSetting("checkBlacklist_ruleTime", 8760), $("#checkBlacklistBtn").attr("data-tip", `上次检测时间: ${t}; 检测间隔时间: ${n}小时`); } async openBlacklistDialog() { const e = this.getBean("TaskPlugin"), t = await storageManager.getSetting(); let n = `\n <div style="padding: 10px 20px; height: 100%;overflow:hidden;"> \n <div style="display: flex;justify-content: space-between;">\n <div style="display: flex; gap:5px">\n <a id="checkBlacklistBtn" class="a-danger" data-tip="上次检测时间: ${localStorage.getItem(e.lastCheckBlacklistTimeKey) || "无"}; 检测间隔时间: ${t.checkBlacklist_intervalTime}小时">${this.blacklistSvg} 手动检测黑名单</a>\n <a class="a-info" id="toSetting">${this.settingSvg} 配置</a>\n </div>\n <div style="display: flex; gap:5px">\n <select id="dataType" style="text-align: center;min-width: 150px;">\n <option value="" selected>所有</option>\n <option value="actor">男演员</option>\n <option value="actress">女演员</option>\n </select>\n <select id="statusType" style="text-align: center;min-width: 150px;">\n <option value="" selected>--检测状态--</option>\n <option value="normal">正常检测</option>\n <option value="stop">停止检测</option>\n </select>\n <select id="urlType" data-tip="在演员页屏蔽时,是否选择了分类" style="text-align: center;min-width: 150px; ${r ? "" : "display: none;"}">\n <option value="" selected>--屏蔽类型--</option>\n <option value="hasT">按所选分类屏蔽</option>\n <option value="noT">未筛选分类</option>\n </select>\n <input id="searchValue" type="text" placeholder="搜索演员" style="padding: 4px 5px;">\n <a id="cleanQueryBtn" class="a-info" style="margin-left: 0">重置</a>\n </div>\n\n </div>\n <div id="table-container" style="height: calc(100% - 50px);"></div>\n </div>\n `; layer.open({ type: 1, title: "演员黑名单", content: n, scrollbar: !1, area: utils.getResponsiveArea([ "80%", "90%" ]), anim: -1, success: async t => { await this.loadTableData(), $(".layui-layer-content").on("click", "#cleanQueryBtn", (async e => { $("#searchValue").val(""), $("#dataType").val(""), $("#statusType").val(""), await this.reloadTable(); })).on("focusout keydown", "#searchValue", (async e => { if ("focusout" === e.type || "Enter" === e.key) { if ("Enter" === e.key && e.preventDefault(), "keydown" === e.type && "Enter" !== e.key) return; $("#dataType").val(""), await this.reloadTable(); } })).on("change", "#dataType", (async () => { $("#searchValue").val(""), await this.reloadTable(); })).on("change", "#statusType", (async () => { await this.reloadTable(); })).on("change", "#urlType", (async () => { await this.reloadTable(); })).on("click", "#toSetting", (() => { this.getBean("SettingPlugin").openSettingDialog("task-panel", (() => { $("#setting-blacklist").css({ border: "1px solid #f40" }); })); })).on("click", ".open-url", (e => { e.preventDefault(); const t = $(e.currentTarget), n = t.attr("data-url"), a = t.attr("data-name"); utils.openPage(n, a, !0, e); })).on("click", "#checkBlacklistBtn", (t => { utils.q({ clientX: t.clientX, clientY: t.clientY + 20 }, "是否手动检测黑名单?", (() => { navigator.locks.request(e.singleTaskKey, { ifAvailable: !0 }, (async t => { t ? (await e.loadConfig(), await e.checkBlacklist(!0)) : show.error("当前有定时任务在后台执行中, 无法发起手动任务"); })).catch((e => { console.error("锁任务出现错误:", e), clog.error("锁任务出现错误:", e); })); })); })); }, end: () => { this.tableObj && (this.tableObj.destroy(), this.tableObj = null), window.refresh(); } }); } async reloadTable() { if (!this.tableObj) return; const e = await this.getTableData(); this.tableObj.setData(e); } async getTableData() { const e = this.getBean("TaskPlugin"), t = await storageManager.getBlacklist(), n = await storageManager.getBlacklistCarList(), a = $("#searchValue").val(), i = $("#statusType").val(), s = $("#dataType"), o = s.val(), r = $("#urlType").val(), l = t.length; let c = 0, d = 0; const h = t.map((t => { t.role === B ? c++ : t.role === P && d++; let n = !1; return t.lastPublishTime && (n = !e.isUnnecessaryCheck(t.lastPublishTime, this.checkBlacklist_ruleTime)), { ...t, isUnCheck: n }; })).filter((e => !(a && !e.name.includes(a)) && (("normal" !== i || !e.isUnCheck) && (!("stop" === i && !e.isUnCheck) && (o ? e.role === o : !("hasT" === r && !e.url.includes("t=")) && ("noT" !== r || !e.url.includes("t="))))))); s.html(`\n <option value="">所有 (${l})</option>\n <option value="actor">男演员 (${c})</option>\n <option value="actress">女演员 (${d})</option>\n `), s.val(o); const g = new Map; for (const m of n) { const e = m.starId; g.has(e) || g.set(e, []), g.get(e).push(m); } const p = h.map((e => { const t = e.starId, n = g.get(t) || []; return { ...e, carList: n, count: n.length }; })); return this.currentCarCount = p.reduce(((e, t) => e + (t.count || 0)), 0), p; } async loadTableData() { this.checkBlacklist_ruleTime = await storageManager.getSetting("checkBlacklist_ruleTime") || 8760; const e = await this.getTableData(); this.tableObj = new Tabulator("#table-container", { layout: "fitColumns", placeholder: "暂无数据", virtualDom: !0, data: e, pagination: !0, paginationMode: "local", paginationSize: 20, paginationSizeSelector: [ 20, 50, 100, 1e3, 99999 ], paginationCounter: (e, t, n, a, i) => `演员: ${a} 番号总数: ${this.currentCarCount} <span id="checkBlacklistMsg" style="margin-left: 10px"></span>`, responsiveLayout: "collapse", responsiveLayoutCollapse: !0, columnDefaults: { headerHozAlign: "center", hozAlign: "center" }, index: "starId", columns: [ { title: "演员", field: "name", sorter: "string", minWidth: 100, responsive: 0, headerSort: !1, formatter: (e, t, n) => { const a = e.getData(); return `<a class="open-url" data-url="${a.url}" href="${a.url}" data-name="${a.name}" target="_blank">${a.name}</a>`; } }, { title: "性别角色", field: "role", sorter: "string", width: 120, responsive: 5, formatter: (e, t, n) => { const a = e.getData().role; let i = a; return a === B ? i = "男演员" : a === P && (i = "女演员"), i; } }, { title: "影视类别", field: "movieType", sorter: "string", width: 120, responsive: 5, formatter: (e, t, n) => { const a = e.getData().movieType; let i = a; return a === D ? i = "有码" : a === A && (i = "无码"), i; } }, { title: "屏蔽类型", field: "url", sorter: "string", minWidth: 120, responsive: 4, visible: r, formatter: (e, t, n) => { let a = e.getData().url.includes("t="); return `<span style="${a ? "color:#cc4444" : ""}">${a ? "按所选分类屏蔽" : "未筛选分类"}</span>`; } }, { title: "番号数量", field: "count", sorter: "number", width: 170, responsive: 1 }, { title: "创建时间", field: "createTime", sorter: "string", width: 170, responsive: 5 }, { title: "最后发行时间", field: "lastPublishTime", sorter: "string", width: 170, responsive: 1 }, { title: "状态", field: "isUnCheck", sorter: "string", width: 120, responsive: 1, formatter: (e, t, n) => { let a = "", i = "正常检测"; return e.getData().isUnCheck && (a = `停更${this.checkBlacklist_ruleTime / 24 / 365}年以上, 下轮任务不再进行检测`, i = "停止检测"), `<span data-tip="${a}" style="${a ? "color: #cc4444;" : ""}">${i}</span>`; } }, { title: "操作", sorter: "string", cssClass: "action-cell-dropdown", minWidth: 150, responsive: 0, headerSort: !1, formatter: (e, t, n) => { const a = e.getData(); return n((() => { var t, n; null == (t = e.getElement().querySelector(".delete-btn")) || t.addEventListener("click", (e => { const t = a.name, n = a.starId; t ? n ? utils.q(e, `是否移除对 ${t} 的屏蔽?`, (async () => { await storageManager.removeBlacklistCarList(n), await storageManager.deleteBlacklistItem(n), show.info("操作成功"), this.reloadTable().then(); })) : show.error("获取starId失败") : show.error("获取名称失败"); })), null == (n = e.getElement().querySelector(".keyword-btn")) || n.addEventListener("click", (e => { const t = a.carList.reduce(((e, t) => { const n = t.carNum.split("-")[0] + "-"; return e[n] = (e[n] || 0) + 1, e; }), {}), n = Object.entries(t).map((([e, t]) => ({ prefix: e, count: t }))).sort(((e, t) => t.count - e.count)); clog.debug(n); })); })), '\n \x3c!-- <a class="a-normal keyword-btn"> <span>提取屏蔽词</span> </a>--\x3e\n <a class="a-danger delete-btn"> <span>删除</span> </a>\n '; } } ], initialSort: [ { column: "createTime", dir: "desc" } ], locale: "zh-cn", langs: { "zh-cn": { pagination: { first: "首页", first_title: "首页", last: "尾页", last_title: "尾页", prev: "上一页", prev_title: "上一页", next: "下一页", next_title: "下一页", all: "所有", page_size: "每页行数" } } } }); } getCurrentStarUrl() { let e = window.location.href.replace(/([&?])sort_type=[^&]+(&|$)/, "$1"); e = e.replace(/[&?]$/, ""), e = e.replace(/\?&/, "?"); let t = e; return t = t.replace(/([&?])page=\d+(&|$)/, "$1"), t = t.replace(/[&?]$/, ""), t = t.replace(/\?&/, "?"), t = t.replace(/\/(\d+)(?:\/(\d+))?(\?|$)/, ((e, t, n, a) => void 0 !== n ? `/${t}${a}` : e)), t; } parseUrlId(e) { if (!e) throw new Error("url未传入"); return new URL(e).pathname.split("/").filter((e => "" !== e.trim())).pop(); } async filterAllVideo(e, t) { let n, a; if (t ? (l && t.find(".avatar-box").length > 0 && t.find(".avatar-box").parent().remove(), n = t.find(this.getSelector().requestDomItemSelector), a = t.find(this.getSelector().nextPageSelector).attr("href")) : (n = $(this.getSelector().itemSelector), a = $(this.getSelector().nextPageSelector).attr("href")), a && 0 === n.length) throw show.error("解析列表失败"), new Error("解析列表失败"); for (const s of n) { const t = $(s), {carNum: n, url: a, publishTime: o} = this.getBean("ListPagePlugin").findCarNumAndHref(t); if (a && n) try { if (await storageManager.getCar(n)) continue; await storageManager.saveCar({ carNum: n, url: a, names: e, actionType: d, publishTime: o }), clog.log("屏蔽演员番号", e, n); } catch (i) { console.error(`保存失败 [${n}]:`, i); } } if (a) { show.info("请不要关闭窗口, 正在解析下一页:" + a), await new Promise((e => setTimeout(e, 500))); const t = await gmHttp.get(a), n = new DOMParser, i = $(n.parseFromString(t, "text/html")); await this.filterAllVideo(e, i); } else show.ok("执行结束!"), window.refresh(); } async batchSaveAllVideos(e, t) { let n, a; n = $(this.getSelector().itemSelector), a = $(this.getSelector().nextPageSelector).attr("href"); if (a && 0 === n.length) throw show.error("解析列表失败"), new Error("解析列表失败"); for (const i of n) { const n = $(i), {carNum: a, url: o, publishTime: r} = this.getBean("ListPagePlugin").findCarNumAndHref(n); if (o && a) try { if (await storageManager.getCar(a)) continue; await storageManager.saveCar({carNum: a, url: o, names: e, actionType: t, publishTime: r}), clog.log("批量操作", e, a, t); } catch (s) { console.error(`保存失败 [${a}]:`, s); } } if (a) { show.info("请不要关闭窗口, 正在解析下一页:" + a), await new Promise((e => setTimeout(e, 500))); const i = await gmHttp.get(a), s = new DOMParser, o = $(s.parseFromString(i, "text/html")); await this.batchSaveAllVideos(e, t); } else show.ok("执行结束!"), window.refresh(); } async filterActorVideo(e, t, n) { let {nextPageLink: a} = await this.parseAndSaveFilterInfo(n, e, t); if (this.nextPageLink = a, a) { let n; this.lastPageLink = a, show.info("请不要关闭窗口, 正在解析下一页:" + a); clog.log("正在请求下一页内容:", a); const i = await gmHttp.get(a); n = utils.htmlTo$dom(i); await this.filterActorVideo(e, t, n); } else show.ok("执行结束!"), window.refresh(); } async parseAndSaveFilterInfo(e, t, n) { let a, i; if (e) { let t = !1, n = T; e.text().includes(I) && (t = !0, n = I), t && e.find(".avatar-box").length > 0 && e.find(".avatar-box").parent().remove(), a = e.find(this.getSelector(n).requestDomItemSelector), i = e.find(this.getSelector(n).nextPageSelector).attr("href"); } else a = $(this.getSelector().itemSelector), i = $(this.getSelector().nextPageSelector).attr("href"); if (i && 0 === a.length) return { nextPageLink: null, lastPublishTime: null }; let s = [], o = null; for (const l of a) { const e = $(l), {carNum: a, url: i, publishTime: r} = this.getBean("ListPagePlugin").findCarNumAndHref(e); o || (o = r), i && a && s.push({ carNum: a, url: i, names: t, actionType: d, starId: n, publishTime: r }); } try { await storageManager.batchSaveBlacklistCarList(s); } catch (r) { clog.error("保存失败:", r), console.error("保存失败:", r); } return { nextPageLink: i, lastPublishTime: o }; } } class Ce extends X { getName() { return "ListPageButtonPlugin"; } async handle() { if (!window.isListPage) return; await this.createMenuBtn(), this.bindEvent(); (await storageManager.getSetting("showWaitCheckBtn", _)) === C && $("#waitCheckBtn").hide(); (await storageManager.getSetting("showWaitDownBtn", _)) === C && $("#waitDownBtn").hide(); this.sortItems().then(); } async createMenuBtn() { if (r) { const e = o.includes("/actors/"); let t = $(".main-tabs, .tabs"), n = "加入黑名单", a = "#d22020", i = "", s = null; if (e) { t = $(".toolbar, .section-addition").filter(":last"); const e = await storageManager.getBlacklist(), i = this.getActressPageInfo(); e.find((e => e.starId === i.starId)) && (n = "已加入黑名单", a = "#885d5d"); } else o.includes("/tags") && utils.loopDetector((() => $("#jhs-check-tag").text().trim()), (async () => { const e = $("#addBlacklistBtn"); e.attr("data-tip", "将当前分类标签加入到黑名单, 后续有作品更新也会纳入屏蔽中"); const t = $("#jhs-check-tag").text().trim(); if (!t) return; const n = "no-" + t, a = await storageManager.getBlacklist(); s = a.find((e => e.starId === n)), s && (e.css("backgroundColor", "#885d5d"), $("#addBlacklistBtn span").text("已加入黑名单")); })); const r = o.includes("advanced_search"); r ? t = $("h2.section-title") : i = "flex-grow:1;"; const l = localStorage.getItem("jhs_sortMethod"), d = "排序方式: " + ("rateCount" === l ? "评价" : "rating" === l ? "评分" : "date" === l ? "时间" : "默认"); t.append(`\n <div class="jhs-list-btn-row" style="display: flex;align-items: center; ${i} ">\n <a id="waitCheckBtn" class="menu-btn main-tab-btn" style="background-color:#56c938 !important;"><span>打开待鉴定</span></a>\n <a id="waitDownBtn" class="menu-btn main-tab-btn" style="background-color:#2caac0 !important;"><span>打开已收藏</span></a>\n ${e ? `\n <a id="addBlacklistBtn" class="menu-btn main-tab-btn" style="background-color:${a} !important;" data-tip="将演员加入黑名单, 后续有作品更新也会纳入屏蔽中"><span>${n}</span></a>\n <a id="filterAllVideo" class="menu-btn main-tab-btn" style="background-color:#e8ab39 !important;" data-tip="一键屏蔽已选分类的视频列表至鉴定记录中"><span>一键屏蔽所有作品</span></a>\n <a id="favoriteAllVideo" class="menu-btn main-tab-btn" style="background-color:#25b1dc !important;" data-tip="一键收藏当前页面所有作品"><span>一键收藏所有作品</span></a>\n <a id="hasDownAllVideo" class="menu-btn main-tab-btn" style="background-color:#7bc73b !important;margin-right: 30px!important;" data-tip="一键标记当前页面所有作品为已下载"><span>一键已下载所有作品</span></a>\n ` : ""}\n ${o.includes("/tags") ? `\n <a id="addBlacklistBtn" class="menu-btn main-tab-btn" style="background-color:${a} !important;" data-tip="将演员加入黑名单, 后续有作品更新也会纳入屏蔽中"><span>${n}</span></a>\n ` : ""}\n </div>\n <div class="jhs-list-btn-row" style="display: flex;align-items: center;">\n <a id="newVideoBtn" class="menu-btn main-tab-btn" style="background-color:#2c6cc0 !important;"><span>新作品检测 (<span id="newVideoCount">0</span>)</span></a>\n <a id="blacklistBtn" class="menu-btn main-tab-btn" style="background-color:#34393f !important;"><span>演员黑名单</span></a>\n ${c || r ? "" : `<a id="sort-toggle-btn" class="menu-btn main-tab-btn" style="background-color:#8783ab !important;"> ${d} </a>`}\n </div>\n `); } if (l) { const e = o.includes("/star/"); let t = "加入黑名单", n = "#d22020"; if (e) { const e = await storageManager.getBlacklist(), a = this.getActressPageInfo(); e.find((e => e.starId === a.starId)) && (t = "已加入黑名单", n = "#885d5d"); } // 非演员页: 新作品检测/统计/演员黑名单 改挂到筛选条右侧(尺寸对齐筛选小胶囊); // 筛选条被关闭时退回原来的按钮行, 否则这几个功能就没入口了 const useFilterBar = !e && await storageManager.getSetting("enableQuickFilter", _) === _; $(".masonry").parent().prepend(`\n <div class="jhs-list-btn-row" style="margin: 10px; display: flex;">\n <a id="waitCheckBtn" class="menu-btn main-tab-btn" style="background-color:#56c938 !important;"><span>打开待鉴定</span></a>\n <a id="waitDownBtn" class="menu-btn main-tab-btn" style="background-color:#2caac0 !important;"><span>打开已收藏</span></a>\n ${e ? ` \n <a id="addBlacklistBtn" class="menu-btn main-tab-btn" style="background-color:${n} !important;" data-tip="将演员加入黑名单, 后续有作品更新也会纳入屏蔽中"><span>${t}</span></a>\n <a id="filterAllVideo" class="menu-btn main-tab-btn" style="background-color:#e8ab39 !important;" data-tip="一键屏蔽已选分类的视频列表至鉴定记录中"><span>一键屏蔽所有作品</span></a>\n <a id="favoriteAllVideo" class="menu-btn main-tab-btn" style="background-color:#25b1dc !important;" data-tip="一键收藏当前页面所有作品"><span>一键收藏所有作品</span></a>\n <a id="hasDownAllVideo" class="menu-btn main-tab-btn" style="background-color:#7bc73b !important;" data-tip="一键标记当前页面所有作品为已下载"><span>一键已下载所有作品</span></a>\n ` : useFilterBar ? "" : '<a id="blacklistBtn" class="menu-btn main-tab-btn" style="background-color:#34393f !important;"><span>演员黑名单</span></a>'}\n </div>\n `); useFilterBar && this.mountBusFilterBarBtns(); } } /** JavBus: 把「统计 / 演员黑名单」挂到筛选条右侧, 尺寸对齐 .jhs-filter-btn 小胶囊。 * 统一延后到轮询里挂载(而不是同步插入): handle() 里 bindEvent 先跑完, 这里再自行绑定, * 避免两边各绑一次导致弹窗开两遍。 * 新作品检测不放这里: 数据存 IndexedDB(按域名隔离), JavBus 域下取不到 JavDB 的收藏演员, 打开必然是空的。 */ async mountBusFilterBarBtns() { // 注意: 不能带 main-tab-btn 类(它有 border-radius:3px!important + height:30px), 也要压掉 .menu-btn 的 min-width:80px const pill = "min-width:0;display:inline-flex;align-items:center;padding:3px 10px;border-radius:12px !important;font-size:12px;line-height:1.5;height:auto;box-shadow:none;cursor:pointer;color:#fff !important;border:none;margin:0;background:"; // handle() 里是用 .hide() 关这两个按钮的(内联 display:none), 而下面 skin() 会整体重写 style 把它抹掉, // 所以这里自己读设置: 关掉的就不搬进筛选条, 留在原按钮行里(整行随后会被隐藏) const showCheck = await storageManager.getSetting("showWaitCheckBtn", _) === _; const showDown = await storageManager.getSetting("showWaitDownBtn", _) === _; const mount = () => { if ($(".jhs-bus-bar-btn").length) return !0; const $qf = $("#jhs-quick-filter"); if (!$qf.length) return !1; // 移动(而不是重建)原按钮行里的两个按钮: jQuery 的 appendTo 只挪节点, 已绑定的事件跟着走 showCheck && $("#waitCheckBtn").appendTo($qf); showDown && $("#waitDownBtn").appendTo($qf); $("#statsBtn").length ? $("#statsBtn").appendTo($qf) : $qf.append('<a id="statsBtn" class="menu-btn"><span>统计</span></a>').find("#statsBtn").on("click.jhsBusBar", (() => this.getBean("StatsPlugin")?.openDialog?.())); $qf.append('<a id="blacklistBtn" class="menu-btn"><span>演员黑名单</span></a>'); $("#blacklistBtn").on("click.jhsBusBar", (() => this.getBean("BlacklistPlugin").openBlacklistDialog())); // 统一成小胶囊; margin-left:auto 只能给这一组第一个「实际存在」的按钮, 给后面的会把前面的挤到左边 let first = !0; [ [ showCheck && "#waitCheckBtn", "#56c938" ], [ showDown && "#waitDownBtn", "#2caac0" ], [ "#statsBtn", "#6c5ce7" ], [ "#blacklistBtn", "#34393f" ] ].forEach((([sel, bg]) => { if (!sel) return; const $b = $(sel); $b.length && ($b.removeClass("main-tab-btn").addClass("jhs-bus-bar-btn").attr("style", pill + bg + (first ? ";margin-left:auto" : "")), first = !1); })); // 右端与下方卡片网格对齐: 筛选条铺满父容器, 但卡片网格右侧可能还留有空白, 直接 margin-left:auto 会顶到父容器右缘 const alignRight = () => { const bar = $qf[0]; bar.style.paddingRight = "0px"; let edge = 0; document.querySelectorAll(".masonry .item").forEach((el => { const r = el.getBoundingClientRect(); r.width && (edge = Math.max(edge, r.right)); })); if (!edge) return; const gap = Math.round(bar.getBoundingClientRect().right - edge); gap > 0 && (bar.style.paddingRight = gap + "px"); }; alignRight(), $(window).off("resize.jhsBusBar").on("resize.jhsBusBar", alignRight); // 原按钮行搬空后收起; 留在里面的只可能是被设置关掉的那两个(它们本就该不可见), // /tags 页还有 addBlacklistBtn 之类要保留的按钮, 那种情况下整行不能隐藏 const $row = $(".jhs-list-btn-row").first(); $row.length && !$row.children().not("#waitCheckBtn, #waitDownBtn").length && $row.hide(); return !0; }; utils.loopDetector(mount, null, 150, 8e3, !1); } bindEvent() { $("#waitCheckBtn").on("click", (e => { this.openWaitCheck(e).then(); })), $("#waitDownBtn").on("click", (e => { this.openFavorite().then(); })), $("#newVideoBtn").on("click", (e => { this.getBean("NewVideoPlugin").openDialog(); })), $("#blacklistBtn").on("click", (e => { this.getBean("BlacklistPlugin").openBlacklistDialog(); })), $("#sort-toggle-btn").on("click", (e => { const t = localStorage.getItem("jhs_sortMethod"); let n; n = t && "default" !== t ? "rateCount" === t ? "rating" : "rating" === t ? "date" : "default" : "rateCount"; const a = { default: "默认", rateCount: "评价", rating: "评分", date: "时间" }[n]; $(e.target).text(`排序方式: ${a}`), localStorage.setItem("jhs_sortMethod", n), this.sortItems().then(); })); const e = this.getBean("BlacklistPlugin"); $("#addBlacklistBtn").on("click", (async t => { await e.addBlacklist(t); })), $("#filterAllVideo").on("click", (async t => { let n = { clientX: t.clientX, clientY: t.clientY + 80 }, a = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); if (0 === a.length) return void show.error("获取演员名称失败"); let i = a.text().trim().split(",")[0]; utils.q(n, "一键屏蔽视频列表?", (async () => { this.loadObj = loading(); try { await e.filterAllVideo(i), window.refresh(); } catch (t) { console.error(t); } finally { this.loadObj.close(); } })); })), $("#favoriteAllVideo").on("click", (async t => { let n = {clientX: t.clientX, clientY: t.clientY + 80}, a = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); if (0 === a.length) return void show.error("获取演员名称失败"); let i = a.text().trim().split(",")[0]; utils.q(n, "一键收藏所有可见作品?", (async () => { this.loadObj = loading(); try { await e.batchSaveAllVideos(i, h), window.refresh(); } catch (t) { console.error(t); } finally { this.loadObj.close(); } })); })), $("#hasDownAllVideo").on("click", (async t => { let n = {clientX: t.clientX, clientY: t.clientY + 80}, a = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); if (0 === a.length) return void show.error("获取演员名称失败"); let i = a.text().trim().split(",")[0]; utils.q(n, "一键已下载所有可见作品?", (async () => { this.loadObj = loading(); try { await e.batchSaveAllVideos(i, g), window.refresh(); } catch (t) { console.error(t); } finally { this.loadObj.close(); } })); })); } async sortItems() { if (o.includes("handle") || o.includes("advanced_search")) return; if (c) return; const t = localStorage.getItem("jhs_sortMethod"); if (!t) return; $(".movie-list .item").each((function(e) { $(this).attr("data-original-index") || $(this).attr("data-original-index", e); })); const n = $(".movie-list"), a = $(".item", n); if ("default" === t) a.sort((function(e, t) { return $(e).data("original-index") - $(t).data("original-index"); })).appendTo(n); else { const e = a.get(); e.sort((function(e, n) { if ("rateCount" === t) { const t = e => { const t = $(e).find(".score .value").text().match(/由(\d+)人/); return t ? parseFloat(t[1]) : 0; }; return t(n) - t(e); } else if ("rating" === t) { const t = e => { const t = parseFloat($(e).find(".score .value, .score").text().trim()); return isNaN(t) ? 0 : t; }; return t(n) - t(e); } { const t = e => { const t = $(e).find(".meta").text().trim(); return new Date(t); }; return t(n) - t(e); } })), n.empty().append(e); } } async openWaitCheck() { let e = this.getSelector(); const t = await storageManager.getSetting("waitCheckCount", 5), n = [ u, b, y, k ]; let a = 0; $(`${e.itemSelector}:visible`).each(((e, i) => { if (a >= t) return !1; const s = $(i); if (n.some((e => s.find(`span.tag:contains('${e}')`).length > 0))) return; const {carNum: o, aHref: r} = this.getBean("ListPagePlugin").findCarNumAndHref(s); if (o.includes("FC2-")) { const e = this.parseMovieId(r); this.getBean("Fc2Plugin").openFc2Page(e, o, r); } else { let e = r + (r.includes("?") ? "&autoPlay=1" : "?autoPlay=1"); window.open(e); } a++; })), 0 === a && show.info("没有需鉴定的视频"); } async openFavorite() { const openCount = await storageManager.getSetting("waitCheckCount", 5); const isRandom = await storageManager.getSetting("randomOpenWaitDown", C) === _; const dataList = await storageManager.getCarList(); const favoriteList = isRandom ? dataList.filter((e => e.status === h)).sort((() => Math.random() - .5)) : dataList.filter((e => e.status === h)).sort(((e, t) => (t.createDate || 0) - (e.createDate || 0))); if (0 === favoriteList.length) return show.info("暂无已收藏作品"); for (let i = 0; i < openCount && i < favoriteList.length; i++) { const {carNum: carNum2, url} = favoriteList[i]; if (carNum2 && carNum2.includes("FC2-")) { const movieId = this.parseMovieId(url); await this.getBean("Fc2Plugin").openFc2Page(movieId, carNum2, url); } else if (url) window.open(url); } } } const _e = async (e, t = "ja", n = "zh-CN") => { if (!e) throw new Error("翻译文本不能为空"); const a = "https://translate-pa.googleapis.com/v1/translate?" + new URLSearchParams({ "params.client": "gtx", dataTypes: "TRANSLATION", key: "AIzaSyDLEeFI5OtFBwYBIoK_jj5m32rZK5CkCXA", "query.sourceLanguage": t, "query.targetLanguage": n, "query.text": e }), i = await fetch(a); if (!i.ok) throw new Error(`${i.status} ${i.statusText}`); return (await i.json()).translation; }, Te = { IS_FILTERED: { text: u, color: f, reasonType: "单番号屏蔽", isCounted: !0, countKey: "currentPageFilterCount" }, IS_FAVORITE: { text: b, color: w, reasonType: "", isCounted: !0, countKey: "currentPageFavoriteCount" }, IS_HAS_DOWN: { text: y, color: x, reasonType: "", isCounted: !0, countKey: "currentPageHasDownCount" }, IS_HAS_WATCH: { text: k, color: S, reasonType: "", isCounted: !0, countKey: "currentPageHasWatchCount" }, IS_KEYWORD_FILTER: { text: "关键词屏蔽", color: "#de3333", reasonType: "", isCounted: !0, countKey: "currentPageKeywordFilterCount" }, IS_ACTOR_FILTER: { text: "男演员屏蔽", color: "#b22222", reasonType: "", isCounted: !0, countKey: "currentPageActorFilterCount" }, IS_ACTRESS_FILTER: { text: "女演员屏蔽", color: "#cd5c5c", reasonType: "", isCounted: !0, countKey: "currentPageActorFilterCount" }, IS_WAIT_CHECK: { text: "", color: "", reasonType: "", isCounted: !0, countKey: "currentPageWaitCheckCount" }, IS_VISITED: { text: "已浏览", color: "rgba(108,117,125,0.65)", reasonType: "", isCounted: !1, countKey: "" } }; class Ie extends X { constructor() { super(...arguments), i(this, "currentPageFilterCount", 0), i(this, "currentPageFavoriteCount", 0), i(this, "currentPageHasDownCount", 0), i(this, "currentPageHasWatchCount", 0), i(this, "currentPageKeywordFilterCount", 0), i(this, "currentPageActorFilterCount", 0), i(this, "currentPageWaitCheckCount", 0), i(this, "currentPageTotalCount", 0), i(this, "cache", localStorage.getItem("jhs_translate") ? JSON.parse(localStorage.getItem("jhs_translate")) : {}), i(this, "writeQueue", Promise.resolve()), i(this, "_debouncedTranslateWrite", null); } getName() { return "ListPagePlugin"; } async handle() { new BroadcastChannel("channel-refresh").addEventListener("message", (async e => { let t = e.data.type; if ("refresh" === t) { await this.doFilter(), this.applyVisibility(); const e = this.getBean("HistoryPlugin"); e.tableObj && e.tableObj.setData(); const t = this.getBean("NewVideoPlugin"); t && (t.showNewVideoCount().then(), t.loadData()); } else "cleanCache_filter_actor_actress_car_list" === t ? storageManager._invalidateCache(storageManager.blacklist_car_list_key) : "clean_cacheSettingObj" === t && storageManager._invalidateCache(storageManager.setting_key); })), (() => { try { new BroadcastChannel("channel-visited").addEventListener("message", (e => { const d = e.data || {}; "visited" === d.type && d.carNum && this.markVisitedTag(d.carNum); })); } catch (e) { clog.debug("已浏览广播监听失败", e); } })(), this.cleanRepeatId(), this.replaceHdImg(), this.addJumpPageControl(), this.fixBusTitleBox(), await this.doFilter(), await this.createQuickFilter(), this.applyVisibility(), this.bindClick().then(), this.bindListPageHotKey().catch((e => clog.error("列表页快捷键绑定失败", e))), this.rememberTagExpand(), $(this.getSelector().itemSelector + " a").attr("target", "_blank"), this.checkDom(); } /** 列表页快捷键: 切换显示状态 / 折叠 / 控制台 / 图片悬停触发屏蔽等 */ async bindListPageHotKey() { this.$currentImage = null; $(document).on("mouseenter", this.getSelector().coverImgSelector, (e => { this.$currentImage = $(e.currentTarget); })).on("mouseleave", this.getSelector().coverImgSelector, (() => { this.$currentImage = null; })); const s = await storageManager.getSetting(); const REGISTRY = { showFilterItemHotKey: "showFilterItem", showFilterActorItemHotKey: "showFilterActorItem", showFilterKeywordItemHotKey: "showFilterKeywordItem", showFavoriteItemHotKey: "showFavoriteItem", showHasDownItemHotKey: "showHasDownItem", showHasWatchItemHotKey: "showHasWatchItem", showAllItemHotKey: "showAllItem" }; for (const [hotkeyName, settingKey] of Object.entries(REGISTRY)) { const key = s[hotkeyName]; if (key && HotkeyManager.isHotkeyFormat(key)) HotkeyManager.registerHotkey(key, (async () => { const cur = await storageManager.getSetting(settingKey); await storageManager.saveSettingItem(settingKey, cur === _ ? C : _); window.refresh && window.refresh(); })); } if (s.clogHotKey && HotkeyManager.isHotkeyFormat(s.clogHotKey)) HotkeyManager.registerHotkey(s.clogHotKey, (() => clog.toggleExpandCollapsed && clog.toggleExpandCollapsed())); if (s.foldCategoryHotKey && HotkeyManager.isHotkeyFormat(s.foldCategoryHotKey)) HotkeyManager.registerHotkey(s.foldCategoryHotKey, (() => { const $b = $("#foldCategoryBtn"); $b.length && $b[0].click(); })); // 快进: 原来注册在 DetailPageButtonPlugin.bindHotkey 里, 那套详情页按钮/热键已删除。 // 挪到这里(本插件始终运行): 依次尝试 预览视频的快进按钮 / 预览容器 / 预告片按钮。 if (s.speedVideoHotKey && HotkeyManager.isHotkeyFormat(s.speedVideoHotKey)) HotkeyManager.registerHotkey(s.speedVideoHotKey, (() => { const ae = document.activeElement; if ("INPUT" === ae.tagName || "TEXTAREA" === ae.tagName || ae.isContentEditable) return; const b = $("#speed-btn"); b.length ? b[0].click() : $(".preview-video-container").length ? $(".preview-video-container")[0].click() : $("#javTrailersBtn").click(); })); if ((s.enableImageHotKey || C) !== _) return; const handleAction = async (boxInfo, status) => { setTimeout((async () => { let actorName = await this.parseActressName(boxInfo.url); await storageManager.saveCar({ carNum: boxInfo.carNum, url: boxInfo.url, names: actorName, actionType: status, publishTime: boxInfo.publishTime }); window.refresh(); show.ok("操作成功"); })); }; const handlers = {}; s.filterHotKey && (handlers[s.filterHotKey] = b => handleAction(b, d)); s.favoriteHotKey && (handlers[s.favoriteHotKey] = b => handleAction(b, h)); s.hasDownHotKey && (handlers[s.hasDownHotKey] = b => handleAction(b, g)); s.hasWatchHotKey && (handlers[s.hasWatchHotKey] = b => handleAction(b, p)); Object.entries(handlers).forEach((([key, handler]) => { if (!HotkeyManager.isHotkeyFormat(key)) return; HotkeyManager.registerHotkey(key, (() => { const ae = document.activeElement; if ("INPUT" === ae.tagName || "TEXTAREA" === ae.tagName || ae.isContentEditable) return; if (!this.$currentImage) return; try { const $box = this.$currentImage.closest(".item"), info = this.findCarNumAndHref($box); handler({ carNum: info.carNum, url: info.aHref || info.url, publishTime: info.publishTime }); } catch (e) { clog.error("列表页图片快捷键失败", e); } })); })); } async createQuickFilter() { if ($("#jhs-quick-filter").length) return; if (await storageManager.getSetting("enableQuickFilter", _) === C) return; // 已浏览标签与筛选条已合并为同一个开关, 走到这里说明开关是开的, 「已浏览」按钮直接给出 const visitedBtn = `\n <a class="jhs-filter-btn" data-jhs-filter="visited" style="padding:3px 10px;border-radius:12px;font-size:12px;cursor:pointer;background:#f5f5f5;color:#666;border:1px solid #ddd">已浏览</a>`; // 123Av-Fc2 页(advanced_search?type=100)上方多了一排排序按钮, JavDB 常规列表页的 -9px 上提在这里会顶得太近, 单独给正向间距 const isFc2By123AvPage = o.includes("/advanced_search?type=100"); const e = this.getSelector(), qfMargin = isFc2By123AvPage ? "14px 0 6px" : r ? "-9px 0 6px" : "8px 0", t = `\n <div id="jhs-quick-filter" style="margin:${qfMargin};display:flex;gap:6px;flex-wrap:wrap;align-items:center">\n <span style="font-size:12px;color:#888;margin-right:4px">筛选:</span>\n <a class="jhs-filter-btn active" data-jhs-filter="all" style="padding:3px 10px;border-radius:12px;font-size:12px;cursor:pointer;background:#485fc7;color:#fff;border:none">全部</a>\n <a class="jhs-filter-btn" data-jhs-filter="waitCheck" style="padding:3px 10px;border-radius:12px;font-size:12px;cursor:pointer;background:#f5f5f5;color:#666;border:1px solid #ddd">待鉴定</a>\n <a class="jhs-filter-btn" data-jhs-filter="favorite" style="padding:3px 10px;border-radius:12px;font-size:12px;cursor:pointer;background:#f5f5f5;color:#666;border:1px solid #ddd">已收藏</a>\n <a class="jhs-filter-btn" data-jhs-filter="hasDown" style="padding:3px 10px;border-radius:12px;font-size:12px;cursor:pointer;background:#f5f5f5;color:#666;border:1px solid #ddd">已下载</a>\n <a class="jhs-filter-btn" data-jhs-filter="hasWatch" style="padding:3px 10px;border-radius:12px;font-size:12px;cursor:pointer;background:#f5f5f5;color:#666;border:1px solid #ddd">已观看</a>\n <a class="jhs-filter-btn" data-jhs-filter="filter" style="padding:3px 10px;border-radius:12px;font-size:12px;cursor:pointer;background:#f5f5f5;color:#666;border:1px solid #ddd">已屏蔽</a>${visitedBtn}\n </div>`; r ? $(e.boxSelector).before(t) : l && $(".masonry").before(t); // JavBus: 筛选条与下方封面首列左边缘对齐 (.masonry 有内边距, 兄弟节点不会自动跟随) if (l) { const align = () => { const $qf = $("#jhs-quick-filter"), $first = $(".masonry .item").first(); if (!$qf.length || !$first.length) return; $qf.css("padding-left", 0); const delta = $first.offset().left - $qf.offset().left; if (delta > 0) $qf.css("padding-left", delta + "px"); }; align(), setTimeout(align, 300), $(window).on("resize", align); } const n = this; // 跨页/刷新后恢复上次选中的筛选标签(否则 javdb 分页整页重载会把筛选重置成"全部") const savedFilter = (localStorage.getItem("jhs_quickfilter") || "all"); this.activeQuickFilter = savedFilter, this.applyQuickFilter(savedFilter), $("#jhs-quick-filter .jhs-filter-btn").each((function() { const $b = $(this); if ($b.data("jhs-filter") === savedFilter) $b.css({ background: "#485fc7", color: "#fff", border: "none" }).addClass("active"); else $b.css({ background: "#f5f5f5", color: "#666", border: "1px solid #ddd" }).removeClass("active"); })), $("#jhs-quick-filter").on("click", ".jhs-filter-btn", (function() { const t = $(this).data("jhs-filter"); $("#jhs-quick-filter .jhs-filter-btn").css({ background: "#f5f5f5", color: "#666", border: "1px solid #ddd" }).removeClass("active"), $(this).css({ background: "#485fc7", color: "#fff", border: "none" }).addClass("active"), n.activeQuickFilter = t, n.applyQuickFilter(t), localStorage.setItem("jhs_quickfilter", t); })); } applyVisibility() { const e = this.activeQuickFilter || "all", t = this.getSelector().itemSelector, n = ["filter", "keywordFilter", "actorFilter"]; $(t).each((function() { const t = $(this), a = t.attr("data-hide") === "yes", i = t.attr("data-jhs-status") || "waitCheck"; // 如果已被 filterMovieList 标记为隐藏/半透明, 保持其处理方式 if (a) { return; } if (e === "all") { n.includes(i) ? t.hide() : t.show(); return; } if (i === e) { t.show(); return; } t.hide(); })); } applyQuickFilter(e) { this.activeQuickFilter = e, this.applyVisibility(); } rememberTagExpand() { if (!window.location.href.includes("actors")) return; const e = $(".tag-expand"); if (0 === e.length) return; const t = "jhs_tag_expand", n = "true" === localStorage.getItem(t), a = $(".actor-tags .content"); n && a.hasClass("collapse") && e[0].click(), e.on("click", (function() { const e = !$(".actor-tags .content").hasClass("collapse"); clog.debug("触发"), localStorage.setItem(t, e.toString()); })); } checkDom() { if (!window.isListPage) return; const e = this.getSelector(), t = document.querySelector(e.boxSelector); if (!t) return void console.error("没有找到容器节点!"); let n = null; const a = new MutationObserver((() => { n && clearTimeout(n), n = setTimeout((async () => { a.disconnect(); try { this.replaceHdImg(), this.addJumpPageControl(), this.fixBusTitleBox(), await this.doFilter(), this.applyVisibility(), await (async () => { const ap = await storageManager.getSetting("autoPage"); if (ap !== _) await this.getBean("ListPageButtonPlugin").sortItems(); })(), this.getBean("CoverButtonPlugin").addSvgBtn(), $(this.getSelector().itemSelector + " a").attr("target", "_blank"), this.getBean("AutoPagePlugin").checkLoad(); } finally { t && t.isConnected && a.observe(t, { childList: !0, subtree: !1 }); } }), 100); })); a.observe(t, { childList: !0, subtree: !1 }); } fixBusTitleBox() { if (!l) return; $(this.getSelector().itemSelector).toArray().forEach((e => { var t; let n = $(e); if (n.find(".avatar-box").length > 0) return; // 幂等: MutationObserver 会触发多次, 已包裹过 .video-title 的直接跳过, 否则会套娃 → 让 115 匹配等插件的 .prepend(".video-title") 命中多个节点, 出现「未匹配」重复、标题被裁剪 if (n.find(".photo-info .video-title").length > 0) return; const a = (null == (t = n.find("img").attr("title")) ? void 0 : t.trim()) || ""; n.find(".photo-info span:first").contents().first().wrap(`<span class="video-title" title="${a}">${a}</span>`), n.find("br").remove(); })); } cleanRepeatId() { if (!l) return; $("#waterfall_h").removeAttr("id").attr("id", "no-page"); const e = $('[id="waterfall"]'); 0 !== e.length && e.each((function() { const e = $(this); if (!e.hasClass("masonry")) { e.children().insertAfter(e), e.remove(); } })); } async doFilter() { if (!window.isListPage) return; let e = $(this.getSelector().itemSelector).toArray(); if (!e.length) return; try { await this.filterMovieList(e); } catch (t) { clog.error("列表页过滤失败", t); } const w = this.getBean("WangPan115MatchPlugin"); w && w.matchMovieList(e).catch((t => clog.error("115匹配失败", t))); l && setTimeout((() => { this.getBean("BusImgPlugin").logImageHeightsByRow().catch((e => clog.error("JavBus图片高度修正失败", e))); })); } async yieldListFrame() { await new Promise((e => { window.requestAnimationFrame ? window.requestAnimationFrame((() => setTimeout(e))) : setTimeout(e); })); } findMatchedTitleKeyword(e, t, n) { for (const a of e) if (t.includes(a) || n.startsWith(a)) return a; return null; } getStatusKey(e) { return e === Te.IS_FILTERED ? "filter" : e === Te.IS_FAVORITE ? "favorite" : e === Te.IS_HAS_DOWN ? "hasDown" : e === Te.IS_HAS_WATCH ? "hasWatch" : e === Te.IS_KEYWORD_FILTER ? "keywordFilter" : e === Te.IS_ACTOR_FILTER ? "actorFilter" : e === Te.IS_ACTRESS_FILTER ? "actorFilter" : e === Te.IS_VISITED ? "visited" : "waitCheck"; } /** 生成封面角标 HTML(鉴定状态/已浏览 共用) */ buildStatusTagHtml(statusObj, tip, tagPosition) { const pos = "rightTop" === tagPosition ? "right: 0; top:5px;" : "left: 0; top:5px;"; return r ? `<span class="tag is-success status-tag" data-tip="${tip}" title=""\n style="margin-right: 5px; border-radius:10px; position:absolute; \n z-index:10; background-color: ${statusObj.color} !important; ${pos}">\n ${statusObj.text}\n </span>` : `<span class="status-tag" data-tip="${tip}" title=""\n style="display:inline-block; margin-right: 5px; padding: 0 6px; line-height: 18px; font-size: 12px; color: #fff !important; border-radius:10px; position:absolute; \n z-index:10; background-color: ${statusObj.color} !important; ${pos}">${statusObj.text}</span>`; } /** 即时给某个番号的列表项打上"已浏览"角标(无需刷新页面) */ async markVisitedTag(carNum) { if (!carNum || !window.isListPage) return; try { if (await storageManager.getSetting("enableQuickFilter", _) !== _) return; const tagPosition = await storageManager.getSetting("tagPosition", "rightTop"); const $items = $(this.getSelector().itemSelector).filter('[data-jhs-status="waitCheck"]'); $items.each((idx, el) => { const $item = $(el); let cur; try { cur = this.findCarNumAndHref($item).carNum; } catch (e) { return; } if (cur !== carNum) return; $item.attr("data-jhs-status", "visited").attr("data-jhs-tip", ""); $item.find(".status-tag").remove(); const html = this.buildStatusTagHtml(Te.IS_VISITED, "", tagPosition); if (r) $item.find(".tags").append(html); else { const $frame = $item.find(".photo-frame"); if ($frame.length) $frame.append(html); else { const $t = $item.find(".item-tag"); $t.length ? $t.append(html) : $item.find(".photo-info > span > div").append(html); } } return !1; }); } catch (e) { clog.debug("标记已浏览角标失败", e); } } async translateListItems(e) { for (let t = 0; t < e.length; t++) t > 0 && t % 8 == 0 && await this.yieldListFrame(), await this.translate(e[t]); } async filterMovieList(e) { utils.time("累计耗费时间"), utils.time("读取数据耗时"); const [n, a, i, s, m] = await Promise.all([ storageManager.getTitleFilterKeyword(), storageManager.getBlacklistMap(), storageManager.getBlacklistCarList(), storageManager.getSetting(), storageManager.getStatusMap() ]), o = utils.time("读取数据耗时"); utils.time("组装数据耗时"); const u = a, {actorCarNumToNameMap: f, actressCarNumToNameMap: v} = i.reduce(((e, t) => { const n = u.get(t.starId)?.role; if (!n) return clog.error("黑名单数据源丢失演员信息", t), e; const a = n === B ? e.actorCarNumToNameMap : e.actressCarNumToNameMap; return a.has(t.carNum) || a.set(t.carNum, t.names), e; }), { actorCarNumToNameMap: new Map, actressCarNumToNameMap: new Map }), b = utils.time("组装数据耗时"), w = (null == s ? void 0 : s.showFilterItem) ?? C, y = (null == s ? void 0 : s.showFilterActorItem) ?? C, x = (null == s ? void 0 : s.showFilterKeywordItem) ?? C, k = (null == s ? void 0 : s.showFavoriteItem) ?? _, S = (null == s ? void 0 : s.showHasDownItem) ?? _, T = (null == s ? void 0 : s.showHasWatchItem) ?? _, I = (null == s ? void 0 : s.showAllItem) ?? C, P = (null == s ? void 0 : s.tagPosition) || "rightTop", V = (null == s ? void 0 : s.movieShowType) || "normal"; const _visitedTagEnabled = ((null == s ? void 0 : s.enableQuickFilter) !== C); const _visitedSet = _visitedTagEnabled ? jhsGetVisitedCarNumSet() : null; const O = n.filter((e => e)); this.currentPageFilterCount = 0, this.currentPageFavoriteCount = 0, this.currentPageHasDownCount = 0, this.currentPageHasWatchCount = 0, this.currentPageKeywordFilterCount = 0, this.currentPageActorFilterCount = 0, this.currentPageWaitCheckCount = 0, this.currentPageTotalCount = 0, utils.time("处理页面耗时"); const R = []; for (let n = 0; n < e.length; n++) { n > 0 && n % 50 == 0 && await this.yieldListFrame(); let t = $(e[n]); if (l && t.find(".avatar-box").length > 0) continue; let a, i; try { ({carNum: a, title: i} = this.findCarNumAndHref(t)); } catch (err) { clog.warn("封面车牌提取失败, 跳过该封面状态处理:", err); continue; } const {filter: s, favorite: o, hasDown: d, hasWatch: h} = m, g = o.has(a), p = d.has(a), u = h.has(a), b = s.has(a), B = f.has(a), D = v.has(a), A = B || D, L = this.findMatchedTitleKeyword(O, i, a), M = !!L; if (!c) { let e = k === C && g || S === C && p || T === C && u || w === C && b && !(g || p || u) || y === C && A || x === C && M; const n = t.attr("data-hide") === _; I === _ && (e = !1); if ("visibility" === V) { e && !n ? t.css({ opacity: .25 }).attr("data-hide", _) : !e && n && t.css({ opacity: "" }).removeAttr("data-hide"); t.show(); } else if ("normal" === V) { // 正常: 只显示鉴定角标, 不隐藏也不半透明 t.css({ opacity: "" }).removeAttr("data-hide").show(); } else { t.css({ opacity: "" }); e && !n ? t.hide().attr("data-hide", _) : !e && n && t.show().removeAttr("data-hide"); } } let N = Te.IS_WAIT_CHECK, j = null; b ? N = Te.IS_FILTERED : g ? N = Te.IS_FAVORITE : p ? N = Te.IS_HAS_DOWN : u ? N = Te.IS_HAS_WATCH : M ? (N = Te.IS_KEYWORD_FILTER, j = L || "未知") : B ? (N = Te.IS_ACTOR_FILTER, j = f.get(a) || "") : D && (N = Te.IS_ACTRESS_FILTER, j = v.get(a) || ""), j || (j = N.reasonType), N.isCounted && this[N.countKey]++, this.currentPageTotalCount++; if (N === Te.IS_WAIT_CHECK && _visitedTagEnabled && _visitedSet && _visitedSet.has(a)) { N = Te.IS_VISITED; j = ""; } const q = this.getStatusKey(N), F = t.attr("data-jhs-status") !== q || t.attr("data-jhs-tip") !== j || t.attr("data-jhs-tag-position") !== P; t.attr("data-jhs-status", q).attr("data-jhs-tip", j).attr("data-jhs-tag-position", P); const E = "rightTop" === P ? "right: 0; top:5px;" : "left: 0; top:5px;"; if (F && (t.find(".status-tag").remove(), N.text)) { const e = this.buildStatusTagHtml(N, j, P); if (r) t.find(".tags").append(e); if (l) { const $frame = t.find(".photo-frame"); if ($frame.length) $frame.append(e); else { const n = t.find(".item-tag"); n.length ? n.append(e) : t.find(".photo-info > span > div").append(e); } } } R.push(t); } this.translateListItems(R).catch((e => clog.error("列表页翻译任务失败", e))); const D = utils.time("处理页面耗时"), A = utils.time("累计耗费时间"); clog.log(`\n <table class="countTable" style='border-collapse: collapse; width: 100%'>\n <tr>\n <td colspan="2" style='padding: 3px; border: 1px solid #ccc;'>${o}</td>\n <td colspan="2" style='padding: 3px; border: 1px solid #ccc;'>${b}</td>\n </tr>\n \n <tr>\n <td colspan="2" style='padding: 3px; border: 1px solid #ccc;'>${D}</td>\n <td colspan="2" style='padding: 3px; border: 1px solid #ccc;'>${A}</td>\n </tr>\n <tr>\n <td style='padding: 3px; border: 1px solid #ccc; font-weight: bold;'>项目</td>\n <td style='padding: 3px; border: 1px solid #ccc; font-weight: bold;'>数量</td>\n <td style='padding: 3px; border: 1px solid #ccc; font-weight: bold;'>项目</td>\n <td style='padding: 3px; border: 1px solid #ccc; font-weight: bold;'>数量</td>\n </tr>\n \n <tr>\n <td style='padding: 3px; border: 1px solid #ccc;'>屏蔽单番号</td>\n <td style='padding: 3px; border: 1px solid #ccc;'><strong>${this.currentPageFilterCount}</strong></td>\n <td style='padding: 3px; border: 1px solid #ccc;'>收藏</td>\n <td style='padding: 3px; border: 1px solid #ccc;'><strong>${this.currentPageFavoriteCount}</strong></td>\n </tr>\n \n <tr>\n <td style='padding: 3px; border: 1px solid #ccc;'>屏蔽演员</td>\n <td style='padding: 3px; border: 1px solid #ccc;'><strong>${this.currentPageActorFilterCount}</strong></td>\n <td style='padding: 3px; border: 1px solid #ccc;'>已下载</td>\n <td style='padding: 3px; border: 1px solid #ccc;'><strong>${this.currentPageHasDownCount}</strong></td>\n </tr>\n \n <tr>\n <td style='padding: 3px; border: 1px solid #ccc;'>屏蔽关键词</td>\n <td style='padding: 3px; border: 1px solid #ccc;'><strong>${this.currentPageKeywordFilterCount}</strong></td>\n <td style='padding: 3px; border: 1px solid #ccc;'>已观看</td>\n <td style='padding: 3px; border: 1px solid #ccc;'><strong>${this.currentPageHasWatchCount}</strong></td>\n </tr>\n \n <tr>\n <td style='padding: 3px; border: 1px solid #ccc;'>待鉴定</td>\n <td style='padding: 3px; border: 1px solid #ccc;'><strong>${this.currentPageWaitCheckCount}</strong></td>\n <td style='padding: 3px; border: 1px solid #ccc;'></td>\n <td style='padding: 3px; border: 1px solid #ccc;'></td>\n </tr>\n \n <tr>\n <td style='padding: 3px; border: 1px solid #ccc;'><strong>总数</strong></td>\n <td style='padding: 3px; border: 1px solid #ccc;'><strong>${this.currentPageTotalCount}</strong></td>\n </tr>\n </table>\n `); } async bindClick() { let e = this.getSelector(); $(e.boxSelector).on("click", ".item img", (async e => { try { if (e.preventDefault(), e.stopPropagation(), $(e.target).closest("div.meta-buttons").length) return; const t = $(e.target).closest(".item"), {carNum: n, aHref: a} = this.findCarNumAndHref(t); const dialogOpenDetail = await storageManager.getSetting("dialogOpenDetail", _); if (n.includes("FC2-")) { let e = this.parseMovieId(a); this.getBean("Fc2Plugin").openFc2Dialog(e, n, a), this.$currentImage = null; } else if (dialogOpenDetail === _) { utils.openPage(a, n, !0, e), this.$currentImage = null; } else { window.open(a); } } catch (t) { console.error("点击图片处理失败:", t); } })), $(e.boxSelector).on("click", ".item video", (async e => { const t = e.currentTarget; t.paused ? t.play().catch((e => console.error("播放失败:", e))) : t.pause(), e.preventDefault(), e.stopPropagation(); })), $(e.boxSelector).on("click", ".item .video-title", (async e => { if ($(e.target).closest('[class^="jhs-match-"]').length) return; const t = $(e.currentTarget).closest(".item"), {carNum: n, aHref: a} = this.findCarNumAndHref(t); if (n.includes("FC2-")) { e.preventDefault(); let t = this.parseMovieId(a); this.getBean("Fc2Plugin").openFc2Dialog(t, n, a); } })), $(e.boxSelector).on("contextmenu", ".item img, .item video", (async e => { try { e.preventDefault(); const t = $(e.target).closest(".item"), {carNum: n, url: a, publishTime: i} = this.findCarNumAndHref(t); let s = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"), o = ""; s.length && (o = s.text().trim().split(",")[0].replace("(無碼)", "")), utils.q(e, `是否屏蔽番号 ${n}?`, (async () => { try { o || (o = await this.parseActressName(a)), await storageManager.saveCar({ carNum: n, url: a, names: o, actionType: d, publishTime: i }), window.refresh(), show.ok("操作成功"); } catch (s) { console.error("屏蔽操作失败:", s), show.error("操作失败"); } })); } catch (t) { console.error("右键菜单处理失败:", t); } })); } async parseActressName(e) { let t = null; if (await storageManager.getSetting("enableSaveActressCarInfo", C) === _) { clog.debug("鉴定补录演员信息-已启用, 开始解析详情页"), clog.debug("开始解析演员详情页", e); try { const n = await gmHttp.get(e), a = utils.htmlTo$dom(n); if (r) { // JavDB: 多重选择器兼容改版 // 1) 传统: 每个 .female 图标前的 <a> 是演员名 const oldNames = a.find(".female").prev().map((e, t) => $(t).text().trim()).get().filter(Boolean); if (oldNames.length) t = oldNames.join(" "); else { // 2) 新版: panel-block 中含「演員/演员」label 的 value 内 <a> const $blocks = a.find(".movie-panel-info .panel-block, .panel-block"); $blocks.each((_, el) => { const $el = $(el), label = $el.find("strong").first().text().trim(); if (label.includes("演員") || label.includes("演员")) { const names = $el.find(".value a, a[href*='/actors/']").map((_, a) => $(a).text().trim()).get().filter(Boolean); if (names.length) t = names.join(" "); return !1; } }); // 3) 兜底: 所有指向 /actors/ 的链接文字(过滤演员图鉴/其他) if (!t) { const names = a.find(".movie-panel-info a[href*='/actors/'], .video-detail a[href*='/actors/']").map((_, x) => $(x).text().trim()).get().filter(Boolean); if (names.length) t = [...new Set(names)].join(" "); } } } else if (l) { // JavBus: 传统 star_ + 兜底 star_ href const oldNames = a.find('span[onmouseover*="star_"] a').map((e, t) => $(t).text().trim()).get().filter(Boolean); if (oldNames.length) t = oldNames.join(" "); else { const names = a.find("a[href*='/star/']").map((_, x) => $(x).text().trim()).get().filter(Boolean); if (names.length) t = [...new Set(names)].join(" "); } } clog.debug("解析到名称:", t); } catch (err) { clog.error("解析演员名称失败:", e, err); } } return t; } findCarNumAndHref(e) { var t, n; let a, i, s, o = e.find("a"), r = o.attr("href"), l = e.find(".video-title"); if (l.length > 0) { let t = l.find("strong"); t.length > 0 && (a = t.text().trim()), i = o.attr("title") ? o.attr("title").trim() : a ? l.text().replace(a, "").trim() : l.text().trim(), s = e.find(".meta").text().trim(); } if (!a) { let o = e.find("img"); r && o.length > 0 && (i = (null == (t = o.attr("title")) ? void 0 : t.trim()) || (null == (n = o.attr("data-title")) ? void 0 : n.trim())); const l = e => /^\d{4}-\d{1,2}-\d{1,2}$/.test(e); s = e.find("date").map(((e, t) => $(t).text().trim())).get().find(l), a = e.find("date").map(((e, t) => $(t).text().trim())).get().find((e => !l(e))); } if (!a) { const e = "提取番号信息失败"; throw show.error(e), new Error(e); } return { carNum: a, aHref: r, url: r, title: i, publishTime: s }; } showCarNumBox(e) { const t = $(".movie-list .item").toArray().find((t => $(t).find(".video-title strong").text() === e)); if (t) { const n = $(t); n.attr("data-hide") === "yes" && (n.show(), n.removeAttr("data-hide")); } } _replaceSingleHdImg(e) { if ("true" === e.dataset.hdReplaced) return; if (r) { const isJavdbCdn = /jdbstatic\.com|javdb\.com/i.test(e.src); if (isJavdbCdn) { const originalSrc = e.src; e.src = e.src.replace("thumbs", "covers"); e.dataset.hdReplaced = "true"; e.title = ""; e.onerror = function() { if (this.src !== originalSrc) { this.src = originalSrc; this.onerror = null; } }; // 封面图无水印显示: 升级成 covers 后这张就带水印了, 换成 App 源的无水印原图 window.jhsCleanCover && window.jhsCleanCover.applyTo(e); } } else if (l) { const t = /\/(imgs|pics)\/(thumb|thumbs)\//, n = /(\.jpg|\.jpeg|\.png)$/i; t.test(e.src) ? (e.src = e.src.replace(t, "/$1/cover/").replace(n, "_b$1"), e.dataset.hdReplaced = "true", e.dataset.title = e.title, e.title = "") : /ps(\.jpg|\.jpeg|\.png)$/i.test(e.src) && (e.src = e.src.replace(/ps(\.jpg|\.jpeg|\.png)$/i, "pl$1"), e.dataset.hdReplaced = "true", e.dataset.title = e.title, e.title = ""); } } replaceHdImg(e) { if (e && "string" == typeof e.jquery && (e = e.toArray()), e || (e = document.querySelectorAll(this.getSelector().coverImgSelector)), !e.length) return; const t = Array.from(e), n = t.slice(0, 12), a = t.slice(12); if (n.forEach((e => this._replaceSingleHdImg(e))), a.length && "IntersectionObserver" in window) { const t = new IntersectionObserver((e => { e.forEach((e => { e.isIntersecting && (this._replaceSingleHdImg(e.target), t.unobserve(e.target)); })); }), { rootMargin: "200px" }); a.forEach((e => t.observe(e))); } else a.forEach((e => this._replaceSingleHdImg(e))); storageManager.getSetting("hoverBigImg", C).then((e => { e === _ && (window.imageHoverPreviewObj ? window.imageHoverPreviewObj.bindEvents() : window.imageHoverPreviewObj = new ImageHoverPreview({ selector: this.getSelector().coverImgSelector })); })); } async translate(e) { if (await storageManager.getSetting("translateTitle", _) !== _) return; if (l && e.find(".avatar-box").length) return; // JavBus 演员页头像卡片无标题, 跳过 let t, n, a = e.find(".video-title"); if (r) { t = a.contents().filter(((e, t) => 3 === t.nodeType && "" !== t.textContent.trim())).text().trim(); n = e.find(".video-title strong").text().trim(); } else { const $img = e.find("img"), $a = e.find("a"); t = ($img.attr("data-title") || $img.attr("title") || "").trim(); n = (($a.attr("href") || "").split("/").filter(Boolean).pop() || "").trim(); } if (!t || !n) return; // 无标题或无番号(如演员页头像卡片)时跳过, 避免 undefined.trim() 报错 if (this.cache[n]) { let e = this; return a.contents().each((function() { 3 === this.nodeType && "" !== this.textContent.trim() && (this.textContent = " " + e.cache[n] + " "); })), void a.attr("title", e.cache[n]); } _e(t).then((e => { r ? (a.contents().each((function() { 3 !== this.nodeType || "" === this.textContent.trim() || this.textContent.includes(n) || (this.textContent = " " + e + " "); })), a.attr("title", e)) : a.text(e), this.cache[n] = e, this._debouncedTranslateWrite && clearTimeout(this._debouncedTranslateWrite), this._debouncedTranslateWrite = setTimeout((() => { localStorage.setItem("jhs_translate", JSON.stringify(this.cache)); }), 500); })).catch((e => { console.error("翻译失败:", e); })); } async revertTranslation() { $(this.getSelector().itemSelector).toArray().forEach((e => { let t = $(e); const n = t.find(".box").attr("title") || t.find(".video-title").attr("title") || t.find("img").attr("data-title"); let a; r && (a = t.find(".video-title strong").text().trim()); const i = t.find(".video-title"); i.contents().each((function() { 3 !== this.nodeType || "" === this.textContent.trim() || this.textContent.includes(a) || (this.textContent = " " + n + " "); })), i.removeAttr("title"); })); } addJumpPageControl() { const e = "gemini-jump-page-control"; if ($("#" + e).length > 0) return; if (0 === $(".pagination-link.is-current").length) return; const t = utils.getUrlParam(o, "page") || 1, n = $("<input>", { type: "number", id: "jumpPageInput", placeholder: "页码", min: "1", style: "width: 60px; margin-left: 10px; padding: 10px; border: 1px solid #ccc; font-size: 14px;", value: t + 1 }), a = $("<button>", { text: "跳转", style: "margin-left: 5px; padding: 9px 8px; cursor: pointer; border: 1px solid #ccc; background-color: #f0f0f0; font-size: 14px;" }), i = $("<li>", { id: e }).append(n).append(a); $(".pagination-list").append(i); const s = () => { const e = parseInt(n.val(), 10); if (isNaN(e) || e < 1) return void n.focus(); const t = new URL(window.location.href); t.searchParams.set("page", e.toString()), window.location.href = t.toString(); }; a.on("click", s), n.on("keypress", (function(e) { 13 === e.which && (s(), e.preventDefault()); })); } } class Be extends X { constructor() { super(...arguments), i(this, "preloadDistance", 1500), i(this, "currentPage", this.getInitialPageNumber()), i(this, "pageItems", []); } getName() { return "AutoPagePlugin"; } async initCss() { return "\n <style>\n .jhs-scroll {\n text-align: center;\n padding-top: 20px;\n font-size: 14px;\n }\n .jhs-scroll.waterfall-loading { color: #000; }\n .jhs-scroll.waterfall-error { color: #f44336; cursor: pointer; }\n .jhs-scroll.waterfall-no-more { color: #4CAF50; }\n </style>\n "; } async handle() { this.waterfall().then(); } getInitialPageNumber() { if (l) { const e = o.match(/\/(page|star\/[^/]+)\/(\d+)/); return e ? parseInt(e[2], 10) : 1; } if (r) { const e = o.match(/[?&]page=(\d+)/); return e ? parseInt(e[1], 10) : 1; } return 1; } async waterfall() { if (await this.shouldDisablePaging()) return; const e = this.getSelector(); if (this.container = document.querySelector(e.boxSelector), !this.container) return void console.error("没有找到容器节点,停止瀑布流!"); this.loader = document.createElement("div"), this.loader.className = "jhs-scroll", this.container.parentNode.insertBefore(this.loader, this.container.nextSibling), this.pageItems.push({ page: this.currentPage, top: 0, url: window.location.href }), this.loader.addEventListener("click", (() => { if (this.loader.classList.contains("waterfall-error")) this.loadNextPage().then(); })), (() => { let t = !1; window.addEventListener("scroll", (() => { t || (t = !0, requestAnimationFrame((() => { this.checkLoad(), this.checkScrollPosition(), t = !1; }))); })); })(); const t = document.querySelector(e.nextPageSelector); this.nextUrl = null == t ? void 0 : t.href, this.hasMore = !!this.nextUrl, setTimeout((() => { this.checkLoad(); }), 300), this.hasMore || this.setState("waterfall-no-more", "已经到底了"); } async loadNextPage() { var e; // 同步占位: 必须在任何 await 之前置位, 否则连续 scroll 会并发进入同一页, // 触发重复车牌检测而误判"页码受限", 导致自动触底加载彻底失效 if (this.isLoading || !this.nextUrl) return; this.isLoading = !0; const t = this.getSelector(); try { if (await storageManager.getSetting("autoPage", _) === C) return void this.setState("waterfall-loading", ""); this.setState("waterfall-loading", "加载中..."); const i = await gmHttp.get(this.nextUrl); clog.log("请求下一页内容:", this.nextUrl); const s = utils.htmlTo$dom(i); l && s.find(".avatar-box").length > 0 && s.find(".avatar-box").parent().remove(); let c = s.find(this.getSelector().requestDomItemSelector); const d = this.getBoxCarInfoList(), h = this.getBoxCarInfoList(c); if (this.checkDuplicateCarNumbers(d, h)) return this.nextUrl = null, this.hasMore = !1, void this.setState("waterfall-error", "翻页内容出现重复数据, 页码受JavDB限制, 已停止瀑布流"); const g = this.container.scrollHeight; this.pageItems.push({ page: this.currentPage + 1, top: g, url: this.nextUrl }); const p = this.getBean("ListPagePlugin"); let m = s.find(this.getSelector().coverImgSelector); p.replaceHdImg(m), $(this.getSelector().boxSelector).append(c), this.nextUrl = null == (e = s.find(t.nextPageSelector)) ? void 0 : e.attr("href"), // 瀑布流追加新页后, 显式按当前选中的筛选标签重新过滤, 避免依赖 MutationObserver 时机导致跨页条目不被筛选 p && p.doFilter().then((() => p.applyVisibility())).catch((e => clog.error("瀑布流追加后重新过滤失败", e))), this.hasMore = !!this.nextUrl; let u = s.find(".pagination"); $(".pagination").replaceWith(u), this.setState("waterfall-loading", ""), this.hasMore || this.setState("waterfall-no-more", "已经到底了"); // 新内容不足一屏时 loader 仍在视口内, 主动续拉一次, 否则会卡住 if (this.hasMore && this.nextUrl) setTimeout((() => this.checkLoad()), 0); } catch (n) { clog.error("加载失败:", n), this.setState("waterfall-error", "加载失败,点击重试"); } finally { this.isLoading = !1; } } checkScrollPosition() { const e = window.scrollY; for (let t = this.pageItems.length - 1; t >= 0; t--) { const n = this.pageItems[t]; if (e >= n.top) { this.currentPage !== n.page && (this.currentPage = n.page, this.updatePageUrl(n.url)); break; } } } checkLoad() { if (!this.loader) return; if (this.loader.getBoundingClientRect().top < window.innerHeight + this.preloadDistance) { // 拦住 加载中/已到底/加载失败 三种态, 避免并发与死循环 if (this.isLoading || !this.hasMore || !this.nextUrl) return; if (this.loader.classList.contains("waterfall-no-more") || this.loader.classList.contains("waterfall-error")) return; this.loadNextPage().then(); } } async shouldDisablePaging() { if (!window.isListPage) return !0; return [ "search?q", "handlePlayback=1", "handleTop=1", "/want_watch_videos", "/watched_videos", "/advanced_search?type=100" ].some((e => o.includes(e))); } updatePageUrl_old(e) { if (window.history.pushState({}, "", e), l) { const t = e.match(/\/(page|star\/.*?)\/(\d+)/), n = t ? parseInt(t[2], 10) : null; document.title = document.title.replace(/第\d+頁/, "第" + n + "頁"); } } updatePageUrl(e) { window.history.replaceState({}, "", e), l && (document.title = document.title.replace(/第\d+頁/, `第${this.currentPage}頁`)); } setState(e, t) { this.loader.className = `jhs-scroll ${e}`, this.loader.textContent = t; } } /** Standalone WebDAV client for remote backup/restore operations. */ class De { constructor(e, t, n) { this.davUrl = e.endsWith("/") ? e : e + "/", this.username = t, this.password = n, this.folderName = null; } _getAuthHeaders() { return { Authorization: `Basic ${btoa(`${this.username}:${this.password}`)}`, Depth: "1" }; } _sendRequest(e, t, n = {}, a) { return new Promise(((i, s) => { const o = this.davUrl + t, r = { ...this._getAuthHeaders(), ...n }; GM_xmlhttpRequest({ method: e, url: o, headers: r, data: a, onload: e => { e.status >= 200 && e.status < 300 ? i(e) : (console.error(e), s(new Error(`请求失败 ${e.status}: ${e.statusText}`))); }, onerror: e => { console.error("请求WebDav发生错误:", e), s(new Error("请求WebDav失败, 请检查服务是否启动, 凭证是否正确")); } }); })); } async _ensureFolder(e) { try { await this._sendRequest("MKCOL", e); } catch (t) { if (!/请求失败 (405|409):/.test(t.message)) throw t; } } async backup(e, t, n) { await this._ensureFolder(e); const a = e + "/" + t; await this._sendRequest("PUT", a, { "Content-Type": "text/plain" }, n); } async getFileList(e) { var t, n, a; const i = (await this._sendRequest("PROPFIND", e, { "Content-Type": "application/xml" }, '<?xml version="1.0"?>\n <d:propfind xmlns:d="DAV:">\n <d:prop>\n <d:displayname />\n <d:getcontentlength />\n <d:creationdate />\n <d:getlastmodified />\n <d:iscollection />\n </d:prop>\n </d:propfind>\n ')).responseText, s = (new DOMParser).parseFromString(i, "text/xml").getElementsByTagNameNS("DAV:", "response"), o = []; for (let r = 0; r < s.length; r++) { if (0 === r) continue; let e = s[r]; const i = e.getElementsByTagNameNS("DAV:", "displayname")[0].textContent, l = (null == (t = e.getElementsByTagNameNS("DAV:", "getcontentlength")[0]) ? void 0 : t.textContent) || "0", c = (null == (n = e.getElementsByTagNameNS("DAV:", "creationdate")[0]) ? void 0 : n.textContent) || (null == (a = e.getElementsByTagNameNS("DAV:", "getlastmodified")[0]) ? void 0 : a.textContent) || ""; "0" !== l && o.push({ fileId: i, name: i, size: Number(l), createTime: c }); } return o.reverse(), o; } async deleteFile(e) { let t = this.folderName + "/" + encodeURI(e); await this._sendRequest("DELETE", t, { "Cache-Control": "no-cache" }); } async getBackupList(e) { return this.folderName = e, await this._ensureFolder(e), this.getFileList(e); } async getFileContent(e) { let t = this.folderName + "/" + e; return (await this._sendRequest("GET", t, { Accept: "application/octet-stream" })).responseText; } } /** * 115 网盘备份。对外接口刻意和 De(WebDav) 完全一致 —— backup / getBackupList / deleteFile / getFileContent, * 这样备份文件列表弹窗(openFileListDialog)一行都不用改就能复用。 * 认证走浏览器里现成的 115 Cookie 会话, 和「115匹配」「字幕直传」同一套, 不需要额外填 token。 */ class Pan115BackupApi { constructor() { this.folderName = null; this.folderId = null; // fileId -> pickcode。115 下载必须用 pickcode 而不是 fid, 列出列表时顺手记下来 this.pickCodes = new Map(); } /** 115 web 接口统一入口: 未登录时它会回一整张登录页 HTML 而不是 JSON, 这里提前识别 */ _req(method, url, data) { return new Promise(((resolve, reject) => { GM_xmlhttpRequest({ method: method, url: url, data: data, timeout: 3e4, headers: data ? { "Content-Type": "application/x-www-form-urlencoded", Referer: "https://115.com/" } : { Referer: "https://115.com/" }, onload: r => { if (!r || r.status < 200 || r.status >= 400) return reject(new Error(`115 请求失败 HTTP ${r && r.status || 0}`)); if (/^\s*</.test(r.responseText || "")) return reject(new Error("115 未登录, 请先登录 115 网页版")); const j = nmpParseJson(r.responseText); j ? resolve(j) : reject(new Error("115 返回内容无法解析")); }, onerror: () => reject(new Error("请求 115 失败, 请检查网络和登录状态")), ontimeout: () => reject(new Error("请求 115 超时")) }); })); } /** 根目录下的备份文件夹, 没有就建一个。目录项有 cid 没 fid, 文件项反之 */ async _ensureFolder(name) { if (this.folderId && this.folderName === name) return this.folderId; this.folderName = name; const list = await this._req("GET", "https://webapi.115.com/files?aid=1&cid=0&offset=0&limit=1150&show_dir=1&natsort=1&format=json"); const arr = list && Array.isArray(list.data) ? list.data : []; const hit = arr.find((x => x && x.cid && !x.fid && x.n === name)); if (hit) return this.folderId = String(hit.cid); show.info("不存在备份目录, 进行创建"); const res = await this._req("POST", "https://webapi.115.com/files/add", `cname=${encodeURIComponent(name)}&pid=0`); const cid = res && (res.cid || res.file_id || res.cate_id); if (!cid) throw new Error(res && (res.error || res.error_msg) || "创建备份目录失败"); return this.folderId = String(cid); } async backup(folderName, fileName, content) { const cid = await this._ensureFolder(folderName); // 复用字幕直传那条链路(sampleinitupload 换 OSS 表单凭证 -> 表单式 POST) await JhsSubtitle.push115(new TextEncoder().encode(content), fileName, cid); } async getBackupList(folderName) { const cid = await this._ensureFolder(folderName); const j = await this._req("GET", `https://webapi.115.com/files?aid=1&cid=${encodeURIComponent(cid)}&offset=0&limit=1150&show_dir=0&o=user_ptime&asc=0&format=json`); const arr = j && Array.isArray(j.data) ? j.data : []; this.pickCodes.clear(); const out = []; arr.forEach((x => { if (!x || !x.fid) return; this.pickCodes.set(String(x.fid), x.pc || ""); out.push({ fileId: String(x.fid), name: x.n || "", size: Number(x.s) || 0, // te 是秒级时间戳。t 字段是 "2026-08-31 15:20" 这种字符串, 各浏览器 new Date() 解析不一致, 不用它 createTime: 1e3 * (Number(x.te) || Number(x.tp) || 0) }); })); return out; } async deleteFile(fileId) { const res = await this._req("POST", "https://webapi.115.com/rb/delete", `fid[0]=${encodeURIComponent(fileId)}&pid=${encodeURIComponent(this.folderId || "")}&ignore_warn=1`); if (res && !1 === res.state) throw new Error(res.error || res.error_msg || "删除失败"); this.pickCodes.delete(String(fileId)); } /** 取备份正文: 先用 pickcode 换一个带签名的临时直链, 再把内容读回来 */ async getFileContent(fileId) { const pc = this.pickCodes.get(String(fileId)); if (!pc) throw new Error("缺少 pickcode, 请重新打开备份列表再试"); const j = await this._req("GET", `https://webapi.115.com/files/download?pickcode=${encodeURIComponent(pc)}`); const raw = j && (j.file_url || j.url); const url = raw && "object" == typeof raw ? raw.url : raw; if (!url) throw new Error(j && (j.error || j.error_msg || j.msg) || "115 未返回下载地址"); return await new Promise(((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: String(url), timeout: 6e4, headers: { Referer: "https://115.com/" }, onload: r => r && r.status >= 200 && r.status < 400 ? resolve(r.responseText || "") : reject(new Error(`下载备份失败 HTTP ${r && r.status || 0}`)), onerror: () => reject(new Error("下载备份失败(网络错误)")), ontimeout: () => reject(new Error("下载备份超时")) }); })); } } /** Generate the settings page CSS based on container config and site type. */ function buildSettingCss(containerWidth, containerColumns, isJavBus, isJavDB) { let base; if (isJavBus) { base = ` .container-fluid .row{ max-width: 1000px !important; min-width: ${containerWidth}%; margin: auto auto; } .container { max-width: 1000px !important; min-width: ${containerWidth}%; margin: auto auto; } .masonry { grid-template-columns: repeat(${containerColumns}, minmax(0, 1fr)); } `; } else { base = ` section .container{ max-width: 1000px !important; min-width: ${containerWidth}%; } /* 详情页: 容器宽度拉大后封面列等比跟随, 避免右侧露灰底 */ .video-detail .column-video-cover img, .video-detail .column-video-cover video { max-width: 100%; } .movie-list, .movie-list.v{ grid-template-columns: repeat(${containerColumns}, minmax(0, 1fr)); } .movie-list > :not(.item) { display: none !important; } `; } return ` <style> ${base} .nav-btn::after { content:none !important; } #cache-data-display pre { font-family: Consolas, Monaco, 'Andale Mono', monospace; white-space: pre-wrap; word-wrap: break-word; line-height: 1.5; color: #333; border: 1px solid #ddd; } /* 恢复点列表: Tabulator 自带的外框 + 灰色表头band在这个面板里太抢戏 (上下左右都是扁平的 setting-item 行), 这里把边框和底色去掉, 只留一条细分隔线, 让它看起来是面板的一部分而不是嵌进来的一张表。 */ #snapshot-list .tabulator { border: 0; background: transparent; font-size: 13px; } #snapshot-list .tabulator .tabulator-header, #snapshot-list .tabulator .tabulator-header .tabulator-col { border: 0; background: transparent; } #snapshot-list .tabulator .tabulator-header .tabulator-col .tabulator-col-title { color: #999; font-weight: normal; font-size: 12px; } #snapshot-list .tabulator .tabulator-row, #snapshot-list .tabulator .tabulator-row.tabulator-row-even { border: 0; border-bottom: 1px solid #f0f0f0; background: transparent; } #snapshot-list .tabulator .tabulator-row:last-child { border-bottom: 0; } #snapshot-list .tabulator .tabulator-row:hover { background: #fafafa; } /* 时间列换行会把行高撑成两倍, 直接禁掉换行 */ #snapshot-list .tabulator .tabulator-row .tabulator-cell { border: 0; white-space: nowrap; } .cache-item { transition: all 0.2s ease; } .cache-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.1); transform: translateY(-2px); } .tooltip-icon { display: inline-block; width: 16px; height: 16px; line-height: 16px; text-align: center; border-radius: 50%; background-color: #ccc; color: white; font-size: 12px; margin-right: 5px; cursor: help; } .setting-item { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; padding: 4px 0; } .simple-setting .setting-item{ align-items:center; } .setting-label { font-size: 14px; min-width: 180px; font-weight: bold; margin-right: 10px; } .form-content{ max-width: 200px; min-width: 180px; flex-shrink: 0; } .form-content * { width: 100%; padding: 5px; margin-right: 10px; text-align: left; box-sizing: border-box; } /* 统一 input / select 的高度与字号, 否则 select 与只读框会与上方输入框错位. 必须排除 checkbox, 否则会覆盖后面 .mini-switch 的宽高 (本规则特异性更高) */ .form-content > input:not([type="checkbox"]), .form-content > select { height: 30px; font-size: 13px; line-height: normal; border: 1px solid #ccc; border-radius: 4px; background-color: #fff; vertical-align: middle; margin-right: 0; } .keyword-label { display: inline-flex; align-items: center; padding: 4px 8px; border-radius: 4px; font-size: 14px; position: relative; margin-left: 8px; margin-bottom: 5px; } .keyword-remove { margin-left: 6px; cursor: pointer; font-size: 12px; line-height: 1; } .keyword-input { padding: 6px 12px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; float:right; } .add-tag-btn { padding: 6px 12px; background-color: #e2e8f0; color: #334155; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; margin-left: 8px; float:right; } .add-tag-btn:hover { background-color: #cbd5e1; } .tag-box { margin-top:15px; } #saveBtn,#moreBtn,#helpBtn,#clean-all { padding: 8px 20px; background-color: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; margin-top: 10px; } #saveBtn:hover { background-color: #45a049; } #moreBtn { background-color: #5cb85c; color: white; } #moreBtn:hover { background-color: #4cae4c; } #helpBtn { background-color: #e67e22; color: white; } #helpBtn:hover { background-color: #d35400; } .simple-setting, .mini-simple-setting { display: none; background: rgba(255,255,255,1); position: absolute; top: ${isJavDB ? "35px" : "25px"}; right: ${isJavDB ? "-300%" : "0"}; z-index: 1000; border: 1px solid #ddd; border-radius: 12px; /* 头部渐变条和底部按钮条是铺满宽度的, 不裁掉就会把圆角顶成直角 */ overflow: hidden; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); padding: 0; margin-top: 5px; color: #333; width: 320px; } /* ===== 设置弹层圆角: 加在 openSettingDialog 的 success 里(见 $(e).addClass("jhs-round-layer")) ===== layui 默认 border-radius 只有 2px, 且标题栏/内容区自己有底色, 要一起裁圆 */ .layui-layer.jhs-round-layer { border-radius: 14px; overflow: hidden; } .layui-layer.jhs-round-layer .layui-layer-title { border-radius: 14px 14px 0 0; } .layui-layer.jhs-round-layer .layui-layer-content { border-radius: 0 0 14px 14px; } /* ===== 快捷设置面板 jhs-qs 样式 ===== */ .jhs-qs-panel { display: flex; flex-direction: column; max-height: 80vh; } .jhs-qs-header { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding: 14px 16px 10px; border-bottom: 1px solid #f1f5f9; background: linear-gradient(180deg, #f8fafc 0%, #fff 100%); } .jhs-qs-title { font-size: 15px; font-weight: 700; color: #1a1a1a; } .jhs-qs-sub { font-size: 11px; color: #999; } .jhs-qs-body { flex: 1; overflow-y: auto; padding: 8px 12px 12px; } .jhs-qs-section { background: #f8fafc; border: 1px solid #f1f5f9; border-radius: 10px; padding: 10px 12px; margin-bottom: 10px; } .jhs-qs-section-title { font-size: 11px; font-weight: 700; color: #94a3b8; margin-bottom: 6px; letter-spacing: 0.08em; } .jhs-qs-subcard { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 4px 10px; margin-top: 6px; } .jhs-qs-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 5px 0; min-height: 30px; } .jhs-qs-row-stacked { flex-direction: column; align-items: stretch; gap: 4px; } .jhs-qs-label { display: flex; align-items: center; gap: 5px; font-size: 13px; font-weight: 500; color: #334155; line-height: 1.3; flex: 1; min-width: 0; } .jhs-qs-label > span:not(.jhs-qs-tip) { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .jhs-qs-control { flex-shrink: 0; display: flex; align-items: center; justify-content: flex-end; } .jhs-qs-row-stacked .jhs-qs-control { width: 100%; } .jhs-qs-tip { display: inline-flex; align-items: center; justify-content: center; width: 15px; height: 15px; border-radius: 50%; background: #e2e8f0; color: #64748b; font-size: 10px; font-weight: 700; cursor: help; flex-shrink: 0; } .jhs-qs-footer { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 0 15px 10px; border-top: 1px solid #f1f5f9; background: #fafbfc; position: relative; top: -2px; } .jhs-qs-btn { flex: 1; margin: 0 14px; padding: 2px 8px; line-height: 1; border: none; border-radius: 4px; font-size: 12px; font-weight: 500; cursor: pointer; transition: background .15s ease, transform .12s ease, box-shadow .15s ease; } .jhs-qs-btn:hover { transform: translateY(-1px); } .jhs-qs-btn:active { transform: translateY(0); } .jhs-qs-btn-ghost { background: #fff; color: #64748b; border: 1px solid #e2e8f0; } .jhs-qs-btn-ghost:hover { background: #f8fafc; color: #475569; border-color: #cbd5e1; } .jhs-qs-btn-primary { background: linear-gradient(135deg, #5d87c2, #4a90d9); color: #fff; box-shadow: 0 2px 8px rgba(74, 144, 217, 0.28); } .jhs-qs-btn-primary:hover { box-shadow: 0 4px 12px rgba(74, 144, 217, 0.35); } .jhs-qs-range { display: flex; align-items: center; gap: 8px; width: 100%; } .jhs-qs-range input[type="range"] { flex: 1; height: 4px; -webkit-appearance: none; appearance: none; background: #ddd; border-radius: 2px; outline: none; } .jhs-qs-range input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; background: #5d87c2; cursor: pointer; } .jhs-qs-range span { min-width: 28px; font-size: 12px; color: #666; text-align: right; } .mini-switch { appearance: none; -webkit-appearance: none; width: 40px; height: 20px; background: #e0e0e0; border-radius: 20px; position: relative; cursor: pointer; outline: none; } .mini-switch:checked { background: #4CAF50; } .mini-switch::before { content: ""; position: absolute; width: 16px; height: 16px; border-radius: 50%; background: white; top: 2px; left: 2px; box-shadow: 0 1px 3px rgba(0,0,0,0.2); } .mini-switch:checked::before { left: calc(100% - 18px); } .side-menu-item { padding: 12px 12px; cursor: pointer; color: #333; border-left: 3px solid transparent; transition: all 0.2s; display: flex; gap: 5px; } .side-menu-item .icon { height: 24px; width: 24px; } .side-menu-item:hover { background-color: #e9e9e9; } .side-menu-item.active { background-color: #e0e0e0; border-left: 3px solid #5d87c2; font-weight: bold; } .content-panel { display: none; margin-top:20px; padding: 0 10px 10px 0; height: 100%; overflow-x: hidden; overflow-y: auto; } .content-panel.active { display: block; } input[type="checkbox"]:disabled { opacity: 0.6; cursor: default !important; } </style> `; } /** Toggle between vertical (cover-fit) and normal (contain) image display modes. */ /** 详情页比例: 封面/信息 两列的 flex 权重. JavDB 与 JavBus 详情页均生效 */ async function applyDetailFlex() { if (!window.isDetailPage) return; const setting = await storageManager.getSetting(); const cover = (parseInt(setting.detailCoverRatio) || 135) / 100; const info = (parseInt(setting.detailInfoRatio) || 105) / 100; if (r) { // JavDB: .column.column-video-cover + 兄弟 .column const coverCol = document.querySelector(".column.column-video-cover"); if (!coverCol) return; const infoCol = coverCol.parentElement?.querySelector(".column:not(.column-video-cover)"); coverCol.style.flex = `${cover} 1 0%`; if (infoCol) infoCol.style.flex = `${info} 1 0%`; return; } if (l) { // JavBus: .row.movie > .col-md-9.screencap + .col-md-3.info (Bootstrap 栅格) // 用 flex 覆盖 float+width 的 Bootstrap 默认样式 const row = document.querySelector(".row.movie"); if (!row) return; const coverCol = row.querySelector(".screencap"); const infoCol = row.querySelector(".info"); if (!coverCol || !infoCol) return; row.style.display = "flex"; row.style.flexWrap = "nowrap"; row.style.alignItems = "flex-start"; [ coverCol, infoCol ].forEach((c => { c.style.float = "none"; c.style.maxWidth = "none"; c.style.width = "auto"; })); coverCol.style.flex = `${cover} 1 0%`; infoCol.style.flex = `${info} 1 0%`; } } async function applyImageMode() { $("#verticalImgStyle").remove(); if (await storageManager.getSetting("enableVerticalModel", C) === _) { let e = "100% 50% !important"; window.location.href.includes("/advanced_search?type=100") && (e = "50% 50% !important"); const t = ` .cover { min-height: 350px !important; overflow: hidden !important; padding-top: 142% !important; } .cover img { object-fit: cover !important; object-position: ${e}; } /* bus的 */ .masonry .movie-box .photo-frame { height: 0 !important; padding-top: 142% !important; overflow: hidden !important; position: relative !important; } .masonry .movie-box img { position: absolute !important; top: 0 !important; left: 0 !important; width: 100% !important; height: 100% !important; max-height: none !important; min-height: 0 !important; object-fit: cover !important; object-position: top right; } `; $("<style>").attr("id", "verticalImgStyle").text(t).appendTo("head"); } else { const e = ` .cover { min-height:auto !important; padding-top: 67% !important; } .cover img { object-fit: contain !important; object-position: 50% 50% !important } /* bus的 */ .masonry .movie-box .photo-frame { height: 0 !important; padding-top: 67% !important; overflow: hidden !important; position: relative !important; } .masonry .movie-box img { position: absolute !important; top: 0 !important; left: 0 !important; width: 100% !important; height: 100% !important; max-height: none !important; min-height: 0 !important; object-fit: contain !important; object-position: top; } `; $("<style>").attr("id", "verticalImgStyle").text(e).appendTo("head"); } l && window.getBeanForSetting("BusImgPlugin").logImageHeightsByRow(); } /** * Build the plugin categories configuration shared between inject and render. * Returns { categories, corePlugins }. */ function getPluginCategories() { return { categories: { // DetailPageButtonPlugin 不列在这里: 它的 handle()/createMenuBtn() 已删除(那排详情页按钮由快捷图标栏取代), // 现在只剩被别处 getBean 复用的方法(filterOne/favoriteOne/toggleStatus/字幕面板), 而 getBean 不受禁用影响 —— // 留个开关只会是个点了没反应的假开关。 status: { label: "状态管理", plugins: ["DetailPagePlugin","ListPagePlugin","NavBarPlugin","BusNavBarPlugin","BusDetailPagePlugin","DetailQuickIconPlugin","ListPageButtonPlugin","NativeMagnetPanelPlugin","NativeReviewPanelPlugin","NativeListPanelPlugin","FoldCategoryPlugin","AutoPagePlugin","HistoryPlugin","WantAndWatchedVideosPlugin","Fc2ActionBarPlugin"] }, blacklist: { label: "屏蔽过滤", plugins: ["BlacklistPlugin","FilterTitleKeywordPlugin"] }, favorite: { label: "收藏", plugins: ["FavoriteActressesPlugin"] }, "new-video": { label: "新作品", plugins: ["NewVideoPlugin","TaskPlugin"] }, // MagnetHubPlugin 不列在这里: 它只有 initCss + createMagnetHub, 没有 handle()。 // 禁用只会让 loadPluginsCss 跳过它的样式表 —— 磁力搜索照样能用, 但面板变成没样式的裸文字。 // 磁力搜索按钮的显隐现在由 Fc2ActionBarPlugin(FC2页弹窗按钮)控制。 "external-search": { label: "外部搜索", plugins: ["OtherSitePlugin","Fc2Plugin","Fc2By123AvPlugin","HitShowPlugin","TOP250Plugin","JavTrailersPlugin","MagnetTablePlugin"] }, "image-viewer": { label: "图片预览", plugins: ["CoverButtonPlugin","PreviewVideoPlugin","BusPreviewVideoPlugin","ScreenShotPlugin","BusImgPlugin"] }, avatar: { label: "演员信息", plugins: ["ActressInfoPlugin","SearchByImagePlugin"] }, translate: { label: "翻译", plugins: ["TranslatePlugin"] }, subtitle: { label: "字幕", plugins: ["SubTitleCatPlugin"] }, backup: { label: "备份设置", plugins: ["SettingPlugin","LocalPlugin"] }, "one-two-three": { label: "云盘", plugins: ["WangPan115MatchPlugin"] }, stats: { label: "统计", plugins: ["StatsPlugin"] } }, corePlugins: ["SettingPlugin","StatsPlugin"], displayNames: { DetailPagePlugin: "详情页", ListPagePlugin: "列表页", NavBarPlugin: "导航栏", BusNavBarPlugin: "JavBus 导航栏", BusDetailPagePlugin: "JavBus 详情页", DetailPageButtonPlugin: "详情页按钮", DetailQuickIconPlugin: "详情页快捷图标", ListPageButtonPlugin: "列表页按钮", NativeMagnetPanelPlugin: "磁力面板", NativeReviewPanelPlugin: "短评面板", NativeListPanelPlugin: "相关清单", FoldCategoryPlugin: "分类折叠", AutoPagePlugin: "自动翻页", HistoryPlugin: "历史记录", WantAndWatchedVideosPlugin: "想看/已看", Fc2ActionBarPlugin: "FC2页弹窗按钮", BlacklistPlugin: "黑名单", FilterTitleKeywordPlugin: "标题关键词过滤", FavoriteActressesPlugin: "收藏演员", NewVideoPlugin: "新作品检测", TaskPlugin: "定时任务", OtherSitePlugin: "第三方站点", Fc2Plugin: "FC2", Fc2By123AvPlugin: "FC2 (123AV 源)", HitShowPlugin: "热播", TOP250Plugin: "TOP250", ReviewPlugin: "评论增强", MagnetHubPlugin: "磁力搜索", MagnetTablePlugin: "磁力表格面板", JavTrailersPlugin: "预告片", CoverButtonPlugin: "封面按钮", PreviewVideoPlugin: "预览视频", BusPreviewVideoPlugin: "JavBus 预览视频", ScreenShotPlugin: "截图预览", BusImgPlugin: "JavBus 图片", ActressInfoPlugin: "演员信息", SearchByImagePlugin: "以图识图", TranslatePlugin: "翻译", SubTitleCatPlugin: "字幕搜索", SettingPlugin: "设置", LocalPlugin: "本地备份", WangPan115MatchPlugin: "115 视频匹配", MobileBottomBarPlugin: "移动端底部栏插件", StatsPlugin: "统计" } }; } /** Build the HTML for the cache items grid in the settings dialog. */ function buildCacheItemsHtml(cacheItems) { return cacheItems.map(e => ` <div class="cache-item" style="border: 1px solid #eee; border-radius: 8px; padding: 12px;"> <div style="font-weight: bold; margin-bottom: 8px;">${e.text}</div> <div style="display: flex; gap: 8px;"> <a class="menu-btn clean-btn" data-key="${e.key}" style="background-color:#448cc2; flex:1; text-align:center;" title="${e.title}"> <span>清理</span> </a> <a class="menu-btn view-btn" data-key="${e.key}" style="background-color:#b2bec0; flex:1; text-align:center;" > <span>查看</span> </a> </div> </div> `).join(""); } /** Build the quality options HTML for the video quality select. */ function buildVideoQualityOptions() { let a = ""; L.forEach(e => { e.canSelect && (a += `<option value="${e.quality}">${e.text}</option>`); }); return a; } /** * 「默认画质」只在拿到 DMM 多档画质表时才有意义(见 handleVideo 里的 Z(Object.keys(s), t)), * 而画质表只有 enableLoadOtherSite 开启时才会去解析, 所以关掉它就把这一行置灰。 */ function syncPreviewVideoRows() { const dmmOn = $("#enableLoadOtherSite").is(":checked"); $("#previewQualityRow").css("opacity", dmmOn ? "" : "0.55"); $("#videoQuality").prop("disabled", !dmmOn); $("#previewQualityHint").prop("hidden", dmmOn); } /** Build the main settings dialog HTML template. */ function buildSettingDialogHtml(activePanel, cacheItems, coverButtonPlugin) { const n = buildCacheItemsHtml(cacheItems); const isMobile = utils.isMobileMode(); const sidebarDir = isMobile ? "column" : "row"; return ` <div style="display: flex; flex-direction: ${sidebarDir}; height: 100%;"> <div class="jhs-mobile-sidebar" style="width: 140px; flex-shrink: 0; padding: 15px 0; background: #f5f5f5; border-right: 1px solid #ddd;"> <div class="side-menu-item ${"backup-panel" === activePanel ? "active" : ""}" data-panel="backup-panel">数据备份</div> <div class="side-menu-item ${"base-panel" === activePanel ? "active" : ""}" data-panel="base-panel">基础配置</div> <div class="side-menu-item ${"appraisal-panel" === activePanel ? "active" : ""}" data-panel="appraisal-panel">鉴定配置</div> <div class="side-menu-item ${"filter-panel" === activePanel ? "active" : ""}" data-panel="filter-panel">屏蔽配置</div> <div class="side-menu-item ${"task-panel" === activePanel ? "active" : ""}" data-panel="task-panel">定时任务</div> <div class="side-menu-item ${"domain-panel" === activePanel ? "active" : ""}" data-panel="domain-panel" title="外部网站域名配置">外部网站</div> <div class="side-menu-item ${"cache-panel" === activePanel ? "active" : ""}" data-panel="cache-panel">清理缓存</div> </div> <div style="flex: 1; display: flex; flex-direction: column; height: 100%; "> <div style="flex: 1; margin: 0 10px; padding-bottom: 20px;overflow-y: auto;overflow-x: hidden"> <div id="backup-panel" class="content-panel" style="display: ${"backup-panel" === activePanel ? "block" : "none"};"> <div class="setting-item"> <span class="setting-label">数据管理</span> <div> <a id="importBtn" class="menu-btn" style="background-color:#d25a88"><span>导入数据</span></a> <a id="exportBtn" class="menu-btn" style="background-color:#85d0a3"><span>导出数据</span></a> </div> </div> <div class="setting-item"> <span class="setting-label" style="display:flex; align-items:center; gap:5px"> 115备份 <span data-tip="使用浏览器里已登录的 115 会话, 备份到 115 网盘根目录的「JHS-数据备份」文件夹, 无需额外填写凭证">(?)</span> </span> <div> <a id="pan115BackupListBtn" class="menu-btn" style="background-color:#5d87c2"><span>查看备份</span></a> <a id="pan115BackupBtn" class="menu-btn" style="background-color:#64bb69"><span>备份数据</span></a> </div> </div> <hr style="border: 0; height: 1px; margin:20px 0;background-image: linear-gradient(to right, rgba(0,0,0,0), rgba(159,137,137,0.75), rgba(0,0,0,0));"/> <div class="setting-item"> <span class="setting-label">WebDav备份</span> <div> <a id="webdavBackupListBtn" class="menu-btn" style="background-color:#5d87c2"><span>查看备份</span></a> <a id="webdavBackupBtn" class="menu-btn" style="background-color:#64bb69"><span>备份数据</span></a> </div> </div> <div class="setting-item"> <span class="setting-label">服务地址:</span> <div class="form-content"> <input id="webDavUrl"> </div> </div> <div class="setting-item"> <span class="setting-label">用户名:</span> <div class="form-content"> <input id="webDavUsername"> </div> </div> <div class="setting-item"> <span class="setting-label">密码:</span> <div class="form-content"> <input id="webDavPassword" type="password"> </div> </div> <hr style="border: 0; height: 1px; margin:20px 0;background-image: linear-gradient(to right, rgba(0,0,0,0), rgba(159,137,137,0.75), rgba(0,0,0,0));"/> <!-- 恢复点区块由 injectSnapshotPanel() 填充。放在这里而不是独立面板: 它和导入/导出是同一份数据的两种存放位置, 拆成两个侧边栏项属于重复入口。 --> <div id="snapshot-section"></div> <hr style="border: 0; height: 1px; margin:20px 0;background-image: linear-gradient(to right, rgba(0,0,0,0), rgba(159,137,137,0.75), rgba(0,0,0,0));"/> <div class="setting-item"> <span class="setting-label">清空所有数据</span> <div> <a id="cleanJhsDataBtn" class="menu-btn" style="background-color:#d22020"><span>清空数据</span></a> </div> </div> </div> <div id="base-panel" class="content-panel" style="display: ${"base-panel" === activePanel ? "block" : "none"};"> <div class="setting-item" style="margin-top:10px"> <span class="setting-label"> 封面快捷按钮 </span> </div> <div class="setting-item"> <span class="setting-label" style="display:flex; align-items:center; gap:5px"> ${coverButtonPlugin.screenSvg}长缩略图: </span> <div class="form-content"> <input type="checkbox" id="enableScreenSvg" class="mini-switch"> </div> </div> <div class="setting-item"> <span class="setting-label" style="display:flex; align-items:center; gap:5px"> ${coverButtonPlugin.videoSvg}预览视频: </span> <div class="form-content"> <input type="checkbox" id="enableVideoSvg" class="mini-switch"> </div> </div> <div class="setting-item"> <span class="setting-label" style="display:flex; align-items:center; gap:5px"> ${coverButtonPlugin.handleSvg}鉴定按钮: </span> <div class="form-content"> <input type="checkbox" id="enableHandleSvg" class="mini-switch"> </div> </div> <div class="setting-item"> <span class="setting-label" style="display:flex; align-items:center; gap:5px"> ${coverButtonPlugin.siteSvg}第三方跳转: </span> <div class="form-content"> <input type="checkbox" id="enableSiteSvg" class="mini-switch"> </div> </div> <div class="setting-item"> <span class="setting-label" style="display:flex; align-items:center; gap:5px"> ${coverButtonPlugin.copySvg}复制按钮: </span> <div class="form-content"> <input type="checkbox" id="enableCopySvg" class="mini-switch"> </div> </div> <hr style="border: 0; height: 1px; margin:20px 0;background-image: linear-gradient(to right, rgba(0,0,0,0), rgba(159,137,137,0.75), rgba(0,0,0,0));"/> <div class="setting-item"> <span class="setting-label">评论区条数:</span> <div class="form-content"> <select id="reviewCount"> <option value="10">10条</option> <option value="20">20条</option> <option value="30">30条</option> <option value="40">40条</option> <option value="50">50条</option> </select> </div> </div> <div class="setting-item"> <span class="setting-label"> 短评排序方式 <span data-tip="详情页短评面板打开时的默认排序; 面板右上角的下拉框仍可临时切换。磁力=只看带磁力的短评">(?)</span> </span> <div class="form-content"> <select id="reviewSortMode"> <option value="time">时间</option> <option value="score">星级</option> <option value="likes">点赞</option> <option value="magnet">磁力</option> </select> </div> </div> <div class="setting-item"> <span class="setting-label"> 磁力排序方式 <span data-tip="详情页磁力面板打开时的默认排序; 面板右上角的下拉框仍可临时切换。标签优先=中字破解 > 破解 > 中字 > 无标签,同档内按时间新到旧">(?)</span> </span> <div class="form-content"> <select id="magnetSortMode"> <option value="size">大小优先</option> <option value="tag">标签优先</option> <option value="newest">最新收录</option> <option value="oldest">最早收录</option> </select> </div> </div> <div class="setting-item"> <span class="setting-label"> 短评磁力预览 <span data-tip="短评正文里的磁力链接后面加一个「预览」按钮, 点开可看资源名/大小/文件数和截图(WhatsLink)">(?)</span> </span> <div class="form-content"> <input type="checkbox" id="enableReviewMagnetPreview" class="mini-switch"> </div> </div> <div class="setting-item ${r ? "" : "do-hide"}"> <span class="setting-label"> 高亮已收藏演员 <span data-tip="详情页, 对已收藏的演员进行边框高亮提醒">(?)</span> </span> <div class="form-content"> <input type="checkbox" id="enableFavoriteActresses" class="mini-switch"> </div> </div> <div class="setting-item ${r ? "" : "do-hide"}"> <span id="highlightedTagLabel" class="setting-label"> 分类标签|高亮演员-边框样式: </span> <div class="form-content" style="display: flex; align-items: center;"> <input type="number" id="highlightedTagNumber" min="0" max="20"> <input type="color" id="highlightedTagColor"> </div> </div> <hr style="border: 0; height: 1px; margin:20px 0;background-image: linear-gradient(to right, rgba(0,0,0,0), rgba(159,137,137,0.75), rgba(0,0,0,0));"/> <div class="setting-item"> <span class="setting-label">请求超时时间(毫秒):</span> <div class="form-content"> <input type="number" id="httpTimeout" min="1000" max="10000" style="width: 100%;"> </div> </div> <div class="setting-item"> <span class="setting-label">请求失败重试次数:</span> <div class="form-content"> <input type="number" id="httpRetryCount" min="0" max="10" style="width: 100%;"> </div> </div> <hr style="border: 0; height: 1px; margin:20px 0;background-image: linear-gradient(to right, rgba(0,0,0,0), rgba(159,137,137,0.75), rgba(0,0,0,0));"/> <div class="setting-item"> <span class="setting-label"> 启用控制台日志: </span> <div class="form-content"> <select id="enableClog"> <option value="no">禁用</option> <option value="yes">开启</option> </select> </div> </div> <div class="setting-item"> <span class="setting-label">日志最大行数:</span> <div class="form-content"> <input type="number" id="clogMsgCount" min="100" max="3000" style="width: 100%;"> </div> </div> <hr style="border: 0; height: 1px; margin:20px 0;background-image: linear-gradient(to right, rgba(0,0,0,0), rgba(159,137,137,0.75), rgba(0,0,0,0));"/> <div class="setting-item"> <span class="setting-label"> 移动端模式 <span data-tip="auto=自动检测设备, on=强制移动端UI, off=强制桌面端UI">(?)</span> </span> <div class="form-content"> <select id="mobileMode"> <option value="auto">自动检测</option> <option value="on">强制开启</option> <option value="off">强制关闭</option> </select> </div> </div> </div> <div id="task-panel" class="content-panel" style="display: ${"task-panel" === activePanel ? "block" : "none"};"> <div class="setting-item"> <span class="setting-label">请求并发数量:</span> <div class="form-content"> <input type="number" id="checkConcurrencyCount" min="2" max="5" style="width: 100%;"> </div> </div> <div class="setting-item"> <span class="setting-label">请求间隔时间(毫秒):</span> <div class="form-content"> <input type="number" id="checkRequestSleep" min="0" max="3000" style="width: 100%;"> </div> </div> <hr style="border: 0; height: 1px; margin:20px 0;background-image: linear-gradient(to right, rgba(0,0,0,0), rgba(159,137,137,0.75), rgba(0,0,0,0));"/> <div id="setting-blacklist" style="border: 1px solid #ccc; padding: 10px; margin-bottom: 15px;"> <span style="font-size: 14px; font-weight: bold; padding:3px">自动检测屏蔽黑名单演员</span> <div class="setting-item"> <span class="setting-label"> 任务开关: <span data-tip="变更后, 刷新页面生效">(?)</span> </span> <div class="form-content"> <select id="enableCheckBlacklist"> <option value="no">禁用</option> <option value="yes">开启</option> </select> </div> </div> <div class="setting-item"> <span class="setting-label">任务间隔时间:</span> <div class="form-content"> <select id="checkBlacklist_intervalTime"> <option value="2">每2小时</option> <option value="3">每3小时</option> <option value="6">每6小时</option> <option value="12">每12小时</option> <option value="24">每24小时</option> </select> </div> </div> <div class="setting-item"> <span class="setting-label">检测规则:</span> <div class="form-content"> <select id="checkBlacklist_ruleTime"> <option value="0">全部检测</option> <option value="8760">不检测停更1年以上</option> <option value="17520">不检测停更2年以上</option> <option value="26280">不检测停更3年以上</option> </select> </div> </div> </div> <div id="setting-checkFavoriteActress" style="border: 1px solid #ccc; padding: 10px; margin-bottom: 15px;" class="${r ? "" : "do-hide"}"> <span style="font-size: 14px; font-weight: bold; padding:3px">自动同步已收藏的演员</span> <div class="setting-item"> <span class="setting-label"> 任务开关: <span data-tip="变更后, 刷新页面生效">(?)</span> </span> <div class="form-content"> <select id="enableCheckFavoriteActress"> <option value="no">禁用</option> <option value="yes">开启</option> </select> </div> </div> <div class="setting-item"> <span class="setting-label">任务间隔时间:</span> <div class="form-content"> <select id="checkFavoriteActress_IntervalTime"> <option value="12">每12小时</option> <option value="24">每24小时</option> </select> </div> </div> </div> <div id="setting-checkNewVideo" style="border: 1px solid #ccc; padding: 10px; margin-bottom: 15px;" class="${r ? "" : "do-hide"}"> <span style="font-size: 14px; font-weight: bold; padding:3px">自动检测已收藏演员的最新作品</span> <div class="setting-item"> <span class="setting-label"> 任务开关: <span data-tip="变更后, 刷新页面生效">(?)</span> </span> <div class="form-content"> <select id="enableCheckNewVideo"> <option value="no">禁用</option> <option value="yes">开启</option> </select> </div> </div> <div class="setting-item"> <span class="setting-label">任务间隔时间:</span> <div class="form-content"> <select id="checkNewVideo_intervalTime"> <option value="2">每2小时</option> <option value="3">每3小时</option> <option value="6">每6小时</option> <option value="12">每12小时</option> <option value="24">每24小时</option> </select> </div> </div> <div class="setting-item"> <span class="setting-label">检测规则:</span> <div class="form-content"> <select id="checkNewVideo_ruleTime"> <option value="0">全部检测</option> <option value="8760">不检测停更1年以上</option> <option value="17520">不检测停更2年以上</option> <option value="26280">不检测停更3年以上</option> </select> </div> </div> </div> </div> <div id="domain-panel" class="content-panel" style="display: ${"domain-panel" === activePanel ? "block" : "none"};"> <style>#domain-panel .setting-item{justify-content:flex-start;gap:0}#domain-panel .setting-label{min-width:auto;margin-right:4px;flex:0 0 auto}#domain-panel .form-content{max-width:none;min-width:0;flex:1;display:flex;gap:8px;align-items:center;justify-content:flex-start}#domain-panel .form-content input[placeholder="域名"]{margin-left:auto}</style> <div class="setting-item"> <span class="setting-label">名称:</span> <div class="form-content" style="display:flex;gap:8px;align-items:center"> <input id="siteNameMissAv" placeholder="名称" style="width:90px;flex:0 0 auto"> <input id="missAvUrl" placeholder="域名" style="width:180px;flex:0 0 auto"> <input id="siteSearchMissAv" placeholder="搜索地址(留空=默认)" style="flex:1 1 180px;min-width:140px"> <input type="checkbox" class="jhs-site-toggle mini-switch" data-site-key="missAv"> <input type="checkbox" class="jhs-cover-toggle mini-switch" data-site-key="missAv" title="封面显示"> </div> </div> <div class="setting-item"> <span class="setting-label">名称:</span> <div class="form-content" style="display:flex;gap:8px;align-items:center"> <input id="siteNameJable" placeholder="名称" style="width:90px;flex:0 0 auto"> <input id="jableUrl" placeholder="域名" style="width:180px;flex:0 0 auto"> <input id="siteSearchJable" placeholder="搜索地址(留空=默认)" style="flex:1 1 180px;min-width:140px"> <input type="checkbox" class="jhs-site-toggle mini-switch" data-site-key="jable"> <input type="checkbox" class="jhs-cover-toggle mini-switch" data-site-key="jable" title="封面显示"> </div> </div> <div class="setting-item"> <span class="setting-label">名称:</span> <div class="form-content" style="display:flex;gap:8px;align-items:center"> <input id="siteNameJavTrailer" placeholder="名称" style="width:90px;flex:0 0 auto"> <input id="javTrailersUrl" placeholder="域名" style="width:180px;flex:0 0 auto"> <input id="siteSearchJavTrailer" placeholder="搜索地址(留空=默认)" style="flex:1 1 180px;min-width:140px"> <input type="checkbox" class="jhs-site-toggle mini-switch" data-site-key="javTrailer"> <input type="checkbox" class="jhs-cover-toggle mini-switch" data-site-key="javTrailer" title="封面显示"> </div> </div> <div class="setting-item"> <span class="setting-label">名称:</span> <div class="form-content" style="display:flex;gap:8px;align-items:center"> <input id="siteNameAv123" placeholder="名称" style="width:90px;flex:0 0 auto"> <input id="av123Url" placeholder="域名" style="width:180px;flex:0 0 auto"> <input id="siteSearchAv123" placeholder="搜索地址(留空=默认)" style="flex:1 1 180px;min-width:140px"> <input type="checkbox" class="jhs-site-toggle mini-switch" data-site-key="av123"> <input type="checkbox" class="jhs-cover-toggle mini-switch" data-site-key="av123" title="封面显示"> </div> </div> <div class="setting-item"> <span class="setting-label">名称:</span> <div class="form-content" style="display:flex;gap:8px;align-items:center"> <input id="siteNameJavDb" placeholder="名称" style="width:90px;flex:0 0 auto"> <input id="javDbUrl" placeholder="域名" style="width:180px;flex:0 0 auto"> <input id="siteSearchJavDb" placeholder="搜索地址(留空=默认)" style="flex:1 1 180px;min-width:140px"> <input type="checkbox" class="jhs-site-toggle mini-switch" data-site-key="javDb"> <input type="checkbox" class="jhs-cover-toggle mini-switch" data-site-key="javDb" title="封面显示"> </div> </div> <div class="setting-item"> <span class="setting-label">名称:</span> <div class="form-content" style="display:flex;gap:8px;align-items:center"> <input id="siteNameJavBus" placeholder="名称" style="width:90px;flex:0 0 auto"> <input id="javBusUrl" placeholder="域名" style="width:180px;flex:0 0 auto"> <input id="siteSearchJavBus" placeholder="搜索地址(留空=默认)" style="flex:1 1 180px;min-width:140px"> <input type="checkbox" class="jhs-site-toggle mini-switch" data-site-key="javBus"> <input type="checkbox" class="jhs-cover-toggle mini-switch" data-site-key="javBus" title="封面显示"> </div> </div> <div class="setting-item"> <span class="setting-label">名称:</span> <div class="form-content" style="display:flex;gap:8px;align-items:center"> <input id="siteNameSupJav" placeholder="名称" style="width:90px;flex:0 0 auto"> <input id="supJavUrl" placeholder="域名" style="width:180px;flex:0 0 auto"> <input id="siteSearchSupJav" placeholder="搜索地址(留空=默认)" style="flex:1 1 180px;min-width:140px"> <input type="checkbox" class="jhs-site-toggle mini-switch" data-site-key="supJav"> <input type="checkbox" class="jhs-cover-toggle mini-switch" data-site-key="supJav" title="封面显示"> </div> </div> <div class="setting-item"> <span class="setting-label">名称:</span> <div class="form-content" style="display:flex;gap:8px;align-items:center"> <input id="siteNameFanza" placeholder="名称" style="width:90px;flex:0 0 auto"> <input id="fanzaUrl" placeholder="域名" style="width:180px;flex:0 0 auto"> <input id="siteSearchFanza" placeholder="搜索地址(留空=默认)" style="flex:1 1 180px;min-width:140px"> <input type="checkbox" class="jhs-site-toggle mini-switch" data-site-key="fanza"> <input type="checkbox" class="jhs-cover-toggle mini-switch" data-site-key="fanza" title="封面显示"> </div> </div> </div> <div id="appraisal-panel" class="content-panel" style="display: ${"appraisal-panel" === activePanel ? "block" : "none"};"> <div class="setting-item"> <span class="setting-label">已鉴定标签展示位置:</span> <div class="form-content"> <select id="tagPosition"> <option value="rightTop">右上</option> <option value="leftTop">左上</option> </select> </div> </div> <div class="setting-item"> <span class="setting-label">已鉴定内容处理方式:</span> <div class="form-content"> <select id="movieShowType"> <option value="normal">正常</option> <option value="hide">隐藏</option> <option value="visibility">透明</option> </select> </div> </div> <div class="setting-item"> <span class="setting-label" style="display:flex; align-items:center; gap:5px"> 首页鉴定筛选功能 <span data-tip="列表页顶部显示 全部/待鉴定/已收藏/已下载/已观看/已屏蔽/已浏览 的快捷筛选按钮; 同时开启「封面已浏览标签」: 点击进入过详情页的番号, 返回列表页时在封面角标位置显示「已浏览」(仅对未鉴定的项目显示)">(?)</span> </span> <div class="form-content"> <input type="checkbox" id="enableQuickFilter" class="mini-switch"> </div> </div> <div class="setting-item"> <span class="setting-label" style="display:flex; align-items:center; gap:5px"> 鉴定补录演员信息 <span data-tip="在列表页进行鉴定是获取不到演员名称的, 开启后, 额外解析详情页补录演员名称, 因发请求解析费时, 会被以往慢1秒左右">(?)</span> </span> <div class="form-content"> <input type="checkbox" id="enableSaveActressCarInfo" class="mini-switch"> </div> </div> <hr style="border: 0; height: 1px; margin:20px 0;background-image: linear-gradient(to right, rgba(0,0,0,0), rgba(159,137,137,0.75), rgba(0,0,0,0));"/> <div class="setting-item" style="margin-top:10px"> <span class="setting-label"> 列表页功能按钮 </span> </div> <div class="setting-item"> <span class="setting-label">按钮-打开待鉴定:</span> <div class="form-content"> <input type="checkbox" id="showWaitCheckBtn" class="mini-switch"> </div> </div> <div class="setting-item"> <span class="setting-label">按钮-打开已收藏:</span> <div class="form-content"> <input type="checkbox" id="showWaitDownBtn" class="mini-switch"> </div> </div> <div class="setting-item"> <span class="setting-label">打开待鉴定|已收藏 窗口数:</span> <div class="form-content"> <input type="number" id="waitCheckCount" min="1" max="20" style="width: 100%;"> </div> </div> <div class="setting-item"> <span class="setting-label">随机打开已收藏:</span> <div class="form-content"> <input type="checkbox" id="randomOpenWaitDown" class="mini-switch"> </div> </div> <hr style="border: 0; height: 1px; margin:20px 0;background-image: linear-gradient(to right, rgba(0,0,0,0), rgba(159,137,137,0.75), rgba(0,0,0,0));"/> <div class="setting-item" style="margin-top:10px"> <span class="setting-label">列表显示</span> </div> <div class="setting-item"> <span class="setting-label" style="display:flex; align-items:center; gap:5px"> <span data-tip="开启后忽略下方分项,强制显示全部已鉴定内容">(?)</span> 显示所有已鉴定 </span> <div class="form-content"> <input type="checkbox" id="showAllItem2" class="mini-switch"> </div> </div> <div class="setting-item"> <span class="setting-label">屏蔽单番号:</span> <div class="form-content"> <input type="checkbox" id="showFilterItem2" class="mini-switch"> </div> </div> <div class="setting-item"> <span class="setting-label">屏蔽演员:</span> <div class="form-content"> <input type="checkbox" id="showFilterActorItem2" class="mini-switch"> </div> </div> <div class="setting-item"> <span class="setting-label">屏蔽关键词:</span> <div class="form-content"> <input type="checkbox" id="showFilterKeywordItem2" class="mini-switch"> </div> </div> <div class="setting-item"> <span class="setting-label">收藏:</span> <div class="form-content"> <input type="checkbox" id="showFavoriteItem2" class="mini-switch"> </div> </div> <div class="setting-item"> <span class="setting-label">已下载:</span> <div class="form-content"> <input type="checkbox" id="showHasDownItem2" class="mini-switch"> </div> </div> <div class="setting-item"> <span class="setting-label">已观看:</span> <div class="form-content"> <input type="checkbox" id="showHasWatchItem2" class="mini-switch"> </div> </div> </div> <div id="filter-panel" class="content-panel" style="display: ${"filter-panel" === activePanel ? "block" : "none"};"> <div class="setting-item"> <span class="setting-label"> 启用划词屏蔽 <span data-tip="视频详情页中, 标题或评论区选中文字, 按右键可快捷加入屏蔽词">(?) </span> </span> <div style="display: flex"> <input type="checkbox" id="enableTitleSelectFilter" class="mini-switch"> </div> </div> <hr style="border: 0; height: 1px; margin:20px 0;background-image: linear-gradient(to right, rgba(0,0,0,0), rgba(159,137,137,0.75), rgba(0,0,0,0));"/> <div id="reviewKeywordContainer"> <div class="setting-item"> <span class="setting-label">评论区屏蔽词:</span> <div style="display: flex"> <input type="text" class="keyword-input" placeholder="添加屏蔽词"> <button class="add-tag-btn">添加</button> </div> </div> <div class="tag-box"> </div> </div> <hr style="border: 0; height: 1px; margin:20px 0;background-image: linear-gradient(to right, rgba(0,0,0,0), rgba(159,137,137,0.75), rgba(0,0,0,0));"/> <div id="filterKeywordContainer"> <div class="setting-item"> <span class="setting-label">视频标题屏蔽词:</span> <div style="display: flex"> <input type="text" class="keyword-input" placeholder="添加屏蔽词"> <button class="add-tag-btn">添加</button> </div> </div> <div class="tag-box"> </div> </div> </div> <div id="cache-panel" class="content-panel" style="display: ${"cache-panel" === activePanel ? "block" : "none"};"> <h1 style="text-align:center;font-size: 20px;font-weight: bold">以下操作, 不会对核心数据造成影响</h1> <br/> <div class="jhs-cache-grid" style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 15px; margin-top: 20px;"> ${n} </div> <div id="cache-data-display" style="margin-top: 20px; display: none;"> <pre style="background: #f5f5f5; padding: 10px; border-radius: 5px; max-height: 400px; overflow: auto;"></pre> </div> </div> <div id="hotkey-panel" class="content-panel" style="display: ${"hotkey-panel" === activePanel ? "block" : "none"};"> <p style="color: #c62222; font-size: 14px;font-weight: bold;margin-bottom: 10px;">快捷键修改后, 刷新页面生效</p> <div style="border: 1px solid #ccc; padding: 10px; margin-bottom: 15px;"> <div class="setting-item"> <span class="setting-label">屏蔽:</span> <div class="form-content"><input id="filterHotKey" placeholder="录入快捷键" data-default-hotkey="a"></div> </div> <div class="setting-item"> <span class="setting-label">收藏:</span> <div class="form-content"><input id="favoriteHotKey" placeholder="录入快捷键" data-default-hotkey="s"></div> </div> <div class="setting-item"> <span class="setting-label">已下载:</span> <div class="form-content"><input id="hasDownHotKey" placeholder="录入快捷键"></div> </div> <div class="setting-item"> <span class="setting-label">已观看:</span> <div class="form-content"><input id="hasWatchHotKey" placeholder="录入快捷键"></div> </div> <div class="setting-item"> <span class="setting-label"><span data-tip="列表页,鼠标放置图片上时可使用快捷键">? </span>对视频列表页启用快捷键:</span> <div class="form-content"><input type="checkbox" id="enableImageHotKey" class="mini-switch"></div> </div> </div> <div style="border: 1px solid #ccc; padding: 10px; margin-bottom: 15px;"> <div class="setting-item"> <span class="setting-label">快进:</span> <div class="form-content"><input id="speedVideoHotKey" placeholder="录入快捷键" data-default-hotkey="z"></div> </div> <div class="setting-item"> <span class="setting-label">折叠:</span> <div class="form-content"><input id="foldCategoryHotKey" placeholder="录入快捷键"></div> </div> <div class="setting-item"> <span class="setting-label">控制台:</span> <div class="form-content"><input id="clogHotKey" placeholder="录入快捷键"></div> </div> <div class="setting-item"> <span class="setting-label">下载图片:</span> <div class="form-content"><input id="saveHoverImageHotKey" placeholder="录入快捷键" data-default-hotkey="q"></div> </div> </div> <div style="border: 1px solid #ccc; padding: 10px; margin-bottom: 15px;"> <span style="font-size: 14px; font-weight: bold; padding:3px">显示已鉴定内容</span> <div class="setting-item"><span class="setting-label">屏蔽单番号:</span><div class="form-content"><input id="showFilterItemHotKey" placeholder="录入快捷键"></div></div> <div class="setting-item"><span class="setting-label">屏蔽演员:</span><div class="form-content"><input id="showFilterActorItemHotKey" placeholder="录入快捷键"></div></div> <div class="setting-item"><span class="setting-label">屏蔽关键词:</span><div class="form-content"><input id="showFilterKeywordItemHotKey" placeholder="录入快捷键"></div></div> <div class="setting-item"><span class="setting-label">收藏:</span><div class="form-content"><input id="showFavoriteItemHotKey" placeholder="录入快捷键"></div></div> <div class="setting-item"><span class="setting-label">已下载:</span><div class="form-content"><input id="showHasDownItemHotKey" placeholder="录入快捷键"></div></div> <div class="setting-item"><span class="setting-label">已观看:</span><div class="form-content"><input id="showHasWatchItemHotKey" placeholder="录入快捷键"></div></div> <div class="setting-item"><span class="setting-label">显示所有:</span><div class="form-content"><input id="showAllItemHotKey" placeholder="录入快捷键"></div></div> </div> </div> <div id="personal-panel" class="content-panel" style="display: ${"personal-panel" === activePanel ? "block" : "none"};"> <h3 style="font-size:15px;font-weight:bold;margin-bottom:10px;">封面图无水印显示</h3> <p style="color:#666;font-size:0.85em;margin-bottom:10px;">详情页大封面换成 App 源的无水印原图; 修改后刷新页面生效</p> <div style="border: 1px solid #ccc; padding: 10px; margin-bottom: 15px;"> <div class="setting-item"> <span class="setting-label" style="display:flex; align-items:center; gap:5px;"> 封面图无水印显示 <span data-tip="网站封面带水印, 源是同一张无水印原图。开启后详情页大封面自动替换; 保存封面(快捷键 q)也会保存无水印版。源较慢, 封面会晚约 1 秒才刷新">(?)</span> </span> <div class="form-content"><input type="checkbox" id="enableCleanCover" class="mini-switch"></div> </div> </div> <h3 style="font-size:15px;font-weight:bold;margin-bottom:10px;">长缩略图切换来源</h3> <p style="color:#666;font-size:0.85em;margin-bottom:10px;">长缩略图底下换源按钮点击可换源加载</p> <div style="border: 1px solid #ccc; padding: 10px; margin-bottom: 15px;"> <div class="setting-item"> <span class="setting-label">显示切源按钮:</span> <div class="form-content"><input type="checkbox" id="enableThumbSourceSwitch" class="mini-switch"></div> </div> </div> <h3 style="font-size:15px;font-weight:bold;margin-bottom:10px;">详情页快捷图标</h3> <p style="color:#666;font-size:0.85em;margin-bottom:10px;">在详情页番号信息面板底部显示小图标按钮; 修改后刷新页面生效</p> <div style="border: 1px solid #ccc; padding: 10px; margin-bottom: 15px;"> <div class="setting-item"> <span class="setting-label" style="display:flex; align-items:center; gap:5px;"> 字幕 <span data-tip="点击打开迅雷字幕搜索面板(可预览/下载)">(?)</span> </span> <div class="form-content"><input type="checkbox" id="enableIconSubtitle" class="mini-switch"></div> </div> <div class="setting-item"> <span class="setting-label" style="display:flex; align-items:center; gap:5px;"> 预告片 <span data-tip="javxy 聚合源解析预告片并在页面内播放, 支持 m3u8(hls)">(?)</span> </span> <div class="form-content"><input type="checkbox" id="enableIconTrailer" class="mini-switch"></div> </div> <div class="setting-item"> <span class="setting-label" style="display:flex; align-items:center; gap:5px;"> 预览图 <span data-tip="等同封面快捷按钮的长缩略图预览">(?)</span> </span> <div class="form-content"><input type="checkbox" id="enableIconPreview" class="mini-switch"></div> </div> <div class="setting-item"> <span class="setting-label" style="display:flex; align-items:center; gap:5px;"> 鉴定处理 <span data-tip="等同封面快捷按钮的鉴定处理: 点击展开 已观看/已下载/收藏/屏蔽 四项, 再次点击同一项即取消">(?)</span> </span> <div class="form-content"><input type="checkbox" id="enableIconHandle" class="mini-switch"></div> </div> <div class="setting-item"> <span class="setting-label" style="display:flex; align-items:center; gap:5px;"> 海报直传 <span data-tip="把详情页大封面按「番号.jpg」传到 115 里影片所在目录(取自「115匹配」的结果); 匹配到多个目录时每个目录各传一份">(?)</span> </span> <div class="form-content"><input type="checkbox" id="enableIconPoster" class="mini-switch"></div> </div> <div class="setting-item"> <span class="setting-label" style="display:flex; align-items:center; gap:5px;"> 外部跳转 <span data-tip="等同封面快捷按钮的第三方网站: 点击展开已启用的站点, 按当前番号跳转搜索; 站点范围与自定义域名沿用「封面快捷按钮」的配置。Ctrl/⌘+点击 后台打开">(?)</span> </span> <div class="form-content"><input type="checkbox" id="enableIconSite" class="mini-switch"></div> </div> </div> <h3 style="font-size:15px;font-weight:bold;margin-bottom:10px;">预览视频</h3> <p style="color:#666;font-size:0.85em;margin-bottom:10px;">三项有层级依赖: 播放器增强 → DMM 多画质源 → 默认画质; 修改后刷新页面生效</p> <div style="border: 1px solid #ccc; padding: 10px; margin-bottom: 15px;"> <div class="setting-item"> <span class="setting-label" style="display:flex; align-items:center; gap:5px;"> 播放器增强 <span data-tip="总开关。开启后详情页预览视频下方会出现工具栏(屏蔽/收藏/快进), 并接管播放逻辑。关闭则完全使用原站播放器">(?)</span> </span> <div class="form-content"><input type="checkbox" id="enableLoadPreviewVideo" class="mini-switch"></div> </div> <div class="setting-item"> <span class="setting-label" style="display:flex; align-items:center; gap:5px;"> DMM 多画质源 <span data-tip="向 DMM 解析多档画质地址, 成功后播放器上会多出一排画质切换按钮。DMM 有地域限制, 海外常 403, 关闭则直接用原站源 / javxy 兜底">(?)</span> </span> <div class="form-content"><input type="checkbox" id="enableLoadOtherSite" class="mini-switch"></div> </div> <div class="setting-item" id="previewQualityRow"> <span class="setting-label" style="display:flex; align-items:center; gap:5px;"> 默认画质 <span data-tip="从 DMM 多档画质里默认选中哪一档; 只在「DMM 多画质源」开启且解析成功时生效">(?)</span> </span> <div class="form-content"> <select id="videoQuality">${buildVideoQualityOptions()}</select> <div id="previewQualityHint" style="font-size:0.8em;color:#d97706;margin-top:4px;" hidden>需开启「DMM 多画质源」后生效</div> </div> </div> </div> <h3 style="font-size:15px;font-weight:bold;margin-bottom:10px;">界面模糊模式</h3> <div style="border: 1px solid #ccc; padding: 10px; margin-bottom: 15px;"> <div class="setting-item"> <span class="setting-label">启用图片和视频模糊:</span> <div class="form-content"><input type="checkbox" id="enableBlurMode" class="mini-switch"></div> </div> <div class="setting-item"> <span class="setting-label">模糊视频图片:</span> <div class="form-content"><input type="checkbox" id="blurImage" class="mini-switch"></div> </div> <div class="setting-item"> <span class="setting-label" style="display:flex; align-items:center; gap:5px;"> 模糊文字 <span data-tip="统一模糊: 视频标题、标题翻译、番号、详情页信息栏(日期/时长/片商/演员等)、磁力面板。悬停任一区域时该区域整块恢复清晰">(?)</span> </span> <div class="form-content"><input type="checkbox" id="blurTitle" class="mini-switch"></div> </div> <div class="setting-item"> <span class="setting-label">悬停时取消模糊:</span> <div class="form-content"><input type="checkbox" id="blurHoverReveal" class="mini-switch"></div> </div> <div class="setting-item"> <span class="setting-label" style="display:flex; align-items:center; gap:5px;"> 模糊程度 <span data-tip="0-100, 越大越模糊。100 对应最大模糊半径 10px">(?)</span> </span> <div class="form-content"><input id="blurLevel" type="number" min="0" max="100" step="1" placeholder="50" style="width:100%;"></div> </div> </div> </div> </div> <div class="jhs-setting-footer" style="flex-shrink: 0; padding: 15px 20px; display: flex; gap: 8px; justify-content: space-between; align-items: baseline; border-top: 1px solid #eee; background: white;"> <div>当前为Pro版,更新上TG: <a href="https://t.me/guowennews" target="_blank">https://t.me/guowennews</a></div> <div style="display:flex;gap:8px;"> <button id="saveBtn">保存设置</button> <button id="clean-all" style="display: none">清理全部缓存</button> </div> </div> </div> </div> `; } /** Inject the Data Health sidebar item and panel HTML into the dialog. */ function injectHealthPanel() { const e = $(".side-menu-item").parent(); e.length && !e.find('[data-panel="health-panel"]').length && e.append('<div class="side-menu-item" data-panel="health-panel">数据体检</div>'); const t = $(".content-panel").parent(); t.length && !$("#health-panel").length && t.append(` <div id="health-panel" class="content-panel" style="display:none;"> <div style="display:flex; gap:8px; margin-bottom:12px;"> <a id="runHealthCheckBtn" class="menu-btn" style="background-color:#448cc2"><span>重新体检</span></a> <a id="repairHealthBtn" class="menu-btn" style="background-color:#64bb69"><span>备份并修复</span></a> </div> <div id="health-data-display" style="background:#f8f9fa; border:1px solid #ddd; border-radius:5px; padding:12px; min-height:180px;">点击重新体检查看结果</div> </div> `); } /** Inject the Plugin Management sidebar item and panel HTML into the dialog. */ function injectPluginMgmtPanel() { const e = $(".side-menu-item").parent(); e.length && !e.find('[data-panel="plugin-mgmt-panel"]').length && e.append('<div class="side-menu-item" data-panel="plugin-mgmt-panel">插件管理</div>'); const t = $(".content-panel").parent(); if (!t.length || $("#plugin-mgmt-panel").length) return; let i = '<div id="plugin-mgmt-panel" class="content-panel" style="display:none;">'; i += '<div style="display:flex;gap:8px;margin-bottom:15px;flex-wrap:wrap;">'; i += '<div style="flex:1;min-width:120px;background:#f0f7ff;border-radius:8px;padding:10px;text-align:center"><div id="pm-total" style="font-size:20px;font-weight:bold;color:#25b1dc">0</div><div style="font-size:12px;color:#888">总插件数</div></div>'; i += '<div style="flex:1;min-width:120px;background:#f0fff4;border-radius:8px;padding:10px;text-align:center"><div id="pm-enabled" style="font-size:20px;font-weight:bold;color:#7bc73b">0</div><div style="font-size:12px;color:#888">已启用</div></div>'; i += '<div style="flex:1;min-width:120px;background:#fff5f5;border-radius:8px;padding:10px;text-align:center"><div id="pm-disabled" style="font-size:20px;font-weight:bold;color:#de3333">0</div><div style="font-size:12px;color:#888">已禁用</div></div>'; i += '</div>'; i += '<p style="color:#666;font-size:0.85em;margin-bottom:10px;">禁用插件后需刷新页面生效。核心插件不可禁用。</p>'; i += '<div id="plugin-mgmt-list"></div>'; i += '<hr style="border:0;height:1px;margin:20px 0;background-image:linear-gradient(to right,rgba(0,0,0,0),rgba(159,137,137,0.75),rgba(0,0,0,0));"/>'; i += '<h3 style="font-size:15px;font-weight:bold;margin-bottom:10px;">插件执行耗时</h3>'; i += '<p style="color:#666;font-size:0.85em;margin-bottom:8px;">页面加载时各插件 handle() 的执行时间。</p>'; i += '<div id="plugin-timing-table"></div>'; i += '<hr style="border:0;height:1px;margin:20px 0;background-image:linear-gradient(to right,rgba(0,0,0,0),rgba(159,137,137,0.75),rgba(0,0,0,0));"/>'; i += '<h3 style="font-size:15px;font-weight:bold;margin-bottom:10px;">错误日志</h3>'; i += '<div style="display:flex;gap:8px;margin-bottom:8px;"><a id="pm-clear-log" class="menu-btn" style="background-color:#e74c3c"><span>清空日志</span></a></div>'; i += '<div id="plugin-error-log" style="max-height:250px;overflow:auto;background:#f8f9fa;border:1px solid #ddd;border-radius:5px;padding:10px;font-size:13px;">无错误记录</div>'; i += '<hr style="border:0;height:1px;margin:20px 0;background-image:linear-gradient(to right,rgba(0,0,0,0),rgba(159,137,137,0.75),rgba(0,0,0,0));"/>'; i += '<h3 style="font-size:15px;font-weight:bold;margin-bottom:10px;">缓存命中率</h3>'; i += '<div id="cache-hit-stats" style="background:#f8f9fa;border:1px solid #ddd;border-radius:5px;padding:10px;"></div>'; i += '</div>'; t.append(i); } /** Inject the snapshot (恢复点) block into the 数据备份 panel. */ function injectSnapshotPanel() { // 恢复点不再是独立侧边栏项, 而是「数据备份」面板里的一段 —— 入口和导入/导出并列。 const host = $("#snapshot-section"); if (!host.length || host.children().length) return; let n = '<div class="setting-item"><span class="setting-label">恢复点</span>'; n += '<div><a id="createSnapshotBtn" class="menu-btn" style="background-color:#64bb69"><span>创建快照</span></a></div>'; n += '</div>'; n += '<p style="color:#999;font-size:0.8em;margin:0 0 6px;">快照保存当前全部数据状态,可用于恢复。最多保留 10 个,超出自动清理最旧的。</p>'; n += '<div id="snapshot-list"></div>'; host.html(n); // 面板一打开就停在「数据备份」时不会触发侧边栏 click, 这里补一次渲染; // Tabulator 在 display:none 的容器里算不出列宽, 所以只在面板可见时渲染。 host.is(":visible") && renderSnapshotPanel(); } /** Inject the Network/External Requests sidebar item and panel HTML into the dialog. */ function injectNetworkPanel() { const e = $(".side-menu-item").parent(); e.length && !e.find('[data-panel="network-panel"]').length && e.append('<div class="side-menu-item" data-panel="network-panel">外部请求</div><div class="side-menu-item" data-panel="hotkey-panel">快捷键配置</div><div class="side-menu-item" data-panel="personal-panel">个性化设置</div><div class="side-menu-item" data-panel="115-panel">115增强功能</div>'); const t = $(".content-panel").parent(); if (!t.length || $("#network-panel").length) return; let n = '<div id="network-panel" class="content-panel" style="display:none;">'; n += '<h3 style="font-size:15px;font-weight:bold;margin-bottom:10px;">熔断器配置</h3>'; n += '<p style="color:#666;font-size:0.85em;margin-bottom:10px;">连续请求失败达到阈值后,自动停止对该站点的请求,避免拖慢整体体验。</p>'; n += '<div class="setting-item"><span class="setting-label">熔断阈值(次):</span><div class="form-content"><input type="number" id="circuitBreakerThreshold" min="2" max="10" style="width:100%;"></div></div>'; n += '<div class="setting-item"><span class="setting-label">冷却时间(秒):</span><div class="form-content"><input type="number" id="circuitBreakerCooldownSec" min="10" max="300" style="width:100%;"></div></div>'; n += '<div style="display:flex;gap:8px;margin:10px 0;"><a id="resetAllBreakersBtn" class="menu-btn" style="background-color:#e74c3c"><span>重置全部熔断</span></a></div>'; n += '<hr style="border:0;height:1px;margin:20px 0;background-image:linear-gradient(to right,rgba(0,0,0,0),rgba(159,137,137,0.75),rgba(0,0,0,0));"/>'; n += '<h3 style="font-size:15px;font-weight:bold;margin-bottom:10px;">站点健康状态</h3>'; n += '<p style="color:#666;font-size:0.85em;margin-bottom:10px;">各外部站点的熔断状态和请求统计。</p>'; n += '<div id="site-health-table"></div>'; n += '<hr style="border:0;height:1px;margin:20px 0;background-image:linear-gradient(to right,rgba(0,0,0,0),rgba(159,137,137,0.75),rgba(0,0,0,0));"/>'; n += '<h3 style="font-size:15px;font-weight:bold;margin-bottom:10px;">域名使用统计</h3>'; n += '<p style="color:#666;font-size:0.85em;margin-bottom:10px;">脚本实际请求过的域名及次数。</p>'; n += '<div style="display:flex;gap:8px;margin-bottom:8px;"><a id="clearDomainStatsBtn" class="menu-btn" style="background-color:#e74c3c"><span>清空统计</span></a></div>'; n += '<div id="domain-stats-table"></div>'; n += '</div>'; t.append(n); } /** Inject the 115 Enhancement sidebar item and panel HTML into the dialog. */ function inject115Panel() { const e = $(".side-menu-item").parent(); e.length && !e.find('[data-panel="115-panel"]').length && e.append('<div class="side-menu-item" data-panel="115-panel">115增强功能</div>'); const t = $(".content-panel").parent(); if (!t.length || $("#115-panel").length) return; let n = '<div id="115-panel" class="content-panel" style="display:none;">'; // ===== 视频匹配卡片 ===== n += '<h3 style="font-size:15px;font-weight:bold;margin-bottom:10px;">115视频匹配</h3>'; n += '<p style="color:#666;font-size:0.85em;margin-bottom:10px;">详情页/列表页按番号搜索 115 网盘里的匹配项;需 115 登录态。修改后刷新页面生效</p>'; n += '<div style="border: 1px solid #ccc; padding: 10px; margin-bottom: 15px;">'; n += '<div class="setting-item">'; n += '<span class="setting-label" style="display:flex; align-items:center; gap:5px;">'; n += '115视频匹配 <span data-tip="开启后, 详情页底部会出现「115匹配」表格(匹配到才显示); 列表页每个影片标题旁会出现匹配图标。关闭则完全跳过 115 搜索">(?)</span>'; n += '</span>'; n += '<div class="form-content"><input type="checkbox" id="enable115Match" class="mini-switch"></div>'; n += '</div>'; n += '</div>'; // ===== 不规则重命名卡片 ===== n += '<h3 style="font-size:15px;font-weight:bold;margin-bottom:10px;">不规则重命名</h3>'; n += '<p style="color:#666;font-size:0.85em;margin-bottom:10px;">仅在精确搜索无结果、走尾部数字模糊兜底命中时,把畸形文件名改成标准番号;前后缀(如 -UN、-CD2)原样保留。修改后刷新页面生效</p>'; n += '<div style="border: 1px solid #ccc; padding: 10px; margin-bottom: 15px;">'; n += '<div class="setting-item">'; n += '<span class="setting-label" style="display:flex; align-items:center; gap:5px;">'; n += '不规则重命名 <span data-tip="依赖「115视频匹配」开启。打开详情页时, 若 115 里文件名是畸形番号(如 mdvr00317), 会自动改名为标准番号; 改名后表格仍显示旧名, 需刷新详情页才生效">(?)</span>'; n += '</span>'; n += '<div class="form-content"><input type="checkbox" id="enable115AutoRename" class="mini-switch"></div>'; n += '</div>'; n += '<div class="setting-item" id="autoRenameScopeRow" style="display:none;">'; n += '<span class="setting-label">重命名范围:</span>'; n += '<div class="form-content"><select id="autoRenameScope" style="padding: 2px 4px;"><option value="vr_only">仅VR</option><option value="all">全部</option></select></div>'; n += '</div>'; n += '<p style="color:#999;font-size:0.8em;margin:6px 0 0;">改名成功/失败只在浏览器控制台输出,页面无提示,表格仍显示旧名,需刷新生效。</p>'; n += '</div>'; n += '</div>'; t.append(n); } // 115 增强开关的即时保存: 直接走 saveSetting(底层 forage.setItem, 不依赖 navigator.locks), // 避免部分浏览器/环境 navigator.locks 不可用导致 saveSettingItem 静默失败、开关状态丢失。 async function persistSetting(key, val) { try { const all = await storageManager.getSetting(); all[key] = val; await storageManager.saveSetting(all); } catch (er) { clog.error("persistSetting 保存失败:", key, er); } } /** Build the HTML for the hover dropdown quick-settings panel. */ function buildSimpleSettingHtml() { return ` <div class="jhs-qs-panel"> <div class="jhs-qs-header"> <span class="jhs-qs-title">快捷设置</span> <span class="jhs-qs-sub">改动即时生效</span> </div> <div class="jhs-qs-body jhs-scrollbar"> <div class="jhs-qs-section"> <div class="jhs-qs-section-title">浏览行为</div> <div class="jhs-qs-row"><div class="jhs-qs-label"><span>弹窗打开详情页</span></div><div class="jhs-qs-control"><input type="checkbox" id="dialogOpenDetail" class="mini-switch"></div></div> <div class="jhs-qs-row"><div class="jhs-qs-label"><span>鉴定后关闭详情页</span></div><div class="jhs-qs-control"><input type="checkbox" id="needClosePage" class="mini-switch"></div></div> <div class="jhs-qs-row"><div class="jhs-qs-label"><span>瀑布流模式</span></div><div class="jhs-qs-control"><input type="checkbox" id="autoPage" class="mini-switch"></div></div> </div> <div class="jhs-qs-section"> <div class="jhs-qs-section-title">增强功能</div> <div class="jhs-qs-row"><div class="jhs-qs-label"><span>标题翻译</span></div><div class="jhs-qs-control"><input type="checkbox" id="translateTitle" class="mini-switch"></div></div> <div class="jhs-qs-row"><div class="jhs-qs-label"><span>悬浮大图</span></div><div class="jhs-qs-control"><input type="checkbox" id="hoverBigImg" class="mini-switch"></div></div> <div class="jhs-qs-row"><div class="jhs-qs-label"><span>加载女优信息</span></div><div class="jhs-qs-control"><input type="checkbox" id="enableLoadActressInfo" class="mini-switch"></div></div> <div class="jhs-qs-row"><div class="jhs-qs-label"><span>加载长缩略图</span></div><div class="jhs-qs-control"><input type="checkbox" id="enableLoadScreenShot" class="mini-switch"></div></div> </div> <div class="jhs-qs-section"> <div class="jhs-qs-section-title">页面布局</div> <div class="jhs-qs-row"><div class="jhs-qs-label"><span>竖图模式</span></div><div class="jhs-qs-control"><input type="checkbox" id="enableVerticalModel" class="mini-switch"></div></div> <div class="jhs-qs-row jhs-qs-row-stacked"><div class="jhs-qs-label"><span>列数</span></div><div class="jhs-qs-control"><div class="jhs-qs-range"><input type="range" id="containerColumns" min="2" max="10" step="1"><span id="showContainerColumns"></span></div></div></div> <div class="jhs-qs-row jhs-qs-row-stacked"><div class="jhs-qs-label"><span>宽度</span></div><div class="jhs-qs-control"><div class="jhs-qs-range"><input type="range" id="containerWidth" min="0" max="30" step="1"><span id="showContainerWidth"></span></div></div></div> <div class="jhs-qs-row"><div class="jhs-qs-label"><span>详情比例</span></div></div> <div class="jhs-qs-row jhs-qs-row-stacked"><div class="jhs-qs-label"><span>封面</span></div><div class="jhs-qs-control"><div class="jhs-qs-range"><input type="range" id="detailCoverRatio" min="50" max="200" step="5"><span id="showDetailCoverRatio"></span></div></div></div> <div class="jhs-qs-row jhs-qs-row-stacked"><div class="jhs-qs-label"><span>信息</span></div><div class="jhs-qs-control"><div class="jhs-qs-range"><input type="range" id="detailInfoRatio" min="50" max="200" step="5"><span id="showDetailInfoRatio"></span></div></div></div> </div> </div> <div class="jhs-qs-footer"> <button type="button" id="helpBtn" class="jhs-qs-btn jhs-qs-btn-ghost">常见问题</button> <button type="button" id="moreBtn" class="jhs-qs-btn jhs-qs-btn-primary">更多设置</button> </div> </div> `; } /** Render the network/external requests panel: circuit breaker status, domain stats. */ async function renderNetworkPanel() { const e = gmHttp.getCircuitBreakerStatus(), t = gmHttp.getDomainStats(), n = await storageManager.getSetting("circuitBreakerThreshold", 3), a = await storageManager.getSetting("circuitBreakerCooldown", 6e4); $("#circuitBreakerThreshold").val(n), $("#circuitBreakerCooldownSec").val(Math.round(a / 1e3)); const i = Object.entries(e); if (i.length) { let t = '<table style="width:100%;border-collapse:collapse;font-size:13px;">'; t += '<tr style="background:#f0f0f0;"><th style="text-align:left;padding:6px;border-bottom:1px solid #ddd;">域名</th><th style="text-align:center;padding:6px;border-bottom:1px solid #ddd;">状态</th><th style="text-align:center;padding:6px;border-bottom:1px solid #ddd;">失败次数</th><th style="text-align:center;padding:6px;border-bottom:1px solid #ddd;">操作</th></tr>'; for (const [n, a] of i) { const i = "open" === a.state ? "熔断" : "half-open" === a.state ? "半开" : "正常", s = "open" === a.state ? `剩余${Math.ceil((a.cooldownMs - (Date.now() - a.openTime)) / 1e3)}秒` : ""; t += `<tr style="border-bottom:1px solid #f0f0f0;"><td style="padding:4px 6px;">${escapeHtml(n)}</td><td style="text-align:center;padding:4px 6px;">${i} ${s}</td><td style="text-align:center;padding:4px 6px;">${Number(a.failCount) || 0}</td><td style="text-align:center;padding:4px 6px;"><a class="a-danger reset-breaker" data-domain="${escapeHtml(n)}">重置</a></td></tr>`; } t += '</table>', $("#site-health-table").html(t); } else $("#site-health-table").html('<p style="color:#888;font-size:13px;">暂无熔断记录</p>'); const s = Object.entries(t).sort(((e, t) => t[1].count - e[1].count)); if (s.length) { let e = '<table style="width:100%;border-collapse:collapse;font-size:13px;">'; e += '<tr style="background:#f0f0f0;"><th style="text-align:left;padding:6px;border-bottom:1px solid #ddd;">域名</th><th style="text-align:right;padding:6px;border-bottom:1px solid #ddd;">请求数</th><th style="text-align:right;padding:6px;border-bottom:1px solid #ddd;">错误数</th><th style="text-align:center;padding:6px;border-bottom:1px solid #ddd;">最后使用</th></tr>'; for (const [t, n] of s) { const a = n.lastUsed ? new Date(n.lastUsed).toLocaleTimeString() : "-"; e += `<tr style="border-bottom:1px solid #f0f0f0;"><td style="padding:4px 6px;">${escapeHtml(t)}</td><td style="text-align:right;padding:4px 6px;">${n.count}</td><td style="text-align:right;padding:4px 6px;color:${n.errors > 0 ? "#e74c3c" : "#333"};">${n.errors}</td><td style="text-align:center;padding:4px 6px;">${a}</td></tr>`; } e += '</table>', e += `<p style="color:#888;font-size:12px;margin-top:8px;">共 ${s.length} 个域名</p>`, $("#domain-stats-table").html(e); } else $("#domain-stats-table").html('<p style="color:#888;font-size:13px;">暂无统计数据</p>'); $(".reset-breaker").off("click").on("click", (e => { const t = $(e.target).data("domain"); gmHttp.resetCircuitBreaker(t), show.ok(`已重置 ${t} 的熔断状态`), renderNetworkPanel(); })), $("#resetAllBreakersBtn").off("click").on("click", (() => { gmHttp.resetAllCircuitBreakers(), show.ok("已重置全部熔断状态"), renderNetworkPanel(); })), $("#clearDomainStatsBtn").off("click").on("click", (() => { gmHttp.clearDomainStats(), show.ok("已清空域名统计"), renderNetworkPanel(); })); } /** Render the snapshot list as a Tabulator table with restore/download/delete actions. */ async function renderSnapshotPanel() { // 现在由「数据备份」面板的切换事件触发, 容器可能还没注入 —— 不存在就直接跳过, 免得 Tabulator 抛错 if (!$("#snapshot-list").length) return; const e = await storageManager.getSnapshotList(); if (0 === e.length) return void $("#snapshot-list").html('<div style="text-align:center;color:#999;padding:30px;">暂无快照,点击上方按钮创建</div>'); $("#snapshot-list").find(".tabulator").length && $("#snapshot-list").empty(); const n = new Tabulator("#snapshot-list", { layout: "fitColumns", // 并入数据备份面板后要给列表限高, 否则 10 条快照会把下面的 WebDav 字段顶得很远 maxHeight: 280, placeholder: "暂无数据", data: e, columnDefaults: { headerHozAlign: "center", hozAlign: "center" }, columns: [ // 用 minWidth + widthGrow 而不是固定 width: 面板比原来的独立面板窄, // 固定宽的列不参与 fitColumns 收缩, 会把「操作」整列挤出容器(按钮被裁掉)。 { title: "名称", field: "name", minWidth: 100, widthGrow: 2, headerSort: !1, hozAlign: "left", headerHozAlign: "left", // 老快照名字是写死存进 IndexedDB 的, 改不了历史数据, 这里只在显示时统一成新叫法 formatter: e => escapeHtml("导入前自动备份" === e.getValue() ? "自动备份" : e.getValue() || "") }, { title: "时间", field: "time", minWidth: 165, widthGrow: 1, headerSort: !1 }, { title: "数量", field: "itemCount", minWidth: 52, widthGrow: 0, headerSort: !1 }, { title: "操作", minWidth: 140, widthGrow: 0, headerSort: !1, formatter: (e, t, a) => { const i = e.getData(); return a((() => { const t = e.getElement().querySelector(".snap-restore"), a = e.getElement().querySelector(".snap-download"), s = e.getElement().querySelector(".snap-delete"); t && t.addEventListener("click", (async e => { utils.q(e, `恢复到快照「${escapeHtml(i.name)}」? 当前数据会自动备份。`, (async () => { let e = loading(); try { await storageManager.restoreSnapshot(i.id), show.ok("恢复成功, 页面将刷新"), setTimeout(() => location.reload(), 1e3); } catch (t) { console.error(t), show.error("恢复失败: " + t.message); } finally { e.close(); } })); })), a && a.addEventListener("click", (async e => { let t = loading(); try { const e = await storageManager.getSnapshot(i.id); if (!e) throw new Error("快照不存在"); utils.download(JSON.stringify(e.data), `snapshot_${escapeHtml(i.name)}.json`), show.ok("下载成功"); } catch (n) { show.error("下载失败: " + n.message); } finally { t.close(); } })), s && s.addEventListener("click", (async e => { utils.q(e, `删除快照「${escapeHtml(i.name)}」?`, (async () => { try { await storageManager.deleteSnapshot(i.id), show.ok("已删除"), renderSnapshotPanel(); } catch (t) { console.error(t), show.error("删除失败: " + t.message); } })); })); })), '<div style="white-space:nowrap;display:flex;gap:3px;justify-content:center;align-items:center"><a class="a-success snap-restore" style="padding:2px 6px;font-size:12px;margin:0">恢复</a><a class="a-primary snap-download" style="padding:2px 6px;font-size:12px;margin:0">下载</a><a class="a-danger snap-delete" style="padding:2px 6px;font-size:12px;margin:0">删除</a></div>'; } } ], locale: "zh-cn" }); } /** Show a data diff preview dialog before importing data. */ function showDiffPreview(e, t, n = null) { const a = e.summary, i = []; for (const [s, o] of Object.entries(e.stores)) { if ("unchanged" === o.status) continue; const e = { store: s, status: o.status, oldCount: o.oldCount, newCount: o.newCount, added: o.added.length, removed: o.removed.length, modified: o.modified.length }; i.push(e); } let s = '<div style="padding:15px;">'; s += '<div style="display:flex;gap:10px;margin-bottom:15px;flex-wrap:wrap;">'; s += `<div style="flex:1;min-width:100px;background:#f0fff4;border-radius:8px;padding:10px;text-align:center"><div style="font-size:20px;font-weight:bold;color:#7bc73b">${a.added}</div><div style="font-size:12px;color:#888">新增数据源</div></div>`; s += `<div style="flex:1;min-width:100px;background:#fff5f5;border-radius:8px;padding:10px;text-align:center"><div style="font-size:20px;font-weight:bold;color:#de3333">${a.removed}</div><div style="font-size:12px;color:#888">缺失数据源</div></div>`; s += `<div style="flex:1;min-width:100px;background:#fff8e1;border-radius:8px;padding:10px;text-align:center"><div style="font-size:20px;font-weight:bold;color:#f59e0b">${a.modified}</div><div style="font-size:12px;color:#888">有变更</div></div>`; s += `<div style="flex:1;min-width:100px;background:#f5f5f5;border-radius:8px;padding:10px;text-align:center"><div style="font-size:20px;font-weight:bold;color:#999">${a.unchanged}</div><div style="font-size:12px;color:#888">无变化</div></div>`; s += '</div>'; if (i.length > 0) { s += '<div style="max-height:350px;overflow:auto;border:1px solid #eee;border-radius:5px;">'; s += '<table style="width:100%;border-collapse:collapse;font-size:13px;">'; s += '<thead><tr style="background:#f5f5f5;"><th style="padding:8px;text-align:left;">数据源</th><th style="padding:8px;">状态</th><th style="padding:8px;">当前</th><th style="padding:8px;">导入</th><th style="padding:8px;">新增</th><th style="padding:8px;">删除</th><th style="padding:8px;">修改</th></tr></thead><tbody>'; const o = { added: "新增", removed: "缺失", modified: "变更", unchanged: "无变化" }; for (const r of i) s += `<tr style="border-bottom:1px solid #eee;"><td style="padding:6px 8px;">${escapeHtml(r.store)}</td><td style="padding:6px 8px;">${o[r.status] || r.status}</td><td style="padding:6px 8px;text-align:center;">${r.oldCount}</td><td style="padding:6px 8px;text-align:center;">${r.newCount}</td><td style="padding:6px 8px;text-align:center;color:#7bc73b;">${r.added || "-"}</td><td style="padding:6px 8px;text-align:center;color:#de3333;">${r.removed || "-"}</td><td style="padding:6px 8px;text-align:center;color:#f59e0b;">${r.modified || "-"}</td></tr>`; s += '</tbody></table></div>'; } else { s += '<div style="text-align:center;color:#999;padding:20px;">数据完全一致,无需导入</div>'; } s += '<div style="margin-top:12px;color:#e74c3c;font-size:12px;">导入将覆盖当前数据,建议先创建快照备份</div>'; s += '</div>'; const r = layer.open({ type: 1, title: "数据差异预览", content: s, area: utils.getResponsiveArea(["700px", "auto"]), btn: ["确认导入", "取消"], anim: -1, yes: async s => { layer.close(s); let o = loading(); try { await storageManager.createSnapshot("自动备份", "auto-import"), n ? (await storageManager.importData(n), show.ok("导入成功!"), void setTimeout(() => location.reload(), 1e3)) : t && (await storageManager.importData(t), show.ok("导入成功!"), void setTimeout(() => location.reload(), 1e3)); } catch (r) { console.error(r), show.error("导入失败: " + r.message); } finally { o.close(); } } }); } /** Render the plugin management panel: categorized plugin list, timing, errors, cache stats. */ async function renderPluginMgmtPanel() { const disabled = JSON.parse(await storageManager.getSetting("disabledPlugins", DEFAULT_DISABLED_PLUGINS)); const allNames = unsafeWindow.pluginManager.getPluginNames(); const { categories, corePlugins, displayNames } = getPluginCategories(); const getDisplayName = (name) => (displayNames && displayNames[name]) ? displayNames[name] : name; const registeredSet = new Set(allNames); // 面板上真正渲染出开关的插件。计数只能按这个集合来: // disabledPlugins 是两个站点共用的一份设置, 里面会留着本站没注册的名字(以及已从分类列表移除的插件), // 直接用 disabled.length 会出现「只关了 1 个却显示已禁用 5 个」。 const shownSet = new Set(); let html = ""; for (const [catKey, cat] of Object.entries(categories)) { const visiblePlugins = cat.plugins.filter(p => registeredSet.has(p)); if (!visiblePlugins.length) continue; visiblePlugins.forEach(p => shownSet.add(p)); html += `<div style="border:1px solid #eee;border-radius:8px;padding:10px;margin-bottom:10px;">`; html += `<div style="font-weight:bold;font-size:14px;margin-bottom:8px;">${escapeHtml(cat.label)}</div>`; for (const pName of visiblePlugins) { const isCore = corePlugins.includes(pName); const isDisabled = disabled.includes(pName); html += `<div style="display:flex;align-items:center;justify-content:space-between;padding:4px 0;">`; html += `<span style="font-size:13px;" title="${escapeHtml(pName)}">${escapeHtml(getDisplayName(pName))}${isCore ? ' <span style="color:#888;font-size:11px;">[核心]</span>' : ""}</span>`; if (isCore) { html += `<input type="checkbox" class="mini-switch" checked disabled>`; } else { html += `<input type="checkbox" class="mini-switch pm-toggle" data-plugin="${escapeHtml(pName)}" ${isDisabled ? "" : "checked"}>`; } html += `</div>`; } html += `</div>`; } $("#plugin-mgmt-list").html(html); // 只统计面板上有开关的插件, 保证 总数 = 已启用 + 已禁用 const countStats = (list) => { const disabledShown = list.filter(n => shownSet.has(n)).length; return { total: shownSet.size, disabled: disabledShown, enabled: shownSet.size - disabledShown }; }; const renderStats = (list) => { const s = countStats(list); $("#pm-total").text(s.total); $("#pm-enabled").text(s.enabled); $("#pm-disabled").text(s.disabled); }; renderStats(disabled); $(".pm-toggle").off("change").on("change", async (e) => { const name = $(e.target).data("plugin"); let list = JSON.parse(await storageManager.getSetting("disabledPlugins", DEFAULT_DISABLED_PLUGINS)); if ($(e.target).is(":checked")) { list = list.filter(x => x !== name); } else { if (!list.includes(name)) list.push(name); } await storageManager.saveSettingItem("disabledPlugins", JSON.stringify(list)); renderStats(list); show.ok(`插件 "${getDisplayName(name)}" 已${$(e.target).is(":checked") ? "启用" : "禁用"},刷新后生效`); }); const timings = unsafeWindow.pluginManager.getTimings(); if (timings.length) { const sorted = [...timings].sort((a, b) => b.elapsed - a.elapsed); let tHtml = '<table style="width:100%;border-collapse:collapse;font-size:13px;">'; tHtml += '<tr style="background:#f0f0f0;"><th style="text-align:left;padding:6px;border-bottom:1px solid #ddd;">插件</th><th style="text-align:right;padding:6px;border-bottom:1px solid #ddd;">耗时(ms)</th><th style="text-align:center;padding:6px;border-bottom:1px solid #ddd;">状态</th></tr>'; for (const t of sorted) { const color = t.status === "disabled" ? "#ccc" : t.elapsed > 500 ? "#e74c3c" : t.elapsed > 200 ? "#f39c12" : "#333"; const statusText = t.status === "disabled" ? "已禁用" : t.status === "error" ? "错误" : "正常"; tHtml += `<tr><td style="padding:4px 6px;border-bottom:1px solid #f0f0f0;color:${color}" title="${escapeHtml(t.name)}">${escapeHtml(getDisplayName(t.name))}</td><td style="text-align:right;padding:4px 6px;border-bottom:1px solid #f0f0f0;color:${color};font-weight:${t.elapsed > 500 ? "bold" : "normal"}">${t.elapsed.toFixed(1)}</td><td style="text-align:center;padding:4px 6px;border-bottom:1px solid #f0f0f0;">${statusText}</td></tr>`; } tHtml += '</table>'; $("#plugin-timing-table").html(tHtml); } else { $("#plugin-timing-table").html('<p style="color:#888;font-size:13px;">暂无数据,刷新页面后自动采集。</p>'); } const errorLog = unsafeWindow.pluginManager.getErrorLog(); if (errorLog.length) { let eHtml = '<table style="width:100%;border-collapse:collapse;font-size:12px;">'; eHtml += '<tr style="background:#f0f0f0;"><th style="text-align:left;padding:4px;">时间</th><th style="text-align:left;padding:4px;">插件</th><th style="text-align:left;padding:4px;">阶段</th><th style="text-align:left;padding:4px;">错误信息</th></tr>'; for (const err of [...errorLog].reverse()) { eHtml += `<tr><td style="padding:3px 4px;border-bottom:1px solid #f0f0f0;color:#888;white-space:nowrap;">${escapeHtml(err.time.substring(11, 19))}</td><td style="padding:3px 4px;border-bottom:1px solid #f0f0f0;" title="${escapeHtml(err.plugin)}">${escapeHtml(getDisplayName(err.plugin))}</td><td style="padding:3px 4px;border-bottom:1px solid #f0f0f0;">${escapeHtml(err.phase)}</td><td style="padding:3px 4px;border-bottom:1px solid #f0f0f0;color:#e74c3c;word-break:break-all;">${escapeHtml(err.message)}</td></tr>`; } eHtml += '</table>'; $("#plugin-error-log").html(eHtml); } else { $("#plugin-error-log").text("无错误记录"); } const cacheStats = storageManager.getCacheHitStats(); $("#cache-hit-stats").html(`<div style="display:flex;gap:15px;flex-wrap:wrap;"><span>命中: <strong style="color:#7bc73b">${cacheStats.hits}</strong></span><span>未命中: <strong style="color:#e74c3c">${cacheStats.misses}</strong></span><span>总计: <strong>${cacheStats.total}</strong></span><span>命中率: <strong style="color:#25b1dc">${cacheStats.rate}</strong></span></div>`); } /** Render the data health check panel with totals and issue breakdown. */ async function renderDataHealthPanel() { const e = $("#health-data-display"); if (!e.length) return; e.text("体检中..."); try { const t = await storageManager.inspectDataHealth(), n = t.fixable.reduce(((e, t) => e + t.count), 0), a = t.readonly.reduce(((e, t) => e + t.count), 0), i = t => t.length ? t.map((e => `<li><strong>${escapeHtml(e.message)}</strong>:${e.count}</li>`)).join("") : "<li>无</li>"; e.html(` <div style="display:grid; grid-template-columns: repeat(4, 1fr); gap:8px; margin-bottom:12px;"> <div>番号记录:<strong>${t.totals.carList}</strong></div> <div>收藏演员:<strong>${t.totals.favoriteActresses}</strong></div> <div>黑名单演员:<strong>${t.totals.blacklist}</strong></div> <div>黑名单作品:<strong>${t.totals.blacklistCarList}</strong></div> </div> <div style="margin-bottom:8px;">体检时间:${escapeHtml(t.checkedAt)};可修复问题 <strong>${n}</strong> 项,只读问题 <strong>${a}</strong> 项。</div> <div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;"> <div><div style="font-weight:bold;margin-bottom:4px;">可安全修复</div><ul>${i(t.fixable)}</ul></div> <div><div style="font-weight:bold;margin-bottom:4px;">仅报告</div><ul>${i(t.readonly)}</ul></div> </div> `); } catch (t) { console.error(t), e.text("体检失败: " + t); } } /** Repair data health issues after auto-backing up current data. */ async function repairDataHealthWithBackup() { const e = JSON.stringify(await storageManager.exportData()), t = `health-backup-${utils.getNowStr("_", "_")}.json`; utils.download(e, t); const n = await storageManager.repairDataHealth(); show.ok(`已修复 ${n.fixedGroups} 组数据问题,修复前备份已下载`), await renderDataHealthPanel(); } /** Load all settings from storage into the main settings dialog form fields. */ /** 外部站点 隐藏/显示 状态管理 (统一控制封面快捷按钮 + 详情页第三方按钮) */ const JHS_SITE_BTN_MAP = { missAv: { detail: "missAvBtn", cover: "site-miss-av" }, jable: { detail: "jableBtn", cover: "site-jable" }, av123: { detail: "123AvBtn", cover: "site-123-av" }, javTrailer: { detail: "javTrailersBtn", cover: null }, supJav: { detail: "supJavBtn", cover: null }, fanza: { detail: "fanzaBtn", cover: null }, javDb: { detail: null, cover: null }, javBus: { detail: null, cover: null } }; const JHS_BTN_TO_SITEKEY = { missAvBtn: "missAv", jableBtn: "jable", "123AvBtn": "av123", javTrailersBtn: "javTrailer", supJavBtn: "supJav", javDbBtn: "javDb", javBusBtn: "javBus", fanzaBtn: "fanza" }; // 每个按钮对应的「自定义搜索地址」设置键(留空则回退到内置 searchPath 规则; fanza 无域名/搜索地址故不含) const SITE_SEARCH_KEY_MAP = { javTrailersBtn: "siteSearchJavTrailer", "123AvBtn": "siteSearchAv123", jableBtn: "siteSearchJable", missAvBtn: "siteSearchMissAv", supJavBtn: "siteSearchSupJav", javDbBtn: "siteSearchJavDb", javBusBtn: "siteSearchJavBus", fanzaBtn: "siteSearchFanza" }; // 每个按钮对应的「域名」设置键(用于拼接相对搜索路径时取原始域名, 避免 getBaseUrl 附加的额外后缀如 123Av 的 /ja) const SITE_DOMAIN_KEY_MAP = { javTrailersBtn: "javTrailersUrl", "123AvBtn": "av123Url", jableBtn: "jableUrl", missAvBtn: "missAvUrl", supJavBtn: "supJavUrl", javDbBtn: "javDbUrl", javBusBtn: "javBusUrl", fanzaBtn: "fanzaUrl" }; function jhsGetHiddenSites() { try { return JSON.parse(localStorage.getItem("jhs_hidden_sites") || "[]"); } catch (e) { return []; } } function jhsIsSiteHidden(key) { return jhsGetHiddenSites().includes(key); } function jhsSetSiteHidden(key, hidden) { let arr = jhsGetHiddenSites(); hidden ? (arr.includes(key) || arr.push(key)) : (arr = arr.filter(k => k !== key)); localStorage.setItem("jhs_hidden_sites", JSON.stringify(arr)); } // 封面按钮显示管理: 控制哪些站点出现在封面快捷按钮中(默认 jable, missAv, av123) const JHS_COVER_DEFAULTS = ["jable", "missAv", "av123"]; function jhsGetCoverSites() { try { const v = localStorage.getItem("jhs_cover_sites"); return v ? JSON.parse(v) : JHS_COVER_DEFAULTS.slice(); } catch (e) { return JHS_COVER_DEFAULTS.slice(); } } function jhsSetCoverSite(key, enabled) { let arr = jhsGetCoverSites(); enabled ? (arr.includes(key) || arr.push(key)) : (arr = arr.filter(k => k !== key)); localStorage.setItem("jhs_cover_sites", JSON.stringify(arr)); } // 已浏览 番号 记录: 用户点击进入详情页后, 番号记入此集合, 列表页显示"已浏览"标签 const JHS_VISITED_KEY = "jhs_visited_carnums"; const JHS_VISITED_MAX = 5000; function jhsGetVisitedCarNums() { try { return JSON.parse(localStorage.getItem(JHS_VISITED_KEY) || "[]"); } catch (e) { return []; } } function jhsGetVisitedCarNumSet() { return new Set(jhsGetVisitedCarNums()); } function jhsIsCarNumVisited(carNum) { if (!carNum) return false; return jhsGetVisitedCarNums().includes(carNum); } function jhsAddVisitedCarNum(carNum) { if (!carNum) return; let arr = jhsGetVisitedCarNums(); const idx = arr.indexOf(carNum); if (idx !== -1) arr.splice(idx, 1); arr.push(carNum); if (arr.length > JHS_VISITED_MAX) arr = arr.slice(arr.length - JHS_VISITED_MAX); try { localStorage.setItem(JHS_VISITED_KEY, JSON.stringify(arr)); } catch (e) { /* quota */ } } // 封面按钮的 CSS class 后缀映射 const COVER_SITE_CLASS = { missAv: "site-miss-av", jable: "site-jable", av123: "site-123-av", javTrailer: "site-javtrailer", supJav: "site-supjav", javDb: "site-javdb", javBus: "site-javbus", fanza: "site-fanza" }; // 封面按钮对应的搜索地址设置键(与 SITE_SEARCH_KEY_MAP 同键名) const COVER_SEARCH_KEY = { missAv: "siteSearchMissAv", jable: "siteSearchJable", av123: "siteSearchAv123", javTrailer: "siteSearchJavTrailer", supJav: "siteSearchSupJav", javDb: "siteSearchJavDb", javBus: "siteSearchJavBus", fanza: "siteSearchFanza" }; const COVER_DOMAIN_KEY = { missAv: "missAvUrl", jable: "jableUrl", av123: "av123Url", javTrailer: "javTrailersUrl", supJav: "supJavUrl", javDb: "javDbUrl", javBus: "javBusUrl", fanza: "fanzaUrl" }; const COVER_DEFAULT_SEARCH = { missAv: "/search/", jable: "/search/", av123: "/ja/search?keyword=", javTrailer: "/search/", supJav: "/?s=", javDb: "/search?q=", javBus: "/", fanza: "/search/=/searchstr=" }; const COVER_DEFAULT_DOMAIN = { missAv: "https://missav.live", jable: "https://jable.tv", av123: "https://123av.com", javTrailer: "https://javtrailers.com", supJav: "https://supjav.com", javDb: "https://javdb.com", javBus: "https://www.javbus.com", fanza: "https://www.dmm.co.jp" }; const COVER_ALL_KEYS = ["missAv", "jable", "av123", "javTrailer", "supJav", "javDb", "javBus", "fanza"]; const COVER_DEFAULT_NAME = { missAv: "MissAv", jable: "Jable", av123: "123Av", javTrailer: "JavTrailer", supJav: "SupJav", javDb: "JavDb", javBus: "JavBus", fanza: "Fanza" }; const COVER_NAME_KEY = { missAv: "siteNameMissAv", jable: "siteNameJable", av123: "siteNameAv123", javTrailer: "siteNameJavTrailer", supJav: "siteNameSupJav", javDb: "siteNameJavDb", javBus: "siteNameJavBus", fanza: "siteNameFanza" }; /** 依据设置计算某站点的封面按钮跳转 URL(与番号页按钮同一套自定义搜索地址逻辑) */ function jhsBuildCoverUrl(siteKey, carNum, setting) { const search = String((setting && setting[COVER_SEARCH_KEY[siteKey]]) || COVER_DEFAULT_SEARCH[siteKey] || "/").trim(); if (/^https?:\/\//i.test(search)) return `${search}${carNum}`; let base = String((setting && setting[COVER_DOMAIN_KEY[siteKey]]) || "").trim(); if (!base) base = COVER_DEFAULT_DOMAIN[siteKey] || ""; base = base.replace(/\/+$/, ""); const sep = search.startsWith("/") ? "" : "/"; return `${base}${sep}${search}${carNum}`; } async function loadSettingForm(getBean) { let e = await storageManager.getSetting(); $("#reviewCount").val(e.reviewCount || 20), $("#reviewSortMode").val(e.reviewSortMode || "time"), $("#magnetSortMode").val(e[NMP_SORT_KEY] || "size"), $("#enableReviewMagnetPreview").prop("checked", !e.enableReviewMagnetPreview || e.enableReviewMagnetPreview === _), $("#tagPosition").val(e.tagPosition || "rightTop"), $("#waitCheckCount").val(e.waitCheckCount || 5), $("#movieShowType").val(e.movieShowType || "normal"), $("#enableQuickFilter").prop("checked", !e.enableQuickFilter || e.enableQuickFilter === _), $("#showWaitCheckBtn").prop("checked", !e.showWaitCheckBtn || e.showWaitCheckBtn === _), $("#showWaitDownBtn").prop("checked", !e.showWaitDownBtn || e.showWaitDownBtn === _), $("#randomOpenWaitDown").prop("checked", !!e.randomOpenWaitDown && e.randomOpenWaitDown === _), // 鉴定配置面板 - 列表显示(镜像自快捷设置, 两处 ID 用 2 后缀区分) $("#showAllItem2").prop("checked", !!e.showAllItem && e.showAllItem === _), $("#showFilterItem2").prop("checked", !!e.showFilterItem && e.showFilterItem === _), $("#showFilterActorItem2").prop("checked", !!e.showFilterActorItem && e.showFilterActorItem === _), $("#showFilterKeywordItem2").prop("checked", !!e.showFilterKeywordItem && e.showFilterKeywordItem === _), $("#showFavoriteItem2").prop("checked", !e.showFavoriteItem || e.showFavoriteItem === _), $("#showHasDownItem2").prop("checked", !e.showHasDownItem || e.showHasDownItem === _), $("#showHasWatchItem2").prop("checked", !e.showHasWatchItem || e.showHasWatchItem === _), // 双向即时保存 - 主开关联动禁用状态 (() => { const $subs = $("#showFilterItem2, #showFilterActorItem2, #showFilterKeywordItem2, #showFavoriteItem2, #showHasDownItem2, #showHasWatchItem2"); const applyDisable = () => { const on = $("#showAllItem2").is(":checked"); $subs.prop("disabled", on); on ? $subs.attr("data-tip", "请先关闭显示所有才可点击") : $subs.removeAttr("data-tip"); }; applyDisable(); $("#showAllItem2").off("change.appraisal").on("change.appraisal", (async () => { await storageManager.saveSettingItem("showAllItem", $("#showAllItem2").is(":checked") ? _ : C); applyDisable(); window.refresh(); })); const bind = (id, key) => $(id).off("change.appraisal").on("change.appraisal", (async () => { await storageManager.saveSettingItem(key, $(id).is(":checked") ? _ : C); window.refresh(); })); bind("#showFilterItem2", "showFilterItem"); bind("#showFilterActorItem2", "showFilterActorItem"); bind("#showFilterKeywordItem2", "showFilterKeywordItem"); bind("#showFavoriteItem2", "showFavoriteItem"); bind("#showHasDownItem2", "showHasDownItem"); bind("#showHasWatchItem2", "showHasWatchItem"); })(); $("#checkConcurrencyCount").val(e.checkConcurrencyCount || 2), $("#checkRequestSleep").val(e.checkRequestSleep || 100), $("#enableCheckBlacklist").val(e.enableCheckBlacklist || _), $("#checkBlacklist_intervalTime").val(e.checkBlacklist_intervalTime || 12), $("#checkBlacklist_ruleTime").val(e.checkBlacklist_ruleTime || 8760), $("#enableCheckFavoriteActress").val(e.enableCheckFavoriteActress || _), $("#checkFavoriteActress_IntervalTime").val(e.checkFavoriteActress_IntervalTime || 24), $("#enableCheckNewVideo").val(e.enableCheckNewVideo || _), $("#checkNewVideo_intervalTime").val(e.checkNewVideo_intervalTime || 12), $("#checkNewVideo_ruleTime").val(e.checkNewVideo_ruleTime || 8760); const t = e.highlightedTagNumber || 1, n = e.highlightedTagColor || "#ce2222"; $("#highlightedTagNumber").val(e.highlightedTagNumber || 1), $("#highlightedTagColor").val(e.highlightedTagColor || "#ce2222"), $("#highlightedTagLabel").css("border", `${t}px solid ${n}`), $("#enableClog").val(e.enableClog || _), $("#clogMsgCount").val(e.clogMsgCount || 2e3), $("#mobileMode").val(e.mobileMode || "auto"), $("#httpTimeout").val(e.httpTimeout || 5e3), $("#httpRetryCount").val(e.httpRetryCount || 3), $("#webDavUrl").val(e.webDavUrl || ""), $("#webDavUsername").val(e.webDavUsername || ""), $("#webDavPassword").val(await decryptCredential(e.webDavPassword) || ""), $("#enableTitleSelectFilter").prop("checked", !e.enableTitleSelectFilter || e.enableTitleSelectFilter === _), $("#enableFavoriteActresses").prop("checked", !e.enableFavoriteActresses || e.enableFavoriteActresses === _), $("#enableSaveActressCarInfo").prop("checked", !!e.enableSaveActressCarInfo && e.enableSaveActressCarInfo === _), $("#enableThumbSourceSwitch").prop("checked", !!e.enableThumbSourceSwitch && e.enableThumbSourceSwitch === _), $("#enableCleanCover").prop("checked", void 0 === e.enableCleanCover ? !0 : e.enableCleanCover === _), $("#enableIconSubtitle").prop("checked", void 0 === e.enableIconSubtitle ? !0 : e.enableIconSubtitle === _), $("#enableIconTrailer").prop("checked", void 0 === e.enableIconTrailer ? !0 : e.enableIconTrailer === _), $("#enableIconPreview").prop("checked", void 0 === e.enableIconPreview ? !0 : e.enableIconPreview === _), $("#enableIconHandle").prop("checked", void 0 === e.enableIconHandle ? !0 : e.enableIconHandle === _), $("#enableIconPoster").prop("checked", void 0 === e.enableIconPoster ? !0 : e.enableIconPoster === _), $("#enableIconSite").prop("checked", void 0 === e.enableIconSite ? !0 : e.enableIconSite === _), // 预览视频组(移入个性化设置): 播放器增强(默认开) / DMM 多画质源(默认关) / 默认画质 $("#enableLoadPreviewVideo").prop("checked", void 0 === e.enableLoadPreviewVideo ? !0 : e.enableLoadPreviewVideo === _), $("#enableLoadOtherSite").prop("checked", e.enableLoadOtherSite === _), $("#videoQuality").val(e.videoQuality || "mhb"), $("#enableLoadOtherSite").off("change.previewVideo").on("change.previewVideo", syncPreviewVideoRows), syncPreviewVideoRows(), $("#enableBlurMode").prop("checked", !!e.enableBlurMode && e.enableBlurMode === _), $("#blurImage").prop("checked", void 0 === e.blurImage ? !0 : e.blurImage === _), $("#blurTitle").prop("checked", void 0 === e.blurTitle ? !0 : e.blurTitle === _), $("#blurHoverReveal").prop("checked", void 0 === e.blurHoverReveal ? !0 : e.blurHoverReveal === _), $("#blurLevel").val(e.blurLevel || 50), $("#enableScreenSvg").prop("checked", !e.enableScreenSvg || e.enableScreenSvg === _), $("#enableVideoSvg").prop("checked", !e.enableVideoSvg || e.enableVideoSvg === _), $("#enableHandleSvg").prop("checked", !e.enableHandleSvg || e.enableHandleSvg === _), $("#enableSiteSvg").prop("checked", !e.enableSiteSvg || e.enableSiteSvg === _), $("#enableCopySvg").prop("checked", !e.enableCopySvg || e.enableCopySvg === _); const a = getBean("OtherSitePlugin"), i = await a.getMissAvUrl(), s = await a.getjableUrl(), r = await a.getJavTrailersUrl(), l = await a.getAv123Url(), c = await a.getJavDbUrl(), d = await a.getJavBusUrl(), h = await a.getSupJavUrl(); $("#missAvUrl").val(i), $("#jableUrl").val(s), $("#javTrailersUrl").val(r), $("#av123Url").val(l), $("#javDbUrl").val(c), $("#javBusUrl").val(d), $("#supJavUrl").val(h); // 外部网站显示名称回填 const setting = await storageManager.getSetting(); $("#siteNameMissAv").val(setting.siteNameMissAv || "MissAv"); $("#siteNameJable").val(setting.siteNameJable || "Jable"); $("#siteNameJavTrailer").val(setting.siteNameJavTrailer || "JavTrailer"); $("#siteNameAv123").val(setting.siteNameAv123 || "123Av"); $("#siteNameJavDb").val(setting.siteNameJavDb || "JavDb"); $("#siteNameJavBus").val(setting.siteNameJavBus || "JavBus"); $("#siteNameSupJav").val(setting.siteNameSupJav || "SupJav"); $("#siteNameFanza").val(setting.siteNameFanza || "Fanza"); $("#fanzaUrl").val(setting.fanzaUrl || "https://www.dmm.co.jp"); // 外部网站「搜索地址」回填(留空则使用内置默认 searchPath; // 为方便用户修改, 若用户未自定义则展示内置默认前缀作为当前值) $("#siteSearchMissAv").val(setting.siteSearchMissAv || "/search/"); $("#siteSearchJable").val(setting.siteSearchJable || "/search/"); $("#siteSearchJavTrailer").val(setting.siteSearchJavTrailer || "/search/"); $("#siteSearchAv123").val(setting.siteSearchAv123 || "/ja/search?keyword="); $("#siteSearchJavDb").val(setting.siteSearchJavDb || "/search?q="); $("#siteSearchJavBus").val(setting.siteSearchJavBus || "/"); $("#siteSearchSupJav").val(setting.siteSearchSupJav || "/?s="); $("#siteSearchFanza").val(setting.siteSearchFanza || "/search/=/searchstr="); // 隐藏/显示 开关(mini-switch 样式 checkbox): 初始化 + 绑定 change (即时生效) // 勾选=显示, 取消勾选=隐藏 (() => { const hidden = jhsGetHiddenSites(); $("#domain-panel .jhs-site-toggle").each((function() { const key = this.dataset.siteKey; this.checked = !hidden.includes(key); })); $("#domain-panel").off("change.jhsSiteToggle").on("change.jhsSiteToggle", ".jhs-site-toggle", (function() { const key = this.dataset.siteKey; const nowHidden = !this.checked; jhsSetSiteHidden(key, nowHidden); // 立即同步当前页已渲染的对应按钮 const map = JHS_SITE_BTN_MAP[key]; if (map) { if (map.detail) $(`#${map.detail}`).toggle(!nowHidden); if (map.cover) $(`.${map.cover}`).toggle(!nowHidden); } })); // 名称和域名即时保存(不需要点保存按钮) $("#domain-panel input[id^='siteName']").off("change.jhsSiteName").on("change.jhsSiteName", (async function() { const id = this.id; // e.g. siteNameMissAv await storageManager.saveSettingItem(id, ($(this).val() || "").trim()); })); $("#domain-panel input[id$='Url']").off("change.jhsSiteUrl").on("change.jhsSiteUrl", (async function() { const id = this.id; // e.g. missAvUrl await storageManager.saveSettingItem(id, ($(this).val() || "").trim().replace(/\/$/, "")); })); // 搜索地址即时保存 $("#domain-panel input[id^='siteSearch']").off("change.jhsSiteSearch").on("change.jhsSiteSearch", (async function() { const id = this.id; // e.g. siteSearchJavDb await storageManager.saveSettingItem(id, ($(this).val() || "").trim()); })); // 「封面显示」开关: 控制封面快捷按钮是否展示该站点 const coverSites = jhsGetCoverSites(); $("#domain-panel .jhs-cover-toggle").each((function() { const key = this.dataset.siteKey; this.checked = coverSites.includes(key); })); $("#domain-panel").off("change.jhsCoverToggle").on("change.jhsCoverToggle", ".jhs-cover-toggle", (function() { const key = this.dataset.siteKey; jhsSetCoverSite(key, this.checked); // 立即同步当前页已渲染的封面按钮 const cls = COVER_SITE_CLASS[key]; if (cls) $(`.${cls}`).toggle(this.checked); })); })(); let g = await storageManager.getReviewFilterKeywordList(), p = await storageManager.getTitleFilterKeyword(); g && g.forEach((e => { addLabelTag("#reviewKeywordContainer", e); })), p && p.forEach((e => { addLabelTag("#filterKeywordContainer", e); })), [ "#reviewKeywordContainer", "#filterKeywordContainer" ].forEach((e => { $(`${e} .add-tag-btn`).on("click", (t => addKeyword(t, e))), $(`${e} .keyword-input`).on("keypress", (t => { "Enter" === t.key && addKeyword(t, e); })); })); // 快捷键面板初始化 initHotkeyPanel(e); // ===== 115 增强功能开关初始化 ===== // 重要: #115-panel 由主设置弹窗渲染(本函数 loadSettingForm), 但原代码只在 hover 快捷面板的 // initSimpleSettingForm 里初始化这些开关 —— 而 hover 面板(buildSimpleSettingHtml)里根本没有 115 开关元素, // 导致主弹窗里 115 开关永远不回显存储值、也不绑定 change 事件, 表现为「勾选/保存无效、重进变回关」。 // 这里必须初始化并即时保存, 与 saveSettingForm 的字段收集互为兜底。 $("#enable115Match").prop("checked", void 0 === e.enable115Match ? !0 : e.enable115Match === _), $("#enable115Match").on("change", (async t => { const n = $("#enable115Match").is(":checked") ? _ : C; await persistSetting("enable115Match", n); })), $("#enable115AutoRename").prop("checked", void 0 === e.enable115AutoRename ? !0 : e.enable115AutoRename === _), $("#enable115AutoRename").on("change", (async t => { const n = $("#enable115AutoRename").is(":checked") ? _ : C; await persistSetting("enable115AutoRename", n), $("#autoRenameScopeRow").toggle(n === _); })), $("#autoRenameScopeRow").toggle(e.enable115AutoRename === _), $("#autoRenameScope").val(e.autoRenameScope || "vr_only"), $("#autoRenameScope").on("change", (async t => { await persistSetting("autoRenameScope", $("#autoRenameScope").val()); })); } /** 快捷键面板: 加载已保存的值, 绑定 keydown 录入 */ function initHotkeyPanel(settingObj) { $("#hotkey-panel input[id]").each(((idx, el) => { const $el = $(el), id = el.id; if ("enableImageHotKey" === id) { $el.prop("checked", !!settingObj.enableImageHotKey && settingObj.enableImageHotKey === _); return; } const defaultVal = void 0 !== settingObj[id] ? settingObj[id] : $el.attr("data-default-hotkey") || ""; $el.val(defaultVal).on("input", (event => { let value = $(event.target).val(); if (/[\u4e00-\u9fa5]/.test(value) || /^Shift[a-zA-Z0-9]+$/.test(value)) { $(event.target).val(""); show.error("非法输入:不能输入中文或输入法转换错误"); } })).on("keydown", (event => { event.preventDefault(); const hotkey = parseHotkeyInput(event); if ("" === hotkey) { $el.val(""); return; } if (isDuplicateHotkey(hotkey, id)) { show.error("该快捷键已被其他功能使用!"); return; } $el.val(hotkey); })); })); } function parseHotkeyInput(event) { if ("Backspace" === event.key || "Process" === event.key) return ""; const keys = []; event.ctrlKey && keys.push("Ctrl"); event.shiftKey && keys.push("Shift"); event.altKey && keys.push("Alt"); event.metaKey && keys.push("Cmd"); const key = { " ": "Space", Control: "Ctrl", Meta: "Cmd", ArrowUp: "Up", ArrowDown: "Down", ArrowLeft: "Left", ArrowRight: "Right" }[event.key] || (event.key.length > 1 ? event.key.replace("Arrow", "") : event.key); [ "Control", "Shift", "Alt", "Meta" ].includes(event.key) || keys.push(key); return keys.length > 0 ? keys.join("+") : ""; } function isDuplicateHotkey(hotkey, currentInputId) { let dup = !1; $("#hotkey-panel input[id]").each(((i, el) => { if (el.id !== currentInputId && hotkey && hotkey === $(el).val()) { dup = !0; return !1; } })); return dup; } /** Initialize the hover quick-settings form with current values and bind event handlers. */ async function initSimpleSettingForm(getBean, getSelector, openSettingDialogFn) { let e = await storageManager.getSetting(); $("#containerColumns").val(e.containerColumns || 5), $("#showContainerColumns").text(e.containerColumns || 5), $("#containerWidth").val((e.containerWidth || 100) - 70), $("#showContainerWidth").text((e.containerWidth || 100) + "%"), // 详情比例: 封面 + 信息 (() => { const coverVal = e.detailCoverRatio || 135, infoVal = e.detailInfoRatio || 105; $("#detailCoverRatio").val(coverVal), $("#showDetailCoverRatio").text((coverVal / 100).toFixed(2).replace(/\.?0+$/, "")); $("#detailInfoRatio").val(infoVal), $("#showDetailInfoRatio").text((infoVal / 100).toFixed(2).replace(/\.?0+$/, "")); $("#detailCoverRatio").on("input", (async () => { const v = parseInt($("#detailCoverRatio").val()) || 135; $("#showDetailCoverRatio").text((v / 100).toFixed(2).replace(/\.?0+$/, "")); await storageManager.saveSettingItem("detailCoverRatio", v); applyDetailFlex(); })); $("#detailInfoRatio").on("input", (async () => { const v = parseInt($("#detailInfoRatio").val()) || 105; $("#showDetailInfoRatio").text((v / 100).toFixed(2).replace(/\.?0+$/, "")); await storageManager.saveSettingItem("detailInfoRatio", v); applyDetailFlex(); })); })(), $("#needClosePage").prop("checked", !e.needClosePage || e.needClosePage === _), $("#autoPage").prop("checked", !e.autoPage || e.autoPage === _), $("#translateTitle").prop("checked", !e.translateTitle || e.translateTitle === _), $("#dialogOpenDetail").prop("checked", !e.dialogOpenDetail || e.dialogOpenDetail === _), $("#dialogOpenDetail").on("change", (async t => { const n = $("#dialogOpenDetail").is(":checked") ? _ : C; await storageManager.saveSettingItem("dialogOpenDetail", n); })), $("#enableLoadActressInfo").prop("checked", !e.enableLoadActressInfo || e.enableLoadActressInfo === _), $("#containerColumns").on("input", (async t => { let n = $("#containerColumns").val(); $("#showContainerColumns").text(n); // 详情页没有 .movie-list / .masonry, querySelector 返回 null, 直接取 .style 会抛异常, // 连后面的 saveSettingItem 都跑不到 => 拖了滑块既不生效也没保存。改用 forEach, 元素不存在就跳过。 const grid = sel => document.querySelectorAll(sel).forEach((el => el.style.gridTemplateColumns = `repeat(${n}, minmax(0, 1fr))`)); if (r) grid(".movie-list"); if (l) grid(".masonry"); await storageManager.saveSettingItem("containerColumns", n), applyImageMode(); })), $("#containerWidth").on("input", (async t => { let n = parseInt($(t.target).val()); const a = n + 70 + "%"; $("#showContainerWidth").text(a); // 同上: 详情页取不到对应容器时不能让它抛异常。 // JavBus 详情页内容在 .container 里(不是 .container-fluid .row), 所以两个都要跟着改。 const live = sel => document.querySelectorAll(sel).forEach((el => el.style.minWidth = a)); if (r) live("section .container"); if (l) live(".container-fluid .row"), live(".container"); storageManager.saveSettingItem("containerWidth", n + 70); })), $("#needClosePage").on("change", (async t => { await storageManager.saveSettingItem("needClosePage", $("#needClosePage").is(":checked") ? _ : C), window.refresh(); })), $("#autoPage").on("change", (async t => { const n = $("#autoPage").is(":checked") ? _ : C; await storageManager.saveSettingItem("autoPage", n); })), $("#translateTitle").on("change", (async t => { const n = $("#translateTitle").is(":checked") ? _ : C; await storageManager.saveSettingItem("translateTitle", n), n === _ ? (await getBean("ListPagePlugin").doFilter(), isDetailPage && await getBean("TranslatePlugin").translate()) : (await getBean("ListPagePlugin").revertTranslation(), $(".translated-title").remove()); })), $("#hoverBigImg").prop("checked", !!e.hoverBigImg && e.hoverBigImg === _), $("#hoverBigImg").on("change", (async t => { const n = $("#hoverBigImg").is(":checked") ? _ : C; await storageManager.saveSettingItem("hoverBigImg", n), n === _ ? window.imageHoverPreviewObj ? window.imageHoverPreviewObj.bindEvents() : window.imageHoverPreviewObj = new ImageHoverPreview({ selector: getSelector().coverImgSelector }) : window.imageHoverPreviewObj && (window.imageHoverPreviewObj.destroy(), window.imageHoverPreviewObj = null); })), $("#enableLoadActressInfo").on("change", (async t => { const n = $("#enableLoadActressInfo").is(":checked") ? _ : C; await storageManager.saveSettingItem("enableLoadActressInfo", n), n === _ ? getBean("ActressInfoPlugin").loadActressInfo() : $(".actress-info").remove(); })), $("#enableLoadScreenShot").prop("checked", !e.enableLoadScreenShot || e.enableLoadScreenShot === _), $("#enableLoadScreenShot").on("change", (async t => { const n = $("#enableLoadScreenShot").is(":checked") ? _ : C; await storageManager.saveSettingItem("enableLoadScreenShot", n), n === _ ? getBean("ScreenShotPlugin").loadScreenShot().then() : $(".screen-container").remove(); })), $("#enable115Match").prop("checked", void 0 === e.enable115Match ? !0 : e.enable115Match === _), $("#enable115Match").on("change", (async t => { const n = $("#enable115Match").is(":checked") ? _ : C; await persistSetting("enable115Match", n); let a = $(getSelector().itemSelector).toArray(); await getBean("WangPan115MatchPlugin").matchMovieList(a); })), $("#enable115AutoRename").prop("checked", void 0 === e.enable115AutoRename ? !0 : e.enable115AutoRename === _), $("#enable115AutoRename").on("change", (async t => { const n = $("#enable115AutoRename").is(":checked") ? _ : C; await persistSetting("enable115AutoRename", n), $("#autoRenameScopeRow").toggle(n === _); })), $("#autoRenameScope").val(e.autoRenameScope || "vr_only"), $("#autoRenameScope").on("change", (async t => { await persistSetting("autoRenameScope", $("#autoRenameScope").val()); })), $("#enableVerticalModel").prop("checked", !!e.enableVerticalModel && e.enableVerticalModel === _), $("#enableVerticalModel").on("change", (async t => { const n = $("#enableVerticalModel").is(":checked") ? _ : C; await storageManager.saveSettingItem("enableVerticalModel", n), applyImageMode(); })), $("#moreBtn").on("click", (() => { $(".simple-setting").html("").hide(), openSettingDialogFn("base-panel"); })), $("#helpBtn").on("click", (() => { layer.open({ type: 1, title: "", shadeClose: !0, scrollbar: !1, content: '\n<style>\n .help-container {\n font-family: \'Segoe UI\', Tahoma, Geneva, Verdana, sans-serif;\n color: #333;\n padding: 15px;\n max-height: 100%;\n overflow-y: auto;\n }\n \n .help-section {\n margin-bottom: 25px;\n }\n \n .help-section summary {\n font-size: 18px;\n color: #3498db;\n margin-bottom: 12px;\n cursor: pointer;\n }\n \n .help-content {\n background-color: #f9f9f9;\n border-radius: 5px;\n padding: 15px;\n border-left: 4px solid #3498db;\n }\n \n .help-content p {\n line-height: 1.6;\n margin-bottom: 10px;\n }\n .help-section img {\n max-width: 100%;\n height: auto;\n border: 1px solid #ddd;\n border-radius: 4px;\n box-shadow: 0 2px 4px rgba(0,0,0,0.1);\n }\n\n</style>\n\n<div class="help-container">\n <h1 style="font-size: 22px; margin-bottom: 20px; color: #2c3e50; border-bottom: 1px solid #eee; padding-bottom: 10px;">使用说明</h1>\n \n <details class="help-section">\n <summary>1. 无法查看预览视频,提示分流?</summary>\n <div class="help-content">\n <p>JavDB限制日本IP的访问,而预览视频来自DMM,需要日本IP才能访问。</p>\n <p>这样会导致二者无法同时使用,需要对其一进行代理转发。</p>\n <p>将 cc3001.dmm.co.jp 及 dmm.co 分流到日本ip。</p>\n <p><a href="https://youtu.be/wQUK8z_YeU4?t=121" target="_blank">Clash Verge分流规则设置 </a> (如果你是别的代理软件,自行搜索如何分流)</p>\n </div>\n </details>\n \n <details class="help-section">\n <summary>2. 如何屏蔽某一系列的番号?</summary>\n <div class="help-content">\n <p>方法一:设置中-添加视频标题关键词,如: VENX-</p>\n <p>方法二:进入详情页,选中标题文字,右键可加入</p>\n <img src="https://i.imgur.com/lVnhK5A.png" alt="进入详情页,选中标题,进行右键"/>\n </div>\n </details>\n\n <details class="help-section">\n <summary>3. 屏蔽某演员,如何只屏蔽单体影片?</summary>\n <div class="help-content">\n <p>屏蔽演员前,先筛选分类,再点屏蔽</p>\n <img src="https://imgur.com/Ue7eCAi.png" alt="屏蔽演员前,先筛选分类,再点屏蔽"/>\n </div>\n </details>\n \n \n</div>\n', area: utils.getResponsiveArea([ "50%", "90%" ]) }); })); } /** Read all form values and save to storage. */ async function saveSettingForm(getBean) { let e = await storageManager.getSetting(); e.reviewCount = $("#reviewCount").val(), e.reviewSortMode = $("#reviewSortMode").val(), e[NMP_SORT_KEY] = $("#magnetSortMode").val(), e.enableReviewMagnetPreview = $("#enableReviewMagnetPreview").is(":checked") ? _ : C, e.tagPosition = $("#tagPosition").val(), e.waitCheckCount = $("#waitCheckCount").val(), e.highlightedTagNumber = $("#highlightedTagNumber").val(), e.movieShowType = $("#movieShowType").val(), e.enableQuickFilter = $("#enableQuickFilter").is(":checked") ? _ : C, e.showWaitCheckBtn = $("#showWaitCheckBtn").is(":checked") ? _ : C, e.showWaitDownBtn = $("#showWaitDownBtn").is(":checked") ? _ : C, e.randomOpenWaitDown = $("#randomOpenWaitDown").is(":checked") ? _ : C, e.highlightedTagColor = $("#highlightedTagColor").val(), e.checkConcurrencyCount = $("#checkConcurrencyCount").val(), e.checkRequestSleep = $("#checkRequestSleep").val(), e.enableCheckBlacklist = $("#enableCheckBlacklist").val(), e.checkBlacklist_intervalTime = $("#checkBlacklist_intervalTime").val(), e.checkBlacklist_ruleTime = $("#checkBlacklist_ruleTime").val(), e.enableCheckFavoriteActress = $("#enableCheckFavoriteActress").val(), e.checkFavoriteActress_IntervalTime = $("#checkFavoriteActress_IntervalTime").val(), e.enableCheckNewVideo = $("#enableCheckNewVideo").val(), e.checkNewVideo_intervalTime = $("#checkNewVideo_intervalTime").val(), e.checkNewVideo_ruleTime = $("#checkNewVideo_ruleTime").val(), e.httpTimeout = Number($("#httpTimeout").val()) || 5e3, e.httpRetryCount = Number($("#httpRetryCount").val()) || 3, e.circuitBreakerThreshold = Number($("#circuitBreakerThreshold").val()) || 3, e.circuitBreakerCooldown = Number($("#circuitBreakerCooldownSec").val()) * 1e3, e.enableClog = $("#enableClog").val(), e.enableClog === _ ? clog.show() : clog.hide(), e.clogMsgCount = $("#clogMsgCount").val(), e.mobileMode = $("#mobileMode").val(), e.webDavUrl = $("#webDavUrl").val(), e.webDavUsername = $("#webDavUsername").val(), e.webDavPassword = await (async () => { try { return await encryptCredential($("#webDavPassword").val()); } catch (er) { return clog.error("WebDav 密码加密失败, 跳过加密", er), $("#webDavPassword").val(); } })(), e.missAvUrl = $("#missAvUrl").val().replace(/\/$/, ""), e.jableUrl = $("#jableUrl").val().replace(/\/$/, ""), e.javTrailersUrl = $("#javTrailersUrl").val().replace(/\/$/, ""), e.av123Url = $("#av123Url").val().replace(/\/$/, ""), e.javDbUrl = $("#javDbUrl").val().replace(/\/$/, ""), e.javBusUrl = $("#javBusUrl").val().replace(/\/$/, ""), e.supJavUrl = $("#supJavUrl").val().replace(/\/$/, ""), e.siteNameMissAv = ($("#siteNameMissAv").val() || "MissAv").trim(), e.siteNameJable = ($("#siteNameJable").val() || "Jable").trim(), e.siteNameJavTrailer = ($("#siteNameJavTrailer").val() || "JavTrailer").trim(), e.siteNameAv123 = ($("#siteNameAv123").val() || "123Av").trim(), e.siteNameJavDb = ($("#siteNameJavDb").val() || "JavDb").trim(), e.siteNameJavBus = ($("#siteNameJavBus").val() || "JavBus").trim(), e.siteNameSupJav = ($("#siteNameSupJav").val() || "SupJav").trim(), e.siteNameFanza = ($("#siteNameFanza").val() || "Fanza").trim(), e.fanzaUrl = ($("#fanzaUrl").val() || "").trim().replace(/\/$/, ""), e.siteSearchMissAv = ($("#siteSearchMissAv").val() || "").trim(), e.siteSearchJable = ($("#siteSearchJable").val() || "").trim(), e.siteSearchJavTrailer = ($("#siteSearchJavTrailer").val() || "").trim(), e.siteSearchAv123 = ($("#siteSearchAv123").val() || "").trim(), e.siteSearchJavDb = ($("#siteSearchJavDb").val() || "").trim(), e.siteSearchJavBus = ($("#siteSearchJavBus").val() || "").trim(), e.siteSearchSupJav = ($("#siteSearchSupJav").val() || "").trim(), e.siteSearchFanza = ($("#siteSearchFanza").val() || "").trim(), e.enableTitleSelectFilter = $("#enableTitleSelectFilter").is(":checked") ? _ : C, e.enableFavoriteActresses = $("#enableFavoriteActresses").is(":checked") ? _ : C, e.enableSaveActressCarInfo = $("#enableSaveActressCarInfo").is(":checked") ? _ : C, e.enableThumbSourceSwitch = $("#enableThumbSourceSwitch").is(":checked") ? _ : C, e.enableCleanCover = $("#enableCleanCover").is(":checked") ? _ : C, e.enableIconSubtitle = $("#enableIconSubtitle").is(":checked") ? _ : C, e.enableIconTrailer = $("#enableIconTrailer").is(":checked") ? _ : C, e.enableIconPreview = $("#enableIconPreview").is(":checked") ? _ : C, e.enableIconHandle = $("#enableIconHandle").is(":checked") ? _ : C, e.enableIconPoster = $("#enableIconPoster").is(":checked") ? _ : C, e.enableIconSite = $("#enableIconSite").is(":checked") ? _ : C, e.enableLoadPreviewVideo = $("#enableLoadPreviewVideo").is(":checked") ? _ : C, e.enableLoadOtherSite = $("#enableLoadOtherSite").is(":checked") ? _ : C, e.videoQuality = $("#videoQuality").val() || e.videoQuality || "mhb", e.enableBlurMode = $("#enableBlurMode").is(":checked") ? _ : C, e.blurImage = $("#blurImage").is(":checked") ? _ : C, e.blurTitle = $("#blurTitle").is(":checked") ? _ : C, e.blurHoverReveal = $("#blurHoverReveal").is(":checked") ? _ : C, e.blurLevel = parseInt($("#blurLevel").val()) || 50, e.enableScreenSvg = $("#enableScreenSvg").is(":checked") ? _ : C, e.enableVideoSvg = $("#enableVideoSvg").is(":checked") ? _ : C, e.enableHandleSvg = $("#enableHandleSvg").is(":checked") ? _ : C, e.enableSiteSvg = $("#enableSiteSvg").is(":checked") ? _ : C, e.enableCopySvg = $("#enableCopySvg").is(":checked") ? _ : C; // 115 增强功能开关(从动态注入的 115-panel 收集; 否则覆盖式 saveSetting 会抹掉 change 事件单独存的值) e.enable115Match = $("#enable115Match").is(":checked") ? _ : C; e.enable115AutoRename = $("#enable115AutoRename").is(":checked") ? _ : C; e.autoRenameScope = $("#autoRenameScope").val() || "vr_only"; // 保存快捷键配置 $("#hotkey-panel input[id]").each(((idx, el) => { if ("enableImageHotKey" === el.id) { e.enableImageHotKey = $(el).is(":checked") ? _ : C; } else { e[el.id] = $(el).val(); } })); await storageManager.saveSetting(e); let t = []; $("#reviewKeywordContainer .keyword-label").toArray().forEach((e => { let n = $(e).text().replace("×", "").replace(/[\r\n]+/g, " ").replace(/\s{2,}/g, " ").trim(); t.push(n); })), await storageManager.saveReviewFilterKeyword(t); let n = []; $("#filterKeywordContainer .keyword-label").toArray().forEach((e => { let t = $(e).text().replace("×", "").replace(/[\r\n]+/g, " ").replace(/\s{2,}/g, " ").trim(); n.push(t); })), await storageManager.saveTitleFilterKeyword(n), window.applyBlurMode && window.applyBlurMode(), show.ok("保存成功"), window.refresh(); const a = getBean("NewVideoPlugin"); a && a.resetBtnTip(), getBean("BlacklistPlugin").resetBtnTip(), getBean("BlacklistPlugin").reloadTable(); } /** Create a removable keyword label tag in the filter panel. */ function addLabelTag(e, t) { const n = $(`${e} .tag-box`); let a, i = "#cbd5e1", s = "#333"; /^[a-z]{2,}-/i.test(t) && r ? (s = "#3477ad", a = $(` <a class="keyword-label" data-keyword="${t}" style="background-color: ${i}; color: ${s}" href="/video_codes/${t.replace("-", "")}" target="_blank"> ${t} <span class="keyword-remove">×</span> </a> `)) : a = $(` <div class="keyword-label" data-keyword="${t}" style="background-color: ${i}; color: ${s}"> ${t} <span class="keyword-remove">×</span> </div> `), a.find(".keyword-remove").click((e => { e.stopPropagation(), e.preventDefault(); const t = $(e.currentTarget); const n = t.closest(".keyword-label").attr("data-keyword").split(" ")[0]; utils.q(e, `是否移除屏蔽词 ${n}?`, (async () => { t.parent().remove(); })); })), n.append(a); } /** Add a keyword from the input field to the tag box. */ function addKeyword(e, t) { let n = $(`${t} .keyword-input`); const a = n.val().trim(); a && (addLabelTag(t, a), n.val("")); } /** Handle JSON file import via file input, run diff analysis, show preview. */ async function importSettingData(showDiffPreviewFn) { try { const input = document.createElement("input"); input.type = "file", input.accept = ".json"; const cleanup = () => { try { document.body.removeChild(input); } catch (e) {} }; input.onchange = async e => { const t = e.target.files[0]; if (!t) return void cleanup(); const n = new FileReader; n.onload = async e => { cleanup(); try { const t = e.target.result.toString(), n = JSON.parse(t); if (!n || "object" != typeof n || Array.isArray(n)) throw new Error("文件内容不是有效的数据对象"); const a = loading(); try { const e = await storageManager.exportData(), t = await storageManager.diffData(e, n); a.close(), showDiffPreviewFn(t, n, null); } catch (i) { a.close(), console.error(i), show.error("差异分析失败: " + i.message); } } catch (t) { console.error(t), show.error("导入失败:文件内容不是有效的JSON格式 " + t.message); } }, n.onerror = () => { cleanup(), show.error("读取文件时出错"); }, n.readAsText(t); }, document.body.appendChild(input), input.click(); setTimeout(cleanup, 3e5); } catch (e) { console.error(e), show.error("导入数据时出错: " + e.message); } } /** Create encrypted backup and upload via WebDAV. */ async function backupDataByWebDav(folderName) { const t = await storageManager.getSetting(), n = t.webDavUrl; if (!n) return void show.error("请填写webDav服务地址并保存后, 再试此功能"); const a = t.webDavUsername; if (!a) return void show.error("请填写webDav用户名并保存后, 再试此功能"); const i = await decryptCredential(t.webDavPassword); if (!i) return void show.error("请填写webDav密码并保存后, 再试此功能"); let s = utils.getNowStr("_", "_") + ".json", o = JSON.stringify(await storageManager.exportData()); o = await encryptBackupData(o); let r = loading(); try { const e = new De(n, a, i); await e.backup(folderName, s, o), show.ok("备份完成"); } catch (l) { console.error(l), show.error(l.toString()); } finally { r.close(); } } /** List WebDAV backups and open the file list dialog. */ async function backupListBtnByWebDav(folderName, openFileListDialogFn) { const t = await storageManager.getSetting(), n = t.webDavUrl; if (!n) return void show.error("请填写webDav服务地址并保存后, 再试此功能"); const a = t.webDavUsername; if (!a) return void show.error("请填写webDav用户名并保存后, 再试此功能"); const i = await decryptCredential(t.webDavPassword); if (!i) return void show.error("请填写webDav密码并保存后, 再试此功能"); let s = loading(); try { const e = new De(n, a, i), t = await e.getBackupList(folderName); openFileListDialogFn(t, e, "WebDav"); } catch (o) { console.error(o), show.error(`发生错误: ${o ? o.message : o}`); } finally { s.close(); } } /** Create encrypted backup and upload to 115. */ async function backupDataTo115(folderName) { const fileName = utils.getNowStr("_", "_") + ".json"; const payload = await encryptBackupData(JSON.stringify(await storageManager.exportData())); let load = loading(); try { await new Pan115BackupApi().backup(folderName, fileName, payload), show.ok("备份完成"); } catch (err) { console.error(err), show.error(err.toString()); } finally { load.close(); } } /** List 115 backups and open the file list dialog. */ async function backupListBtnBy115(folderName, openFileListDialogFn) { let load = loading(); try { const api = new Pan115BackupApi(), files = await api.getBackupList(folderName); openFileListDialogFn(files, api, "115"); } catch (err) { console.error(err), show.error(`发生错误: ${err ? err.message : err}`); } finally { load.close(); } } /** Mobile-specific backup file list dialog using card-based UI. */ function openFileListDialogMobile(e, t, n, folderName, showDiffPreviewFn) { const formatSize = (size) => { const units = ["B", "KB", "MB", "GB", "TB", "PB"]; let i = 0, s = size; for (; s >= 1024 && i < units.length - 1;) s /= 1024, i++; return `${s % 1 == 0 ? s.toFixed(0) : s.toFixed(2)} ${units[i]}`; }; const renderCards = (files) => { if (!files || files.length === 0) { return '<div class="jhs-backup-empty">暂无数据</div>'; } return files.map((file, idx) => ` <div class="jhs-backup-card" data-idx="${idx}"> <div class="jhs-backup-card-name">${escapeHtml(file.name)}</div> <div class="jhs-backup-card-meta">${formatSize(file.size)} · ${utils.getNowStr("-", ":", file.createTime)}</div> <div class="jhs-backup-card-actions"> <button class="jhs-backup-btn jhs-backup-btn-danger" data-action="delete" data-idx="${idx}">删除</button> <button class="jhs-backup-btn jhs-backup-btn-primary" data-action="download" data-idx="${idx}">下载</button> <button class="jhs-backup-btn jhs-backup-btn-success" data-action="import" data-idx="${idx}">导入</button> </div> </div> `).join(""); }; const containerId = "jhs-backup-card-list"; layer.open({ type: 1, title: n + "备份文件", content: `<div id="${containerId}" style="padding:0 4px;">${renderCards(e)}</div>`, area: utils.getResponsiveArea(["800px", "70%"]), anim: -1, success: (layerEl) => { const container = $(layerEl).find(`#${containerId}`); container.on("click", ".jhs-backup-btn", async (ev) => { const btn = $(ev.currentTarget); const action = btn.data("action"); const idx = btn.data("idx"); const file = e[idx]; if (!file) return; if (action === "delete") { layer.confirm(`是否删除 ${file.name} ?`, { icon: 3, title: "提示", btn: ["确定", "取消"] }, async (confirmIdx) => { layer.close(confirmIdx); let load = loading(); try { await t.deleteFile(file.fileId); e = await t.getBackupList(folderName); container.html(renderCards(e)); layer.alert("删除成功"); } catch (err) { console.error(err), show.error(`发生错误: ${err ? err.message : err}`); } finally { load.close(); } }); } else if (action === "download") { let load = loading(); try { const data = await decryptBackupData(await t.getFileContent(file.fileId)); utils.download(data, file.name); } catch (err) { clog.error(err), show.error("下载失败: " + err); } finally { load.close(); } } else if (action === "import") { let load = loading(); try { let data = await t.getFileContent(file.fileId); data = await decryptBackupData(data); const parsed = JSON.parse(data); const currentData = await storageManager.exportData(); const diff = await storageManager.diffData(currentData, parsed); load.close(); showDiffPreviewFn(diff, null, parsed); } catch (err) { load.close(), console.error(err), show.error("预览失败: " + (err ? err.message : err)); } } }); } }); } /** Desktop backup file list dialog using Tabulator table. */ function openFileListDialog(e, t, n, folderName, showDiffPreviewFn) { if (utils.isMobileMode()) { openFileListDialogMobile(e, t, n, folderName, showDiffPreviewFn); return; } layer.open({ type: 1, title: n + "备份文件", content: '\n <div style="height: 100%;overflow:hidden;"> \n <div id="table-container" style="margin:auto auto !important;"></div>\n </div>\n ', area: utils.getResponsiveArea([ "800px", "70%" ]), anim: -1, success: a => { const i = new Tabulator("#table-container", { layout: "fitColumns", placeholder: "暂无数据", virtualDom: !0, data: e, responsiveLayout: "collapse", responsiveLayoutCollapse: !0, columnDefaults: { headerHozAlign: "center", hozAlign: "center" }, columns: [ { title: "文件名", field: "name", width: 200, headerSort: !1, responsive: 0 }, { title: "文件大小", field: "size", responsive: 1, headerSort: !1, formatter: (e, t, n) => { const a = [ "B", "KB", "MB", "GB", "TB", "PB" ]; let i = 0, s = e.getData().size; for (;s >= 1024 && i < a.length - 1; ) s /= 1024, i++; return `${s % 1 == 0 ? s.toFixed(0) : s.toFixed(2)} ${a[i]}`; } }, { title: "备份日期", field: "createTime", responsive: 2, headerSort: !1, formatter: (e, t, n) => { const a = e.getData(); return `${utils.getNowStr("-", ":", a.createTime)}`; } }, { title: "操作", minWidth: 250, responsive: 0, headerSort: !1, formatter: (e, a, s) => { const o = e.getData(); return s((() => { const a = e.getElement().querySelector(".a-danger"), s = e.getElement().querySelector(".a-primary"), r = e.getElement().querySelector(".a-success"); a && a.addEventListener("click", (e => { layer.confirm(`是否删除 ${o.name} ?`, { icon: 3, title: "提示", btn: [ "确定", "取消" ] }, (async e => { layer.close(e); let a = loading(); try { await t.deleteFile(o.fileId); let e = await t.getBackupList(folderName); i.replaceData(e), layer.alert("删除成功"); } catch (s) { console.error(s), show.error(`发生错误: ${s ? s.message : s}`); } finally { a.close(); } })); })), s && s.addEventListener("click", (async e => { let a = loading(); try { const e = await decryptBackupData(await t.getFileContent(o.fileId)); utils.download(e, o.name); } catch (i) { clog.error(i), show.error("下载失败: " + i); } finally { a.close(); } })), r && r.addEventListener("click", (async e => { let a = loading(); try { let e = await t.getFileContent(o.fileId); e = await decryptBackupData(e); const n = JSON.parse(e), i = await storageManager.exportData(), s = await storageManager.diffData(i, n); a.close(), showDiffPreviewFn(s, null, n); } catch (i) { a.close(), console.error(i), show.error("预览失败: " + (i ? i.message : i)); } })); })), '\n <a class="a-danger">删除</a>\n <a class="a-primary">下载</a>\n <a class="a-success">导入</a>\n '; } } ], locale: "zh-cn", langs: { "zh-cn": { pagination: { first: "首页", first_title: "首页", last: "尾页", last_title: "尾页", prev: "上一页", prev_title: "上一页", next: "下一页", next_title: "下一页", all: "所有", page_size: "每页行数" } } } }); } }); } /** Export all data as a downloadable JSON file. */ async function exportSettingData() { try { const e = JSON.stringify(await storageManager.exportData()), t = `${utils.getNowStr("_", "_")}.json`; utils.download(e, t), show.ok("数据导出成功"); } catch (t) { console.error(t), show.error("导出数据时出错: " + t.message); } } class Ae extends X { constructor() { super(...arguments), i(this, "folderName", "JHS-数据备份"), i(this, "cacheItems", [ { key: "jhs_dmm_video", text: "预览视频缓存", title: "预览视频缓存" }, { key: "jhs_other_site", text: "第三方站点缓存", title: "第三方站点资源检测结果, 如missav,123Av等" }, { key: "jhs_screenShot", text: "缩略图缓存", title: "缩略图缓存" }, { key: "jhs_translate", text: "标题翻译", title: "标题翻译" }, { key: "jhs_actress_info", text: "演员信息", title: "演员的年龄三围等数据信息" }, { key: "jhs_score_info", text: "Top250|热播 评分数据", title: "Top250及热播的评分数据" }, { key: "third_party_ttl_cache", text: "第三方TTL缓存", title: "评论、相关清单、磁力搜索、缩略图等请求缓存" }, { key: "jhs_visited_carnums", text: "已浏览记录", title: "进入过详情页的番号记录, 用于列表页封面的「已浏览」角标" }, { key: "_circuitBreaker", text: "熔断状态", title: "各站点的熔断计数和状态" }, { key: "_domainStats", text: "域名请求统计", title: "各域名的请求次数和错误统计" } ]); } getName() { return "SettingPlugin"; } async initCss() { const e = await storageManager.getSetting(); let t = (null == e ? void 0 : e.containerWidth) ?? "100"; utils.isMobileMode() && (t = "100"); let n = utils.isMobile() && window.innerWidth < 1e3 ? 1 : (null == e ? void 0 : e.containerColumns) ?? 5; window.getBeanForSetting = this.getBean.bind(this); applyImageMode().catch((e => console.error("[JHS] applyImageMode failed:", e))); applyDetailFlex().catch((e => console.error("[JHS] applyDetailFlex failed:", e))); return buildSettingCss(t, n, l, r); } async handle() { if (await storageManager.getSetting("enableClog", _) === _ && clog.show(), r) { let e = function() { $(".navbar-search").is(":hidden") ? ($(".mini-setting-box").hide(), $(".setting-box").show()) : ($(".mini-setting-box").show(), $(".setting-box").hide()); }; $("#navbar-menu-user .navbar-end").prepend('<div class="navbar-item has-dropdown is-hoverable setting-box" style="position:relative;">\n <a id="setting-btn" class="navbar-link nav-btn" style="color: #fff !important;padding-right:15px !important;">\n 设置\n </a>\n <div class="simple-setting"></div>\n </div>'), utils.loopDetector((() => $("#miniHistoryBtn").length > 0), (() => { $(".miniHistoryBtnBox").before('\n <div class="navbar-item mini-setting-box" style="position:relative;margin-left: auto;">\n <a id="mini-setting-btn" class="navbar-link nav-btn" style="color: #fff !important;padding-left:0 !important;padding-right:0 !important;">\n 设置\n </a>\n <div class="mini-simple-setting"></div>\n </div>\n '), e(); })), $(window).resize(e); } l && this.mountBusNavSettingBox(), $(document).off("click.jhsSettingBtn").on("click.jhsSettingBtn", "#setting-btn, #mini-setting-btn", (() => { $(".simple-setting, .mini-simple-setting").html("").hide(), clog.lowZIndex(), this.openSettingDialog("base-panel"); })), $(".main-nav, .container-fluid, .navbar, #navbar").on("mouseenter", ".setting-box", (() => { $(".simple-setting").html(buildSimpleSettingHtml()).show(), initSimpleSettingForm(this.getBean.bind(this), this.getSelector.bind(this), this.openSettingDialog.bind(this)).then(), clog.lowZIndex(); })).on("mouseleave", ".setting-box", (() => { $(".simple-setting").html("").hide(); })), $(".main-nav, .container-fluid, .navbar, #navbar").on("mouseenter", ".mini-setting-box", (() => { $(".mini-simple-setting").html(buildSimpleSettingHtml()).show(), initSimpleSettingForm(this.getBean.bind(this), this.getSelector.bind(this), this.openSettingDialog.bind(this)).then(), clog.lowZIndex(); })).on("mouseleave", ".mini-setting-box", (() => { $(".mini-simple-setting").html("").hide(); })); } /** JavBus 顶部导航「语言」下拉所在的 li (地球图标 / 语言文案) */ _findBusLangItem() { // 优先: 地球图标所在的 li (顶部导航语言 dropdown 的 toggle 本身) const $globe = $(".glyphicon-globe, .fa-globe, [class*='globe']").first(); if ($globe.length) { const $li = $globe.closest("li"); if ($li.length) return $li; } // 兜底: 导航栏内 (排除 dropdown-menu 内选项) a 文本以语言词开头的 li const langRe = /^(語言|语言|English|日本語|繁體|简体|한국어|Language)/; return $(".navbar li, .nav li, nav li").filter(((_, el) => { const $el = $(el); if ($el.closest(".dropdown-menu").length) return !1; return langRe.test(($el.children("a").first().text() || "").trim()); })).first(); } /** JavBus 顶部导航「已有磁力」所在的 li */ _findBusMagnetNavItem() { const re = /(已有磁力|有磁力|有磁鏈|有磁链)/; return $(".navbar li, .nav li, nav li").filter(((_, el) => { const $el = $(el); if ($el.closest(".dropdown-menu").length) return !1; return re.test(($el.children("a").first().text() || "").trim()); })).first(); } /** JavBus: 设置按钮插到导航栏「已有磁力」之前(无则「语言」之前), 找不到则回退 */ mountBusNavSettingBox() { const box = '\n <div id="top-right-box" style="position: relative; display: flex; align-items: center; gap: 5px; z-index: 12345679 !important;">\n <div class="setting-box" style="position: relative; display: flex; align-items: center;">\n <a id="setting-btn" class="menu-btn main-tab-btn" style="background-color:#6e685e !important; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; height: 30px; padding: 0 12px; line-height: 1; margin: 0 !important;">\n <span>设置</span>\n </a>\n <div class="simple-setting"></div>\n </div>\n </div>\n'; // 等导航项或标题任一就绪再插入 (导航栏可能异步渲染) utils.loopDetector((() => this._findBusMagnetNavItem().length > 0 || this._findBusLangItem().length > 0 || $("h3").length > 0), (() => { if ($("#top-right-box").length) return; // 防重复插入 const $magnet = this._findBusMagnetNavItem(); const $target = $magnet.length ? $magnet : this._findBusLangItem(); if ($target.length) { // 高度对齐相邻导航项, 否则 float 布局下按钮会顶到行首导致文字不在同一水平线 const h = $target.outerHeight() || 40; $target.before(`<li class="jhs-nav-setting" style="float: left; display: flex; align-items: center; justify-content: center; height: ${h}px; padding: 0 10px; margin: 0; list-style: none;">${box}</li>`); } else { $("h3").first().before(`\n <div class="container-fluid" style="margin-top:20px">${box}</div>\n `); } }), 1, 1e4, !1); } async openSettingDialog(e = "backup-panel", t) { const a = this.getBean("CoverButtonPlugin"); const s = buildSettingDialogHtml(e, this.cacheItems, a); layer.open({ type: 1, title: "设置", content: s, area: utils.getResponsiveArea([ "55%", "90%" ]), scrollbar: !1, success: (e, n) => { // 圆角: layui 默认只有 2px, 这里给这一层单独加类(样式见 .layui-layer.jhs-round-layer) $(e).addClass("jhs-round-layer"), $(e).find(".layui-layer-content").css("position", "relative"), injectHealthPanel(), injectPluginMgmtPanel(), injectSnapshotPanel(), injectNetworkPanel(), inject115Panel(), loadSettingForm(this.getBean.bind(this)), this.bindClick(), utils.setupEscClose(n), t && t(); if (utils.isMobileMode()) { this.collapseAdvancedTabs(); } }, end: () => { this.getBean("CoverButtonPlugin").enableSvgBtn(); } }); } collapseAdvancedTabs() { const advancedPanels = [ { id: "health-panel", label: "数据体检", render: renderDataHealthPanel }, { id: "plugin-mgmt-panel", label: "插件管理", render: renderPluginMgmtPanel }, { id: "network-panel", label: "外部请求", render: renderNetworkPanel } ]; const sidebar = $(".jhs-mobile-sidebar"); const contentParent = $(".content-panel").parent(); if (!sidebar.length || !contentParent.length) return; advancedPanels.forEach(p => { sidebar.find(`[data-panel="${p.id}"]`).remove(); }); if (!sidebar.find('[data-panel="more-tools-panel"]').length) { sidebar.append('<div class="side-menu-item" data-panel="more-tools-panel">更多工具</div>'); } if ($("#more-tools-panel").length) return; let subTabsHtml = advancedPanels.map((p, i) => `<div class="jhs-sub-tab${i === 0 ? " active" : ""}" data-sub-panel="${p.id}">${p.label}</div>` ).join(""); let subPanelsHtml = advancedPanels.map((p, i) => `<div id="sub-${p.id}" class="jhs-sub-panel${i === 0 ? " active" : ""}" data-rendered="false"></div>` ).join(""); const wrapperHtml = ` <div id="more-tools-panel" class="content-panel" style="display:none;"> <div class="jhs-sub-tabs">${subTabsHtml}</div> <div class="jhs-sub-panels">${subPanelsHtml}</div> </div> `; contentParent.append(wrapperHtml); advancedPanels.forEach(p => { const src = $(`#${p.id}`); if (src.length) { src.children().appendTo(`#sub-${p.id}`); src.remove(); $(`#sub-${p.id}`).attr("data-rendered", "true"); } }); sidebar.on("click", '[data-panel="more-tools-panel"]', function() { $(".side-menu-item").removeClass("active"), $(this).addClass("active"), $(".content-panel").hide(); $("#more-tools-panel").show(), $("#saveBtn").show(), $("#clean-all").hide(); }); $("#more-tools-panel").on("click", ".jhs-sub-tab", function() { const target = $(this).data("sub-panel"); $("#more-tools-panel .jhs-sub-tab").removeClass("active"); $(this).addClass("active"); $("#more-tools-panel .jhs-sub-panel").removeClass("active"); $(`#sub-${target}`).addClass("active"); if ($(`#sub-${target}`).attr("data-rendered") !== "true") { $(`#sub-${target}`).attr("data-rendered", "true"); } const panel = advancedPanels.find(p => p.id === target); if (panel && panel.render) { panel.render(); } }); } bindClick() { const settingPlugin = this; $(".side-menu-item").on("click", (function() { $(".side-menu-item").removeClass("active"), $(this).addClass("active"), $(".content-panel").hide(); const e = $(this).data("panel"); $("#" + e).show(), "cache-panel" === e ? ($("#saveBtn").hide(), $("#clean-all").show()) : ($("#saveBtn").show(), $("#clean-all").hide()), "health-panel" === e && ($("#saveBtn").hide(), $("#clean-all").hide(), renderDataHealthPanel()), "plugin-mgmt-panel" === e && ($("#saveBtn").hide(), $("#clean-all").hide(), renderPluginMgmtPanel()), "hotkey-panel" === e && ($("#saveBtn").show(), $("#clean-all").hide()), "personal-panel" === e && ($("#saveBtn").show(), $("#clean-all").hide()), "115-panel" === e && ($("#saveBtn").show(), $("#clean-all").hide()), // 恢复点已并入数据备份面板: 保存按钮要保持显示(WebDav 那几个字段靠它保存), 只补一次列表渲染 "backup-panel" === e && renderSnapshotPanel(), "network-panel" === e && ($("#saveBtn").hide(), $("#clean-all").hide(), renderNetworkPanel()); })), $("#importBtn").on("click", (e => importSettingData(showDiffPreview))), $("#exportBtn").on("click", (e => exportSettingData())), $("#cleanJhsDataBtn").on("click", (e => { utils.q(null, "是否清空所有数据? 即鉴定记录、黑名单数据、配置等...<br/><span style='color: #f40'>此操作不可逆, 请备份好数据</span>", (async () => { await storageManager.importData({}); show.ok("已清空数据, 即将刷新页面..."); await utils.sleep(200); window.location.reload(); })); })), $("#pan115BackupBtn").on("click", (e => backupDataTo115(this.folderName))), $("#pan115BackupListBtn").on("click", (e => backupListBtnBy115(this.folderName, (files, client, label) => openFileListDialog(files, client, label, this.folderName, showDiffPreview)))), $("#webdavBackupBtn").on("click", (e => backupDataByWebDav(this.folderName))), $("#webdavBackupListBtn").on("click", (e => backupListBtnByWebDav(this.folderName, (files, client, label) => openFileListDialog(files, client, label, this.folderName, showDiffPreview)))), $("#saveBtn").on("click", (() => saveSettingForm(this.getBean.bind(this)))), $("#runHealthCheckBtn").on("click", (() => renderDataHealthPanel())), $("#repairHealthBtn").on("click", (e => { utils.q(e, "修复前会自动下载备份,是否继续?", (() => repairDataHealthWithBackup())); })), $("#pm-clear-log").on("click", (() => { unsafeWindow.pluginManager.clearErrorLog(), $("#plugin-error-log").text("无错误记录"), show.ok("错误日志已清空"); })), $("#createSnapshotBtn").on("click", (async () => { let e = loading(); try { await storageManager.createSnapshot("手动快照", "manual"), show.ok("快照创建成功"), renderSnapshotPanel(); } catch (t) { console.error(t), show.error("创建快照失败: " + t.message); } finally { e.close(); } })), $(".clean-btn").on("click", (async e => { const t = $(e.currentTarget).data("key"), n = this.cacheItems.find((e => e.key === t)); t === storageManager.third_party_cache_key ? await storageManager.clearThirdPartyCache() : "_circuitBreaker" === t ? gmHttp.resetAllCircuitBreakers() : "_domainStats" === t ? gmHttp.clearDomainStats() : localStorage.removeItem(t), show.ok(`${n.text} 清理成功`), $("#cache-data-display").hide(), "jhs_dmm_video" === t && localStorage.removeItem("jhs_other_site_dmm"); })), $("#clean-all").on("click", (async () => { this.cacheItems.forEach((e => localStorage.removeItem(e.key))), show.ok("全部缓存已清理"), $("#cache-data-display").hide(), localStorage.removeItem("jhs_other_site_dmm"), await storageManager.clearThirdPartyCache(); })), $(".view-btn").on("click", (async e => { const t = $(e.currentTarget).data("key"); let n; if (t === storageManager.third_party_cache_key) n = JSON.stringify(await storageManager.getThirdPartyCache()); else if ("_circuitBreaker" === t) n = JSON.stringify(gmHttp.getCircuitBreakerStatus()); else if ("_domainStats" === t) n = JSON.stringify(gmHttp.getDomainStats()); else n = localStorage.getItem(t); const a = $("#cache-data-display"), i = a.find("pre"); if (a.show(), n) try { const e = JSON.parse(n); i.text(JSON.stringify(e, null, 2)); } catch { i.text(n); } else i.text("无数据"); })); const e = $("#highlightedTagNumber"), t = $("#highlightedTagColor"), n = $("#highlightedTagLabel"); function a() { const a = e.val(), i = t.val(); n.css("border", `${a}px solid ${i}`); } e.on("input", a), t.on("input", a); } } const ENCRYPTION_SALT = "x7k9p3"; async function getEncryptionKey() { const enc = new TextEncoder(); const keyMaterial = await crypto.subtle.importKey("raw", enc.encode(ENCRYPTION_SALT + ".jhs.v1"), { name: "PBKDF2" }, false, [ "deriveKey" ]); return crypto.subtle.deriveKey({ name: "PBKDF2", salt: enc.encode("jhs-backup"), iterations: 1e5, hash: "SHA-256" }, keyMaterial, { name: "AES-GCM", length: 256 }, false, [ "encrypt", "decrypt" ]); } function arrayBufferToBase64(e) { const t = new Uint8Array(e), n = 0x8000; let a = ""; for (let i = 0; i < t.length; i += n) a += String.fromCharCode.apply(null, t.subarray(i, i + n)); return btoa(a); } function base64ToArrayBuffer(e) { const t = atob(e), n = new Uint8Array(t.length); for (let a = 0; a < t.length; a++) n[a] = t.charCodeAt(a); return n; } async function encryptData(e) { const t = await getEncryptionKey(), n = crypto.getRandomValues(new Uint8Array(12)), a = new TextEncoder(), i = await crypto.subtle.encrypt({ name: "AES-GCM", iv: n }, t, a.encode(e)), s = new Uint8Array(n.length + i.byteLength); return s.set(n), s.set(new Uint8Array(i), n.length), arrayBufferToBase64(s); } async function decryptData(e) { const t = await getEncryptionKey(), n = base64ToArrayBuffer(e), a = n.slice(0, 12), i = n.slice(12), s = await crypto.subtle.decrypt({ name: "AES-GCM", iv: a }, t, i); return new TextDecoder().decode(s); } /** 读取 App 授权 token: 优先按加密解, 解不开则视为明文(兼容 jav-jhs 等直接存明文的脚本) */ async function decryptTokenSafe(e) { if (!e) return ""; try { const t = await decryptData(e); if (t) return String(t).replace(/^Bearer\s+/i, ""); } catch (t) { clog.debug("token 解密失败, 按明文处理:", t && t.message); } return String(e).replace(/^Bearer\s+/i, ""); } const CREDENTIAL_PREFIX = "AES:"; async function encryptCredential(e) { return e && !e.startsWith(CREDENTIAL_PREFIX) ? CREDENTIAL_PREFIX + await encryptData(e) : e; } async function decryptCredential(e) { return e && e.startsWith(CREDENTIAL_PREFIX) ? await decryptData(e.slice(CREDENTIAL_PREFIX.length)) : e; } /* ===== V2 password-based backup crypto (ported from Power Tools) ===== */ const BACKUP_V2_ITERATIONS = 1e5; const BACKUP_V2_SALT_BYTES = 16; const BACKUP_V2_IV_BYTES = 12; function _v2BytesToBase64(bytes) { let s = ""; for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); return btoa(s); } function _v2Base64ToBytes(b64) { const bin = atob(b64), out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } async function _deriveBackupV2Key(password, salt) { const enc = new TextEncoder(); const km = await crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey"]); return crypto.subtle.deriveKey({ name: "PBKDF2", salt, iterations: BACKUP_V2_ITERATIONS, hash: "SHA-256" }, km, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); } async function encryptBackupV2(plaintext, password) { const enc = new TextEncoder(); const salt = crypto.getRandomValues(new Uint8Array(BACKUP_V2_SALT_BYTES)); const iv = crypto.getRandomValues(new Uint8Array(BACKUP_V2_IV_BYTES)); const key = await _deriveBackupV2Key(password, salt); const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, enc.encode(plaintext)); return JSON.stringify({ v: 2, alg: "AES-GCM", kdf: "PBKDF2-SHA-256", iter: BACKUP_V2_ITERATIONS, salt: _v2BytesToBase64(salt), iv: _v2BytesToBase64(iv), ct: _v2BytesToBase64(new Uint8Array(ct)), ts: new Date().toISOString() }); } async function decryptBackupV2(envelopeJson, password) { const envelope = JSON.parse(envelopeJson); if (envelope.v !== 2) throw new Error(`不支持的备份格式版本: ${envelope.v}`); const key = await _deriveBackupV2Key(password, _v2Base64ToBytes(envelope.salt)); const buf = await crypto.subtle.decrypt({ name: "AES-GCM", iv: _v2Base64ToBytes(envelope.iv) }, key, _v2Base64ToBytes(envelope.ct)); return new TextDecoder().decode(buf); } function isBackupV2(content) { try { const p = JSON.parse(content); return p && p.v === 2 && p.alg === "AES-GCM"; } catch { return false; } } /** Read the configured backup password (plaintext). */ async function getBackupPassword() { const s = await storageManager.getSetting(); return await decryptCredential(s.backupPassword) || ""; } /** Encrypt backup payload: V2 (password) if backupPassword set, else device-key AES. */ async function encryptBackupData(plaintext) { const pwd = await getBackupPassword(); return pwd ? await encryptBackupV2(plaintext, pwd) : await encryptData(plaintext); } /** Decrypt backup content: auto-detect V2 envelope, fallback to legacy device-key. */ async function decryptBackupData(content) { if (isBackupV2(content)) { const pwd = await getBackupPassword(); if (!pwd) throw new Error("该备份使用 V2 加密, 请先在数据备份面板设置备份口令"); try { return await decryptBackupV2(content, pwd); } catch { throw new Error("备份口令错误或文件已损坏"); } } return await decryptData(content); } class je extends X { getName() { return "BusPreviewVideoPlugin"; } async initCss() { return "\n /* 弹窗/Modal 样式 */\n .bus-preview-modal {\n position: fixed;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background-color: rgba(0, 0, 0, 0.95); \n /* 关键修改:更新 z-index */\n z-index: 12345699; \n display: flex;\n justify-content: center;\n align-items: center;\n opacity: 0; \n visibility: hidden; \n transition: opacity 0.2s ease;\n }\n .bus-preview-modal.is-open {\n opacity: 1;\n visibility: visible;\n }\n /* 垂直排列视频和按钮,并居中 */\n .bus-preview-modal-content {\n position: relative;\n max-width: 95%; \n max-height: 95%;\n display: flex; \n flex-direction: column; \n align-items: center; \n gap: 15px; \n }\n \n /* 移除 .bus-preview-close-btn 的样式 */\n\n /* 视频播放器容器 */\n .video-player-wrapper {\n /* 关键修改:更新 width 和 max-height */\n width: 80vw; \n max-height: 85vh; \n aspect-ratio: 16 / 9; \n position: relative; \n background-color: black; \n max-width: 100%; \n }\n /* 视频元素 */\n .video-player-wrapper #preview-video {\n position: absolute; \n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: block;\n }\n\n /* 画质控制盒 (底部按钮) */\n .video-control-box {\n display: flex;\n flex-direction: row; \n justify-content: center; \n flex-wrap: wrap; \n gap: 10px;\n padding: 10px 0; \n }\n\n /* 按钮样式 (保留) */\n .video-control-btn {\n min-width:80px;\n padding: 6px 12px;\n background: rgba(255,255,255,0.2);\n color: white;\n border: 1px solid rgba(255,255,255,0.5);\n border-radius: 4px;\n cursor: pointer;\n text-align: center;\n font-size: 14px;\n transition: background-color 0.2s, border-color 0.2s;\n }\n .video-control-btn:hover {\n background: rgba(255,255,255,0.4);\n }\n .video-control-btn.active {\n background-color: #1890ff; \n color: white;\n font-weight: bold;\n border: 1px solid #096dd9;\n }\n "; } initModal() { if (0 === $("#bus-preview-modal").length) { $("body").append('\n <div id="bus-preview-modal" class="bus-preview-modal">\n <div class="bus-preview-modal-content">\n </div>\n </div>\n '); const e = $("#bus-preview-modal"); e.on("click", (e => { "bus-preview-modal" === e.target.id && this.closeVideoModal(); })), $(document).on("keydown", (t => { "Escape" === t.key && e.hasClass("is-open") && this.closeVideoModal(); })); } } closeVideoModal() { const e = $("#preview-video"); e.length > 0 && e[0].pause(), $("#bus-preview-modal").removeClass("is-open"); } async handle() { if (!isDetailPage) return; this.initModal(); const e = $("#sample-waterfall .sample-box .photo-frame img:first").attr("src"), t = $(`\n <a class="preview-video-container sample-box" style="cursor: pointer">\n <div class="photo-frame" style="position:relative;">\n <img src="${e}" class="video-cover" alt="">\n <div class="play-icon" style="position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); \n color:white; font-size:40px; text-shadow:0 0 10px rgba(0,0,0,0.5);">\n ▶\n </div>\n </div>\n </a>`); $("#sample-waterfall").prepend(t); "yes" === await storageManager.getSetting("enableLoadPreviewVideo", "yes") && ne(this.getPageInfo().carNum, !1).then(); let n = !1, a = $(".preview-video-container"); a.on("click", (async e => { if (e.preventDefault(), e.stopPropagation(), n) show.info("正在加载中, 勿重复点击"); else { n = !0; try { await this.handleVideo(); } finally { n = !1; } } })), window.location.href.includes("autoPlay=1") && a.trigger("click"); } async handleVideo() { const e = $("#bus-preview-modal"), t = e.find(".bus-preview-modal-content"); let n = $("#preview-video"); if (n.length > 0) return e.addClass("is-open"), void n[0].play().catch((e => console.warn("尝试播放失败 (可能被浏览器阻止):", e))); let a = this.getPageInfo().carNum; const i = await ne(a); // DMM/javp 没结果 -> 直接开一个空播放器交给 javxy(会自动换 JavTrailers/JavDB) if (!i || 0 === Object.keys(i).length) return void await this.playByJavxy(a, e, t); await this.createVideoPlayerAndControls(i, t), n = $("#preview-video"); if (!n.length) return void show.error("视频播放器创建失败。"); // DMM 地址可能因地域限制 403, 播不动就原位换 javxy n[0].addEventListener("error", (() => { clog.warn("JavBus 预览视频 DMM 播不动, 改用 javxy:", a), this.playByJavxy(a, e, t); }), { once: !0 }), e.addClass("is-open"), n[0].play().catch((e => console.warn("尝试播放失败 (可能被浏览器阻止):", e))); } /** 用 javxy 在同一个弹窗里播放(不带 DMM 画质切换条) */ async playByJavxy(carNum, $modal, $content) { $content.html('\n <div class="video-player-wrapper">\n <video id="preview-video" controls playsinline></video>\n </div>\n '), $modal.addClass("is-open"); const video = $("#preview-video")[0]; if (!video) return void show.error("视频播放器创建失败。"); await JhsTrailer.bindVideo(video, carNum, { isAborted: () => !document.getElementById("preview-video"), onSource: res => clog.log("[JavBus 预览视频] 使用", res.source, res.quality), onFail: () => show.error("未找到可用的视频源。") }); } async createVideoPlayerAndControls(e, t) { let n = await storageManager.getSetting("videoQuality"); n = Z(Object.keys(e), n); let a = e[n]; t.html(`\n <div class="video-player-wrapper">\n <video id="preview-video" controls playsinline>\n <source src="${a}" />\n </video>\n </div>\n <div class="video-control-box">\n </div>\n `); const i = $("#preview-video"), s = i.find("source"), o = t.find(".video-control-box"); if (!i.length || !s.length) return; const r = i[0], l = localStorage.getItem("jhs_videoMuted"); r.muted = !l || "yes" === l, r.addEventListener("volumechange", (function() { localStorage.setItem("jhs_videoMuted", r.muted ? "yes" : "no"); })); let c = ""; L.forEach((t => { let a = e[t.quality]; if (a) { const e = n === t.quality; c += `\n <button class="video-control-btn${e ? " active" : ""}" \n data-quality="${t.quality}"\n data-video-src="${a}">\n ${t.text}\n </button>\n `; } })), o.html(c); const d = o.find(".video-control-btn"); o.off("click").on("click", ".video-control-btn", (async e => { try { const t = $(e.currentTarget); if (t.hasClass("active")) return; let n = t.attr("data-video-src"); s.attr("src", n); const a = r.currentTime; r.load(), r.currentTime = a, await r.play(), d.removeClass("active"), t.addClass("active"); } catch (t) { console.error("切换画质失败:", t); } })); } } class Ee extends X { constructor() { super(...arguments), i(this, "siteList", [ { name: "Google旧版", url: "https://www.google.com/searchbyimage?image_url={占位符}&client=firefox-b-d", ico: "https://www.google.com/favicon.ico" }, { name: "Google", url: "https://lens.google.com/uploadbyurl?url={占位符}", ico: "https://www.google.com/favicon.ico" }, { name: "Yandex", url: "https://yandex.ru/images/search?rpt=imageview&url={占位符}", ico: "https://yandex.ru/favicon.ico" } ]), i(this, "isUploading", !1); } getName() { return "SearchByImagePlugin"; } async initCss() { return "\n <style>\n #upload-area {\n border: 2px dashed #85af68;\n border-radius: 8px;\n padding: 40px;\n text-align: center;\n margin-bottom: 20px;\n transition: all 0.3s;\n background-color: #f9f9f9;\n }\n #upload-area:hover {\n border-color: #76b947;\n background-color: #f0f0f0;\n }\n /* 拖拽进入 */\n #upload-area.highlight {\n border-color: #2196F3;\n background-color: #e3f2fd;\n }\n \n \n #select-image-btn {\n background-color: #4CAF50;\n color: white;\n border: none;\n padding: 10px 20px;\n border-radius: 4px;\n cursor: pointer;\n font-size: 16px;\n transition: background-color 0.3s;\n }\n #select-image-btn:hover {\n background-color: #45a049;\n }\n \n \n #handle-btn, #cancel-btn {\n padding: 8px 16px;\n border-radius: 4px;\n cursor: pointer;\n font-size: 14px;\n border: none;\n transition: opacity 0.3s;\n }\n #handle-btn {\n background-color: #2196F3;\n color: white;\n }\n #handle-btn:hover {\n opacity: 0.9;\n }\n #cancel-btn {\n background-color: #f44336;\n color: white;\n }\n #cancel-btn:hover {\n opacity: 0.9;\n }\n \n .search-img-site-btns-container {\n display: flex;\n flex-wrap: wrap;\n gap: 10px;\n margin-top: 15px;\n }\n .search-img-site-btn {\n display: flex;\n align-items: center;\n padding: 8px 12px;\n background-color: #f5f5f5;\n border-radius: 4px;\n text-decoration: none;\n color: #333;\n transition: all 0.2s;\n font-size: 14px;\n border: 1px solid #ddd;\n }\n .search-img-site-btn:hover {\n background-color: #e0e0e0;\n transform: translateY(-2px);\n box-shadow: 0 2px 5px rgba(0,0,0,0.1);\n }\n .search-img-site-btn img {\n width: 16px;\n height: 16px;\n margin-right: 6px;\n }\n .search-img-site-btn span {\n white-space: nowrap;\n }\n </style>\n "; } open(e) { layer.open({ type: 1, title: "以图识图", content: '\n <div style="padding: 20px">\n <div id="upload-area">\n <div style="color: #555;margin-bottom: 15px;">\n <p>拖拽图片到此处 或 点击按钮选择图片</p>\n <p>也可以直接 Ctrl+V 粘贴图片或 图片URL</p>\n </div>\n <button id="select-image-btn">选择图片</button>\n <input type="file" style="display: none" id="image-file" accept="image/*">\n </div>\n \n <div id="url-input-container" style="margin-top: 15px;display: none;">\n <input type="text" id="image-url" placeholder="粘贴图片URL地址..." style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box;">\n </div>\n \n <div id="preview-area" style="margin-bottom: 20px; text-align: center; display: none;">\n <img id="preview-image" alt="" src="" style="max-width: 100%; max-height: 300px; border-radius: 4px; box-shadow: 0 2px 5px rgba(0,0,0,0.1);">\n <div style="margin-top: 15px; display: flex; justify-content: center; gap: 10px;" id="action-btns">\n <button id="handle-btn">搜索图片</button>\n <button id="cancel-btn">取消</button>\n </div>\n \n <div id="search-results" style="display: none;">\n <p style="margin: 20px auto">请选择识图网站:<a id="openAll" style="cursor: pointer">全部打开</a></p>\n <div class="search-img-site-btns-container" id="search-img-site-btns-container"></div>\n </div>\n </div>\n \n </div>\n ', area: utils.isMobile() ? utils.getResponsiveArea() : [ "40%", "80%" ], success: async t => { this.initEventListeners(), e && e(); }, end: () => { $(document).off("paste.searchImg"); } }); } initEventListeners() { const e = $("#upload-area"), t = $("#image-file"), n = $("#select-image-btn"), a = $("#preview-area"), i = $("#preview-image"), s = $("#action-btns"), o = $("#handle-btn"), r = $("#cancel-btn"), l = $("#url-input-container"), c = $("#image-url"), d = $("#search-results"), h = $("#search-img-site-btns-container"); e.on("dragover", (t => { t.preventDefault(), e.addClass("highlight"); })).on("dragleave", (() => { e.removeClass("highlight"); })).on("drop", (t => { t.preventDefault(), e.removeClass("highlight"), t.originalEvent.dataTransfer.files && t.originalEvent.dataTransfer.files[0] && (this.handleImageFile(t.originalEvent.dataTransfer.files[0]), this.resetSearchUI()); })), n.on("click", (() => { t.trigger("click"); })), t.on("change", (e => { e.target.files && e.target.files[0] && (this.handleImageFile(e.target.files[0]), this.resetSearchUI()); })), $(document).on("paste.searchImg", (async e => { const t = e.originalEvent.clipboardData.items; for (let a = 0; a < t.length; a++) if (-1 !== t[a].type.indexOf("image")) { const e = t[a].getAsFile(); return this.handleImageFile(e), void this.resetSearchUI(); } const n = e.originalEvent.clipboardData.getData("text"); n && utils.isUrl(n) && (l.show(), c.val(n), i.attr("src", n), a.show(), this.resetSearchUI()); })), o.on("click", (async () => { const e = i.attr("src"); if (e) { if (!this.isUploading) { this.isUploading = !0; try { const t = await this.searchByImage(e); s.hide(), d.show(), h.empty(); const n = "jhs_selectedSites", a = JSON.parse(localStorage.getItem(n) || "{}"); this.siteList.forEach((e => { const n = e.url.replace("{占位符}", encodeURIComponent(t)), i = !1 !== a[e.name]; h.append(`\n <a href="${n}" class="search-img-site-btn" target="_blank" title="${e.name}">\n <input type="checkbox" \n class="site-checkbox" \n data-site-name="${e.name}" \n style="margin-right: 5px"\n ${i ? "checked" : ""}>\n <img src="${e.ico}" alt="${e.name}">\n <span>${e.name}</span>\n </a>\n `); })), h.on("change", ".site-checkbox", (function() { const e = $(this).data("site-name"); a[e] = $(this).is(":checked"), localStorage.setItem(n, JSON.stringify(a)); })), h.show(); } finally { this.isUploading = !1; } } } else show.info("请粘贴或上传图片"); })), r.on("click", (() => { a.hide(), l.hide(), t.val(""), c.val(""); })), c.on("change", (() => { utils.isUrl(c.val()) && (i.attr("src", c.val()), a.show()); })), $("#openAll").on("click", (() => { $(".search-img-site-btn").each((function() { $(this).find(".site-checkbox").is(":checked") && window.open($(this).attr("href")); })); })); } resetSearchUI() { $("#action-btns").show(), $("#search-results").hide(), $("#search-img-site-btns-container").hide().empty(); } handleImageFile(e) { const t = document.getElementById("preview-image"), n = document.getElementById("preview-area"), a = document.getElementById("url-input-container"); if (!e.type.match("image.*")) return void show.info("请选择图片文件"); const i = new FileReader; i.onload = e => { t.src = e.target.result, n.style.display = "block", a.style.display = "none", $("#handle-btn")[0].click(); }, i.readAsDataURL(e); } async searchByImage(e) { let t = loading(); try { let t = e; if (e.startsWith("data:")) { show.info("开始上传图片..."); const n = await async function(e) { var t; const n = e.match(/^data:(.+);base64,(.+)$/); if (!n || n.length < 3) throw new Error("无效的Base64图片数据"); const a = n[1], i = n[2], s = atob(i), o = new Array(s.length); for (let g = 0; g < s.length; g++) o[g] = s.charCodeAt(g); const r = new Uint8Array(o), l = new Blob([ r ], { type: a }), c = new FormData; c.append("image", l); const d = await fetch("https://api.imgur.com/3/image", { method: "POST", headers: { Authorization: "Client-ID d70305e7c3ac5c6" }, body: c }), h = await d.json(); if (h.success && h.data && h.data.link) return h.data.link; throw new Error((null == (t = h.data) ? void 0 : t.error) || "上传到Imgur失败"); }(e); if (!n) return void show.error("上传失败"); t = n; } return t; } catch (n) { show.error(`搜索失败: ${n.message}`), console.error("搜索失败:", n); } finally { t.close(); } } } class Fe extends X { getName() { return "BusNavBarPlugin"; } handle() { $("#navbar > div > div > span").append('\n <button class="btn btn-default" style="color: #0d9488" id="search-img-btn">识图</button>\n '), $("#search-img-btn").on("click", (() => { this.getBean("SearchByImagePlugin").open(); })); } } class ze extends X { constructor() { super(...arguments), i(this, "type", null); } getName() { return "WantAndWatchedVideosPlugin"; } async handle() { window.location.href.includes("/want_watch_videos") && ($("h3").append('<a class="a-primary" id="wantWatchBtn" style="padding:10px;">导入至 JHS</a>'), $("#wantWatchBtn").on("click", (e => { this.type = h, this.importWantWatchVideos(e, "是否将 想看的影片 导入到 JHS-收藏?"); }))), window.location.href.includes("/watched_videos") && ($("h3").append('<a class="a-success" id="wantWatchBtn" style="padding:10px;">导入至 JHS</a>'), $("#wantWatchBtn").on("click", (e => { this.type = g, this.importWantWatchVideos(e, "是否将 看过的影片 导入到 JHS-已下载?"); }))); } importWantWatchVideos(e, t) { utils.q(null, `${t} <br/> <span style='color: #f40'>执行此功能前请记得备份数据</span>`, (async () => { let e = loading(); try { await this.parseMovieList(); } catch (t) { console.error(t); } finally { e.close(); } })); } async parseMovieList(e) { let t, n; e ? (t = e.find(this.getSelector().itemSelector), n = e.find(".pagination-next").attr("href")) : (t = $(this.getSelector().itemSelector), n = $(".pagination-next").attr("href")); for (const i of t) { const e = $(i), t = e.find("a").attr("href"), n = e.find(".video-title strong").text().trim(), s = e.find(".meta").text().trim(); if (t && n) try { if (await storageManager.getCar(n)) { show.info(`${n} 已存在, 跳过`); continue; } await storageManager.saveCar({ carNum: n, url: t, names: null, actionType: this.type, publishTime: s }); } catch (a) { console.error(`保存失败 [${n}]:`, a); } } n ? (show.info("发现下一页,正在解析:", n), await new Promise((e => setTimeout(e, 1e3))), $.ajax({ url: n, method: "GET", success: e => { const t = new DOMParser, n = $(t.parseFromString(e, "text/html")); this.parseMovieList(n); }, error: function(e) { console.error(e), show.error("加载下一页失败:" + e.message); } })) : (show.ok("导入结束!"), window.refresh()); } } class Ue extends X { getName() { return "CoverButtonPlugin"; } async initCss() { return `\n <style>\n .box .tags {\n justify-content: space-between;\n }\n .tool-box span{\n opacity:.3\n }\n .tool-box span:hover{\n opacity:1\n }\n ${l ? ".tool-box .icon, .setting-label .icon{ height: 24px; width: 24px; }" : ""}\n .tool-box svg path {\n fill: blue;\n }\n [data-theme="dark"] .tool-box svg path {\n fill: white;\n }\n \n \n /* 鼠标移入时的弹性动画 */\n .elastic-in {\n animation: elasticIn 0.2s ease-out forwards; /* 动画名称 | 时长 | 缓动函数 | 保持最终状态 */\n }\n \n /* 鼠标移出时的弹性动画 */\n .elastic-out {\n animation: elasticOut 0.2s ease-in forwards;\n }\n /* 弹性进入动画(像果冻弹入) */\n @keyframes elasticIn {\n 0% {\n opacity: 0;\n transform: scale(0.8); /* 起始状态:80% 大小 */\n }\n 50% {\n opacity: 1;\n transform: scale(1.1); /* 弹到 110%(超调一点) */\n }\n 70% {\n transform: scale(0.95); /* 回弹到 95%(模拟弹性阻尼) */\n }\n 100% {\n opacity: 1;\n transform: scale(1); /* 最终恢复正常大小 */\n }\n }\n /* 弹性离开动画(像果冻弹出) */\n @keyframes elasticOut {\n 0% {\n opacity: 1;\n transform: scale(1); /* 起始状态:正常大小 */\n }\n 30% {\n transform: scale(1.05); /* 先弹大一点(105%) */\n }\n 100% {\n opacity: 0;\n transform: scale(0.8); /* 最终缩小并消失 */\n }\n }\n \n \n .loading {\n opacity: 0.7;\n filter: blur(1px);\n }\n .loading-spinner {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n width: 40px;\n height: 40px;\n border: 3px solid rgba(255,255,255,.3);\n border-radius: 50%;\n border-top-color: #fff;\n animation: spin 1s ease-in-out infinite;\n z-index: 20;\n }\n @keyframes spin {\n to { transform: translate(-50%, -50%) rotate(360deg); }\n }\n </style>\n `; } handle() { window.isListPage && (this.addSvgBtn(), this.bindClick().then()); } async addSvgBtn() { const _st = await storageManager.getSetting(); // 动态生成封面按钮: 根据 coverSites 和 hiddenSites 控制显示 const _coverSites = jhsGetCoverSites(); const _hidden = jhsGetHiddenSites(); const coverBtnsHtml = (stylePrefix) => COVER_ALL_KEYS.map(key => { const cls = COVER_SITE_CLASS[key]; const show = _coverSites.includes(key) && !_hidden.includes(key); // 注意: 不能写成 `${sty}${stylePrefix}`, 否则隐藏站点会拼出 "display:none;display:inline;" 被后者覆盖, 导致关掉的站点仍显示。 // 统一计算最终 display: 隐藏 → none; 显示 → JavBus 用 inline, JavDB 用默认。 const isInline = stylePrefix.includes("inline"); const displayVal = show ? (isInline ? "display:inline;" : "") : "display:none;"; const nameKey = "siteName" + key.charAt(0).toUpperCase() + key.slice(1); const name = _st[nameKey] || key; return `<a class="site-btn ${cls}" style="${displayVal}color:white !important;margin-bottom: 5px;background-color:#71bb59;"><span style="opacity: 1;${isInline ? "display: inline; color:white !important" : ""}">${name}</span></a>`; }).join("\n "); $(this.getSelector().itemSelector).toArray().forEach((e => { let t = $(e); if (!(t.find(".tool-box").length > 0) && (r && t.find(".tags").append(`\n <div class="tool-box" style="margin-left: auto; display: flex; align-items: center">\n <span class="screenSvg" title="长缩略图" style="margin-right: 15px;">${this.screenSvg}</span>\n \n <span class="videoSvg" title="播放视频" style="margin-right: 15px;">${this.videoSvg}</span>\n \n <div class="more-tools-container handleSvg" style="position: relative; margin-right: 15px;">\n <div title="鉴定处理" style="padding: 5px; margin: -5px;opacity:.3">${this.handleSvg}</div>\n \n <div class="more-tools" style=" position: absolute; bottom: 33px; right: -30px; display: none;\n background-color: rgba(255, 255, 255, 0);z-index: 10;">\n <a class="menu-btn hasWatchBtn" style="background-color:${S};color:white !important;margin-bottom: 5px"><span style="opacity: 1;">${k}</span></a>\n <a class="menu-btn hasDownBtn" style="background-color:${x}; color:white !important;margin-bottom: 5px"><span style="opacity: 1;">${y}</span></a>\n <a class="menu-btn favoriteBtn" style="background-color:${w}; color:white !important;margin-bottom: 5px"><span style="opacity: 1;">${v}</span></a>\n <a class="menu-btn filterBtn" style="background-color:${f}; color:white !important;margin-bottom: 5px"><span style="opacity: 1;">${m}</span></a>\n </div>\n </div>\n \n <div class="more-tools-container siteSvg" style="position: relative; margin-right: 15px;">\n <div title="第三方网站" style="padding: 5px; margin: -5px;opacity:.3">${this.siteSvg}</div>\n \n <div class="more-tools" style=" position: absolute; bottom: 33px; right: -30px; display: none;\n background-color: rgba(255, 255, 255, 0);z-index: 10;">\n ${coverBtnsHtml("")}\n </div>\n </div>\n \n <div class="more-tools-container copySvg" style="position: relative; margin-right: 15px;">\n <div title="复制按钮" style="padding: 5px; margin: -5px;opacity:.3">${this.copySvg}</div>\n \n <div class="more-tools" style="\n position: absolute;\n bottom: 20px;\n right: -10px;\n display: none;\n background: white;\n box-shadow: 0 2px 8px rgba(0,0,0,0.15);\n border-radius: 20px;\n padding: 10px 0;\n margin-bottom: 15px;\n z-index: 10;\n ">\n <span class="carNumSvg" title="复制番号" style="padding: 5px 10px; white-space: nowrap;">${this.carNumSvg}</span>\n <span class="titleSvg" title="复制标题" style="padding: 5px 10px; white-space: nowrap;">${this.titleSvg}</span>\n <span class="downSvg" title="下载封面" style="padding: 5px 10px; white-space: nowrap;">${this.downSvg}</span>\n </div>\n </div>\n </div>\n `), l)) { if (t.find(".avatar-box").length > 0) return; t.find(".photo-info").append(`\n <div class="tool-box" style="display: flex; align-items: center;justify-content: flex-end">\n <span class="screenSvg" title="长缩略图" style="margin-right: 15px;">${this.screenSvg}</span>\n\n <span class="videoSvg" title="播放视频" style="margin-right: 15px;">${this.videoSvg}</span>\n \n <div class="more-tools-container handleSvg" style="position: relative; margin-right: 15px;">\n <div title="鉴定处理" style="padding: 5px; margin: -5px;opacity:.3">${this.handleSvg}</div>\n \n <div class="more-tools" style=" position: absolute; bottom: 33px; right: -30px; display: none;\n background-color: rgba(255, 255, 255, 0);z-index: 10;">\n <a class="menu-btn hasWatchBtn" style="background-color:${S};color:white;margin-bottom: 5px"><span style="opacity: 1;display: inline; color:white !important">${k}</span></a>\n <a class="menu-btn hasDownBtn" style="background-color:${x}; color:white;margin-bottom: 5px"><span style="opacity: 1;display: inline; color:white !important">${y}</span></a>\n <a class="menu-btn favoriteBtn" style="background-color:${w}; color:white;margin-bottom: 5px"><span style="opacity: 1;display: inline; color:white !important">${v}</span></a>\n <a class="menu-btn filterBtn" style="background-color:${f}; color:white;margin-bottom: 5px"><span style="opacity: 1;display: inline; color:white !important">${m}</span></a>\n </div>\n </div>\n \n <div class="more-tools-container siteSvg" style="position: relative; margin-right: 15px;">\n <div title="第三方网站" style="padding: 5px; margin: -5px;opacity:.3">${this.siteSvg}</div>\n \n <div class="more-tools" style=" position: absolute; bottom: 33px; right: -30px; display: none;\n background-color: rgba(255, 255, 255, 0);z-index: 10;">\n ${coverBtnsHtml("display: inline;")}\n </div>\n </div>\n \n <div class="more-tools-container copySvg" style="position: relative;">\n <div title="复制按钮" style="padding: 5px; margin: -5px;opacity:.3">${this.copySvg}</div>\n \n <div class="more-tools" style="\n max-width: 44px;\n position: absolute;\n bottom: 20px;\n right: -10px;\n display: none;\n background: white;\n box-shadow: 0 2px 8px rgba(0,0,0,0.15);\n border-radius: 20px;\n padding: 10px 0;\n margin-bottom: 15px;\n z-index: 10;\n text-align: center;\n ">\n <span class="carNumSvg" title="复制番号" style="padding: 5px 10px; white-space: nowrap;display: inline">${this.carNumSvg}</span>\n <span class="titleSvg" title="复制标题" style="padding: 5px 10px; white-space: nowrap;display: inline">${this.titleSvg}</span>\n <span class="downSvg" title="下载封面" style="padding: 5px 10px; white-space: nowrap;display: inline">${this.downSvg}</span>\n </div>\n </div>\n </div>\n `); } })), this.enableSvgBtn(); } async enableSvgBtn() { const e = await storageManager.getSetting(), {enableScreenSvg: t = _, enableVideoSvg: n = _, enableHandleSvg: a = _, enableSiteSvg: i = _, enableCopySvg: s = _} = e; [ { selector: ".screenSvg", enabled: t }, { selector: ".videoSvg", enabled: n }, { selector: ".handleSvg", enabled: a }, { selector: ".siteSvg", enabled: i }, { selector: ".copySvg", enabled: s } ].forEach((({selector: e, enabled: t}) => { $(e).toggle(t === _); })); } async bindClick() { this.getSelector(); const e = this.getBean("ListPagePlugin"); $(document).on("click", ".more-tools-container", (e => { e.preventDefault(); var t = $(e.target).closest(".more-tools-container").find(".more-tools"); $(".more-tools").not(t).stop(!0, !0).removeClass("elastic-in").addClass("elastic-out").hide(), t.is(":visible") ? t.stop(!0, !0).removeClass("elastic-in").addClass("elastic-out").hide() : t.stop(!0, !0).removeClass("elastic-out").addClass("elastic-in").show(); })), $(document).on("click", (function(e) { $(e.target).closest(".more-tools-container").length || $(".more-tools").stop(!0, !0).removeClass("elastic-in").addClass("elastic-out").hide(); })), $(document).on("click", ".videoSvg", (t => { t.preventDefault(), $('.videoSvg[title!="播放视频"]').each(((t, n) => { const a = $(n); let i = a.closest(".item"), s = i.find("img"), {carNum: o} = e.findCarNumAndHref(i); this.showImg(a, s, o), a.html(this.videoSvg).attr("title", "播放视频"); })); const n = $(t.target).closest(".item"), a = n.find(".videoSvg"); if ("播放视频" === a.attr("title")) { a.html(this.recoveryVideoSvg).attr("title", "切回封面"); const {carNum: t} = e.findCarNumAndHref(n); let i = n.find("img"); if (!i.length) return void show.error("没有找到图片"); this.showVideo(a, i, t).then(); } })), $(document).on("click", ".screenSvg", (async t => { t.preventDefault(); let n = loading(); try { const a = $(t.currentTarget).closest(".item"); let {carNum: i} = e.findCarNumAndHref(a); i = i.replace("FC2-", ""); const shot = this.getBean("ScreenShotPlugin"); const s = await shot.getScreenshot(i); n.close(); if (!s) { show.error(`${i} 未找到缩略图`); return; } // 构建切源/下载工具条配置(与详情页 addImg 保持一致) let opts = null; try { if (await storageManager.getSetting("enableThumbSourceSwitch", C) === _) { const colorMap = { javfree: "#4caf50", javbest: "#ffc107", javstore: "#f44336", projectjav: "#ca8a04" }; const list = await shot.resolveThumbSources(); if (list.length >= 1) { opts = { carNum: i, sources: list.map(ss => ({ key: ss.key, name: ss.name, color: colorMap[ss.key] || "#888" })), fetchSource: async key => shot.getScreenshot(i, key) }; } } } catch (err) { clog.debug("构建首页长缩略图切源配置失败", err); } showImageViewer(s, "缩略图", opts); } catch (a) { console.error("图片预览出错:", a), show.error("图片预览出错:" + a); } finally { n.close(); } })), $(document).on("click", ".filterBtn, .favoriteBtn, .hasDownBtn, .hasWatchBtn", (async t => { t.preventDefault(), t.stopPropagation(); try { const n = $(t.target).closest(".menu-btn"), a = n.closest(".item"), {carNum: i, url: s, publishTime: o} = e.findCarNumAndHref(a); // 判定本次点击对应的鉴定类型 let actionType = null; if (n.hasClass("filterBtn")) actionType = d; else if (n.hasClass("favoriteBtn")) actionType = h; else if (n.hasClass("hasDownBtn")) actionType = g; else if (n.hasClass("hasWatchBtn")) actionType = p; if (!actionType) return; // 已存在且是同一鉴定 → 第二次点击, 移除该鉴定 const existing = await storageManager.getCar(i); const isSameStatus = existing && existing.status === actionType; const save = async () => { // 先保存、先刷新, 演员名后台补录(避免等 parseActressName 网络请求) await storageManager.saveCar({ carNum: i, url: s, names: null, actionType, publishTime: o }), show.ok("操作成功"), window.refresh(); // 后台异步补录演员名, 不阻塞用户操作. // 注意: updateCarInfo 找不到记录会 show.error 弹窗, 所以先确认记录还在 e.parseActressName(s).then((async names => { if (!names) return; try { if (!await storageManager.getCar(i)) return; await storageManager.updateCarInfo({ carNum: i, url: s, names, actionType }); } catch (err) { clog.error("补录演员信息失败", err); } })).catch((err => clog.error("解析演员信息失败", err))); }; const doRemove = async () => { await storageManager.removeCar(i), window.refresh(), show.ok("已取消鉴定"); }; if (isSameStatus) { // 移除鉴定. 屏蔽这类需要二次确认, 其余直接取消 if (actionType === d) { utils.q(t, `是否取消屏蔽${i}?`, doRemove); } else { await doRemove(); } } else { // 首次标记, 保持原有行为: 屏蔽需二次确认, 其余直接保存 if (actionType === d) { utils.q(t, `是否屏蔽${i}?`, save); } else { await save(); } } $(".more-tools").stop(!0, !0).removeClass("elastic-in").addClass("elastic-out").hide(); } catch (err) { console.error("按钮点击处理失败:", err); } })); const _coverSetting = await storageManager.getSetting(); const coverSelector = COVER_ALL_KEYS.map(k => "." + COVER_SITE_CLASS[k]).join(", "); $(document).on("click", coverSelector, (t => { try { t.preventDefault(), t.stopPropagation(); const o = $(t.currentTarget), rItem = o.closest(".item"), {carNum: carNum} = e.findCarNumAndHref(rItem); // 根据 class 找到对应 siteKey let siteKey = null; for (const k of COVER_ALL_KEYS) { if (o.hasClass(COVER_SITE_CLASS[k])) { siteKey = k; break; } } if (!siteKey || !carNum) return; const url = jhsBuildCoverUrl(siteKey, carNum, _coverSetting); t && (t.ctrlKey || t.metaKey) ? GM_openInTab(url, { insert: 0 }) : window.open(url); } catch (t) { console.error("站点按钮处理失败:", t); } })), $(document).on("click", ".titleSvg, .carNumSvg, .downSvg", (t => { t.preventDefault(), t.stopPropagation(); const n = $(t.currentTarget).closest(".item"), {carNum: a, title: i} = e.findCarNumAndHref(n), s = n.find(l ? ".photo-frame img" : ".cover img"); $(t.currentTarget).hasClass("titleSvg") ? utils.copyToClipboard("标题", i) : $(t.currentTarget).hasClass("carNumSvg") ? utils.copyToClipboard("番号", a) : $(t.currentTarget).hasClass("downSvg") && fetch(s.attr("src")).then((e => e.blob())).then((e => { // 文件名只用番号: 原来是「番号 标题.jpg」, 标题太长还可能带路径非法字符 utils.download(e, (String(a || "cover").replace(/[\\/:*?"<>|]/g, "_").trim() || "cover") + ".jpg"); })); })); // 只有「播放视频」图标开着时, 预取才有意义 await storageManager.getSetting("enableVideoSvg", _) === _ && this._bindTrailerPrefetch(e); // 同理, 长缩略图图标关着就不必预取 await storageManager.getSetting("enableScreenSvg", _) === _ && this._bindThumbPrefetch(e); } /** * 悬停预取预告片地址: 鼠标在封面停留 200ms 就后台跑一遍 ne(), 把结果写进 localStorage 缓存。 * 等真正点「播放视频」时 _checkCache 直接命中, 省掉点击后那几个串行请求的干等。 * 只焐热缓存, 不改播放来源与优先级(仍是 DMM -> javxy -> 站点兜底)。 * 三个约束: 防抖(快速划过不发) / 同番号一次会话只预取一次 / 并发上限 2(一屏几十个封面不至于把请求打爆)。 */ _bindTrailerPrefetch(listPlugin) { const DELAY = 200, MAX_RUNNING = 2; const asked = new Set(), queue = []; let running = 0, timer = null; const pump = () => { while (running < MAX_RUNNING && queue.length) { const carNum = queue.shift(); running++; // 第二个参数 false: 预取是静默的, 解析失败不能弹提示打扰用户 ne(carNum, !1).catch((err => clog.debug("[预告片预取] 失败", carNum, err))).finally((() => { running--, pump(); })); } }; const itemSelector = this.getSelector().itemSelector || ".item"; $(document).on("mouseenter", itemSelector, (ev => { const $item = $(ev.currentTarget); clearTimeout(timer), timer = setTimeout((() => { let carNum = ""; try { carNum = (listPlugin.findCarNumAndHref($item) || {}).carNum || ""; } catch (err) { return; } if (!carNum || asked.has(carNum)) return; asked.add(carNum), queue.push(carNum), clog.debug("[预告片预取] 排队:", carNum), pump(); }), DELAY); })).on("mouseleave", itemSelector, (() => clearTimeout(timer))); } /** * 悬停预取长缩略图: 和上面预告片那条完全同构(防抖 200ms / 同番号一会话只抓一次 / 并发上限 2), * 只是把 ne() 换成 ScreenShotPlugin.getScreenshot()。 * 走的还是首页原来那条带校验的完整路径(探活 + 宽高比), 所以不会引入裂图/单帧横图的风险; * 而 getScreenshot 内部的探活本身就是 new Image 把整张图拉下来, 图片字节顺带进了浏览器 HTTP 缓存, * 点开时查看器取同一 URL 基本是白拿 —— 慢的那几秒(串行换源 + 下载)提前到悬停时花掉了。 * getScreenshot 有 _dedupeShot 兜着, 预取还没跑完就点也不会重打一遍请求。 */ _bindThumbPrefetch(listPlugin) { const DELAY = 200, MAX_RUNNING = 2; const asked = new Set(), queue = []; let running = 0, timer = null; const shot = this.getBean("ScreenShotPlugin"); if (!shot) return; const pump = () => { while (running < MAX_RUNNING && queue.length) { const carNum = queue.shift(); running++; Promise.resolve(shot.getScreenshot(carNum)).catch((err => clog.debug("[长缩略图预取] 失败", carNum, err))).finally((() => { running--, pump(); })); } }; const itemSelector = this.getSelector().itemSelector || ".item"; $(document).on("mouseenter", itemSelector, (ev => { const $item = $(ev.currentTarget); clearTimeout(timer), timer = setTimeout((() => { let carNum = ""; try { carNum = ((listPlugin.findCarNumAndHref($item) || {}).carNum || "").replace("FC2-", ""); } catch (err) { return; } if (!carNum || asked.has(carNum)) return; asked.add(carNum), queue.push(carNum), clog.debug("[长缩略图预取] 排队:", carNum), pump(); }), DELAY); })).on("mouseleave", itemSelector, (() => clearTimeout(timer))); } showImg(e, t, n) { e.html(this.videoSvg).attr("title", "播放视频"); let a = $(`#${`${n}_preview_video`}`); a.length > 0 && (a[0].pause(), a.parent().hide()), t.show(), t.removeClass("loading"), t.next(".loading-spinner").remove(); } /** 番号 -> DMM/JavTrailers 的 content_id (小写前缀 + 5位零填充数字), 无法推导则返回 null */ toContentId(e) { const t = String(e || "").trim().toLowerCase().match(/^([a-z]+)-?(\d+)$/); return t ? t[1] + String(t[2]).padStart(5, "0") : null; } /** DMM 解析失败时的兜底: 打开 JavTrailers 对应页面(其站内播放器不受 CORS 限制) */ openJavTrailers(e) { const t = this.toContentId(e); if (!t) return show.error("未解析到视频"), !1; const n = `https://javtrailers.com/video/${t}`; return show.error(`未解析到视频, 已打开 JavTrailers: ${t}`), GM_openInTab(n, { active: !0, insert: !0 }), !0; } /** 封面原位内嵌播放器的状态角标(和详情页弹层同一套语义, 只是尺寸更小) */ ensureCoverVideoStyle() { if ($("#jhs-cover-video-style").length) return; $("<style>").attr("id", "jhs-cover-video-style").text(` .jhs-cover-video-wrap{position:relative} .jhs-cover-video-status{position:absolute;left:6px;top:6px;z-index:12;max-width:calc(100% - 12px);display:inline-flex;align-items:center;gap:5px;padding:3px 7px;border-radius:4px;background:rgba(2,6,23,.7);color:#e2e8f0;font-size:11px;line-height:1.35;backdrop-filter:blur(4px);pointer-events:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .jhs-cover-video-status[hidden]{display:none} .jhs-cover-video-status.is-error{color:#fca5a5;white-space:normal} .jhs-cover-video-spin{width:9px;height:9px;flex:0 0 9px;border:2px solid rgba(226,232,240,.3);border-top-color:#e2e8f0;border-radius:50%;animation:jhsTrailerSpin .7s linear infinite} `).appendTo("head"); } /** * 封面「播放视频」: 保持原位内嵌形态。 * 优先 DMM(有日本分流时画质最好), 解析不到或播不动就原位降级到 javxy(会自动换 JavTrailers/JavDB), * 两条路都失败才退回打开 JavTrailers 站点。 */ async showVideo(e, t, n) { const videoId = `${n}_preview_video`; let $video = $(`#${videoId}`); if ($video.length > 0) return $video.parent().show(), $video[0].play(), void t.hide(); this.ensureCoverVideoStyle(), t.addClass("loading"), t.after('<div class="loading-spinner"></div>'); const poster = t.attr("src"); let dmmSrc = ""; try { const map = await ne(n); if (map) { const q = Z(Object.keys(map), await storageManager.getSetting("videoQuality")); dmmSrc = map[q] || ""; } } catch (err) { clog.debug("DMM 解析失败, 交给 javxy", err); } // 外层容器沿用原来的定位方式: JavDB 绝对定位盖在封面上, JavBus 普通块级 const inner = `\n <video poster="${poster}" id="${videoId}" controls loop muted playsinline\n style="max-height: 100%; max-width: 100%; object-fit: contain"></video>\n <span class="jhs-cover-video-status" hidden></span>\n `; const wrap = l ? `<div class="jhs-cover-video-wrap">${inner}</div>` : `<div class="jhs-cover-video-wrap" style="display: flex; justify-content: center; align-items: center; position: absolute; top:0; left:0; height: 100%; width: 100%; z-index: 10; overflow: hidden">${inner}</div>`; t.parent().append(wrap), t.hide(), t.removeClass("loading"), t.next(".loading-spinner").remove(), $video = $(`#${videoId}`); const video = $video[0], $status = $video.siblings(".jhs-cover-video-status"); const setStatus = (text, opts = {}) => { $status.removeClass("is-error"), opts.error && $status.addClass("is-error"), $status.html(`${!1 === opts.loading ? "" : '<span class="jhs-cover-video-spin"></span>'}<span>${escapeHtml(text)}</span>`).prop("hidden", !1); }; const hideStatus = () => $status.prop("hidden", !0); // 封面被切回图片模式后就别再往这个 video 上折腾了。 // 注意只看 display, 不用 utils.isHidden: 绝对定位的容器在封面图隐藏瞬间可能量到 0 尺寸而被误判 const aborted = () => { const el = document.getElementById(videoId); if (!el) return !0; const wrap = el.parentElement; return !!wrap && "none" === window.getComputedStyle(wrap).display; }; const playViaJavxy = async () => { try { video.removeAttribute("src"), video.load(); } catch (err) {} await JhsTrailer.bindVideo(video, n, { isAborted: aborted, onStatus: setStatus, onReady: () => { hideStatus(), video.muted = !1; }, onFail: info => { clog.warn("javxy 也播不了, 退回 JavTrailers 站点:", n, info && info.reason), this.openJavTrailers(n), this.showImg(e, t, n); } }); }; if (!dmmSrc) return void await playViaJavxy(); setStatus("正在加载: DMM"), video.addEventListener("error", (() => { clog.warn("DMM 视频加载失败, 原位降级到 javxy:", n, video.error && video.error.message), playViaJavxy(); }), { once: !0 }), video.addEventListener("playing", (() => hideStatus()), { once: !0 }), video.src = dmmSrc, video.load(), video.muted = !1, video.play().catch((() => {})), $video.trigger("focus"); } } class Oe extends X { constructor() { super(...arguments), i(this, "$contentBox", $(".section .container")), i(this, "urlParams", new URLSearchParams(window.location.search)), i(this, "sortVal", this.urlParams.get("sort") || "release_date"), i(this, "currentPage", this.urlParams.get("page") ? parseInt(this.urlParams.get("page")) : 1), i(this, "maxPage", null), i(this, "keyword", this.urlParams.get("keyword") || null); } getName() { return "Fc2By123AvPlugin"; } async getBaseUrl() { const e = this.getBean("OtherSitePlugin"); return await e.getAv123Url() + "/ja"; } handle() { $("#navbar-menu-hero > div > div:nth-child(1) > div > a:nth-child(4)").after('<a class="navbar-item" href="/advanced_search?type=100&released_start=2099-09">123Av-Fc2</a>'), $('.tabs li:contains("FC2")').after('<li><a href="/advanced_search?type=100&released_start=2099-09"><span>123Av-Fc2</span></a></li>'), o.includes("/advanced_search?type=100") && (this.hookPage(), this.handleQuery().then()); } hookPage() { let e = $("h2.section-title"); e.contents().first().replaceWith("123Av"), e.css("marginBottom", "0"), e.append('\n <div style="margin-left: 100px; width: 400px;">\n <input id="search-123av-keyword" type="text" placeholder="搜索123Av Fc2ppv内容" style="padding: 4px 5px;margin-right: 0">\n <a id="search-123av-btn" class="a-primary" style="margin-left: 0">搜索</a>\n <a id="clear-123av-btn" class="a-info" style="margin-left: 0">重置</a>\n </div>\n '), $("#search-123av-keyword").val(this.keyword), $("#search-123av-btn").on("click", (async () => { let e = $("#search-123av-keyword").val().trim(); e && (this.keyword = e, utils.setHrefParam("keyword", e), await this.handleQuery()); })), $("#clear-123av-btn").on("click", (async () => { $("#search-123av-keyword").val(""), this.keyword = "", utils.setHrefParam("keyword", ""), $(".page-box").show(), $(".tool-box").show(), await this.handleQuery(); })), $(".empty-message").remove(), $("#foldCategoryBtn").remove(), $(".section .container .box").remove(), $("#sort-toggle-btn").remove(), this.$contentBox.append('<div class="tool-box" style="margin-top: 10px"></div>'), this.$contentBox.append('<div class="movie-list h cols-4 vcols-8" style="margin-top: 10px"></div>'), this.$contentBox.append('<div class="page-box"></div>'); $(".tool-box").append('\n <div class="button-group">\n <div class="buttons has-addons" id="conditionBox">\n <a style="padding:18px 18px !important;" class="button is-small" data-sort="release_date">发布日期</a>\n <a style="padding:18px 18px !important;" class="button is-small" data-sort="recent_update">最近更新</a>\n <a style="padding:18px 18px !important;" class="button is-small" data-sort="trending">热门</a>\n <a style="padding:18px 18px !important;" class="button is-small" data-sort="most_viewed_today">今天最多观看</a>\n <a style="padding:18px 18px !important;" class="button is-small" data-sort="most_viewed_week">本周最多观看</a>\n <a style="padding:18px 18px !important;" class="button is-small" data-sort="most_viewed_month">本月最多观看</a>\n <a style="padding:18px 18px !important;" class="button is-small" data-sort="most_viewed">最多观看</a>\n <a style="padding:18px 18px !important;" class="button is-small" data-sort="most_favourited">最受欢迎</a>\n </div>\n </div>\n '), $(`#conditionBox a[data-sort="${this.sortVal}"]`).addClass("is-info"), utils.setHrefParam("sort", this.sortVal), utils.setHrefParam("page", this.currentPage), $("#conditionBox").on("click", "a.button", (e => { let t = $(e.target); this.sortVal = t.data("sort"), utils.setHrefParam("sort", this.sortVal), t.siblings().removeClass("is-info"), t.addClass("is-info"), this.handleQuery(); })); $(".page-box").append('\n <nav class="pagination">\n <a class="pagination-previous">上一页</a>\n <ul class="pagination-list"></ul>\n <a class="pagination-next">下一页</a>\n </nav>\n '), $(document).on("click", ".pagination-link", (e => { e.preventDefault(), this.currentPage = parseInt($(e.target).data("page")), utils.setHrefParam("page", this.currentPage), this.renderPagination(), this.handleQuery(); })), $(".pagination-previous").on("click", (e => { e.preventDefault(), this.currentPage > 1 && (this.currentPage--, utils.setHrefParam("page", this.currentPage), this.renderPagination(), this.handleQuery()); })), $(".pagination-next").on("click", (e => { e.preventDefault(), this.currentPage < this.maxPage && (this.currentPage++, utils.setHrefParam("page", this.currentPage), this.renderPagination(), this.handleQuery()); })); } renderPagination() { const e = $(".pagination-list"); e.empty(); let t = Math.max(1, this.currentPage - 2), n = Math.min(this.maxPage, this.currentPage + 2); this.currentPage <= 3 ? n = Math.min(6, this.maxPage) : this.currentPage >= this.maxPage - 2 && (t = Math.max(this.maxPage - 5, 1)), t > 1 && (e.append('<li><a class="pagination-link" data-page="1">1</a></li>'), t > 2 && e.append('<li><span class="pagination-ellipsis">…</span></li>')); for (let a = t; a <= n; a++) { const t = a === this.currentPage ? " is-current" : ""; e.append(`<li><a class="pagination-link${t}" data-page="${a}">${a}</a></li>`); } n < this.maxPage && (n < this.maxPage - 1 && e.append('<li><span class="pagination-ellipsis">…</span></li>'), e.append(`<li><a class="pagination-link" data-page="${this.maxPage}">${this.maxPage}</a></li>`)); } async handleQuery() { let e = loading(); try { let e = []; e = 1 === this.currentPage ? [ 1, 2 ] : [ 2 * this.currentPage - 1, 2 * this.currentPage ], this.keyword && (e = [ 1 ], $(".page-box").hide(), $(".tool-box").hide()); const t = await this.getBaseUrl(), n = e.map((e => { // 123av 改版: FC2 分类路径由 /tags/fc2 迁到 /makers/fc2 (旧路径已 404) let n = `${t}/makers/fc2?sort=${this.sortVal}&page=${e}`; return this.keyword && (n = `${t}/search?keyword=${this.keyword}`), gmHttp.get(n); })), a = await Promise.all(n); let i = []; for (const o of a) { let e = $(o); // 改版后的条目结构: main .grid .card + .card__img / .card__link, // 链接文本形如 "FC2-PPV-123456 — 标题", 用 " — " 切分 e.find("main .grid .card").each(((e, n) => { const a = $(n), s = a.find(".card__img").attr("src") || a.find(".card__img").attr("data-src"); const r = a.find(".card__link"), l = r.attr("href") || ""; if (!l) return; const c = l.startsWith("/ja") ? l.replace("/ja", "") : l, d = t + (c.startsWith("/") ? c : "/" + c); const p = r.text().trim().split(" — "); const u = (p[0] || "").replace("FC2-PPV", "FC2"); u && i.push({ imgSrc: s, carNum: u, href: d, title: p.slice(1).join(" — ") || u }); })); if (!this.maxPage) { let t, n = e.find(".page-item:not(.disabled)").last(); if (n.find("a.page-link").length) { let e = n.find("a.page-link").attr("href"); t = parseInt(e.split("page=")[1]); } else t = parseInt(n.find("span.page-link").text()); Number.isFinite(t) && t > 0 && (this.maxPage = Math.ceil(t / 2), this.renderPagination()); } } if (0 === i.length) { clog.log(i), show.error("无结果"); let e = `${t}/makers/fc2?sort=${this.sortVal}`; this.keyword && (e = `${t}/search?keyword=${this.keyword}`), console.error("获取数据失败!", e); } let s = this.markDataListHtml(i); $(".movie-list").html(s), await utils.smoothScrollToTop(); } catch (t) { console.error(t); } finally { e.close(); } } async open123AvFc2Dialog(e, t) { // 第三方站点栏与屏蔽/收藏/已下载/已观看已从 FC2 弹层移除, 只留字幕搜索(磁力直接嵌在 #magnets-content) // 按钮行受「插件管理 > 状态管理 > FC2页弹窗按钮」控制 const bar = await Fc2ActionBarPlugin.isEnabled() ? '<div style="margin: 10px 0; display:flex; gap:8px; align-items:center; justify-content:flex-end; flex-wrap:wrap">\n <a id="xunLeiSubtitleBtn" class="menu-btn" style="background:linear-gradient(to left, #375f7c, #2196F3)">\n <span>字幕搜索</span>\n </a>\n </div>' : ""; let a = `\n <div class="movie-detail-container">\n \x3c!-- <div class="movie-poster-container">\n <iframe class="movie-trailer" frameborder="0" allowfullscreen scrolling="no"></iframe>\n </div>\n <div class="right-box">--\x3e\n <div class="movie-info-container">\n <div class="search-loading">加载中...</div>\n </div>\n \n ${bar}\n <div class="message video-panel" style="margin-top:20px">\n <div id="magnets-content" class="magnet-links">\n </div>\n </div>\n <div id="reviews-content">\n </div>\n <div id="related-content">\n </div>\n <span id="data-actress" style="display: none"></span>\n \x3c!-- </div>--\x3e\n </div>\n `; layer.open({ type: 1, title: e, content: a, area: utils.getResponsiveArea(), skin: "movie-detail-layer", scrollbar: !1, success: (n, a) => { utils.setupEscClose(a), this.loadData(e, t); let i = e.replace("FC2-", ""); // 面板样式由「插件管理 > 外部搜索 > 磁力表格面板」决定: 开=移植的表格, 关=原来的卡片列表 MagnetTablePlugin.createPanel(this.getBean.bind(this), i).then((panel => $("#magnets-content").append(panel))), $("#xunLeiSubtitleBtn").on("click", (() => this.getBean("DetailPageButtonPlugin").searchXunLeiSubtitle(e))); this.getBean("DetailQuickIconPlugin")?.mountFc2QuickIcons($("#xunLeiSubtitleBtn"), { carNum: e, url: t, names: () => $("#data-actress").text(), publishTime: () => $("#data-publishTime").text() }); } }); } async loadData(e, t) { let n = loading(); try { const {id: n, publishDate: a, title: i, moviePoster: s} = await this.get123AvVideoInfo(t); $(".movie-info-container").html(`\n <h3 class="movie-title" style="margin-bottom: 10px"><strong class="current-title">${escapeHtml(i || "无标题")}</strong></h3>\n <div class="movie-meta" style="margin-bottom: 10px">\n <span><strong>番号: </strong>${e || "未知"}</span>\n <span><strong>年份: </strong>${a || "未知"}</span>\n <span>\n <strong>站点: </strong>\n <a href="https://fc2ppvdb.com/articles/${e.replace("FC2-", "")}" target="_blank">fc2ppvdb</a>\n <a style="margin-left: 5px;" href="https://adult.contents.fc2.com/article/${e.replace("FC2-", "")}/" target="_blank">fc2电子市场</a>\n </span>\n </div>\n <div class="movie-actors" style="margin-bottom: 10px">\n <div class="actor-list"><strong>主演: </strong></div>\n </div>\n <div class="movie-seller" style="margin-bottom: 10px">\n <span><strong>販売者: </strong></span>\n </div>\n <div class="movie-gallery" style="margin-bottom: 10px">\n <strong>剧照: </strong>\n <div class="image-list"></div>\n </div>\n \n <div id="data-publishTime" style="display: none">${a || ""}</div>\n\n `), this.getImgList(e).then(), this.getActressInfo(e).then(), this.getBean("TranslatePlugin").translate(e, !1).then(); } catch (a) { console.error(a); } finally { n.close(); } } handleLongImg(e) { utils.loopDetector((() => $(".movie-gallery .image-list").length > 0), (async () => { $(".movie-gallery .image-list").prepend(' <a class="tile-item screen-container" style="overflow:hidden;max-height: 150px;max-width:150px; text-align:center;"><div style="margin-top: 52px;color: #000;cursor: auto;font-size:15px">正在加载缩略图</div></a> '); const t = await this.getBean("ScreenShotPlugin").getScreenshot(e); t && ($(".screen-container").html(`<img src="${t}" alt="" loading="lazy" style="width: 100%;">`), $(".screen-container").on("click", (e => { e.stopPropagation(), e.preventDefault(), showImageViewer(e.currentTarget); }))); })); } async get123AvVideoInfo(e) { const t = await gmHttp.get(e), n = t.match(/v-scope="Movie\({id:\s*(\d+),/), a = n ? n[1] : null, i = utils.htmlTo$dom(t); return { id: a, publishDate: i.find('span:contains("リリース日:")').next("span").text(), title: i.find("h1").text().trim(), moviePoster: i.find("#player").attr("data-poster") }; } async getActressInfo(e) { let t = `https://fc2ppvdb.com/articles/${e.replace("FC2-", "")}`; const n = await gmHttp.get(t), a = $(n), i = a.find("div").filter((function() { return 0 === $(this).text().trim().indexOf("女優:"); })); if (0 === i.length || i.length > 1) return void show.error("解析女优信息失败"); const s = $(i[0]).find("a"); let o = "<strong>主演: </strong>"; if (s.length > 0) { let e = ""; s.each(((t, n) => { let a = $(n), i = a.text(), s = a.attr("href"); o += `<span class="actor-tag"><a href="https://fc2ppvdb.com${escapeHtml(s)}" target="_blank">${escapeHtml(i)}</a></span>`, e += i + " "; })), $("#data-actress").text(e); } else o += "<span>暂无演员信息</span>"; $(".actor-list").html(o); const r = a.find("div").filter((function() { return 0 === $(this).text().trim().indexOf("販売者:"); })); if (r.length > 0) { const e = $(r[0]).find("a"); if (e.length > 0) { const t = $(e[0]); let n = t.text(), a = t.attr("href"); $(".movie-seller").html(`<span><strong>販売者: </strong><a href="https://fc2ppvdb.com${escapeHtml(a)}" target="_blank">${escapeHtml(n)}</a></span>`); } } } async getImgList(e) { let t = e.replace("FC2-", ""), n = `https://adult.contents.fc2.com/article/${e.replace("FC2-", "")}/`; const a = await gmHttp.get(n, null, { referer: n }); let i = $(a).find(".items_article_SampleImagesArea img").map((function() { return $(this).attr("src"); })).get(), s = ""; Array.isArray(i) && i.length > 0 ? s = i.map(((e, t) => `\n <a href="${e}" data-fancybox="movie-gallery" data-caption="剧照 ${t + 1}">\n <img src="${e}" class="movie-image-thumb" loading="lazy" alt=""/>\n </a>\n `)).join("") : $(".movie-gallery").html("<h4>剧照: 暂无剧照</h4>"), $(".image-list").html(s), this.handleLongImg(t); } async getMovie(e, t) { let n = `${await this.getBaseUrl()}/ajax/v/${e}/videos`, a = loading(); try { let e = (await gmHttp.get(n)).result.watch; return e.length > 0 ? (e.forEach((e => { e.url = e.url + "?poster=" + t; })), e) : null; } catch (i) { console.error(i); } finally { a.close(); } } markDataListHtml(e) { let t = ""; return e.forEach((e => { t += `\n <div class="item">\n <a href="${e.href}" class="box" title="${e.title}">\n <div class="cover ">\n <img loading="lazy" src="${e.imgSrc.replace("/s360", "")}" alt="">\n </div>\n <div class="video-title"><strong>${e.carNum}</strong> ${e.title}</div>\n <div class="score">\n </div>\n <div class="meta">\n </div>\n <div class="tags has-addons">\n </div>\n </a>\n </div>\n `; })), t; } } /** 磁力评分:基于做种数/分辨率/字幕/新鲜度/完整性,返回 0-100 分 */ function calcMagnetScore(e) { let t = 0; const n = (e.seeders || 0); const seedersScore = n >= 50 ? 35 : n >= 10 ? 25 : n >= 1 ? 15 : 3; t += seedersScore; const a = (e.title || "").toLowerCase(); const resolutionScore = /4k|2160p/.test(a) ? 25 : /1080p/.test(a) ? 20 : /720p/.test(a) ? 15 : 5; t += resolutionScore; const subtitleScore = /-c\b|-uc\b|chinese|中字|字幕/.test(a) ? 20 : 0; t += subtitleScore; const i = e.date ? _daysSince(e.date) : 999; const freshnessScore = i <= 7 ? 15 : i <= 30 ? 12 : i <= 90 ? 8 : 3; t += freshnessScore; const completenessScore = /sample|预告|trailer/.test(a) ? -15 : 0; t += completenessScore; return { total: Math.max(0, Math.min(100, t)), seeders: seedersScore, resolution: resolutionScore, subtitle: subtitleScore, freshness: freshnessScore, completeness: completenessScore }; } function _daysSince(e) { try { const t = new Date(e); if (isNaN(t.getTime())) return 999; return Math.max(0, Math.floor((Date.now() - t.getTime()) / 864e5)); } catch (t) { return 999; } } class Re extends X { constructor() { super(...arguments), i(this, "currentEngine", null), i(this, "searchEngines", [ { name: "U9A9", id: "u9a9", url: "https://u9a9.com/?type=2&search={keyword}", targetPage: "https://u9a9.com/?type=2&search={keyword}", parseHtml: this.parseTorrentList }, { name: "U3C3", id: "u3c3", url: "https://u3c3.com/?search2=a8lr16lo&search={keyword}", targetPage: "https://u3c3.com/?search2=a8lr16lo&search={keyword}", parseHtml: this.parseTorrentList }, { name: "Sukebei", id: "Sukebei", url: "https://sukebei.nyaa.si/?f=0&c=0_0&q={keyword}", targetPage: "https://sukebei.nyaa.si/?f=0&c=0_0&q={keyword}", parseHtml: this.parseTorrentList } ]); } getName() { return "MagnetHubPlugin"; } async initCss() { return "\n <style>\n .magnet-container {\n margin: 20px auto;\n width: 100%;\n font-family: Arial, sans-serif;\n }\n .magnet-tabs {\n display: flex;\n border-bottom: 1px solid #ddd;\n margin-bottom: 15px;\n justify-content: space-between;\n }\n .magnet-tab {\n padding: 5px 12px;\n cursor: pointer;\n border: 1px solid transparent;\n border-bottom: none;\n margin-right: 5px;\n background: #f5f5f5;\n border-radius: 5px 5px 0 0;\n }\n .magnet-tab.active {\n background: #fff;\n border-color: #ddd;\n border-bottom: 1px solid #fff;\n margin-bottom: -1px;\n font-weight: bold;\n }\n .magnet-tab:hover:not(.active) {\n background: #e9e9e9;\n }\n \n .magnet-results {\n min-height: 200px;\n }\n .magnet-result {\n padding: 15px;\n border-bottom: 1px solid #eee;\n position: relative; \n }\n .magnet-result:hover {\n background-color: #f9f9f9;\n }\n .magnet-title {\n font-weight: bold;\n margin-bottom: 5px;\n white-space: nowrap;\n overflow: hidden; \n text-overflow: ellipsis;\n padding-right: 80px; \n }\n .magnet-info {\n display: flex;\n justify-content: space-between;\n font-size: 12px;\n color: #666;\n margin-bottom: 5px;\n }\n .magnet-loading {\n text-align: center;\n padding: 20px;\n }\n .magnet-error {\n color: #f44336;\n padding: 10px;\n }\n \n .magnet-copy {\n position: absolute;\n right: 15px;\n top: 12px;\n }\n .magnet-hub-btn {\n background-color: #f0f0f0;\n color: #555;\n border: 1px solid #ddd;\n padding: 3px 8px;\n border-radius: 3px;\n cursor: pointer;\n font-size: 12px;\n transition: all 0.2s;\n margin-left: 10px;\n }\n .magnet-hub-btn:hover {\n background-color: #e0e0e0;\n border-color: #ccc;\n }\n .magnet-hub-btn.copied {\n background-color: #4CAF50;\n color: white;\n border-color: #4CAF50;\n }\n </style>\n "; } createMagnetHub(e) { e = e.replace("FC2-", ""); const t = $('<div class="magnet-container"></div>'), n = $('<div class="magnet-tabs"></div>'), a = "jhs_magnetHub_selectedEngine", i = localStorage.getItem(a); let s = 0; const o = $('<div style="display: flex;"></div>'); this.searchEngines.forEach(((e, t) => { const n = $(`<div class="magnet-tab" data-engine="${e.id}">${e.name}</div>`); i && e.id === i ? (n.addClass("active"), this.currentEngine = e, s = t) : 0 !== t || i || (n.addClass("active"), this.currentEngine = e), o.append(n); })), n.append(o), n.append(`<a style="margin-right: 20px;margin-top:3px" id="targetBox" href="${this.currentEngine.targetPage.replace("{keyword}", encodeURIComponent(e))}" target="_blank">原网页</a>`), t.append(n); const r = $('<div class="magnet-results"></div>'); return t.append(r), t.on("click", ".magnet-tab", (n => { const i = $(n.target).data("engine"); this.currentEngine = this.searchEngines.find((e => e.id === i)), $("#targetBox").attr("href", this.currentEngine.targetPage.replace("{keyword}", encodeURIComponent(e))), localStorage.setItem(a, i), t.find(".magnet-tab").removeClass("active"), $(n.target).addClass("active"), this.searchEngine(r, this.currentEngine, e); })), this.searchEngine(r, this.currentEngine || this.searchEngines[s], e), t; } async searchEngine(e, t, n) { e.html(`<div class="magnet-loading">正在从 ${escapeHtml(t.name)} 搜索 "${escapeHtml(n)}"...</div>`); const a = `${t.name}_${n}`; if (t.parseHtml) try { const i = t.url.replace("{keyword}", encodeURIComponent(n)), s = await storageManager.cachedRequest(`magnet:${t.id}:${n}`, 216e5, (() => gmHttp.get(i).then((e => t.parseHtml.call(this, e, n))))); return void this.displayResults(e, s, t.name); } catch (s) { return void e.html(`<div class="magnet-error">解析 ${escapeHtml(t.name)} 结果失败: ${escapeHtml(s.message)}</div>`); } t.parseJson && await t.parseJson.call(this, e, t, n, a); } displayResults(e, t, n) { // 「磁力表格面板」(MagnetTablePlugin) 借这里把 parseJson 引擎的结果接走自己渲染 const sink = e && e.data && e.data("jhsNongSink"); if ("function" == typeof sink) return void sink(t); function a(e) { const t = e.text(); e.addClass("copied").text("已复制"), setTimeout((() => { e.removeClass("copied").text(t); }), 2e3); } function i(e, t) { const n = document.createElement("textarea"); n.value = e, n.style.position = "fixed", document.body.appendChild(n), n.select(); try { document.execCommand("copy"), a(t); } catch (i) { console.error("复制失败:", i), alert("复制失败,请手动复制链接"); } document.body.removeChild(n); } e.empty(), 0 !== t.length ? (t.forEach((e => { e._score = this.calcMagnetScore(e); })), t.sort(((e, t) => t._score.total - e._score.total)), t.forEach((t => { const n = t._score ? t._score.total : 0, a = n >= 80 ? "高" : n >= 60 ? "中" : "低", i = t._score ? `做种:${t._score.seeders}/35 分辨率:${t._score.resolution}/25 字幕:${t._score.subtitle}/20 新鲜度:${t._score.freshness}/15 完整性:${t._score.completeness}/5` : ""; const safeTitle = escapeHtml(t.title), safeMagnet = escapeHtml(t.magnet); $(`\n <div class="magnet-result">\n <div class="magnet-title">\n <span class="magnet-score" title="${i}" style="cursor:help;font-size:14px;margin-right:6px;">${a} ${n}</span>\n <a href="${safeMagnet}">${safeTitle}</a>\n </div>\n <div class="magnet-info">\n <span>大小: ${t.size || "未知"}</span>\n <span>做种: ${t.seeders || "—"}</span>\n <span>日期: ${t.date || "未知"}</span>\n </div>\n <div class="magnet-copy">\n <button class="magnet-hub-btn copy-btn" data-magnet="${safeMagnet}">复制链接</button>\n </div>\n </div>\n `).appendTo(e); })), e.on("click", ".copy-btn", (function() { const e = $(this), t = e.data("magnet"); navigator.clipboard ? navigator.clipboard.writeText(t).then((() => { a(e); })).catch((n => { i(t, e); })) : i(t, e); }))) : e.append('<div class="magnet-error">没有找到相关结果</div>'); } async parseBTSOW(e, t, n, a) { try { const i = await gmHttp.post(t.url, [{ search: n }, 50, 1]), s = i.data || [], o = []; for (let e = 0; e < s.length; e++) { let t = s[e]; o.push({ title: t.name, magnet: "magnet:?xt=urn:btih:" + t.hash, size: (t.size / 1073741824).toFixed(2) + " GB", date: utils.formatDate(new Date(1e3 * t.lastUpdateTime)) }); } o.length > 0 && this.displayResults(e, o, t.name); } catch (i) { e.html(`<div class="magnet-error">从 ${escapeHtml(t.name)} 获取数据失败: ${escapeHtml(String(i.message || i))}</div>`); } } parseTorrentList(e, t) { const n = utils.htmlTo$dom(e), a = []; return n.find(".torrent-list tbody tr").each(((e, n) => { const i = $(n); if (i.text().includes("置顶")) return; const s = i.find("td:nth-child(2) a").attr("title") || i.find("td:nth-child(2) a").text().trim(); if (!s.toLowerCase().includes(t.toLowerCase())) return; const o = i.find("td:nth-child(3) a[href^='magnet:']").attr("href"), r = i.find("td:nth-child(4)").text().trim(), l = i.find("td:nth-child(5)").text().trim(), c = parseInt(i.find("td:nth-child(6)").text().trim()) || 0, d = parseInt(i.find("td:nth-child(7)").text().trim()) || 0; o && a.push({ title: s, magnet: o, size: r, date: l, seeders: c, leechers: d }); })), a; } calcMagnetScore(e) { return calcMagnetScore(e); } } class Ve extends X { getName() { return "ScreenShotPlugin"; } async handle() { this.loadScreenShot().then(); } async loadScreenShot() { if (!isDetailPage) return; if ("yes" !== await storageManager.getSetting("enableLoadScreenShot", "yes")) return; // 修正: 长缩略图很高, 会把 .preview-images (bulma tiles, 默认 align-items:stretch) 整行撑高, // 导致同排的预告片/剧照缩略图被纵向拉伸、底部露出灰色。改为顶部对齐, 各图保持自身比例。 if (r && !$("#jhs-preview-images-fix").length) { $("head").append('<style id="jhs-preview-images-fix">' + '.preview-images{align-items:flex-start !important}' + '.preview-images .screen-container{align-self:flex-start !important;height:var(--jhs-tile-h,auto)}' /* 「页面布局-宽度」调大后 section .container 变宽, tile 也随之变宽; 若格子里的图是固定宽度就会在右侧露出灰色底。统一让缩略图撑满格子宽度。 */ + '.preview-images .tile-item img,.preview-images .preview-video-container img{width:100% !important;height:auto !important;display:block}' + '.preview-images .screen-container img{height:100% !important;object-fit:cover;object-position:center}' + '.preview-images .preview-video-container{display:block}' + '</style>'); } let e = this.getPageInfo().carNum; this._currentCarNum = e; // 需求: 加载不出来/加载中都不显示, 只有拿到缩略图才插入容器展示 let t = null; try { t = await this.getScreenshot(e); } catch (err) { clog.debug("缩略图加载失败, 不显示:", err && err.message || err); return; } if (!t) return; r && $(".preview-images .tile-item").first().before(' <a class="tile-item screen-container" style="overflow:hidden;text-align:center;position:relative;"></a> '), l && $("#sample-waterfall .sample-box:first").after(' <a class="sample-box screen-container" style="overflow:hidden; height: 110px; text-align:center;"></a> '); this.addImg("缩略图", t), clog.log("加载缩略图:", t); if (r) { // 同排图片可能还在懒加载, 延迟再校准一次高度; 窗口尺寸变化时同步 setTimeout((() => this.syncScreenTileHeight()), 400); setTimeout((() => this.syncScreenTileHeight()), 1500); $(window).off("resize.jhsScreenTile").on("resize.jhsScreenTile", (() => this.syncScreenTileHeight())); } if (r) this.renderThumbSourceSwitch(e).catch(err => clog.debug("渲染切源按钮失败", err)); } /** 详情页缩略图左下角的手动切源按钮 (已禁用/移除) */ async renderThumbSourceSwitch(carNum) { return; // 切源标签已移除 } /** 各来源 key -> 抓取方法名, 供个性化设置里的顺序/启用配置使用 */ /** 来源顺序 = 优先级(resolveThumbSources 按 Object.keys 的顺序走, 阶梯竞速也按这个顺序起跑)。 * 来源顺序 javfree → projectjav → javstore。 * 它没有 javbest, 我这边把 javbest 放中间; javstore 放最后是关键——它的抓取会逐个候选详情页 * 下载图片探活(getJavStoreScreenShot), 早起跑会把带宽吃掉, 拖慢本该更快的 javfree。 */ getThumbSourceMap() { return { javfree: { name: "JavFree", fn: e => this.getJavFreeScreenShot(e), settingKey: "thumbSrcJavFree" }, javbest: { name: "JavBest", fn: e => this.getJavBestScreenShot(e), settingKey: "thumbSrcJavBest" }, javstore: { name: "JavStore", fn: e => this.getJavStoreScreenShot(e), settingKey: "thumbSrcJavStore" } }; } /** 按「个性化设置」解析出本次要用的来源列表(已过滤禁用项, 已按配置排序) */ async resolveThumbSources() { const map = this.getThumbSourceMap(); const out = []; for (const k of Object.keys(map)) { if (await storageManager.getSetting(map[k].settingKey, _) === C) continue; out.push({ key: k, ...map[k] }); } return out.length ? out : Object.keys(map).map(k => ({ key: k, ...map[k] })); } /** * 探活: 用 Image 预加载判断 URL 是否真能出图。 * javstore 改版后老条目的图(img.javstore.net/images/<hash>.jpg)全部 404, * 但解析阶段拿到的是合法 URL, 直接塞进 <img> 就会显示裂图。 * 这里在返回前先探一次, 死链当失败处理, 让竞速去用别的源。 */ _probeImageAlive(url, timeout = 8e3) { return new Promise((resolve => { if (!url) return resolve(!1); let done = !1; const img = new Image; const finish = ok => { done || (done = !0, img.onload = img.onerror = null, img.src = "", resolve(ok)); }; const timer = setTimeout((() => finish(!1)), timeout); img.onload = () => { clearTimeout(timer), finish(img.naturalWidth > 0 && img.naturalHeight > 0); }; img.onerror = () => { clearTimeout(timer), finish(!1); }; img.src = url; })); } /** * 探活并返回真实尺寸, 用于判断是"拼版长图"还是"单帧横图"。 * 三源并行竞速时, 单帧图体积小、加载快, 会抢先赢下竞速顶掉真正的长图, * 所以这里量出比例, 由调用方决定是否弃用。 */ _probeImageMetric(url, timeout = 8e3) { return new Promise((resolve => { if (!url) return resolve({ ok: !1 }); let done = !1; const img = new Image; const finish = v => { done || (done = !0, img.onload = img.onerror = null, img.src = "", resolve(v)); }; const timer = setTimeout((() => finish({ ok: !1 })), timeout); img.onload = () => { clearTimeout(timer); const w = img.naturalWidth, h = img.naturalHeight; finish({ ok: w > 0 && h > 0, w: w, h: h, ratio: w > 0 && h > 0 ? w / h : 0 }); }; img.onerror = () => { clearTimeout(timer), finish({ ok: !1 }); }; img.src = url; })); } /** 归一化 URL(截掉前缀噪音 + 升级 https), 与最终写缓存前的处理保持一致 */ _normalizeThumbUrl(u) { if (!u) return u; const i = u.indexOf("https://"); return -1 !== i && (u = u.substring(i)), u.replace(/^http:\/\//i, "https://"); } /** * 后台校验(不阻塞出图): 探活 + 量宽高比。 * 死链或单帧横图 => 清掉本地/第三方缓存, 下次打开会重新竞速换源。 * 延迟 12s 再跑: 这一步会再取一次同一张图, 立刻跑会和查看器正在加载的那张抢带宽, 反而更慢。 */ _revalidateLater(carNum, url, delay = 12e3) { setTimeout((async () => { try { const m = await this._probeImageMetric(url); const bad = !m.ok ? "死链" : m.ratio >= 1.2 ? `单帧横图 ${m.w}x${m.h} r=${m.ratio.toFixed(2)}` : ""; if (!bad) return void clog.debug("[缩略图] 后台校验通过:", carNum, url); clog.warn(`[缩略图] 后台校验不合格(${bad}), 已清缓存, 下次会换源:`, carNum, url); const map = localStorage.getItem("jhs_screenShot") ? JSON.parse(localStorage.getItem("jhs_screenShot")) : {}; map[carNum] === url && (delete map[carNum], localStorage.setItem("jhs_screenShot", JSON.stringify(map))); try { storageManager._invalidateCache && storageManager._invalidateCache(`screenshot:${carNum}`); } catch (ce) {} } catch (err) { clog.debug("[缩略图] 后台校验异常(忽略):", err && err.message || err); } }), delay); } /** 同一番号并发取图时共用一次请求。cachedRequest(pro-1.js:702) 只在"拿到结果后"写缓存, * 并发调用会各跑一遍 factory ——详情页缩略图格子那条流程还没跑完时点预览图, 就会把三个源重打一遍。 */ _dedupeShot(key, factory) { this._shotInflight = this._shotInflight || {}; if (this._shotInflight[key]) return clog.debug("[缩略图] 复用进行中的请求:", key), this._shotInflight[key]; const p = Promise.resolve().then(factory).finally((() => { delete this._shotInflight[key]; })); return this._shotInflight[key] = p; } getScreenshotFast(e) { // 完整校验版正在跑就直接等它: 它最终也会给出同一张图, 还顺手把图片下进浏览器缓存 const full = this._shotInflight && this._shotInflight[`full:${e}`]; return full || this._dedupeShot(`fast:${e}`, (() => this._getScreenshotFastRaw(e))); } getScreenshot(e, forceSource) { return forceSource ? this._getScreenshotRaw(e, forceSource) : this._dedupeShot(`full:${e}`, (() => this._getScreenshotRaw(e))); } /** * 「快出图」模式取图: 竞速只比"谁先解析出 URL", 不在这里下载整张图校验。 * 出图早, 图片交给 showFastImageViewer 边下边渲染。 * 代价: 可能拿到死链或单帧横图 => 由 _revalidateLater 事后清缓存, 当次靠查看器里的切源按钮兜底。 */ /** 阶梯竞速: 先只跑第一个源, gap 毫秒内没结果才并上下一个。 * 三源齐发时 javstore 的抓取会逐个候选详情页下载图片探活, 把带宽吃掉, 反而拖慢本该最快的 javfree。 */ _staggeredRace(sources, run, gap = 1200) { return new Promise(((resolve, reject) => { if (!sources.length) return void reject(new Error("没有启用的缩略图源")); let done = !1, started = 0, finished = 0; const errs = []; const check = () => { done || started < sources.length || finished < sources.length || reject(new Error(errs.join(" | "))); }; const startNext = () => { if (done || started >= sources.length) return; const s = sources[started++]; const timer = setTimeout(startNext, gap); Promise.resolve(run(s)).then((u => { if (done) return; clearTimeout(timer); if (u) return done = !0, void resolve(u); errs.push(`${s.name} 无结果`), finished++, startNext(), check(); }), (err => { if (done) return; clearTimeout(timer), errs.push(`${s.name}: ${err && err.message || err}`), finished++, startNext(), check(); })); }; startNext(); })); } async _getScreenshotFastRaw(e) { const _t0 = performance.now(), _ms = () => Math.round(performance.now() - _t0); const t = localStorage.getItem("jhs_screenShot") ? JSON.parse(localStorage.getItem("jhs_screenShot")) : {}; if (t[e]) { const u = this._normalizeThumbUrl(String(t[e])); u !== t[e] && (t[e] = u, localStorage.setItem("jhs_screenShot", JSON.stringify(t))); return clog.log(`[缩略图][快速] 缓存命中(+${_ms()}ms):`, e, u), this._revalidateLater(e, u), u; } const sources = await this.resolveThumbSources(); clog.log("[缩略图][快速] 启用源:", sources.map(s => s.key).join(" → "), "| 番号:", e); let n; // 这里不走 storageManager.cachedRequest: 它每次都要整块读写 IndexedDB 里的 // third_party_ttl_cache(getThirdPartyCache/setThirdPartyCache, pro-1.js:682-687), 条目多了很吃时间。 // 快速路径的持久缓存用 localStorage.jhs_screenShot 就够了。 try { n = await this._staggeredRace(sources, (async s => { const u = await s.fn(e); return u ? this._normalizeThumbUrl(u) : null; })); } catch (err) { return clog.error("[缩略图][快速] 所有源均失败:", e, err && err.message || err), this.showErrorFallback(e, err), null; } if (!n) return this.showErrorFallback(e, null), null; n = this._normalizeThumbUrl(n), t[e] = n, localStorage.setItem("jhs_screenShot", JSON.stringify(t)); return clog.log(`[缩略图][快速] 解析成功(+${_ms()}ms):`, n), this._revalidateLater(e, n), n; } async _getScreenshotRaw(e, forceSource) { const _t0 = performance.now(); const _ms = () => Math.round(performance.now() - _t0); clog.log(`[缩略图][v1.3.13] getScreenshot 被调用, 番号=${e}, forceSource=${forceSource || "自动"}`); // 版本升级后自动清旧缓存(避免旧版死链/空值卡住新版本) const _thumbVer = "1.3.13"; if (localStorage.getItem("jhs_thumbCacheVer") !== _thumbVer) { try { localStorage.removeItem("jhs_screenShot"); } catch (_) {} try { const c = await storageManager.getThirdPartyCache(); let n = 0; Object.keys(c).filter(k => k.startsWith("screenshot:")).forEach(k => { delete c[k]; n++; }); if (n > 0) { await storageManager.setThirdPartyCache(c); clog.warn(`[缩略图] 版本升级, 已清除 ${n} 条旧第三方缓存`); } } catch (_) {} localStorage.setItem("jhs_thumbCacheVer", _thumbVer); } const t = localStorage.getItem("jhs_screenShot") ? JSON.parse(localStorage.getItem("jhs_screenShot")) : {}; // 缓存命中直接返回, 不再阻塞探活: 探活等的是 onload, 相当于把整张图先下载一遍, // 缓存命中也要等 1~3s。改成后台校验, 死链下次打开自动换源。 if (!forceSource && t[e]) { const u = this._normalizeThumbUrl(String(t[e])); u !== t[e] && (t[e] = u, localStorage.setItem("jhs_screenShot", JSON.stringify(t))); return clog.log(`[缩略图] 缓存命中直接返回(+${_ms()}ms):`, e, u), this._revalidateLater(e, u), u; } const thumbDiag = []; const asReject = (p, name) => Promise.resolve(p).then((v => v || Promise.reject(new Error(`${name} 无结果`))), (err => Promise.reject(err))); // 单帧横图判定阈值: 宽高比 >= 该值视为单帧/封面(如 800x534 r=1.5), 不是拼版长截图。 const LONG_MAX_RATIO = 1.2; // 竞速仍然带上"下载整张图"这一步, 这是有意的: // 1) 胜者=最快真正加载完的图, 正好等于查看器要花的时间; 只比"谁先解析出 URL"会选到图床很慢的源, 反而更慢。 // 2) 这一次下载会进浏览器 HTTP 缓存, 查看器随后取同一 URL 基本是白拿。 // 3) 死链和单帧横图在这里就被判失败, 让竞速去用别的源。 const asAliveReject = (s) => asReject(s.fn(e), s.name).then((async url => { const norm = this._normalizeThumbUrl(url); const m = await this._probeImageMetric(norm); if (!m.ok) { thumbDiag.push(`❌ ${s.name} 图死链(跳过): ${norm}`); throw new Error(`${s.name} 图片死链`); } if (m.ratio >= LONG_MAX_RATIO) { thumbDiag.push(`❌ ${s.name} 单帧横图(跳过) ${m.w}x${m.h} r=${m.ratio.toFixed(2)}: ${norm}`); throw new Error(`${s.name} 非长图(单帧 r=${m.ratio.toFixed(2)})`); } thumbDiag.push(`✅ ${s.name} 命中(+${_ms()}ms) ${m.w}x${m.h} r=${m.ratio.toFixed(2)}: ${norm}`); return norm; }), (err => { thumbDiag.push(`❌ ${s.name} 失败: ${err && err.message || err}`); throw err; })); let n; // 手动指定来源: 跳过缓存与模式, 直接打那一个源 if (forceSource) { const map = this.getThumbSourceMap(); const src = map[String(forceSource).toLowerCase()]; if (!src) return null; try { n = await src.fn(e); } catch (err) { clog.warn(`${src.name} 抓取失败:`, err && err.message || err); return null; } if (!n) return null; n = this._normalizeThumbUrl(n); } else { const sources = await this.resolveThumbSources(); clog.log(`[缩略图][v1.3.13] 启用源顺序:`, sources.map(s => s.key).join(" → "), "| 番号:", e, "| GM_xhr:", "function" == typeof GM_xmlhttpRequest ? "OK" : "缺失!"); try { n = await storageManager.cachedRequest(`screenshot:${e}`, 864e5, (async () => { // 并行竞速: 谁先返回"能加载的"非空长图用谁 return Promise.any(sources.map((s => asAliveReject(s)))); })); } catch (i) { const aggMsg = i && i.errors ? i.errors.map(e => e && e.message || e).join(" | ") : (i && i.message || i); clog.error("【逐源诊断】所有缩略图源均失败:", e); thumbDiag.forEach(line => clog.error(" " + line)); clog.error("【聚合错误】", aggMsg); return this.showErrorFallback(e, i), null; } if (!n) return this.showErrorFallback(e, null), null; n = this._normalizeThumbUrl(n); } thumbDiag.length && clog.log("【逐源诊断-成功】", e, "|", thumbDiag.join(" | ")); t[e] = n, localStorage.setItem("jhs_screenShot", JSON.stringify(t)); // 这条路径的图刚刚在竞速里验过, 不再重复校验(重复校验会和查看器抢带宽) return clog.log(`缩略图获取成功(+${_ms()}ms):`, n), n; } async getJavStoreScreenShot(e) { const code = String(e).replace(/^fc2-?/i, ""); const norm = code.replace(/-/g, "").toLowerCase(); clog.log("[缩略图] javstore 搜索:", code); const t = `https://javstore.net/search?q=${encodeURIComponent(code)}`; let n = await gmHttp.fetchRaw(t); const a = utils.htmlTo$dom(n); // 收集所有候选详情页(番号归一化后包含目标), 逐个尝试, 提高命中率 const detailUrls = []; a.find("a[href]").each((function() { const href = $(this).attr("href") || ""; let u; try { u = new URL(href, "https://javstore.net/"); } catch (err) { return; } if (!/javstore\.net$/i.test(u.hostname)) return; if (/^\/search(?:[/?#]|$)/i.test(u.pathname)) return; // 放宽详情页判定: 除 .html 外, 也接受 /<数字>- 开头的路径 const looksLikeDetail = /\.html$/i.test(u.pathname) || /^\/\d+[-/]/.test(u.pathname); if (!looksLikeDetail) return; const last = decodeURIComponent(u.pathname.split("/").pop() || "").toLowerCase().replace(/-/g, ""); if (last.includes(norm)) { detailUrls.indexOf(u.href) < 0 && detailUrls.push(u.href); } })); if (!detailUrls.length) return clog.error("JavStore, 查询番号失败:", t), null; // 关键: 探活放在候选循环"内部"。老条目(图已被 javstore 删除)会返回死链, // 之前探活在 getScreenshot 外层做, 第一个候选页返回死链就判定整个 javstore 源失败, // 不会去试后面那些图还活着的候选页 —— 这正是 SSNI-865 / IPX-933 抓不到的原因。 const absUrl = (u, base) => { if (!u) return ""; if (u.startsWith("http")) return u; try { return new URL(u, base).href; } catch (err) { return u; } }; for (const detailUrl of detailUrls) { try { let s = await gmHttp.fetchRaw(detailUrl); const o = utils.htmlTo$dom(s); // 详情页番号明显不匹配则跳过(尝试下一个候选)。只看标题, 不拼 detailUrl—— // detailUrl 本身就因含番号才被选中, 拼进来会让这个校验永远为真、形同虚设。 const pageTitle = (o.find("title").text() || "").toLowerCase().replace(/-/g, ""); if (norm && pageTitle && !pageTitle.includes(norm)) { clog.warn("JavStore 跳过番号不匹配页:", detailUrl); continue; } const codeLetters = code.replace(/[^a-zA-Z]/g, "").toLowerCase(); const codeDigits = code.replace(/[^0-9]/g, ""); const codeL = code.toLowerCase(); const isCover = src => /pl\.jpg$/i.test(src) || /pics\.dmm\.co\.jp/i.test(src); const allImgs = o.find("img").filter((i, el) => { const src = $(el).attr("src") || ""; return /javstore\.net/i.test(src) && /\.(jpg|jpeg|png|webp)$/i.test(src); }); // 按优先级收集本页所有候选图, 逐个探活, 全死则换下一个详情页 const picks = []; // 1) CLICK HERE: 优先 1080p 大图 const clickAnchors = o.find("a").filter((i, el) => ($(el).text() || "").includes("CLICK HERE")); if (clickAnchors.length) { const hd = clickAnchors.filter((i, el) => /1080p/i.test($(el).attr("href") || "")).first(); hd.length && picks.push({ tag: "CLICK HERE 1080p", url: absUrl(hd.attr("href"), detailUrl) }); clickAnchors.each((i, el) => picks.push({ tag: "CLICK HERE", url: absUrl($(el).attr("href"), detailUrl) })); } // 2) alt = "Screenshot N" 的截图(排除封面)。 // ⚠️ 注意区分两种情况: // a) 拼版长图: 单张 alt=Screenshot 的大图, 比例 <1 (竖长) —— 想要的 // b) 单帧集合: 多张 xxx-1.jpg…xxx-10.jpg, 比例 ~1.5 横图 —— 不是长截图 // 遇到 (b) 直接放弃这一页, 让 JavFree/JavBest 提供真正的长图。 const shots = allImgs.filter((i, el) => /^\s*screenshot\b/i.test($(el).attr("alt") || "") && !isCover($(el).attr("src") || "")); const looksLikeFrameSet = shots.length >= 2 && shots.toArray().every(el => /-\d+[a-f0-9]*\.(jpg|jpeg|png|webp)$/i.test($(el).attr("src") || "")); if (!looksLikeFrameSet) { for (let k = shots.length - 1; k >= 0; k--) picks.push({ tag: "Screenshot", url: absUrl($(shots[k]).attr("src"), detailUrl) }); } else { clog.debug("JavStore 只有单帧截图集(非拼版长图), 跳过 Screenshot 分支:", detailUrl); } // 3) 文件名/alt 与番号匹配、且不是封面的图。 // 同样排除 xxx-N.jpg 单帧序列图, 只可能是"零散帧", 不是长截图。 allImgs.each((i, el) => { const src = $(el).attr("src") || ""; if (isCover(src)) return; if (looksLikeFrameSet && /-\d+[a-f0-9]*\.(jpg|jpeg|png|webp)$/i.test(src)) return; const alt = ($(el).attr("alt") || "").toLowerCase(); const base = src.split("/").pop().toLowerCase().replace(/[^a-z0-9]/g, ""); if (alt.includes(codeL) || (codeLetters && base.startsWith(codeLetters) && ("" === codeDigits || base.includes(codeDigits)))) { picks.push({ tag: "番号匹配图", url: absUrl(src, detailUrl) }); } }); // 4) 最后兜底: _s.jpg → _l.jpg (封面大图, 比长截图差但有总比没有好) const simg = o.find("img[src*='_s.jpg']").first(); if (simg.length) picks.push({ tag: "_l.jpg 封面", url: absUrl(simg.attr("src"), detailUrl).replace(/_s\.jpg$/i, "_l.jpg") }); // 逐个探活, 第一个能真加载出来的就用 const seen = []; for (const p of picks) { let u2 = (p.url || "").replace(".th", ""); if (!u2 || seen.indexOf(u2) >= 0) continue; seen.push(u2); u2 = this._normalizeThumbUrl(u2); if (await this._probeImageAlive(u2, 6e3)) { return clog.debug(`JavStore 命中(${p.tag}):`, u2), u2; } clog.debug(`JavStore ${p.tag} 死链, 跳过:`, u2); } clog.warn("JavStore 该页所有候选图均为死链, 换下一个候选页:", detailUrl); } catch (err) { clog.warn("JavStore 详情页解析异常:", detailUrl, err && err.message || err); } } return clog.error("JavStore, 解析预览图失败:", code), null; } async getJavBestScreenShot(e) { clog.log("[缩略图] javbest 搜索:", e); let t = `https://javbest.net/?s=${e}`; let n = await gmHttp.fetchRaw(t); const a = utils.htmlTo$dom(n), i = a.find(".app_loop_thumb a").first().attr("href"); if (!i) throw clog.error("解析JavBest搜索页失败:", t), new Error("解析JavBest搜索页失败"); const s = a.find(".app_loop_thumb a").first().attr("title"); if (!s.toLowerCase().includes(e.toLowerCase())) throw clog.error("解析JavBest搜索页失败:", s), new Error("解析JavBest搜索页失败"); const o = await gmHttp.fetchRaw(i); let r = $(o).find('#content a img[src*="_t.jpg"]').attr("src"); if (!r) throw clog.error("解析JavBest缩略图失败:", t), new Error("解析JavBest缩略图失败"); return r = r.replace("_t", "").replace("http:", "https:"), r; } async getJavFreeScreenShot(e) { clog.log("[缩略图] javfree 搜索:", e); let t = `https://javfree.me/search/${e}/`, n = await gmHttp.fetchRaw(t); // 兼容多种主题结构: entry-title / post-title / 直接 article 内链接 const links = utils.htmlTo$dom(n).find("article h2.entry-title a, article .entry-title a, h2.entry-title a, .post-title a, article a.entry-title"); if (!links || 0 === links.length) return null; const href = $(links[0]).attr("href"); if (!href) return null; let s = await gmHttp.fetchRaw(href); const imgs = utils.htmlTo$dom(s).find("#main > article > .entry-content > p img, .entry-content p img, .post-content p img") .filter((function() { const srcAttr = $(this).attr("src"); return srcAttr && srcAttr.toLowerCase().endsWith(".jpeg"); })); if (!imgs || 0 === imgs.length) return null; // 优先 1080p / demosaic 大图, 否则取最后一张 let pick = imgs.filter((i, el) => /1080p|demosaic/i.test($(el).attr("src") || "")).first(); if (!pick.length) pick = imgs.last(); const r = pick.attr("src"); return r || null; } addImg(e, t) { t && (r && $(".screen-container").html(`<span class="jhs-screen-label" style="position:absolute;left:4px;top:4px;z-index:2;background:${this.getPreviewLabelBg()};color:#CBC9C9;font-size:12px;line-height:1;padding:3.75px 2px 3.75px 2px;border-radius:4px;box-sizing:border-box;width:40px;height:21px;display:inline-flex;align-items:center;justify-content:center;text-align:center;">缩略图</span><img src="${t}" alt="${e}" loading="lazy" style="width: 100%; height: 100%; object-fit: cover; object-position: center;">`).css("position","relative"), l && $(".screen-container").html(`<div class="photo-frame" style="width:100%;height:100%;"><img src="${t}" style="width:100%;height:100%;object-fit:contain;object-position:center;display:block;" title="${e}" alt="${e}"></div>`), r && this.syncScreenTileHeight(), // 兜底: 探活漏网的死链(缓存里的旧 URL 等)不留裂图, 直接移除整个格子 $(".screen-container").find("img").on("error", (ev => { clog.warn("缩略图加载失败(死链), 移除格子:", $(ev.currentTarget).attr("src")), $(".screen-container").remove(); try { const c = localStorage.getItem("jhs_screenShot") ? JSON.parse(localStorage.getItem("jhs_screenShot")) : {}; const k = this._currentCarNum; k && c[k] && (delete c[k], localStorage.setItem("jhs_screenShot", JSON.stringify(c))); } catch (ce) {} })), $(".screen-container").off("click").on("click", (async e => { e.stopPropagation(), e.preventDefault(); const $img = $(e.currentTarget).find("img"); if (!($img.length && $img.attr("src"))) return; const src = $img.attr("src"); // 详情页且开启「切源按钮」时, 把切换来源做进放大预览界面里 let opts = null; if (r) { try { if (await storageManager.getSetting("enableThumbSourceSwitch", C) === _) { const carNum = this._currentCarNum || this.getPageInfo().carNum; const colorMap = { javfree: "#4caf50", javbest: "#ffc107", javstore: "#f44336", projectjav: "#ca8a04" }; const list = await this.resolveThumbSources(); if (list.length >= 1) { opts = { carNum: carNum, sources: list.map(s => ({ key: s.key, name: s.name, color: colorMap[s.key] || "#888" })), fetchSource: async key => this.getScreenshot(carNum, key) }; } } } catch (err) { clog.debug("构建放大预览切源配置失败", err); } } showImageViewer(src, "缩略图", opts); }))); } /** 读取原站「預告片」角标的实际背景色, 保证颜色完全一致; 读不到时回退到 #BE6E29 */ getPreviewLabelBg() { try { const el = $(".preview-images .preview-video-container span, .preview-images .tile-item span").filter((i, n) => { const txt = $(n).text().trim(); return txt && txt.length <= 4 && !$(n).hasClass("jhs-screen-label"); }).get(0); if (el) { const bg = getComputedStyle(el).backgroundColor; if (bg && bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent") { // 将原站背景色变暗 20% 以区分 const m = bg.match(/(\d+)/g); if (m && m.length >= 3) { const darken = v => Math.round(v * 0.8); return `rgb(${darken(+m[0])},${darken(+m[1])},${darken(+m[2])})`; } return bg; } } } catch (e) { clog.debug("读取预告片角标背景色失败", e); } return "#995a22"; } /** 让长缩略图 tile 高度覆盖同排缩略图的两行 (预告片图较矮, 单行会截掉长缩略图第2行) */ syncScreenTileHeight() { try { const $tiles = $(".preview-images .tile-item").not(".screen-container"); let h = 0; $tiles.each((i, el) => { const eh = el.getBoundingClientRect().height; if (eh > 0 && (0 === h || eh < h)) h = eh; }); if (h > 0) $(".preview-images .screen-container").css("height", Math.round(h) + "px"); } catch (e) { clog.debug("同步长缩略图高度失败", e); } } showErrorFallback(e, t) { var n; const errMsg = t ? (t.message || t.status || JSON.stringify(t).slice(0, 100)) : ("所有源均失败(Promise.any AggregateError)"); console.error("获取缩略图失败:", errMsg, "| 番号:", e); let a = l ? "margin-top: 30px" : "margin-top: 50px"; $(".screen-container").html(`<div style="${a}; cursor:auto;color:#000;">获取缩略图失败</div><br/><a href='#' class='retry-link'>点击重试</a> 或 <a class="check-link" href='https://javstore.net/search/${e}.html' target='_blank'>前往确认</a>`).off("click", ".retry-link").off("click", ".check-link").on("click", ".retry-link", (async t => { t.stopPropagation(), t.preventDefault(), $(".screen-container").html(`<div style="${a};cursor:auto;color:#000;">正在重新加载...</div>`); try { // 重试时清掉这个番号的缓存, 确保重新请求而不是命中之前的失败缓存 try { const c = localStorage.getItem("jhs_screenShot") ? JSON.parse(localStorage.getItem("jhs_screenShot")) : {}; delete c[e]; localStorage.setItem("jhs_screenShot", JSON.stringify(c)); } catch (ce) {} try { storageManager._invalidateCache && storageManager._invalidateCache(`screenshot:${e}`); } catch (ce) {} const t = await this.getScreenshot(e); this.addImg("缩略图", t); if (r) { this.syncScreenTileHeight(); this.renderThumbSourceSwitch(e).catch(() => {}); } } catch (n) { this.showErrorFallback(e, n); } })).on("click", ".check-link", (async t => { t.stopPropagation(), t.preventDefault(), window.open(`https://javstore.net/search/${e}.html`, "_blank"); })); } } /** * 长预览图的「快出图」实现。 * 与 ScreenShotPlugin 里原实现的区别: 顺序试源(javfree → projectjav → javstore, 第一个有结果就停), * 不做图片探活/比例校验, 缓存用 sessionStorage 按番号单独存。 * 开关: localStorage.setItem("jhs_fastThumb","off") 可切回原实现, 删掉该项恢复本实现。 */ const jhsFastThumb = { order: [ "javfree", "projectjav", "javstore" ], cacheKey(code) { return `thumb_cache_v3_${code}`; }, lookupCode(code) { const text = String(code || "").trim(); const fc2 = text.match(/^(?:FC2[-_\s]?(?:PPV[-_\s]?)?)?(\d{6,9})$/i); return fc2 ? fc2[1] : text; }, /** 对应 lao 的 Utils.request: 不带任何自定义头, 30s 超时, 2xx/3xx 都算成功 */ _req(url) { return new Promise(((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: url, timeout: 3e4, onload: r => r.status >= 200 && r.status < 400 ? resolve(r.responseText || "") : reject(new Error("HTTP " + r.status)), onerror: () => reject(new Error("请求失败")), ontimeout: () => reject(new Error("请求超时")) }); })); }, /** projectjav 专用: lao 在这个源上单独带了 UA/Accept, 20s 超时, 且要用 finalUrl */ _reqFull(url) { return new Promise(((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: url, timeout: 2e4, headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8" }, onload: r => r.status >= 200 && r.status < 400 ? resolve(r) : reject(new Error("HTTP " + r.status)), onerror: () => reject(new Error("请求失败")), ontimeout: () => reject(new Error("请求超时")) }); })); }, _dom(html) { return new DOMParser().parseFromString(html, "text/html"); }, normalizeForCompare(text) { return String(text || "").toLowerCase().replace(/[^a-z0-9]/g, ""); }, isCodeMatched(text, code) { const t = this.normalizeForCompare(text), c = this.normalizeForCompare(code); return !!c && t.includes(c); }, isDetailMatched(doc, url, code) { const title = doc?.querySelector("title")?.textContent || ""; const headings = [ ...doc?.querySelectorAll("h1,h2,h3,.entry-title,.movie-title,.post-title") || [] ].map((el => el.textContent || "")).join(" "); const bodyText = (doc?.body?.textContent || "").slice(0, 5e3); return this.isCodeMatched([ url, title, headings, bodyText ].join(" "), code); }, normalizePreviewUrl(url, baseUrl = "") { if (!url) return ""; const absolute = /^https?:\/\//i.test(url) ? url : baseUrl ? new URL(url, baseUrl).href : url; return absolute.replace(/^http:/, "https:"); }, isJavfreePreviewImage(url, code) { const cleanUrl = String(url || "").split("?")[0], lookupCode = this.lookupCode(code); const isFc2Numeric = /^\d{6,9}$/.test(lookupCode); const fc2ShotPattern = isFc2Numeric ? new RegExp(`${lookupCode}_\\d+\\.(?:jpe?g|png|webp)$`, "i") : null; return this.isCodeMatched(cleanUrl, code) && (/-(?:1080p|demosaic)\.(?:jpe?g|png|webp)$/i.test(cleanUrl) || isFc2Numeric && fc2ShotPattern.test(cleanUrl)); }, selectJavfreePreviewUrl(doc, detailUrl, code) { const urls = [ ...doc.querySelectorAll("p > img[src]") ].map((img => this.normalizePreviewUrl(img.getAttribute("src") || img.src || "", detailUrl))).filter((u => this.isJavfreePreviewImage(u, code))); return urls.find((u => /-1080p\./i.test(u))) || urls.find((u => /-demosaic\./i.test(u))) || urls.find((u => /_1\.(?:jpe?g|png|webp)$/i.test(u))) || ""; }, async javfree(code) { code = this.lookupCode(code); const cached = sessionStorage.getItem(this.cacheKey(code)); if (cached) return cached; try { const html = await this._req(`https://javfree.me/search/${code}`); const doc = this._dom(html); const link = [ ...doc.querySelectorAll(".entry-title>a") ].find((a => this.isCodeMatched([ a.href, a.textContent ].join(" "), code)))?.href; if (!link) return null; const dDoc = this._dom(await this._req(link)); if (!this.isDetailMatched(dDoc, link, code)) return null; const url = this.selectJavfreePreviewUrl(dDoc, link, code); return url && sessionStorage.setItem(this.cacheKey(code), url), url || null; } catch (e) { return null; } }, async _extractImgFromDetail(detailUrl, code) { try { const detailDoc = this._dom(await this._req(detailUrl)); if (!this.isDetailMatched(detailDoc, detailUrl, code)) return null; for (const link of detailDoc.querySelectorAll("a")) { if (link.textContent.includes("CLICK HERE")) { const u = link.href || link.getAttribute("href") || ""; if (u) return this.normalizePreviewUrl(u, detailUrl); } } const img = detailDoc.querySelector('img[src*="_s.jpg"]'); if (img) { let src = img.getAttribute("src") || ""; return src.startsWith("http") || (src = new URL(src, detailUrl).href), this.normalizePreviewUrl(src.replace(/_s\.jpg$/, "_l.jpg"), detailUrl); } return null; } catch (e) { return null; } }, async javstore(code) { code = this.lookupCode(code); try { const normalizedCode = code.replace(/^fc2-?/i, "").replace(/-/g, "").toLowerCase(); const searchUrl = `https://javstore.net/search?q=${encodeURIComponent(code)}`; const searchDoc = this._dom(await this._req(searchUrl)); const detailUrls = []; for (const link of searchDoc.querySelectorAll('a[href*="/"]')) { const href = link.getAttribute("href"); if (!href) continue; if (href.startsWith("http") && !href.includes("javstore.net")) continue; let urlObj; try { urlObj = new URL(href, searchUrl); } catch (er) { continue; } if (!/javstore\.net$/i.test(urlObj.hostname)) continue; if (/^\/search(?:[/?#]|$)/i.test(urlObj.pathname)) continue; const pathLastPart = decodeURIComponent(urlObj.pathname.split("/").pop() || ""); const normalizedPath = pathLastPart.toLowerCase().replace(/-/g, ""); const looksLikeDetail = /\.html$/i.test(urlObj.pathname) || /^\/\d+[-/]/.test(urlObj.pathname); looksLikeDetail && normalizedPath.includes(normalizedCode) && !detailUrls.includes(urlObj.href) && detailUrls.push(urlObj.href); } if (!detailUrls.length) return null; for (const detailUrl of detailUrls) { const imgUrl = await this._extractImgFromDetail(detailUrl, code); if (imgUrl) return imgUrl; } return null; } catch (e) { return null; } }, async projectjav(code) { code = this.lookupCode(code); try { const searchUrl = `https://projectjav.com/?searchTerm=${encodeURIComponent(code)}`; const searchRes = await this._reqFull(searchUrl); const searchHtml = searchRes.responseText || "", finalSearchUrl = searchRes.finalUrl || searchUrl; const searchDoc = this._dom(searchHtml); let detailUrl = /\/movie\//i.test(new URL(finalSearchUrl).pathname) ? finalSearchUrl : ""; if (!detailUrl) { const firstLink = [ ...searchDoc.querySelectorAll('a[href*="/movie/"]') ][0]?.getAttribute("href") || ""; if (!firstLink) return null; detailUrl = firstLink.startsWith("http") ? firstLink : `https://projectjav.com${firstLink}`; } const detailRes = finalSearchUrl === detailUrl ? searchRes : await this._reqFull(detailUrl); const finalDetailUrl = detailRes.finalUrl || detailUrl; const detailDoc = this._dom(detailRes.responseText || ""); const screenshotLink = [ ...detailDoc.querySelectorAll('.col-md-12.thumbnail a[data-featherlight="image"], .thumbnail a[data-featherlight="image"]') ] .find((a => this.isCodeMatched([ a.outerHTML, a.closest(".thumbnail")?.outerHTML, finalDetailUrl ].join(" "), code))); if (screenshotLink) { const href = screenshotLink.getAttribute("href") || ""; if (href) return this.normalizePreviewUrl(href, finalDetailUrl); const thumbImg = screenshotLink.querySelector("img"); if (thumbImg) { const src = (thumbImg.getAttribute("src") || "").replace(/\?.*$/, ""); if (src) return this.normalizePreviewUrl(src, finalDetailUrl); } } return null; } catch (e) { return null; } }, /** 顺序试源, 第一个有结果就停。带耗时日志便于排查慢在哪一源。 */ /** 和「加载长缩略图」格子共用同一份持久缓存(localStorage.jhs_screenShot)。 * 之前两边各存一处(格子写 jhs_screenShot, 这里只写 sessionStorage), * 所以开着格子点「预览图」照样是冷启动 —— 这就是开关打开也没变快的原因。 */ _lsGet(code) { try { const m = JSON.parse(localStorage.getItem("jhs_screenShot") || "{}"); return m[code] || null; } catch (e) { return null; } }, _lsSet(code, url) { try { const m = JSON.parse(localStorage.getItem("jhs_screenShot") || "{}"); m[code] = url, localStorage.setItem("jhs_screenShot", JSON.stringify(m)); } catch (e) {} }, async get(code) { const t0 = performance.now(), ms = () => Math.round(performance.now() - t0); const lookup = this.lookupCode(code), key = this.cacheKey(lookup); const cached = sessionStorage.getItem(key) || this._lsGet(code) || this._lsGet(lookup); if (cached) return clog.log(`[长缩略图] 缓存命中(+${ms()}ms):`, code, cached), sessionStorage.setItem(key, cached), cached; // 预抓(详情页加载时)和点击可能同时发生, 复用同一个 Promise, 别把三个源重打一遍 this._inflight = this._inflight || {}; if (this._inflight[key]) return clog.debug("[长缩略图] 复用进行中的请求:", key), this._inflight[key]; const p = (async () => { for (const src of this.order) { if ("function" != typeof this[src]) continue; const st = performance.now(); let url = null; try { url = await this[src](code); } catch (e) { url = null; } clog.log(`[长缩略图] ${src} ${url ? "命中" : "无结果"} 用时 ${Math.round(performance.now() - st)}ms`); if (url) return sessionStorage.setItem(key, url), this._lsSet(code, url), clog.log(`[长缩略图] 最终结果(+${ms()}ms, 来源 ${src}):`, url), url; } return clog.warn(`[长缩略图] 三个源都没有结果(+${ms()}ms):`, code), null; })().finally((() => { delete this._inflight[key]; })); return this._inflight[key] = p; }, /** 详情页一打开就先抓(不依赖「加载长缩略图」那个格子的开关), 点按钮时直接命中缓存。 * 关键: 光解析出地址不够 —— 长缩略图动辄几 MB, 点开后的等待主要花在下载图片本身。 * 所以解析完立刻用一个游离的 Image 把字节也拉进浏览器 HTTP 缓存, 查看器再取同一 URL 就是白拿。 */ prefetch(code) { try { Promise.resolve(this.get(code)).then((url => { if (!url) return; const t0 = performance.now(), img = new Image(); img.decoding = "async"; img.onload = () => clog.log(`[长缩略图] 预热图片完成(+${Math.round(performance.now() - t0)}ms):`, url); img.onerror = () => clog.debug("[长缩略图] 预热图片失败(忽略):", url); img.src = url; this._warm = img; // 留个引用, 别让 GC 在下载完成前把它回收掉 })).catch((() => {})); } catch (e) {} } }; class Xe extends X { getName() { return "FavoriteActressesPlugin"; } async handle() { this.bindEvent(), await this.highlightActress(), this.replaceActressAvatar(); } async highlightActress() { if (!isDetailPage) return; if (await storageManager.getSetting("enableFavoriteActresses", _) !== _) return; const e = await storageManager.getFavoriteActressList(); if (!e || 0 === e.length) return; const t = new Set; e.forEach((e => { e.starId && t.add(String(e.starId).trim()); })), 0 !== t.size && $(".female").prev().each(((e, n) => { const a = $(n), i = a.attr("href"); let s = null; if (i) { const e = (i.endsWith("/") ? i.slice(0, -1) : i).split("/"), t = e[e.length - 1]; t && (s = t.trim()); } let o = !1; s && (o = t.has(s)), o && (a.addClass("highlighted"), a.attr("title", "高亮已收藏演员, 可在设置-基础配置中关闭")); })); } async removeActorFromStorage(e) { await storageManager.removeFavoriteActress(e) && clog.log("移除演员成功"); } bindEvent() { const e = /\/actors\/(\w+)\/(collect|uncollect)/; $(document).on("confirm:complete", 'a[href*="/actors/"][href*="/uncollect"]', (async t => { const [n] = t.detail; if (!n) return; const a = $(t.currentTarget).attr("href").match(e), i = a ? a[1] : null; i && await this.removeActorFromStorage(i); })), $("#button-collect-actor").click((async t => { const n = $("#button-collect-actor").attr("href").match(e), a = n ? n[1] : null; let i = [], s = $(".actor-section-name"); s.length && s.text().trim().split(",").forEach((e => { i.push(e.trim()); })); let o = $(".section-meta:not(:contains('影片'))"); if (o.length && o.text().trim().split(",").forEach((e => { i.push(e.trim()); })), !i.length) return void clog.error("获取演员名称失败"); const r = i[0]; if (!a) return void clog.error("无法获取演员ID进行收藏操作。"); const l = ($(".avatar").first().css("background-image") || "").replace(/^url\(["']?|["']?\)$/g, ""), c = { starId: a, name: r, allName: i, avatar: l }; 1 === await storageManager.addFavoriteActressList([ c ]) ? clog.log(`收藏演员成功: ${r} (ID: ${a})`) : clog.log(`收藏演员失败: ${r} (ID: ${a})`); })), $("#button-uncollect-actor").click((async t => { const n = $("#button-uncollect-actor").attr("href").match(e), a = n ? n[1] : null; a ? await this.removeActorFromStorage(a) : clog.error("无法获取演员ID进行取消收藏操作。"); })); } async replaceActressAvatar() { const e = this.getActressId(); if (!e) return; const t = (await storageManager.getFavoriteActressList()).find((t => t.starId === e)); if (t && t.avatar) { const e = `url('${t.avatar}')`; let n = $(".avatar").first(); if (0 === n.length) { const e = '<div class="column actor-avatar"> <div class="image"> <span class="avatar"></span> </div> </div>'; $(".section-columns").prepend(e), n = $(".avatar").first(); } if (0 === n.length) return; n.css("background-image").trim().toLowerCase() !== e.trim().toLowerCase() && (n.css("background-image", e), n.css("background-size", "cover"), n.css("background-position", "top center"), n.css("background-repeat", "no-repeat")); } } } class Qe extends X { getName() { return "BusImgPlugin"; } handle() {} async getVisibleImageItems(e, t) { let n = []; const a = document.querySelectorAll(e); for (const i of a) { if (!utils.isHidden(i)) { const e = i.querySelector(t); if (!(e instanceof HTMLImageElement)) continue; e.style.removeProperty("height"); let a = e.offsetHeight; a > 0 && n.push({ element: i, imgElement: e, height: a }); } } return n; } async logImageHeightsByRow() { if (await storageManager.getSetting("enableVerticalModel", C) === _) return; // applyImageMode 已注入固定比例容器样式, 不再手动调整 img 高度 if ($("#verticalImgStyle").length) return; const e = this.getSelector().itemSelector, t = await storageManager.getSetting("containerColumns", 5), n = await this.getVisibleImageItems(e, "img"); if (0 === n.length) return; const a = []; for (let i = 0; i < n.length; i++) { const e = Math.floor(i / t); a[e] || (a[e] = []), a[e].push(n[i]); } a.forEach(((e, t) => { const n = e.map((e => e.height)); if (n.length < 2) return; const a = Math.min(...n), i = Math.max(...n); let s = 0; i - a > 50 && (s = a, e.forEach((e => { if (e.height !== s) { const t = `${s}px`; e.imgElement.style.setProperty("height", t, "important"); } }))); })); } } class Ze extends X { getName() { return "TranslatePlugin"; } async initCss() { return "\n <style> \n .translated-title {\n margin-top: 8px; \n padding: 12px; \n border-radius: 5px; \n border-left: 4px solid rgb(76, 175, 80);\n background: linear-gradient(135deg, rgb(255, 255, 255) 0%, rgb(245, 245, 245) 100%); \n box-shadow: 0 4px 8px rgba(0, 0, 0, 0.08);\n font-size: 20px;\n }\n </style>\n "; } handle() { isDetailPage && this.translate(); } async translate(e, t = !0) { if (await storageManager.getSetting("translateTitle", _) !== _) return; l && (t = !1); let n = $(".origin-title"); if (n.length || (n = $(".current-title")), n.length || (n = $("h3")), !n.length) return; const a = n.text().trim(); if (!a) return void show.error("获取标题失败, 无法进行翻译"); n.after('<div class="translated-title">翻译中...</div>'); const i = n.next(".translated-title"); e || (e = this.getPageInfo().carNum); const s = localStorage.getItem("jhs_translate") ? JSON.parse(localStorage.getItem("jhs_translate")) : {}; s[e] ? i.html(t ? e + " " + s[e] : s[e]) : _e(a, "ja", "zh-CN").then((n => { i.html(t ? e + " " + n : n); })).catch((e => { console.error("翻译失败:", e), i.replaceWith(`<div class="translated-title" style="color: red;">翻译失败: ${escapeHtml(e.message)}</div>`); })); } } class et extends X { constructor() { super(...arguments), i(this, "singleTaskKey", "checkNewActressActorFilterCar"), i(this, "taskConfig", null), i(this, "storageQueue", new ve), i(this, "lastCheckFavoriteActressTimeKey", "jhs_time_checkFavoriteActress"), i(this, "lastCheckBlacklistTimeKey", "jhs_time_checkBlacklist"), i(this, "lastCheckNewVideoTimeKey", "jhs_time_checkNewVideo"); } getName() { return "TaskPlugin"; } async limitConcurrency(e, t, n, a) { this.showIsRun(); const i = [], s = e.length; let o = 0, _consecutiveFails = 0; const MAX_CONSECUTIVE_FAILS = 5; for (const r of e) { // 连续失败过多时中止, 避免 Cloudflare 拦截下浪费大量请求 if (_consecutiveFails >= MAX_CONSECUTIVE_FAILS) { clog.error(`连续失败 ${MAX_CONSECUTIVE_FAILS} 次, 提前中止任务`); show.error("检测任务: 连续失败过多, 已中止 (可能被 Cloudflare 拦截)"); break; } const e = a(r).then(() => { _consecutiveFails = 0; }).catch(() => { _consecutiveFails++; }).finally((() => { i.splice(i.indexOf(e), 1); })); if (i.push(e), o++, i.length >= t) { const e = s - o; clog.debug(`剩余任务数: <span style="color: #f40">${e}</span>`), await Promise.race(i), await utils.sleep(n); } } await Promise.all(i); } isUnnecessaryCheck(e, t) { if (!t) throw new Error("未传入checkIntervalTime"); t = parseInt(t); return utils.getHourDifference(new Date(e), new Date) < t; } handle() { this.doTask().then(); } showIsRun() { show.info("正在执行检测任务中, 请勿关闭当前窗口", { duration: 3e3 }); } async doTask() { if (isListPage) return await this.loadConfig(), this.javDbUrl = await this.getBean("OtherSitePlugin").getJavDbUrl(), // 延迟 5 秒再开始后台任务, 给页面渲染和用户交互让出优先级 await utils.sleep(5e3), navigator.locks.request(this.singleTaskKey, { ifAvailable: !0 }, (async e => { if (e) { if (isListPage && (this.taskConfig.enableCheckBlacklist === _ ? await this.checkBlacklist() : clog.warn("自动检测屏蔽黑名单-禁用"), !l)) { if (this.taskConfig.enableCheckFavoriteActress === _) { const e = localStorage.getItem(this.lastCheckFavoriteActressTimeKey), t = this.taskConfig.checkFavoriteActress_IntervalTime, n = e && this.isUnnecessaryCheck(e, t), a = $('a[href*="/users/profile"]').length > 0; n && clog.debug(`检测同步演员, 上次检测时间: ${e} 检测间隔时间: ${t}小时 未到时间`), !n && a && await this.checkFavoriteActress(); } else clog.warn("自动同步已收藏的演员-禁用"); this.taskConfig.enableCheckNewVideo === _ ? await this.checkNewVideo() : clog.warn("自动检测已收藏演员的最新作品-禁用"); } } else clog.debug("争夺任务锁失败, 跳过执行"); })).catch((e => { console.error("锁任务出现错误:", e), clog.error("锁任务出现错误:", e); })).finally((() => { setTimeout((() => { this.doTask(); }), 3e5); })); } async loadConfig() { const e = await storageManager.getSetting(); this.taskConfig = { checkConcurrencyCount: e.checkConcurrencyCount ? Number(e.checkConcurrencyCount) : 2, checkRequestSleep: e.checkRequestSleep ? Number(e.checkRequestSleep) : 100, enableCheckBlacklist: e.enableCheckBlacklist || _, checkBlacklist_intervalTime: e.checkBlacklist_intervalTime ? Number(e.checkBlacklist_intervalTime) : 12, checkBlacklist_ruleTime: e.checkBlacklist_ruleTime ? Number(e.checkBlacklist_ruleTime) : 8760, enableCheckFavoriteActress: e.enableCheckFavoriteActress || _, checkFavoriteActress_IntervalTime: e.checkFavoriteActress_IntervalTime ? Number(e.checkFavoriteActress_IntervalTime) : 24, enableCheckNewVideo: e.enableCheckNewVideo || _, checkNewVideo_intervalTime: e.checkNewVideo_intervalTime ? Number(e.checkNewVideo_intervalTime) : 12, checkNewVideo_ruleTime: e.checkNewVideo_ruleTime ? Number(e.checkNewVideo_ruleTime) : 8760 }; } async checkBlacklist(e) { let t = await storageManager.getBlacklist(); if (0 === t.length) return; t = t.sort(((e, t) => e.createTime < t.createTime ? 1 : e.createTime > t.createTime ? -1 : 0)); const n = this.taskConfig.checkConcurrencyCount, a = this.taskConfig.checkRequestSleep, i = this.taskConfig.checkBlacklist_intervalTime, s = this.taskConfig.checkBlacklist_ruleTime, o = localStorage.getItem(this.lastCheckBlacklistTimeKey); if (!e && o && this.isUnnecessaryCheck(o, i)) return void clog.debug(`检测黑名单, 上次检测时间: ${o} 检测间隔时间: ${i}小时 未到时间`); const r = [], l = []; for (const h of t) { let t = h.name, n = h.checkTime, a = h.lastPublishTime, o = h.url; if (new URL(window.location.href).hostname === new URL(o).hostname) { if (e || !n || !this.isUnnecessaryCheck(n, i)) if (!a || 0 === s || this.isUnnecessaryCheck(a, s)) r.push(h); else { let e = `检测黑名单: ${t} ${a} 停更超过${s / 24 / 365}年,跳过检测`; l.push(e), $("#checkBlacklistMsg").text(e); } } else clog.log("黑名单地址非同域名,跳过", o); } if (0 === r.length) return; l.forEach((e => { clog.log(e); })), clog.log(`<span style='color: #f40'>检测屏蔽黑名单, 总任务数: ${r.length}, 并发限制:${n}, 请求间隔时间:${a}ms</span>`); const c = this.getBean("BlacklistPlugin"); await this.limitConcurrency(r, n, a, (async e => { let {starId: t, name: n, url: a} = e; try { clog.log("正在检屏黑名单演员:", n, a), $("#checkBlacklistMsg").text(`正在检屏黑名单演员: ${n} ${a}`); const e = await gmHttp.get(a), i = utils.htmlTo$dom(e); this.storageQueue.addTask((async () => { let {lastPublishTime: e} = await c.parseAndSaveFilterInfo(i, n, t); await storageManager.updateBlacklistItem({ starId: t, name: n, checkTime: utils.getNowStr(), lastPublishTime: e }); })); } catch (i) { $("#checkBlacklistMsg").text(`检测屏蔽演员信息, 发生错误: ${a}`), clog.error("检测屏蔽演员信息, 发生错误:", a, i), show.error("检测屏蔽演员信息, 发生错误:" + i, "bottom", "right"); } })), await this.storageQueue.waitAllFinished(); const d = utils.getNowStr(); localStorage.setItem(this.lastCheckBlacklistTimeKey, d), clog.log('<span style="color: #f40">-------- END 检测屏蔽黑名单 END --------</span>'), $("#checkBlacklistMsg").text("检测屏蔽黑名单, 结束"), this.getBean("BlacklistPlugin").resetBtnTip().then(); } async checkFavoriteActress() { const e = `${this.javDbUrl}/users/collection_actors`, t = []; await this.scrapeActorInfo(e, t), clog.log("所有演员信息已收集, 总计数量:", t.length), $("#checkNewVideoMsg").text("同步完成"), t.length > 0 && (await storageManager.addFavoriteActressList(t), localStorage.setItem(this.lastCheckFavoriteActressTimeKey, utils.getNowStr()), this.getBean("NewVideoPlugin").resetBtnTip().then()); } async scrapeActorInfo(e, t, pageCount = 0, failCount = 0) { // 安全上限: 防止网站改版 / Cloudflare 拦截导致无限翻页死循环 const MAX_PAGES = 200, MAX_FAIL = 3; if (pageCount >= MAX_PAGES) { clog.warn(`同步演员: 已达最大翻页上限 ${MAX_PAGES}, 停止`); return; } clog.log(`正在抓取页面: ${e}`), $("#checkNewVideoMsg").text(`正在解析已收藏的演员: ${e}`); let nextUrl = null; try { const n = await gmHttp.get(e); // Cloudflare 验证页检测: 命中则立即中止, 避免死循环 if ("string" == typeof n && /Just a moment|challenges\.cloudflare\.com|cf-chl|_cf_chl_opt/i.test(n)) { clog.error("同步演员: 遭遇 Cloudflare 验证, 请先在浏览器手动通过验证后重试"); show.error("同步演员失败: 需先通过 JavDB 的 Cloudflare 验证"); return; } const a = utils.htmlTo$dom(n); a.find("#actors .actor-box a").each(((e, n) => { const a = $(n), i = a.attr("title"), s = a.attr("href"); if (i && s) { const e = i.split(",").map((e => e.trim())).filter((e => e.length > 0)), n = e[0] || "", o = new URL(s, this.javDbUrl).pathname.split("/").filter((e => e.length > 0)); let r = ""; o.length > 0 && (r = o[o.length - 1]); let l = D; const c = a.find("img").attr("src"), d = a.find(".info"); d.length && d.text().trim().includes("無碼") && (l = A), t.push({ starId: r, name: n, allName: e, avatar: c, actressType: l, lastCheckTime: null, lastUpdateTime: null }); } })); const i = a.find(".pagination-next").attr("href"); if (i) nextUrl = new URL(i, this.javDbUrl).href; failCount = 0; // 本页成功, 重置连续失败计数 } catch (n) { failCount++; clog.error(`抓取 ${e} 时发生错误 (连续失败 ${failCount}/${MAX_FAIL}):`, n); if (failCount >= MAX_FAIL) { clog.error("同步演员: 连续失败次数过多, 已停止 (可能是网站改版或 Cloudflare 拦截)"); show.error("同步演员失败: 网站返回异常, 已停止"); return; } const url = new URL(e), page = parseInt(url.searchParams.get("page") || "1"); if (!isNaN(page)) { url.searchParams.set("page", String(page + 1)); nextUrl = url.href; } } if (nextUrl) await this.scrapeActorInfo(nextUrl, t, pageCount + 1, failCount); } async checkNewVideo(e) { const t = await storageManager.getFavoriteActressList(), n = utils.genericSort(t, [ { key: e => { var t; return (null == (t = e.newVideoList) ? void 0 : t.length) ?? 0; }, order: "desc" }, { key: "lastPublishTime", order: "desc" } ]), a = this.taskConfig.checkConcurrencyCount, i = this.taskConfig.checkRequestSleep, s = this.taskConfig.checkNewVideo_intervalTime, o = this.taskConfig.checkNewVideo_ruleTime, r = localStorage.getItem(this.lastCheckNewVideoTimeKey); if (!e && r && this.isUnnecessaryCheck(r, s)) return void clog.debug(`检测新作品, 上次检测时间: ${r} 检测间隔时间: ${s}小时 未到时间`); const l = [], c = []; for (const m of n) { const {lastCheckTime: t, lastPublishTime: n, name: a} = m; !e && t && this.isUnnecessaryCheck(t, s) || (!n || 0 === o || this.isUnnecessaryCheck(n, o) ? l.push(m) : c.push(`检测新作品: ${a} ${n} 停更超过${o / 24 / 365}年,跳过检测`)); } if (0 === l.length) return; c.forEach((e => { clog.log(e); })), clog.log(`<span style='color: #f40'>检测最新作品, 总任务数: ${l.length}, 并发限制:${a}, 请求间隔时间:${i}ms</span>`); const d = await storageManager.getTitleFilterKeyword(), h = await storageManager.getBlacklistCarList(), g = new Set(h.map((e => e.carNum))); await this.limitConcurrency(l, a, i, (async e => { const {lastCheckTime: t, name: n, starId: a} = e; let i = `${this.javDbUrl}/actors/${a}?t=d`; try { clog.log("正在检测最新作品, 演员:", n, i), $("#checkNewVideoMsg").text(`正在检测最新作品, 演员: ${n}`); const e = await gmHttp.get(i); // Cloudflare 验证页检测: 命中则抛出让 limitConcurrency 计入连续失败 if ("string" == typeof e && /Just a moment|challenges\.cloudflare\.com|cf-chl|_cf_chl_opt/i.test(e)) { throw new Error("Cloudflare 验证拦截, 请先在浏览器手动通过验证"); } const t = utils.htmlTo$dom(e); this.storageQueue.addTask((async () => { await this.parsePage(t, a, n, d, g); })); } catch (s) { clog.error("检测新作品失败, 演员:", n, i, s), show.error(`检测新作品失败, ${n}: ${s.message || s}`, "bottom", "right"); throw s; } })), await this.storageQueue.waitAllFinished(), localStorage.setItem(this.lastCheckNewVideoTimeKey, utils.getNowStr()), clog.log('<span style="color: #f40">检测最新作品---结束</span>'), $("#checkNewVideoMsg").text("检测完毕"); const p = this.getBean("NewVideoPlugin"); p.loadData(), p.resetBtnTip().then(); } async parsePage(e, t, n, a, i) { let s, o, r = !1, l = T; if (e.text().includes(I) && (r = !0, l = I), r && e.find(".avatar-box").length > 0 && e.find(".avatar-box").parent().remove(), s = e.find(this.getSelector(l).requestDomItemSelector), o = e.find(this.getSelector(l).nextPageSelector).attr("href"), o && 0 === s.length) throw clog.error("新作品检测-解析列表失败"), show.error("新作品检测-解析列表失败"), new Error("新作品检测-解析列表失败"); let c = [], d = null; for (const m of s) { const e = $(m), {carNum: s, url: o, title: r, publishTime: l} = this.getBean("ListPagePlugin").findCarNumAndHref(e); if (!s) continue; a.find((e => r.includes(e) || s.includes(e))) || (i.has(s) || (d || (d = l), (() => { let coverUrl = e.find("img").attr("src") || ""; if (coverUrl && !coverUrl.startsWith("http")) { coverUrl = coverUrl.startsWith("/") ? this.javDbUrl + coverUrl : this.javDbUrl + "/" + coverUrl; } let url = o || ""; if (url && !url.startsWith("http")) { url = url.startsWith("/") ? this.javDbUrl + url : this.javDbUrl + "/" + url; } const scoreText = e.find(".score .value, .score").text().trim(); const score = parseFloat(scoreText) || 0; const voteMatch = scoreText.match(/由(\d+)人/); let voteCount = 0; if (voteMatch) { voteCount = parseInt(voteMatch[1]); } else { const voteText = e.find(".score .count, .meta .count").text().trim(); voteCount = parseInt(voteText.replace(/[^\d]/g, "")) || 0; } c.push({ carNum: s, coverUrl: coverUrl, title: r || "", publishTime: l || "", score: score, voteCount: voteCount, url: url }); })())); } const h = await storageManager.getCarMap(), p = c.filter((e => !h.has(e.carNum))); p.length > 0 && clog.log(`<span style='color: #f40'>检测出新作品, ${n}, 共${p.length}部</span>`), await storageManager.updateFavoriteActress({ starId: t, lastCheckTime: utils.getNowStr(), newVideoList: p, lastPublishTime: d }); } async checkOneNewVideo(e) { const t = await storageManager.getTitleFilterKeyword(), n = await storageManager.getBlacklistCarList(), a = new Set(n.map((e => e.carNum))), {lastCheckTime: i, name: s, starId: o} = e; let r = `${this.javDbUrl}/actors/${o}?t=d`; const l = $("#checkNewVideoMsg"); try { clog.log("正在检测最新作品, 演员:", s, r), l.text(`正在检测最新作品, 演员: ${s}`); const e = await gmHttp.get(r); // Cloudflare 验证页检测 if ("string" == typeof e && /Just a moment|challenges\.cloudflare\.com|cf-chl|_cf_chl_opt/i.test(e)) { show.error("检测失败: 需先通过 JavDB 的 Cloudflare 验证"); l.text("检测失败: Cloudflare 验证拦截"); return; } const n = utils.htmlTo$dom(e); await this.parsePage(n, o, s, t, a), clog.log('<span style="color: #f40">检测最新作品---结束</span>'), l.text("检测完毕"); this.getBean("NewVideoPlugin").loadData(); } catch (c) { clog.error("检测新作品失败, 演员:", s, r, c), show.error(`检测新作品失败: ${c.message || c}`, "bottom", "right"), l.text(`检测新作品失败: ${s}`); } } } const tt = [ { name: "jsDelivr (全球CDN)", json: "https://cdn.jsdelivr.net/gh/gfriends/gfriends/Filetree.json", base: "https://cdn.jsdelivr.net/gh/gfriends/gfriends/Content/" }, { name: "GitHub Raw (备用)", json: "https://raw.githubusercontent.com/gfriends/gfriends/master/Filetree.json", base: "https://raw.githubusercontent.com/gfriends/gfriends/master/Content/" } ], nt = "jhs_img_cdn_index"; let at = parseInt(localStorage.getItem(nt) || "0", 10); (at >= tt.length || at < 0) && (at = 0); let it = tt[at].json, st = tt[at].base; const ot = "filetreeStore", rt = "filetree_data", lt = { db: null, async open() { return this.db ? this.db : new Promise(((e, t) => { const n = indexedDB.open("GfriendsAvatarDB", 1); n.onupgradeneeded = e => { this.db = e.target.result, this.db.objectStoreNames.contains(ot) || this.db.createObjectStore(ot); }, n.onsuccess = t => { this.db = t.target.result, e(this.db); }, n.onerror = e => { console.error("IndexedDB open error:", e.target.errorCode), t(new Error("Failed to open IndexedDB")); }; })); }, async get(e) { return await this.open(), new Promise((t => { const n = this.db.transaction([ ot ], "readonly").objectStore(ot).get(e); n.onsuccess = () => t(n.result), n.onerror = () => t(null); })); }, async set(e, t) { return await this.open(), new Promise(((n, a) => { const i = this.db.transaction([ ot ], "readwrite").objectStore(ot).put(t, e); i.onsuccess = () => n(), i.onerror = e => { console.error("IndexedDB set error:", e.target.errorCode), a(new Error("Failed to write to IndexedDB")); }; })); } }; let ct = null, dt = null; function ht(e) { if (!e || !e.Content) return null; const t = {}, n = e.Content; for (const a in n) { const e = encodeURIComponent(a); for (const i in n[a]) { let s = i.replace(/\.jpg$/i, "").split("-")[0]; s.startsWith("AI-Fix-") && (s = s.substring(7)); const o = s.toLowerCase().trim(); if (o.length > 0) { const s = n[a][i], r = s.indexOf("?"); let l, c = ""; r > -1 ? (l = encodeURIComponent(s.substring(0, r)), c = s.substring(r)) : l = encodeURIComponent(s); const d = `${st}${e}/${l}${c}`; t[o] || (t[o] = []), t[o].includes(d) || t[o].push(d); } } } return t; } async function gt(e) { let t = loading(); try { await async function() { if (ct && dt) return ct; let e = null; try { e = await lt.get(rt); } catch (a) { console.error("读取 IndexedDB 失败:", a); } if (e && e.Content && (ct = e, dt = ht(e), dt)) return ct; show.info("正在载入头像数据源..."); const t = await fetch(it); if (!t.ok) throw new Error(`请求头像源失败: ${t.status}`); const n = await t.json(); if (n && n.Content) { ct = n, dt = ht(n); try { await lt.set(rt, n), clog.debug("载入头像数据源并写入缓存成功!"); } catch (a) { clog.error(a), show.error("头像数据源写入缓存失败,可能磁盘已满或其他权限问题。"); } return ct; } clog.error(n); throw new Error("解析头像数据源失败"); }(); } catch (i) { return show.error(i), []; } finally { t.close(); } if (!dt) return []; const n = new Set, a = e.map((e => e.toLowerCase().trim())).filter((e => e.length > 0)); if (0 === a.length) return []; for (const s of a) { const e = dt[s]; e && e.forEach((e => n.add(e))); } return Array.from(n); } class pt extends X { constructor() { super(...arguments), i(this, "currentPage", 1), i(this, "pageSize", 30), i(this, "nvCurrentPage", 1), i(this, "nvPageSize", 60), i(this, "nvFlatListCache", null), i(this, "nvSortBy", "publishTime_desc"); } getName() { return "NewVideoPlugin"; } async initCss() { return "\n <style>\n #actress-card-container {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(243px, 1fr)); /* 响应式3-5列 */\n gap: 20px;\n padding-bottom: 20px;\n padding-right: 10px;\n background: #f9f9f9;\n border-radius: 5px;\n overflow-y: auto;\n }\n .actress-card {\n background: #fff;\n border: 1px solid #e0e0e0;\n border-radius: 8px;\n box-shadow: 0 4px 8px rgba(0, 0, 0, 0.08);\n padding: 15px;\n text-align: center;\n display: flex;\n flex-direction: column;\n justify-content: space-between;\n position: relative;\n overflow: hidden;\n }\n .actress-card:hover {\n box-shadow: 0 8px 15px rgba(0, 0, 0, 0.15);\n }\n .actress-card-name {\n font-size: 1.2em;\n font-weight: bold;\n color: #007bff;\n margin-top: 10px;\n }\n .actress-card-allname {\n font-size: 0.9em;\n color: #999;\n margin-top: 5px;\n height: 30px; /* 保证高度一致性 */\n overflow: hidden;\n white-space: nowrap; /* 防止文字换行 */\n text-overflow: ellipsis; /* 当文本溢出时,显示省略号 */\n }\n .actress-card-avatar {\n width: 100px;\n height: 100px;\n border-radius: 50%;\n object-fit: contain;\n margin: 0 auto;\n border: 4px solid #f0f0f0;\n box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);\n }\n \n .card-tag {\n position: absolute;\n top: 15px; /* 调整标签距离顶部的距离 */\n right: -50px; /* 调整标签距离右侧的距离,负值让它移到外面一点 */\n \n width: 150px; /* 标签的宽度,影响斜角长度 */\n padding: 5px 0; /* 上下内边距 */\n text-align: center;\n \n background-color: #ff4757; /* 标签颜色 */\n color: white; /* 文字颜色 */\n font-size: 14px;\n font-weight: bold;\n z-index: 10; /* 确保标签在其他内容之上 */\n \n /* 3. 核心:旋转标签,使其倾斜 */\n transform: rotate(45deg); /* 45度斜角 */\n \n /* 可选:添加一些阴影或边框效果 */\n box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);\n }\n \n .card-new-count-tag {\n position: absolute;\n top: 5px;\n left: 5px;\n text-align: center;\n font-size: 14px;\n font-weight: bold;\n z-index: 10;\n }\n \n #actress-pagination {\n padding-top: 10px;\n text-align: center;\n border-top: 1px solid #ddd;\n }\n @media (max-width: 600px) {\n .page-number-btn {\n display: none !important;\n }\n }\n \n \n .card-btn {\n width: 44px;\n height: 44px;\n border-radius: 50%;\n display: flex;\n justify-content: center;\n align-items: center;\n text-decoration: none;\n border: none;\n cursor: pointer;\n background: linear-gradient(145deg, #e0e0e0 0%, #f7f7f7 100%);\n box-shadow: 8px 8px 16px rgba(0, 0, 0, 0.08),\n -8px -8px 16px rgba(255, 255, 255, 1.0);\n transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);\n }\n \n .card-btn svg,\n .card-btn svg path {\n transition: fill 0.3s ease;\n }\n \n .card-btn:hover {\n box-shadow: inset 5px 5px 10px rgba(0, 0, 0, 0.1),\n inset -5px -5px 10px rgba(255, 255, 255, 0.9);\n transform: scale(0.97);\n background: #e0e0e0;\n }\n \n .btn-check-actress svg path {\n fill: #4CAF50;\n }\n .btn-check-actress:hover svg path {\n fill: #388E3C;\n }\n \n .btn-edit-actress svg path {\n fill: #FFC107;\n }\n .btn-edit-actress:hover svg path {\n fill: #FFB300;\n }\n \n .btn-delete-actress svg path {\n fill: #F44336;\n }\n .btn-delete-actress:hover svg path {\n fill: #D32F2F;\n }\n .nv-card {\n transition: transform 0.15s ease, box-shadow 0.15s ease;\n border-radius: 8px;\n overflow: hidden;\n background: #fff;\n box-shadow: 0 1px 4px rgba(0,0,0,0.08);\n }\n .nv-card:hover {\n transform: translateY(-3px);\n box-shadow: 0 6px 16px rgba(0,0,0,0.15);\n }\n .nv-cover-img {\n cursor: zoom-in;\n }\n </style>\n "; } async handle() { await this.showNewVideoCount(); } getPendingNewVideoCount(e, t) { return Array.isArray(e?.newVideoList) ? e.newVideoList.filter((e => { const n = "string" == typeof e ? e : e.carNum; return !t.has(n); })).length : 0; } async getPendingNewVideoTotal() { const e = await storageManager.getCarMap(); return (await storageManager.getFavoriteActressList()).reduce(((t, n) => t + this.getPendingNewVideoCount(n, e)), 0); } async showNewVideoCount() { const e = await this.getPendingNewVideoTotal(); $("#newVideoCount").text(`${e}`); } async resetBtnTip() { const e = this.getBean("TaskPlugin"), t = await storageManager.getSetting(), n = localStorage.getItem(e.lastCheckFavoriteActressTimeKey) || "无", a = t.checkFavoriteActress_IntervalTime, i = localStorage.getItem(e.lastCheckNewVideoTimeKey) || "无", s = t.checkNewVideo_intervalTime; $("#checkFavoriteActress").attr("data-tip", `上次自动同步时间: ${n}; 检测间隔时间: ${a}小时`), $("#checkNewVideo").attr("data-tip", `上次检测时间: ${i}; 检测间隔时间: ${s}小时`); } async openDialog() { const e = this.getBean("TaskPlugin"), t = await storageManager.getSetting(), n = localStorage.getItem(e.lastCheckFavoriteActressTimeKey) || "无", a = t.checkFavoriteActress_IntervalTime, i = localStorage.getItem(e.lastCheckNewVideoTimeKey) || "无", s = t.checkNewVideo_intervalTime; let o = `\n <div class="newVideoToolBox" style="display: flex; flex-direction: column; height: 100%; overflow: hidden; padding:10px">\n <div style="margin-bottom: 15px;display: flex; justify-content: space-between; align-items: center; gap:10px;">\n <div style="display:flex; align-items:center; flex-wrap:wrap; gap:6px; min-width:0;">\n <a class="a-danger" id="checkFavoriteActress" data-tip="上次自动同步时间: ${n}; 检测间隔时间: ${a}小时">${this.actressSvg} 手动同步演员</a>\n <a class="a-warning" id="checkNewVideo" data-tip="上次检测时间: ${i}; 检测间隔时间: ${s}小时">${this.newSvg} 手动检测最新作品</a>\n <a class="a-info" id="toSetting">${this.settingSvg} 配置</a>\n <span id="checkNewVideoMsg" style="display:inline-block;max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle;"></span>\n </div>\n <div style="display: flex; align-items: center; flex-wrap:wrap; gap:10px; flex:0 0 auto;">\n <select id="paramActressType" style="text-align: center; height: 32px; min-width: 150px; border: 1px solid #ddd; flex:0 0 auto;">\n <option value="all" selected>所有</option>\n <option value="uncensored">无码</option>\n <option value="censored">有码</option>\n <option value="">未知</option>\n </select>\n <select id="paramSortBy" style="text-align: center; height: 32px; min-width: 150px; border: 1px solid #ddd; flex:0 0 auto;">\n <option value="default" selected>默认排序</option>\n <optgroup label="发行时间">\n <option value="lastPublishTime_desc">发行时间 新→旧</option>\n <option value="lastPublishTime_asc">发行时间 旧→新</option>\n </optgroup>\n <optgroup label="检测时间">\n <option value="lastCheckTime_desc">检测时间 新→旧</option>\n <option value="lastCheckTime_asc">检测时间 旧→新</option>\n </optgroup>\n <optgroup label="新作品数">\n <option value="newVideoCount_desc">新作品数 多→少</option>\n <option value="newVideoCount_asc">新作品数 少→多</option>\n </optgroup>\n </select>\n <select id="nvSortBy" style="display:none; text-align:center; height:100%; min-width:150px; border:1px solid #ddd;">\n <option value="publishTime_desc" selected>发行时间 新→旧</option>\n <option value="publishTime_asc">发行时间 旧→新</option>\n <option value="voteCount_desc">评价人数 多→少</option>\n <option value="voteCount_asc">评价人数 少→多</option>\n <option value="actress_asc">演员名 A→Z</option>\n <option value="actress_desc">演员名 Z→A</option>\n <option value="carNum_asc">番号 A→Z</option>\n <option value="carNum_desc">番号 Z→A</option>\n </select>\n <a class="a-normal" id="toggleViewMode" style="margin-left: 5px;">新作品列表</a>\n <a class="a-normal" id="reLoad">${this.refreshSvg} 刷新</a>\n </div>\n\n </div>\n <div id="actress-card-container" class="jhs-scrollbar"></div>\n <div id="new-video-list-container" style="display:none; flex:1; overflow-y:auto;"></div>\n <div id="new-video-list-footer" style="display:none; padding:8px 0; border-top:1px solid #eee; font-size:13px; color:#666;"></div>\n <div id="actress-pagination"></div>\n </div>\n `; layer.open({ type: 1, title: '<span style="padding: 0 10px;" data-tip="数据来源: 女优页面首页,含磁链分类">新作品检测</span>', content: o, scrollbar: !1, area: utils.getResponsiveArea([ "80%", "90%" ]), anim: -1, success: async (e, t) => { this.loadData(), this.bindClick(), utils.setupEscClose(t); } }); } bindClick() { const e = this.getBean("TaskPlugin"); $("#reLoad").on("click", (e => { this.loadData(), $("#checkNewVideoMsg").text(""); })), $("#toSetting").on("click", (e => { this.getBean("SettingPlugin").openSettingDialog("task-panel", (() => { $("#setting-checkFavoriteActress").css({ border: "1px solid #f40" }), $("#setting-checkNewVideo").css({ border: "1px solid #f40" }); })); })); $("#checkFavoriteActress").on("click", (t => { utils.q({ clientX: t.clientX, clientY: t.clientY + 20 }, "是否手动同步演员?", (() => { navigator.locks.request(e.singleTaskKey, { ifAvailable: !0 }, (async t => { if (!t) return void show.error("当前有定时任务在后台执行中, 无法发起手动任务"); $('a[href*="/users/profile"]').length > 0 ? (await e.checkFavoriteActress(), this.loadData()) : show.error("未登录JavDb, 同步失败"); })).catch((e => { console.error("锁任务出现错误:", e), clog.error("锁任务出现错误:", e); })); })); })), $("#checkNewVideo").on("click", (t => { utils.q({ clientX: t.clientX, clientY: t.clientY + 20 }, "是否手动检测最新作品?", (() => { navigator.locks.request(e.singleTaskKey, { ifAvailable: !0 }, (async t => { t ? await e.checkNewVideo(!0) : show.error("当前有定时任务在后台执行中, 无法发起手动任务"); })).catch((e => { console.error("锁任务出现错误:", e), clog.error("锁任务出现错误:", e); })); })); })), $("#paramActressType").on("change", (e => { "list" === this._viewMode ? this.renderNewVideoList() : this.loadData(); })), $("#paramSortBy").on("change", (e => { this.loadData(); })), $("#nvSortBy").on("change", (e => { this.nvSortBy = $("#nvSortBy").val(), this.nvCurrentPage = 1, this.nvRenderPage(); })), $("#toggleViewMode").on("click", (e => { this._viewMode = "list" === this._viewMode ? "card" : "list"; const t = "list" === this._viewMode; $("#actress-card-container").toggle(!t), $("#actress-pagination").toggle(!t), $("#new-video-list-container").toggle(t), $("#new-video-list-footer").toggle(t), $("#paramSortBy").toggle(!t), $("#nvSortBy").toggle(t), $("#toggleViewMode").text(t ? "演员视图" : "新作品列表"), t ? this.renderNewVideoList() : this.loadData(); })); } loadData() { this.currentPage = 1; this.renderActressCards().catch(e => { clog.error("加载演员卡片失败:", e); show.error("加载数据失败"); }); } async renderActressCards() { const e = $("#actress-card-container"); if (!e.length) return; e.html('<div style="text-align:center; padding: 40px; color: #999;">加载中...</div>'); let t = await storageManager.getFavoriteActressList(); const n = $("#paramActressType").val(); "all" !== n && (t = t.filter((e => e.actressType === n))); const _carSet = await storageManager.getCarMap(); const _newVideoCount = e => this.getPendingNewVideoCount(e, _carSet); const sortBy = $("#paramSortBy").val(); const sortMap = { "lastPublishTime_desc": [{ key: "lastPublishTime", order: "desc" }], "lastPublishTime_asc": [{ key: "lastPublishTime", order: "asc" }], "lastCheckTime_desc": [{ key: "lastCheckTime", order: "desc" }], "lastCheckTime_asc": [{ key: "lastCheckTime", order: "asc" }], "newVideoCount_desc": [{ key: _newVideoCount, order: "desc" }], "newVideoCount_asc": [{ key: _newVideoCount, order: "asc" }] }; const defaultSort = [{ key: _newVideoCount, order: "desc" }, { key: "lastPublishTime", order: "desc" }]; const a = utils.genericSort(t, sortMap[sortBy] || defaultSort); const i = a.length, s = Math.ceil(i / this.pageSize), o = (this.currentPage - 1) * this.pageSize, r = o + this.pageSize, l = a.slice(o, r), c = await this.getBean("OtherSitePlugin").getJavDbUrl(), d = this.getBean("TaskPlugin"), h = await storageManager.getSetting("checkNewVideo_ruleTime") || 8760; if (0 === l.length) { e.html('<div style="text-align:center; padding: 40px; color: #999;">暂无数据</div>'); return void this.renderPagination(i, s); } const _escHtml = s => String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""); const g = l.map((e => { const a = Array.isArray(e.allName) ? e.allName.join(",") : ""; const _a = _escHtml(a), _name = _escHtml(e.name || ""), _remark = _escHtml(e.remark || ""); const _newCount = this.getPendingNewVideoCount(e, _carSet); const _effectivePublishTime = _newCount > 0 ? (e.lastPublishTime || "") : ""; const i = `${c}/actors/${e.starId}?t=d`; let s = !1; _effectivePublishTime && (s = !d.isUnnecessaryCheck(_effectivePublishTime, h)); let o = "未知", r = "#9E9E9E"; e.actressType === A ? (o = "无码", r = "#4CAF50") : e.actressType === D && (o = "有码", r = "#FF9800"); let l = ""; return s && (l = "background: linear-gradient(145deg, #e0e0e0 0%, #cabdbd 100%);box-shadow: none"), `\n <div class="actress-card" data-starId="${e.starId}" style="${s ? "background: #d4cece; " : ""}min-height: 370px;">\n <a href="${i}" target="_blank" style="text-decoration: none; color: inherit; display: block; flex-grow: 1;">\n <img src="${e.avatar || "https://c0.jdbstatic.com/images/actor_unknow.jpg"}" alt="${_a}" class="actress-card-avatar" loading="lazy">\n </a>\n\n <div>\n <a href="${i}" target="_blank" style="text-decoration: none; color: inherit; display: block; flex-grow: 1;">\n <div class="actress-card-name">${_name}</div>\n </a>\n <div class="actress-card-allname" title="${_a}">${_a}</div>\n </div>\n\n <div style="font-size: 0.8em; margin-top: 5px;">\n <span>上次检测: ${e.lastCheckTime || ""}</span>\n </div>\n <div style="font-size: 0.8em; margin-top: 5px; min-height: 1.2em;">\n <span>${_effectivePublishTime ? "最后发行作品: " + _effectivePublishTime : (_newCount === 0 && e.lastPublishTime ? "已全部标记" : "")}</span>\n </div>\n\n <div style="font-size: 0.7em; color: #cc4444; margin-top: 5px; min-height: 18px">\n <span>${s ? "停更" + h / 24 / 365 + "年以上, 下轮任务不再进行检测" : ""}</span>\n </div>\n\n <div style="font-size: 0.8em; margin-top: 5px; color: #3765c5; min-height: 10px">\n <span>${_remark}</span>\n </div>\n\n <div style="margin-top: 10px;display: flex; justify-content:center; gap: 10px;">\n <a title="编辑" class="card-btn btn-edit-actress" style="${l}" data-starId="${e.starId}">${this.editSvg}</a>\n <a title="取消收藏" class="card-btn btn-delete-actress" style="${l}" data-starId="${e.starId}">${this.deleteSvg}</a>\n <a title="重新检测该演员" class="card-btn btn-check-actress" style="${l}" data-starId="${e.starId}">${this.checkSvg}</a>\n </div>\n\n <div class="card-tag" style="background-color:${r}">${o}</div>\n <div class="card-new-count-tag" data-tip="最新作品数量: ${_newCount}">${_newCount}</div>\n </div>\n `; })).join(""); e.html(g), $(".btn-delete-actress").off("click").on("click", (e => { e.preventDefault(); const t = $(e.currentTarget).attr("data-starId"), n = a.find((e => e.starId === t)); utils.q(e, `是否取消收藏 ${n.name}?`, (async () => { let e = `${await this.getBean("OtherSitePlugin").getJavDbUrl()}/actors/${t}/uncollect`; const n = document.querySelector("meta[name=csrf-token]").content, a = await gmHttp.post(e, null, { "x-csrf-token": n }); a.includes("removeClass") ? (await storageManager.removeFavoriteActress(t), this.loadData(), this.showNewVideoCount()) : (show.error("移除失败"), clog.error("移除失败,返回值:", a)); })); })), $(".btn-edit-actress").off("click").on("click", (e => { e.preventDefault(); const t = $(e.currentTarget).attr("data-starId"), n = a.find((e => e.starId === t)); n ? this.editActress(n) : show.error(`未找到 starId 为 ${t} 的女优记录。`); })), $(".btn-check-actress").off("click").on("click", (e => { e.preventDefault(), navigator.locks.request(d.singleTaskKey, { ifAvailable: !0 }, (async t => { if (!t) return void show.error("当前有定时任务在后台执行中, 无法发起手动任务"); const n = $(e.currentTarget).attr("data-starId"), i = a.find((e => e.starId === n)); await d.checkOneNewVideo(i); })).catch((e => { console.error("锁任务出现错误:", e), clog.error("锁任务出现错误:", e); })); })), this.renderPagination(i, s), show.ok("加载完成"); } async getNewVideoFlatList() { const e = await storageManager.getFavoriteActressList(), t = await storageManager.getCarMap(), n = $("#paramActressType").val(), a = []; for (const i of e) { if ("all" !== n && i.actressType !== n) continue; if (!Array.isArray(i.newVideoList)) continue; for (const e of i.newVideoList) { const n = "string" == typeof e ? e : e.carNum; if (t.has(n)) continue; const s = "object" == typeof e ? e : {}; a.push({ carNum: n, coverUrl: s.coverUrl || "", title: s.title || "", publishTime: s.publishTime || "", actressName: i.name || "", starId: i.starId || "", score: s.score || 0, voteCount: s.voteCount || 0, url: s.url || "" }); } } return a.sort(((e, t) => (t.publishTime || "").localeCompare(e.publishTime || ""))), a; } async loadCoverForItems(e) { const t = await this.getBean("OtherSitePlugin").getJavDbUrl(), n = {}; for (const a of e) { if (n[a.starId]) continue; if (e.every((e => e.starId !== a.starId || e.coverUrl))) continue; n[a.starId] = !0; try { const i = await gmHttp.get(`${t}/actors/${a.starId}?t=d`), s = utils.htmlTo$dom(i); s.find(".movie-list .item").each(((e, n) => { const i = $(n), o = i.find(".video-title strong").text().trim(), r = i.find("img").attr("src") || ""; if (!o || !r) return; let l = r; if (!l.startsWith("http")) { l = l.startsWith("/") ? t + l : t + "/" + l; } const c = l.replace("thumbs", "covers"), d = i.find(".video-title").text().replace(o, "").trim(); $(`.nv-card[data-car="${o}"]`).each(((e, t) => { const n = $(t), i = n.find("img"); if (i.length) { i.attr("src", c).on("error", (function() { $(this).hide().next().show(); })); } else { const e = n.find(".nv-placeholder, div[style*='加载中']"); if (e.length) { e.replaceWith(`<img src="${c}" loading="lazy" style="width:100%;height:100%;object-fit:cover;" onerror="this.style.display='none';this.nextElementSibling.style.display='flex';"><div style="display:none;align-items:center;justify-content:center;height:100%;color:#999;font-size:12px;">无封面</div>`); } } d && n.attr("title", d); })); })); } catch (i) { clog.warn("获取演员封面失败:", a.actressName, i); } } } async renderNewVideoList() { const e = $("#new-video-list-container"); if (!e.length) return; e.html('<div style="text-align:center;padding:40px;color:#999;">加载中...</div>'); let t; try { t = await this.getNewVideoFlatList(); } catch (n) { return console.error(n), void e.html(`<div style="text-align:center;padding:40px;color:#e74c3c;">加载失败: ${escapeHtml(n.message)}</div>`); } if (0 === t.length) return e.html('<div style="text-align:center;padding:40px;color:#999;">暂无待鉴定的新作品</div>'), void $("#new-video-list-footer").html(""); this.nvFlatListCache = t, this.nvCurrentPage = 1, this.nvSortBy = $("#nvSortBy").val() || "publishTime_desc"; const a = new Set; for (const i of t) a.add(i.actressName); $("#new-video-list-footer").html(`<span>共 <b>${t.length}</b> 个待鉴定番号,涉及 <b>${a.size}</b> 位演员</span>\n <a class="a-normal" id="batchMarkWatched" style="margin-left:15px;">全部标记已看</a>\n <a class="a-normal" id="batchMarkDownloaded" style="margin-left:8px;">全部标记已下载</a>`); this.nvRenderPage(), this.loadCoverForItems(t).catch((e => clog.warn("封面加载异常:", e))); $("#batchMarkWatched").off("click").on("click", (async () => { if (!this.nvFlatListCache || 0 === this.nvFlatListCache.length) return; utils.q({ clientX: 0, clientY: 0 }, `确认将 ${this.nvFlatListCache.length} 个番号全部标记为已看?`, (async () => { const e = this.nvFlatListCache.map((e => ({ carNum: e.carNum, url: `/search?q=${encodeURIComponent(e.carNum)}`, names: e.actressName, actionType: p }))); try { await storageManager.saveCarList(e), show.ok(`已标记 ${e.length} 个`), this.renderNewVideoList(), this.showNewVideoCount(); } catch (n) { show.error("标记失败: " + n.message); } })); })), $("#batchMarkDownloaded").off("click").on("click", (async () => { if (!this.nvFlatListCache || 0 === this.nvFlatListCache.length) return; utils.q({ clientX: 0, clientY: 0 }, `确认将 ${this.nvFlatListCache.length} 个番号全部标记为已下载?`, (async () => { const e = this.nvFlatListCache.map((e => ({ carNum: e.carNum, url: `/search?q=${encodeURIComponent(e.carNum)}`, names: e.actressName, actionType: g }))); try { await storageManager.saveCarList(e), show.ok(`已标记 ${e.length} 个`), this.renderNewVideoList(), this.showNewVideoCount(); } catch (n) { show.error("标记失败: " + n.message); } })); })); } nvSortList(e) { const t = this.nvSortBy || "publishTime_desc", [n, a] = t.split("_"); return e.slice().sort(((e, t) => { let i = 0; switch (n) { case "publishTime": i = (e.publishTime || "").localeCompare(t.publishTime || ""); break; case "actress": i = (e.actressName || "").localeCompare(t.actressName || ""); break; case "carNum": i = (e.carNum || "").localeCompare(t.carNum || ""); break; case "voteCount": i = (e.voteCount || 0) - (t.voteCount || 0); break; } return "desc" === a ? -i : i; })); } nvRenderPage() { const e = this.nvFlatListCache; if (!e || 0 === e.length) return; const t = this.nvSortList(e), n = this.nvPageSize, a = (this.nvCurrentPage - 1) * n, i = a + n, s = t.slice(a, i), o = Math.ceil(t.length / n), r = this.getBean("OtherSitePlugin").getJavDbUrl().then((r => { const l = $("#new-video-list-container"); let c = ""; c += '<div id="nv-grid" class="jhs-new-video-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:16px;padding:5px;">'; for (const n of s) { const e = escapeHtml(n.carNum), t = escapeHtml(n.title || n.carNum), a = n.coverUrl ? n.coverUrl.replace("thumbs", "covers") : "", i = n.url || `${r}/search?q=${encodeURIComponent(n.carNum)}`; let o = `番号: ${e}\\n演员: ${escapeHtml(n.actressName)}\\n发行: ${n.publishTime || "未知"}`; n.voteCount && (o += `\\n评价人数: ${n.voteCount}`); const l = n.voteCount ? `<span style="position:absolute;top:4px;right:4px;background:rgba(0,0,0,0.7);color:#fff;font-size:11px;padding:2px 5px;border-radius:3px;">${n.voteCount}人评价</span>` : ""; c += `<div class="nv-card" data-car="${e}" title="${o}">`; c += `<a href="${i}" target="_blank" style="display:block;text-decoration:none;color:inherit;">`; c += `<div style="width:100%;aspect-ratio:3/2;overflow:hidden;border-radius:6px;background:#f0f0f0;position:relative;">`; a ? c += `<img class="nv-cover-img" src="${a}" data-full="${a}" loading="lazy" style="width:100%;height:100%;object-fit:cover;" onerror="this.style.display='none';this.nextElementSibling.style.display='flex';">${l}<div style="display:none;align-items:center;justify-content:center;height:100%;color:#999;font-size:12px;">无封面</div>` : c += `<div class="nv-placeholder" style="display:flex;align-items:center;justify-content:center;height:100%;color:#999;font-size:12px;">加载中...</div>`; c += `</div>`; c += `<div style="padding:8px 4px;">`; c += `<div style="font-size:13px;font-weight:bold;color:#333;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="${e}">${e}</div>`; c += `<div style="font-size:12px;color:#666;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="${escapeHtml(n.actressName)}">${escapeHtml(n.actressName)}</div>`; n.publishTime && (c += `<div style="font-size:11px;color:#999;">${n.publishTime}</div>`); c += `</div></a></div>`; } c += "</div>"; if (o > 1) { c += '<div id="nv-pagination-bar" style="padding:12px 0;text-align:center;border-top:1px solid #eee;margin-top:8px;">'; this.nvCurrentPage > 1 && (c += `<button class="pagination-btn" data-nvpage="${this.nvCurrentPage - 1}" style="padding:6px 14px;margin:0 3px;background:#f0f0f0;border:1px solid #ddd;border-radius:4px;cursor:pointer;">上一页</button>`); let e = Math.max(1, this.nvCurrentPage - 2), n = Math.min(o, e + 4); n - e < 4 && (e = Math.max(1, n - 4)); for (let t = e; t <= n; t++) c += `<button class="pagination-btn" data-nvpage="${t}" style="padding:6px 14px;margin:0 3px;border:1px solid #ddd;border-radius:4px;cursor:pointer;${t === this.nvCurrentPage ? "background:#007bff;color:white;border-color:#007bff;" : "background:#f0f0f0;"}">${t}</button>`; this.nvCurrentPage < o && (c += `<button class="pagination-btn" data-nvpage="${this.nvCurrentPage + 1}" style="padding:6px 14px;margin:0 3px;background:#f0f0f0;border:1px solid #ddd;border-radius:4px;cursor:pointer;">下一页</button>`), c += `<span style="margin-left:15px;color:#666;font-size:13px;">第 ${this.nvCurrentPage}/${o} 页,共 ${t.length} 条</span>`, c += "</div>"; } l.html(c), l.find(".pagination-btn").off("click").on("click", (e => { const n = parseInt($(e.currentTarget).data("nvpage")); n >= 1 && n <= o && n !== this.nvCurrentPage && (this.nvCurrentPage = n, this.nvRenderPage(), l.scrollTop(0)); })), window.imageHoverPreviewObj ? window.imageHoverPreviewObj.bindEvents() : window.imageHoverPreviewObj = new ImageHoverPreview({ selector: ".nv-cover-img", dataAttribute: "data-full" }); })); } async editActress(e) { const t = e.name, n = e.avatar, a = e.remark || "", i = Array.isArray(e.allName) ? e.allName.join(",") : "", s = Array.isArray(e.newVideoList) ? e.newVideoList.map((e => "string" == typeof e ? e : e.carNum)).join(",") : "", o = e.starId, r = "width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; min-height: 60px; overflow-y: hidden;", l = e.actressType || "", c = `\n <div style="padding: 20px;">\n <div style="margin-bottom: 15px; text-align: center;">\n <img id="edit-avatar-preview" src="${n}" alt="Avatar Preview" \n style="width: 100px; height: 100px; border-radius: 50%; object-fit: cover; margin-bottom: 10px; border: 2px solid #ddd;">\n <div style="text-align: left">\n <label style="display: block; margin-bottom: 5px; font-weight: bold;">头像链接:</label>\n <input type="text" id="edit-actress-avatar" value="${n}" \n style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;">\n <div style="display: flex; gap: 5px; margin-top: 5px;">\n <button type="button" id="search-avatar-btn" \n style="flex-grow: 1; padding: 8px; background-color: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer;">\n 搜索头像\n </button>\n <button type="button" id="select-cdn-btn" \n style="width: 100px; padding: 8px; background-color: #6c757d; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">\n 选择 CDN 源\n </button>\n </div>\n </div>\n </div>\n <div style="margin-bottom: 15px;">\n <label style="display: block; margin-bottom: 5px; font-weight: bold;">主名称:</label>\n <input type="text" id="edit-actress-name" value="${t}" \n style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;">\n </div>\n <div style="margin-bottom: 15px;">\n <label style="display: block; margin-bottom: 5px; font-weight: bold;">所有别名(用逗号隔开):</label>\n <textarea id="edit-actress-allname" style="${r}">${i}</textarea>\n </div>\n <div style="margin-bottom: 15px;">\n <label style="display: block; margin-bottom: 5px; font-weight: bold;">演员类别:</label>\n <select id="actressType" style="width: 100%; padding: 10px; border: 1px solid #ddd;">\n <option value="" ${"" === l ? "selected" : ""}>未知</option>\n <option value="censored" ${"censored" === l ? "selected" : ""}>有码</option>\n <option value="uncensored" ${"uncensored" === l ? "selected" : ""}>无码</option>\n </select>\n </div>\n <div style="margin-bottom: 15px;">\n <label style="display: block; margin-bottom: 5px; font-weight: bold;">最新作品(用逗号隔开):</label>\n <textarea id="edit-actress-newvideolist" style="${r}">${s}</textarea>\n </div>\n <div style="margin-bottom: 15px;">\n <label style="display: block; margin-bottom: 5px; font-weight: bold;">备注:</label>\n <textarea id="edit-remark" style="${r}">${a}</textarea>\n </div>\n </div>\n `; layer.open({ type: 1, title: `编辑女优: ${t} (${o})`, area: utils.getResponsiveArea(["500px", "750px"]), content: c, btn: [ "保存", "取消" ], success: (e, t) => { const n = e => { e.css("height", "auto"), e.css("height", e[0].scrollHeight + 15 + "px"); }; $("#edit-actress-avatar").on("input", (function() { const e = $(this).val(); $("#edit-avatar-preview").attr("src", e); })); const a = $("#edit-actress-allname"); a.on("input", (function() { n($(this)); })), n(a); const i = $("#edit-actress-newvideolist"); i.on("input", (function() { n($(this)); })), n(i), $("#search-avatar-btn").on("click", (async () => { await this.searchAvatar(); })), $("#select-cdn-btn").on("click", (async () => { await async function() { const e = at, t = tt.map(((t, n) => `\n <div style="margin-bottom: 10px;">\n <input type="radio" id="cdn-${n}" name="cdn-source" value="${n}" ${n === e ? "checked" : ""} style="margin-right: 10px;">\n <label for="cdn-${n}">${t.name} ${t.json.includes("jsdelivr") ? "(推荐)" : ""}</label>\n </div>\n `)).join(""), n = `\n <div style="padding: 20px;">\n <p style="margin-bottom: 15px; font-weight: bold; color: #333;">请选择头像数据源 (当前: ${tt[e].name}):</p>\n ${t}\n <p style="margin-top: 20px; color: #555; font-size: 12px;">切换源会清除本地缓存的数据,并在下次搜索时重新加载。</p>\n </div>\n `; layer.open({ type: 1, title: "选择 CDN 源", area: utils.getResponsiveArea([ "400px", "auto" ]), content: n, btn: [ "确定", "取消" ], success: (e, t) => { utils.setupEscClose(t); }, yes: async e => { const t = $('input[name="cdn-source"]:checked').val(), n = parseInt(t, 10); if (n !== at) { at = n, localStorage.setItem(nt, n.toString()), it = tt[n].json, st = tt[n].base, ct = null, dt = null; try { await lt.set(rt, null); } catch (a) { clog.error("清除 IndexedDB 缓存失败:", a); } show.ok(`CDN 源已切换为: ${tt[n].name}`), layer.close(e); } else layer.close(e); } }); }(); })), utils.setupEscClose(t); }, yes: async t => { const n = $("#edit-actress-avatar").val().trim(), a = $("#edit-actress-name").val().trim(), i = $("#edit-actress-allname").val().trim(), s = $("#edit-actress-newvideolist").val().trim(), o = $("#edit-remark").val().trim(), r = $("#actressType").val(); if (!a) return show.error("主名称不能为空"), !1; const l = i.split(/[\uff0c,]/).map((e => e.trim())).filter((e => e.length > 0)), c = s.split(/[\uff0c,]/).map((e => e.trim())).filter((e => e.length > 0)); e.avatar = n, e.name = a, e.allName = l, e.newVideoList = c, e.actressType = r, e.remark = o; try { await storageManager.updateFavoriteActress(e); this.renderActressCards().then(); this.showNewVideoCount(); show.ok(`女优 ${a} 信息已更新`); layer.close(t); } catch(err) { show.error("修改失败: " + (err.message || err)); } } }); } renderPagination(e, t) { const n = this.currentPage; let a = ""; const i = $("#actress-pagination"); if (0 === t) return a = '<span style="color: #666;">共 0 条记录</span>', void i.html(a); n > 1 && t > 5 && (a += '<button class="pagination-btn" data-page="1" style="padding: 8px 12px; margin: 0 5px; background: #f0f0f0; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;">首页</button>'), n > 1 && (a += `<button class="pagination-btn" data-page="${n - 1}" style="padding: 8px 12px; margin: 0 5px; background: #f0f0f0; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;">上一页</button>`); let s = Math.max(1, n - Math.floor(2.5)), o = Math.min(t, s + 5 - 1); o - s < 4 && (s = Math.max(1, o - 5 + 1)); for (let r = s; r <= o; r++) { a += `<button class="pagination-btn page-number-btn ${r === n ? "active" : ""}" data-page="${r}" style="padding: 8px 12px; margin: 0 3px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer; ${r === n ? "background: #007bff; color: white; border-color: #007bff;" : ""}">${r}</button>`; } n < t && (a += `<button class="pagination-btn" data-page="${n + 1}" style="padding: 8px 12px; margin: 0 5px; background: #f0f0f0; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;">下一页</button>`), n < t && t > 5 && (a += `<button class="pagination-btn" data-page="${t}" style="padding: 8px 12px; margin: 0 5px; background: #f0f0f0; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;">尾页</button>`), a += `<span style="margin-left: 20px; color: #666;">共 ${e} 条记录 (第 ${n}/${t} 页)</span>`, i.html(a), i.find(".pagination-btn").off("click").on("click", (e => { if ($(e.currentTarget).is("[disabled]")) return; const n = parseInt($(e.currentTarget).data("page")); n >= 1 && n <= t && n !== this.currentPage && (this.currentPage = n, this.renderActressCards()); })); } async searchAvatar() { const e = $("#edit-actress-name"), t = $("#edit-actress-allname"), n = e.val().trim(), a = t.val().trim().split(/[\uff0c,]/).map((e => e.trim())).filter((e => e.length > 0)); if (n && a.unshift(n), 0 === a.length) return void show.error("请先填写女优主名称或别名进行搜索。"); const i = loading("正在搜索头像..."); let s = []; try { s = await gt(a); } catch (c) { return void show.error(`头像数据加载或搜索失败: ${c.message || c}`); } finally { i.close(); } if (0 === s.length) return void show.error(`未找到与 '${a.join("、")}' 相关的头像。请检查名称。`); const o = s.map(((e, t) => `\n <div id="wrapper-${t}" class="gfriends-image-item-wrapper">\n <img alt="" src="${e}" data-url="${e}" class="gfriends-selectable-img" data-wrapper-id="wrapper-${t}" >\n <div class="gfriends-size-tag" data-size-for="wrapper-${t}">...</div> \n </div>\n `)).join(""), r = `\n <style>\n /* 保持上一个回答的美化样式 */\n #gfriends-image-list-container { padding: 15px; height: 100%; box-sizing: border-box; background-color: #f8f9fa; }\n #gfriends-prompt { color: #555; font-weight: 500; border-bottom: 1px solid #eee; padding-bottom: 10px; }\n #gfriends-image-list { display: flex; flex-wrap: wrap; gap: 15px; justify-content: center; }\n .gfriends-image-item-wrapper {\n width: 160px; height: 225px; /* 增加高度以容纳尺寸标签 */\n overflow: hidden; border-radius: 6px;\n box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); transition: transform 0.2s ease, box-shadow 0.2s ease;\n cursor: pointer; position: relative; \n padding-bottom: 25px; /* 为尺寸标签留出空间 */\n }\n .gfriends-selectable-img {\n width: 100%; height: 200px; /* 固定图片高度 */\n object-fit: cover; border: 3px solid transparent; \n border-radius: 6px; transition: border 0.2s ease;\n }\n .gfriends-image-item-wrapper:hover {\n transform: translateY(-4px) scale(1.02);\n box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);\n }\n .gfriends-selectable-img.is-selected {\n border-color: #ff6347;\n box-shadow: 0 0 0 3px #ff6347;\n }\n /* 新增:尺寸标签样式 */\n .gfriends-size-tag {\n position: absolute;\n bottom: 0; /* 定位到图片容器底部 */\n left: 0;\n right: 0;\n height: 25px;\n line-height: 25px;\n text-align: center;\n background-color: rgba(0, 0, 0, 0.7); /* 半透明背景 */\n color: #fff;\n font-size: 11px;\n font-weight: bold;\n border-bottom-left-radius: 6px;\n border-bottom-right-radius: 6px;\n user-select: none;\n }\n </style>\n \n <div id="gfriends-image-list-container">\n <p id="gfriends-prompt" style="text-align: center; font-size: 15px; margin-bottom: 15px;">\n 点击图片即可选择(初始共 ${s.length} 张)\n </p>\n <div style="overflow-y: auto; height: calc(100% - 40px);">\n <div id="gfriends-image-list">\n ${o}\n </div>\n </div>\n </div>\n `; let l = 0; layer.open({ type: 1, title: `选择女优头像 (${s.length} 张)`, area: utils.getResponsiveArea([ "900px", "85%" ]), content: r, btn: [ "关闭" ], success: (e, t) => { const n = $(e), a = n.find(".gfriends-selectable-img"), i = n.find("#gfriends-prompt"); a.each((function() { const e = $(this), a = e.data("wrapper-id"), o = n.find(`#${a}`), r = n.find(`.gfriends-size-tag[data-size-for="${a}"]`); e.on("load", (function() { const e = this.naturalWidth, t = this.naturalHeight; r.text(`${e} x ${t}`); })), e.on("error", (function() { o.remove(), l++; const e = s.length - l; i.text(`点击图片即可选择(已移除 ${l} 张错误图片,剩余 ${e} 张)`), 0 === e && (show.error("所有搜索到的头像链接均已失效,无法选择。"), layer.close(t)); })), this.complete && (this.naturalWidth > 0 ? e.trigger("load") : e.trigger("error")); })), a.on("click", (function() { const e = $(this), n = e.data("url"); $("#edit-actress-avatar").val(n), $("#edit-avatar-preview").attr("src", n), a.removeClass("is-selected"), e.addClass("is-selected"), setTimeout((() => { layer.close(t); }), 150); })), utils.setupEscClose(t); } }); } } class mt extends X { getName() { return "LocalPlugin"; } async handle() { if (r && !window.location.href.includes("/actors/")) { this.baseUrl = "http://127.0.0.1:7890", this.canRun = !1; try { const result = await utils.pingLocalService(this.baseUrl, 3e4); this.canRun = result && result.ok; } catch (e) { this.canRun = !1, console.error("本地服务连通性检查失败:", e); } this.canRun && isListPage && utils.loopDetector((() => $("#addBlacklistBtn").length), (() => { this.createBtn(); }), 1, 1e4, !1); } } createBtn() { $("#addBlacklistBtn").last().after('\n <a id="archiveBtn" class="menu-btn main-tab-btn" style="background-color:#39babe !important;margin-left: 20px!important;"><span>视频归档</span></a>\n <a id="checkSubtitleBtn" class="menu-btn main-tab-btn" style="background-color:#d08736 !important;"><span>检查字幕</span></a>\n '), $("#archiveBtn").on("click", (e => { this.archiveFile().then(); })), $("#checkSubtitleBtn").on("click", (e => { this.checkSubTitle().then(); })); } async archiveFile() { let e = await storageManager.getCarList(); const t = await gmHttp.post(this.baseUrl + "/archiveFile", { carList: e }); let n = t.dataList, a = t.updateHasDownCarNumList; if (a && a.length) { const t = new Set(a), n = Array.from(t); for (const a of n) { const t = e.find((e => e.carNum === a)); t && (await storageManager.saveCar({ carNum: a, url: t.url, actionType: g }), show.ok(`归档成功, ${a}标记为已下载`)); } } n.length > 0 ? layer.open({ type: 1, title: "归档信息", shadeClose: !0, scrollbar: !1, content: '\n <div style="height: 100%;overflow:hidden;"> \n <div id="archive-container" style="height: 100%;"></div>\n </div>\n ', anim: -1, area: utils.getResponsiveArea([ "50%", "70%" ]), success: e => { new Tabulator("#archive-container", { layout: "fitColumns", placeholder: "暂无数据", virtualDom: !0, data: n, responsiveLayout: "collapse", responsiveLayoutCollapse: !0, columnDefaults: { headerHozAlign: "center", hozAlign: "center" }, columns: [ { title: "信息", field: "msg", headerSort: !1, formatter: (e, t, n) => { const a = e.getData(); return "ok" === a.type ? `<span style="color:#58ad67">${a.msg}</span>` : `<span style="color:#c52323">${a.msg}</span>`; } }, { title: "操作", headerSort: !1, width: 200, formatter: (e, t, n) => { const a = e.getData(); return n((() => { var t; null == (t = e.getElement().querySelector(".a-primary")) || t.addEventListener("click", (e => { gmHttp.get(this.baseUrl + "/openFilePath", { filePath: a.file ? String(a.file).replace(/\.\./g, "") : "" }); })); })), '<a class="a-primary">打开路径</a>'; } } ], locale: "zh-cn", langs: { "zh-cn": { pagination: { first: "首页", first_title: "首页", last: "尾页", last_title: "尾页", prev: "上一页", prev_title: "上一页", next: "下一页", next_title: "下一页", all: "所有", page_size: "每页行数" } } } }); }, end() { window.refresh && window.refresh(); } }) : show.info("没有可归档文件"); } async checkSubTitle() { let e = await storageManager.getCarList(); let t = (await gmHttp.post(this.baseUrl + "/checkSubTitle", { dataList: e })).data; 0 !== t.length ? layer.open({ type: 1, title: "检测缺失字幕", shadeClose: !0, scrollbar: !1, content: '\n <div style="height: 100%;overflow:hidden;"> \n <div id="checkSubTitle-table-container" style="height: 100%;padding-bottom: 10px"></div>\n </div>\n ', anim: -1, area: utils.getResponsiveArea([ "70%", "70%" ]), success: e => { new Tabulator("#checkSubTitle-table-container", { layout: "fitColumns", placeholder: "暂无数据", virtualDom: !0, data: t, responsiveLayout: "collapse", responsiveLayoutCollapse: !0, columnDefaults: { headerHozAlign: "center", hozAlign: "center" }, columns: [ { title: "番号", width: 150, field: "carNum", headerSort: !1, formatter: (e, t, n) => { const a = e.getData(), i = a.type; return a.msg, "error" === i ? `<span style="color: #f40">${a.msg}</span>` : a.carNum; } }, { title: "文件路径", field: "filePath", headerSort: !1, formatter: (e, t, n) => e.getData().filePath }, { title: "操作", headerSort: !1, responsive: 0, formatter: (e, t, n) => { const a = e.getData(); return n((() => { var t, n, i, s, o; null == (t = e.getElement().querySelector(".a-success")) || t.addEventListener("click", (e => { gmHttp.get(this.baseUrl + "/openFilePath", { filePath: a.filePath ? String(a.filePath).replace(/\.\./g, "") : "" }); })), null == (n = e.getElement().querySelector(".a-info")) || n.addEventListener("click", (e => { let t = a.carNum, n = a.url; if (n) if (t.includes("FC2-")) { let e = this.parseMovieId(n); this.getBean("Fc2Plugin").openFc2Dialog(e, t, n); } else utils.openPage(n, t, !0, e); else show.error("没有找到url"); })), null == (i = e.getElement().querySelector(".a-warning")) || i.addEventListener("click", (e => { this.getBean("DetailPageButtonPlugin").searchXunLeiSubtitle(a.carNum); })), null == (s = e.getElement().querySelector(".a-primary")) || s.addEventListener("click", (e => { utils.openPage("" + ("https://subtitlecat.com/index.php?search=" + a.carNum.replace("FC2-", "")), a.carNum.replace("FC2-", ""), !0, e); })), null == (o = e.getElement().querySelector(".a-danger")) || o.addEventListener("click", (e => { const t = a.filePath.split("<br/>").filter((e => "" !== e.trim())); utils.q(e, `是否调用AI程序生成字幕,共${t.length}个视频文件`, (() => { this.aiSubtitle(t); })); })); })), '\n <a class="a-success">打开路径</a>\n <a class="a-info">详情页</a>\n <a class="a-warning">迅雷字幕</a>\n <a class="a-primary">SubTitleCat字幕</a>\n <a class="a-danger">AI字幕</a>\n '; } } ], locale: "zh-cn", langs: { "zh-cn": { pagination: { first: "首页", first_title: "首页", last: "尾页", last_title: "尾页", prev: "上一页", prev_title: "上一页", next: "下一页", next_title: "下一页", all: "所有", page_size: "每页行数" } } } }); }, end() { window.refresh && window.refresh(); } }) : show.info("视频字幕完整"); } async aiSubtitle(e) { const t = await gmHttp.post(this.baseUrl + "/aiSubtitle", { fileList: e }); 200 === t.code ? show.info("已调用后台程序, 请自行确认") : show.error(t.msg); } checkHasDown() { this.allowRepeatDown = !1; // 原来挂在 #enable-magnets-filter 后面, 那个「磁力过滤」按钮已随详情页按钮排一起删除, // 锚点没了会导致这个开关整个插不进 DOM, 所以退回磁力区/选项卡上方。 const $btn = $('<a id="allowRepeatDown" class="menu-btn" style="background-color:#b8d747;margin-left: 5px"><span>关闭重复下载检验</span></a>'); const $anchor = $("#enable-magnets-filter"); $anchor.length ? $anchor.after($btn) : $(".tabs").length ? $(".tabs").after($btn) : $("#magnets-content").before($btn); $("#allowRepeatDown").on("click", (e => { this.allowRepeatDown = !this.allowRepeatDown, $("#allowRepeatDown span").text(this.allowRepeatDown ? "开启重复下载检验" : "关闭重复下载检验"); })); let e = this.findJavDbCarNum(), t = !1; $("#magnets-content .item a").on("click", (n => { let a = $(n.target).closest("a, button")[0] || n.target; if (t) t = !1; else { if (n.preventDefault(), this.allowRepeatDown) return t = !0, void a.click(); gmHttp.get(baseUrl + "/checkHasDown?carNum=" + e).then((e => { "no" === e.data ? (t = !0, a.click()) : show.info(e.msg, { icon: 2 }); })); } })); } } const searchFiles115 = async (search_value, offset = 0, limit = 30) => { const url = `https://webapi.115.com/files/search?search_value=${encodeURIComponent(search_value)}&offset=${offset}&limit=${limit}`; return await gmHttp.get(url); }; /** 目录 cid -> 完整路径("根目录/xxx/xxx") 的缓存。存 Promise, 同一个 cid 并发只打一次请求。 */ const folderPathCache115 = new Map(); /** 取某个目录的完整路径。webapi.115.com/files 的响应里带 path 面包屑数组(第一项就是「根目录」)。 */ const getFolderPath115 = async cid => { const key = String(cid || "0"); if ("0" === key || "" === key) return "根目录"; if (folderPathCache115.has(key)) return folderPathCache115.get(key); const p = (async () => { const j = await gmHttp.get(`https://webapi.115.com/files?aid=1&cid=${encodeURIComponent(key)}&offset=0&limit=1&show_dir=1&natsort=1&format=json`); const arr = j && Array.isArray(j.path) ? j.path : []; return arr.length ? arr.map((x => x.name)).filter(Boolean).join("/") : ""; })().catch((err => (clog.debug("[115匹配] 取目录路径失败:", key, err && err.message || err), ""))); folderPathCache115.set(key, p); return p; }; /** 常见字幕后缀。用于「115匹配」表格的字幕列, 以及判断目录里是否已有字幕。 */ const JHS_SUB_FILE_RE = /\.(srt|ass|ssa|vtt|sub|sup|smi|idx)$/i; /** 海报候选的图片后缀。海报列只认 番号.jpg / poster.jpg 这两种命名, 后缀放宽到常见几种。 */ const JHS_IMG_FILE_RE = /\.(jpg|jpeg|png|webp)$/i; /** 目录 cid -> 该目录下的文件名数组。存 Promise, 同一 cid 并发只打一次请求。 * 字幕列和海报列共用这一份, 别各自再打一遍。 */ const dirFilesCache115 = new Map(); /** 列出某目录下的文件名。失败一律当作空目录, 不让它把表格搞崩。 */ const listDirFiles115 = async cid => { const key = String(cid || ""); if (!key) return []; if (dirFilesCache115.has(key)) return dirFilesCache115.get(key); const p = (async () => { const j = await gmHttp.get(`https://webapi.115.com/files?aid=1&cid=${encodeURIComponent(key)}&offset=0&limit=500&show_dir=0&natsort=1&format=json`); const arr = j && Array.isArray(j.data) ? j.data : []; return arr.map((x => x && x.n || "")).filter(Boolean); })().catch((err => (clog.debug("[115匹配] 取目录文件列表失败:", key, err && err.message || err), []))); dirFilesCache115.set(key, p); return p; }; /** 该目录下的字幕文件 */ const listSubFiles115 = async cid => (await listDirFiles115(cid)).filter((n => JHS_SUB_FILE_RE.test(n))); /** 该目录下的海报文件: 只认 番号.jpg 和 poster.jpg(后缀放宽), 别的图片不算 */ const listPosterFiles115 = async (cid, code) => { const base = String(code || "").trim().toLowerCase(); return (await listDirFiles115(cid)).filter((n => { if (!JHS_IMG_FILE_RE.test(n)) return !1; const stem = n.replace(/\.[^.]+$/, "").toLowerCase(); return "poster" === stem || !!base && stem === base; })); }; /** 传完字幕/海报后要让那一格重新去查, 否则还显示「无」 */ const clearDirFilesCache115 = cid => { null == cid ? dirFilesCache115.clear() : dirFilesCache115.delete(String(cid)); }; const _WangPan115MatchPlugin = class _WangPan115MatchPlugin extends X { constructor() { super(...arguments); i(this, "loginStatus", _WangPan115MatchPlugin.LoginStatus.UNCHECKED); } getName() { return "WangPan115MatchPlugin"; } async initCss() { return "\n <style>\n [class^='jhs-match-'] {\n padding: 1px 6px !important;\n margin-left: 0 !important;\n margin-right: 2px !important;\n font-size: 12px !important;\n line-height: 1.5 !important;\n min-height: 0 !important;\n height: auto !important;\n border-radius: 4px !important;\n display: inline-block !important;\n vertical-align: middle !important;\n position: relative !important;\n top: -2px !important;\n }\n /* 列表页标题里的匹配标签: 让标签与标题文字同一行居中对齐 */\n .video-title { -webkit-box-align: center !important; }\n .video-title > [class^='jhs-match-'] { vertical-align: middle !important; }\n \n .jhs-match-detail {\n display: block;\n width: 100%;\n z-index: 1000;\n background: #fffdfa;\n border: 1px solid #e6ddd3;\n border-radius: 7px;\n padding: 12px 16px 20px !important;\n overflow-y: auto;\n box-sizing: border-box;\n margin-top: 17px !important;\n margin-bottom: 7px !important;\n box-shadow: 0 8px 24px rgba(68,49,31,.07);\n }\n .jhs-match-detail.isListPage{\n position: absolute;\n box-shadow: 0 4px 16px rgba(0,0,0,0.15);\n width: 600px;\n max-width: 90vw;\n }\n .jhs-match-detail table {\n width: 100%;\n border-collapse: collapse;\n }\n .jhs-match-detail th, .jhs-match-detail td {\n padding: 8px 12px;\n border: 1px solid #eee7df;\n text-align: left;\n font-size: 13px;\n color: #172033;\n }\n .jhs-match-detail th {\n background-color: #fffbf6;\n font-weight: 600;\n color: #172033;\n }\n .jhs-match-detail tr:hover {\n background-color: #fff9f1;\n }\n .jhs-match-detail th:nth-child(1),\n .jhs-match-detail td:nth-child(1) {\n min-width: 180px;\n max-width: 400px;\n word-break: break-all;\n }\n .jhs-match-detail th:nth-child(2),\n .jhs-match-detail td:nth-child(2),\n .jhs-match-detail th:nth-child(3),\n .jhs-match-detail td:nth-child(3) {\n width: 100px;\n padding-left: 4px;\n padding-right: 4px;\n text-align: center;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n .jhs-match-detail th:nth-child(4),\n .jhs-match-detail td:nth-child(4) {\n width: 100px;\n text-align: center;\n white-space: nowrap;\n }\n .jhs-match-detail th:nth-child(5),\n .jhs-match-detail td:nth-child(5) {\n width: 140px;\n text-align: center;\n white-space: nowrap;\n }\n .jhs-match-detail th:nth-child(6),\n .jhs-match-detail td:nth-child(6) {\n width: 110px;\n text-align: center;\n white-space: nowrap;\n }\n /* 打开 与 播放 之间留点距离 */\n .jhs-match-detail .jhs-open-dir {\n margin-right: 10px;\n }\n /* 结果还没回来时的占位行 */\n .jhs-match-detail .jhs-115-loading {\n text-align: center;\n color: #8a7f74;\n }\n /* 标题行类名以 jhs-match- 开头, 会被上面的匹配标签样式命中变成 inline-block, 这里还原成表格行 */\n .jhs-match-detail .jhs-match-title-row {\n display: table-row !important;\n padding: 0 !important;\n margin: 0 !important;\n border-radius: 0 !important;\n position: static !important;\n top: 0 !important;\n font-size: inherit !important;\n }\n .jhs-match-detail .jhs-match-title-row th {\n background-color: #fffbf6;\n font-size: 16px;\n font-weight: 720;\n color: #172033;\n padding: 10px 12px;\n border: none;\n border-bottom: 1px solid #e6ddd3;\n }\n </style>\n "; } async handle() { $(document).on("click", ".jhs-match-no-login-btn", (async event => { event.preventDefault(); event.stopPropagation(); await this.handleLoginRedirect(); })); $(document).on("click", ".jhs-match-btn", (event => { event.preventDefault(); event.stopImmediatePropagation(); this.showMatchDetail(event.currentTarget); })); $(document).on("click", ".jhs-match-error-btn", (async event => { event.preventDefault(); event.stopPropagation(); await this.retryMatch(event.currentTarget); })); await this.matchDetailPage(); await this.matchListPage(); $(document).on("click", ".jhs-match-detail-error-btn", (async event => { event.preventDefault(); event.stopPropagation(); $(event.currentTarget).replaceWith("<a class='jhs-match-btn' title=\"匹配中...\">匹配中...</a>"); try { const carNum2 = this.getPageInfo().carNum, matchList = await this.searchFiles(carNum2); $(".jhs-115-match-detail").remove(); await this.matchDetailPage(matchList); } catch (error) { console.error(`重新匹配失败:`, error); } })); } async matchDetailPage(matchList) { if (!window.isDetailPage) return; if (await storageManager.getSetting("enable115Match", _) !== _) return; const $detail = $('\n <div class="jhs-match-detail jhs-115-match-detail" id="115-match-table">\n <table>\n <thead>\n <tr class="jhs-match-title-row">\n <th colspan="6">115匹配</th>\n </tr>\n <tr>\n <th>文件位置</th>\n <th>海报</th>\n <th>字幕</th>\n <th>大小</th>\n <th>时间</th>\n <th>操作</th>\n </tr>\n </thead>\n <tbody>\n </tbody>\n </table>\n </div>\n '), $tbody = $detail.find("tbody"); // 本地改动: 先查 115, 确认真的匹配到了才把框挂上去。 // 旧逻辑是先把「正在匹配」占位框挂上、查完没匹配再撤掉 —— 等待期间(115 搜索 // 常卡在油猴请求队列十几秒)那个框会实打实地显示出来, 请求被卡住时还会一直停在 // 「正在匹配」。现在: 匹配到才显示, 未匹配时详情页自始至终不出现 115 匹配框。 const carNum2 = this.getPageInfo().carNum; try { if (!matchList) { // 首次加载: 展示用 searchFiles(pro 原行为); 不规则重命名再用两阶段搜索判断 matchList = await this.searchFiles(carNum2); try { const { results: phaseResults, usedPhase2 } = await this._searchFilesWithPhase(carNum2); if (usedPhase2 && phaseResults.length > 0) await this.autoRenameFiles(carNum2, phaseResults); } catch (renameErr) { console.warn("[115重命名] 检查跳过:", renameErr && renameErr.message); } } await this.checkLoginStatus(); // 只有「匹配到了」和「未登录(要给出登录入口)」两种情况才显示匹配框; // 已登录但 115 里确实没有 → 直接返回, 不显示 115 匹配框 if (this.loginStatus !== _WangPan115MatchPlugin.LoginStatus.LOGGED_OUT && !(matchList && matchList.length > 0)) return; this.mount115MatchDetail($detail); if (this.loginStatus === _WangPan115MatchPlugin.LoginStatus.LOGGED_OUT) $tbody.append(`<tr><td colspan="6">\n <a class='jhs-match-no-login-btn a-info'\n data-keyword="${carNum2}"\n title="未登录115网盘">未登录</a>\n </td></tr>`); else if (matchList.length > 0) { const rowsHtml = matchList.map((match => `\n <tr>\n <td>${this.pathCellHtml(match)}</td>\n <td>${this.posterCellHtml(match, carNum2)}</td>\n <td>${this.subCellHtml(match)}</td>\n <td>${this.formatSize(match.size)}</td>\n <td>${match.createTime}</td>\n <td>\n ${this.openDirHtml(match)}<a href="https://115vod.com/?pickcode=${match.videoId}&share_id=0"\n target="_blank"\n class="a-success"\n title="播放">播放</a>\n </td>\n </tr>\n `)).join(""); $tbody.append(rowsHtml); this.bindPathCells($detail); this.bindPosterCells($detail); this.bindSubCells($detail); } else $detail.remove(); // 兜底: 上面已提前返回, 正常走不到; 万一走到也不留空框 } catch (error) { // 查询失败也要出框(给「加载失败,请重试」的入口), 所以在这里补挂 this.mount115MatchDetail($detail); $tbody.empty(); $tbody.append(`<tr><td colspan="6">\n <a class="a-danger jhs-match-detail-error-btn" title="${error.message || "加载失败"}">加载失败,请重试</a>\n </td></tr>`); console.error("加载文件列表时发生错误:", error); } } /** 两阶段搜索: 先精确(完整番号+正则过滤), 搜不到再用尾部数字模糊兜底。模糊命中即 usedPhase2, 供不规则重命名判断。 * 移植自 1.js 的 WangPan115MatchPlugin._searchFilesWithPhase, 适配本脚本的 searchFiles115。 */ async _searchFilesWithPhase(carNum2) { const { regex } = codeParse(carNum2); const searchKeyword = carNum2.toLowerCase().replace("fc2-", ""); const mapAndFilter = (rawData) => (rawData || []).map((data) => ({ folderId: data.fid, dirId: data.cid, videoId: data.pc, name: data.n, createTime: utils.formatDate(new Date(1e3 * data.te)), size: data.s, isVideo: true })).filter((x) => x.folderId && regex.test(x.name)); const resp = await searchFiles115(searchKeyword); const preciseResults = mapAndFilter(resp && resp.data); if (preciseResults.length > 0) return { results: preciseResults, usedPhase2: false }; const numberPart = extractNumberPart(carNum2); if (numberPart === searchKeyword) return { results: preciseResults, usedPhase2: false }; const fuzzyResp = await searchFiles115(numberPart); return { results: mapAndFilter(fuzzyResp && fuzzyResp.data), usedPhase2: true }; } /** 不规则重命名核心: 把文件名里畸形写的番号段替换成规范番号, 前后缀原样保留。移植自 1.js。 */ buildRenameTarget(carNum2, originalName) { const { regex } = codeParse(carNum2); const match = regex.exec(originalName); if (!match) return null; const before = originalName.slice(0, match.index); const after = originalName.slice(match.index + match[0].length); return before + carNum2 + after; } /** 不规则重命名: 模糊搜命中(usedPhase2)时才调用。读取设置开关+范围, 计算新名后批量提交 115 batch_rename。移植自 1.js。 */ async autoRenameFiles(carNum2, matchList) { try { const enableAutoRename = await storageManager.getSetting("enable115AutoRename", _); if (enableAutoRename !== _) return; const scope = await storageManager.getSetting("autoRenameScope", "vr_only"); if (scope === "vr_only" && !carNum2.toUpperCase().includes("VR")) return; const renameObj = {}; matchList.forEach((match) => { const newName = this.buildRenameTarget(carNum2, match.name); if (newName && newName !== match.name) renameObj[match.folderId] = newName; }); if (Object.keys(renameObj).length === 0) return; const formData = {}; Object.entries(renameObj).forEach(([fid, newName]) => { formData[`files_new_name[${fid}]`] = newName; }); const result = await gmHttp.postForm("https://webapi.115.com/files/batch_rename", formData); if (result && result.state) { Object.entries(renameObj).forEach(([fid, newName]) => { const match = matchList.find((m) => m.folderId === fid); console.log(`[115重命名] ${match.name} → ${newName}`); }); } else console.warn("[115重命名] 重命名失败:", result && result.error || "未知错误"); } catch (e) { console.warn("[115重命名] 重命名失败,跳过:", e.message); } } /** 把 115 匹配框挂到页面上。抽出来是因为要先查完 115、确认有匹配(或未登录/出错)才挂载, * 未匹配时压根不进 DOM, 详情页就不会出现空的 115 匹配框。 */ mount115MatchDetail($detail) { if (r) { if (!$("#all-match-box").length) { const box = "<div id='all-match-box' style='width: 100%;'></div>"; // 直接塞进「磁力」pane 内部: 显隐完全跟随标签切换(原生/脚本两种都适用), // 不再依赖判断当前是哪个 tab; pane 还没渲染时才回退到标签容器外面 $("#magnets").length ? $("#magnets").prepend(box) : $("#tabs-container").before(box); } $("#all-match-box").append($detail); jhsBind115MatchTabSync(); jhsSync115MatchVisibility(); } else $("#mag-submit-show").before($detail); } async matchListPage() { if (!window.isListPage) return; if (await storageManager.getSetting("enable115Match", _) !== _) return; try { const items = $(this.getSelector().itemSelector).toArray(); if (items.length) await this.matchMovieList(items); } catch (error) { clog.error("115列表页匹配失败", error); } } async matchMovieList(movieListElement) { if (await storageManager.getSetting("enable115Match", _) === _) { await this.checkLoginStatus(); await this.processMovieElements(movieListElement); } else $(".video-title [class^='jhs-match-']").remove(); } showMatchDetail(buttonElement) { const $el = $(buttonElement), matchData = $el.attr("data-match"); $(".jhs-match-detail").remove(); const matches = this.parseMatchData(matchData); if (0 === matches.length) return; if (1 === matches.length) { const pickcode = matches[0].videoId; window.open(`https://115vod.com/?pickcode=${pickcode}&share_id=0`, "_blank"); return; } const $detail = this.createMatchDetailElement(matches, $el.attr("data-keyword") || ""); this.positionDetailElement($detail, $el); this.addOutsideClickHandler($detail); $detail.on("click", (e => { e.stopPropagation(); })); } parseMatchData(matchData) { try { return JSON.parse(matchData) || []; } catch (e) { console.error("解析匹配数据失败:", e); return []; } } createMatchDetailElement(matches, code) { const $detail = $(`\n <div class="jhs-match-detail isListPage">\n <table>\n <thead>\n <tr>\n <th>名称</th>\n <th>海报</th>\n <th>字幕</th>\n <th>大小</th>\n <th>时间</th>\n <th>播放</th>\n </tr>\n </thead>\n <tbody>\n ${matches.map((match => `\n <tr>\n <td>${this.pathCellHtml(match)}</td>\n <td>${this.posterCellHtml(match, code)}</td>\n <td>${this.subCellHtml(match)}</td>\n <td>${this.formatSize(match.size)}</td>\n <td>${match.createTime}</td>\n <td>\n ${this.openDirHtml(match)}<a href="https://115vod.com/?pickcode=${match.videoId}&share_id=0" \n target="_blank" \n class="a-success"\n title="播放">播放</a>\n </td>\n </tr>\n `)).join("")}\n </tbody>\n </table>\n </div>\n `); $("body").append($detail); this.bindPathCells($detail); this.bindPosterCells($detail); this.bindSubCells($detail); return $detail; } positionDetailElement($detail, $trigger) { const offset = $trigger.offset(), winW = $(window).width(), detailW = $detail.outerWidth(), detailH = $detail.outerHeight(); let left = offset.left, top = offset.top - detailH + 20; left + detailW > winW - 10 && (left = winW - detailW - 10); left < 0 && (left = 10); top < 0 && (top = offset.top + $trigger.outerHeight() + 5); $detail.css({ top, left }); } addOutsideClickHandler($detail) { setTimeout((() => { $(document).on("click.jhs-match-detail", (e => { if (!$detail.is(e.target) && 0 === $detail.has(e.target).length) { $detail.remove(); $(document).off("click.jhs-match-detail"); } })); }), 100); } async retryMatch(buttonElement) { const $el = $(buttonElement), $box2 = $el.closest(".movie-box, .item"), carNum2 = $el.attr("data-keyword"); $el.replaceWith("<a class='jhs-match-btn' title=\"匹配中...\">匹配中...</a>"); try { const matchList = await this.searchFiles(carNum2); this.updateMatchStatus($box2, carNum2, matchList); } catch (error) { console.error(`重新匹配失败 [${carNum2}]:`, error); this.showMatchError($box2, carNum2, error); } } updateMatchStatus($box2, carNum2, matchList) { matchList.length > 0 ? $box2.find(".jhs-match-btn").replaceWith(`<a class='jhs-match-btn a-success' \n data-keyword="${carNum2}"\n data-match='${JSON.stringify(matchList)}'\n title="点击查看匹配详情">匹配${matchList.length}个</a>`) : $box2.find(".jhs-match-btn").replaceWith(`<a class='jhs-match-error-btn a-info' data-keyword="${carNum2}" \n title="点击重新尝试匹配">未匹配</a>`); } async handleLoginRedirect() { window.open("https://115.com"); } async searchFiles(carNum2) { var _a2; let searchKeyword = carNum2.toLowerCase().replace("fc2-", ""); // 本地改动: 这次真实搜索的返回里已经能判断登录态(未登录时 error 带「登录」), // 顺手记下来, 省掉 checkLoginStatus 里那次关键词为 test 的串行搜索。 const resp = await searchFiles115(searchKeyword); this.applyLoginStatusFrom(resp); return (null == (_a2 = resp.data) ? void 0 : _a2.map((data => ({ folderId: data.fid, parentId: data.cid, videoId: data.pc, name: data.n, createTime: utils.formatDate(new Date(1e3 * data.te)), size: data.s, isVideo: [ ".mp4", ".avi", ".mov", ".mkv", ".flv", ".wmv" ].some((ext => { var _a3; return null == (_a3 = data.n) ? void 0 : _a3.toLowerCase().endsWith(ext); })) }))).filter((x => x.folderId && x.isVideo && x.name.toLowerCase().includes(searchKeyword)))) || []; } /** 海报格: 影片同目录下的 番号.jpg / poster.jpg, 没有就「无」。和字幕格共用一次目录列表请求。 */ posterCellHtml(match, code) { const cid = null == match.parentId ? "" : String(match.parentId); if (!cid) return "无"; const attr = s => escapeHtml(String(s == null ? "" : s)).replace(/"/g, """); return `<span class="jhs-match-poster" data-cid="${attr(cid)}" data-code="${attr(code)}">…</span>`; } /** 把 .jhs-match-poster 的占位换成海报文件名, 多个只显示第一个 + 「等N个」 */ bindPosterCells($root) { $root.find(".jhs-match-poster").each(((_i, ele) => { const $s = $(ele), cid = $s.attr("data-cid"), code = $s.attr("data-code"); Promise.resolve(listPosterFiles115(cid, code)).then((names => { if (!names || !names.length) return void $s.text("无").attr("title", "该目录下没有 番号.jpg / poster.jpg"); $s.text(names.length > 1 ? `${names[0]} 等${names.length}个` : names[0]).attr("title", names.join("\n")); })).catch((() => $s.text("无"))); })); } /** 字幕格: 影片同目录下的字幕文件, 没有就「无」。 * 目录列表要额外打一次 115 接口, 所以和路径格一样先占位, 再异步填。 */ subCellHtml(match) { const cid = null == match.parentId ? "" : String(match.parentId); if (!cid) return "无"; return `<span class="jhs-match-sub" data-cid="${escapeHtml(cid).replace(/"/g, """)}">…</span>`; } /** 把 .jhs-match-sub 的占位换成字幕文件名; 多个只显示第一个 + 「等N个」, 完整清单放 title */ bindSubCells($root) { $root.find(".jhs-match-sub").each(((_i, ele) => { const $s = $(ele), cid = $s.attr("data-cid"); Promise.resolve(listSubFiles115(cid)).then((names => { if (!names || !names.length) return void $s.text("无").attr("title", "该目录下没有字幕文件"); $s.text(names.length > 1 ? `${names[0]} 等${names.length}个` : names[0]).attr("title", names.join("\n")); })).catch((() => $s.text("无"))); })); } /** 播放格里的「打开」: 直接跳 115 网盘并定位到影片所在目录(parentId 就是该目录的 cid)。 * 类名故意不用 jhs-match- 前缀, 否则会被上面的匹配标签样式改成 inline-block 带底色。 */ openDirHtml(match) { const cid = null == match.parentId ? "" : String(match.parentId); if (!cid) return ""; const url = `https://115.com/?cid=${encodeURIComponent(cid)}&offset=0&tab=&mode=wangpan`; return `<a href="${url}" target="_blank" class="a-primary jhs-open-dir" title="在115网盘中打开所在目录">打开</a>`; } /** 名称格: 先只显示文件名(路径要额外打一次 115 接口, 不该拖慢表格), * 路径取到后前置拼成「根目录/xxx/xxx/文件名」, 颜色跟随表格正常文字。 */ pathCellHtml(match) { const attr = s => escapeHtml(String(s == null ? "" : s)).replace(/"/g, """); const cid = null == match.parentId ? "" : String(match.parentId); const name = escapeHtml(match.name || ""); if (!cid) return name; return `<span class="jhs-match-path" data-cid="${attr(cid)}" data-name="${attr(match.name)}"` + ` style="word-break:break-all">${name}</span>`; } /** 只做两件事: 单元格垂直居中; 第一列(名称/路径)表头与内容左对齐且同一个左内边距, * 这样「名称」和「根目录/...」起始位置对得上。「115匹配」标题行不受影响。 * 用独立 style 追加, 不去动 initCss 那一大串。 */ ensureMatchAlignStyle() { if (document.getElementById("jhs-match-align-style")) return; const st = document.createElement("style"); st.id = "jhs-match-align-style"; st.textContent = ".jhs-match-detail th, .jhs-match-detail td{vertical-align:middle !important}" + ".jhs-match-detail thead tr:not(.jhs-match-title-row) th:first-child," + ".jhs-match-detail tbody td:first-child{text-align:left !important;padding:8px 12px !important;" + "text-indent:0 !important;box-sizing:border-box !important}" + ".jhs-match-detail tbody td:first-child .jhs-match-path{display:inline-block;margin:0 !important;padding:0 !important}" // 路径/海报/字幕 三个格子里的 span 类名也以 jhs-match- 开头, 会被那条小标签规则命中, // 拿到 top:-2px / padding / font-size:12px —— 文字就比同行其他单元格高 2px 且偏小。这里还原。 + ".jhs-match-detail .jhs-match-path,.jhs-match-detail .jhs-match-sub,.jhs-match-detail .jhs-match-poster" + "{position:static !important;top:0 !important;margin:0 !important;padding:0 !important;" + "font-size:inherit !important;line-height:inherit !important;vertical-align:middle !important}"; document.head.appendChild(st); } /** 把 .jhs-match-path 的文字换成「根目录/xxx/xxx/文件名」; 取不到路径就保持原来的文件名 */ bindPathCells($root) { this.ensureMatchAlignStyle(); $root.find(".jhs-match-path").each(((_i, ele) => { const $p = $(ele), name = $p.attr("data-name") || ""; getFolderPath115($p.attr("data-cid")).then((dir => { dir && $p.text(`${dir}/${name}`); })); })); } showMatchError($box2, carNum2, error) { $box2.find(".jhs-match-btn").replaceWith(`<a class='jhs-match-error-btn' data-keyword="${carNum2}" \n title="匹配失败,点击重试">匹配失败</a>`); show.error(`${carNum2} 匹配失败: ${error.message || "网络错误"}`); } /** 从任意一次 /files/search 的返回里推断登录态。未登录时 115 会在 error 里带「登录」字样。 * 已经判定过就不再改, 避免偶发的接口报错把状态翻成未登录。 */ applyLoginStatusFrom(resp) { if (this.loginStatus !== _WangPan115MatchPlugin.LoginStatus.UNCHECKED) return; const err = resp && resp.error; this.loginStatus = "string" == typeof err && err.includes("登录") ? _WangPan115MatchPlugin.LoginStatus.LOGGED_OUT : _WangPan115MatchPlugin.LoginStatus.LOGGED_IN; } async checkLoginStatus() { var _a2; if (this.loginStatus === _WangPan115MatchPlugin.LoginStatus.UNCHECKED) try { const testResult = await searchFiles115("test"); this.loginStatus = (null == (_a2 = testResult.error) ? void 0 : _a2.includes("登录")) ? _WangPan115MatchPlugin.LoginStatus.LOGGED_OUT : _WangPan115MatchPlugin.LoginStatus.LOGGED_IN; } catch { this.loginStatus = _WangPan115MatchPlugin.LoginStatus.LOGGED_OUT; } } async processMovieElements(movieListElement) { const promises = Array.from(movieListElement).filter((ele => !utils.isHidden(ele))).filter((ele => !(l && $(ele).find(".avatar-box").length > 0))).map((ele => this.processSingleMovieElement(ele))); await Promise.allSettled(promises); } async processSingleMovieElement(element) { const $box2 = $(element); let carNum2; try { carNum2 = this.getBoxCarInfo($box2).carNum; } catch (error) { return; } if (!carNum2) return; if (!($box2.find("[class^='jhs-match-']").length > 0)) if (this.loginStatus !== _WangPan115MatchPlugin.LoginStatus.LOGGED_OUT) try { const matchList = await this.searchFiles(carNum2); this.addTag($box2, carNum2, matchList); } catch (error) { console.error(`搜索失败 [${carNum2}]:`, error); this.addTag($box2, carNum2, []); } else this.addTag($box2, carNum2, []); } addTag($box2, carNum2, matchList) { if ($box2.find("[class^='jhs-match-']").length > 0) return; // 只挂到第一个 .video-title: 万一页面上出现嵌套/重复的 .video-title, prepend 到集合会给每个都加一个标签 → 出现重复「未匹配」 const $title = $box2.find(".video-title").first(); if (!$title.length) return; if (this.loginStatus === _WangPan115MatchPlugin.LoginStatus.LOGGED_OUT) $title.prepend(`<a class='jhs-match-no-login-btn a-info' \n data-keyword="${carNum2}" \n title="未登录115网盘">未登录</a>`); else if (matchList.length > 0) { const title = 1 === matchList.length ? "点击直接播放" : `点击查看${matchList.length}个匹配结果`; $title.prepend(`<a class='jhs-match-btn a-success' \n data-keyword="${carNum2}"\n data-match='${JSON.stringify(matchList)}'\n title="${title}">匹配${matchList.length}个</a>`); } else $title.prepend(`<a class='jhs-match-error-btn a-info' \n data-keyword="${carNum2}" \n title="未匹配,点击重试">未匹配</a>`); } formatSize(bytes) { if (!bytes) return "-"; const units = [ "B", "KB", "MB", "GB", "TB" ]; let size = parseFloat(bytes), unit = 0; for (;size >= 1024 && unit < units.length - 1; ) { size /= 1024; unit++; } return `${size.toFixed(2)} ${units[unit]}`; } }; i(_WangPan115MatchPlugin, "LoginStatus", { UNCHECKED: -1, LOGGED_OUT: 0, LOGGED_IN: 1 }); let WangPan115MatchPlugin = _WangPan115MatchPlugin; /** 不规则重命名用: 番号尾部数字提取 + 拆段正则。移植自 1.js。 */ const extractNumberPart = (carNum2) => { let cleaned = carNum2.replace(/^fc2[-_]?(ppv[-_])?/i, ""); const match = cleaned.match(/(\d+)$/); return match ? match[1] : cleaned; }; const codeParse = (code) => { const codes = code.split(/-|_/); const sep = "\\s?(0|-|_){0,2}\\s?"; let pattern = codes.join(sep); if (/^fc2/i.test(code)) pattern = `${codes[0]}${sep}(ppv)?${sep}${codes.at(-1)}`; return { code, codes, prefix: codes[0], regex: new RegExp(`(?<![a-z])${pattern}(?!\\d)`, "i") }; }; class StatsPlugin extends X { getName() { return "StatsPlugin"; } async handle() { window.isListPage && this.createBtn(); } createBtn() { const e = '<a id="statsBtn" class="menu-btn main-tab-btn" style="background-color:#6c5ce7 !important;"><span>统计</span></a>'; r ? $("#newVideoBtn").after(e) : l && $("#newVideoBtn").after(e); $("#statsBtn").on("click", (() => { this.openDialog(); })); } async openDialog() { const e = await storageManager.getCarList(), t = await storageManager.getFavoriteActressList(), n = await storageManager.getBlacklist(), a = e.length, m = await storageManager.getStatusMap(), i = { filter: m[d].size, favorite: m[h].size, hasDown: m[g].size, hasWatch: m[p].size }, s = {}; e.forEach((e => { e.names && e.names.split(" ").forEach((e => { e && (s[e] = (s[e] || 0) + 1); })); })); const o = Object.entries(s).sort(((e, t) => t[1] - e[1])).slice(0, 10), r = o.length > 0 ? o[0][1] : 1, l = a - i.filter - i.favorite - i.hasDown - i.hasWatch; let c = 0; const pendingCarSet = await storageManager.getCarMap(), pendingCounter = this.getBean("NewVideoPlugin"); t.forEach((e => { if (pendingCounter) c += pendingCounter.getPendingNewVideoCount(e, pendingCarSet); })); const barPct = e => '<span style="display:inline-block;height:18px;width:' + Math.round(e / r * 100) + '%;background:#6c5ce7;border-radius:3px;min-width:2px"></span>', barRow = (e, t, r) => { const c = a > 0 ? Math.round(e / a * 100) : 0; return '<div style="display:flex;align-items:center;margin-bottom:6px;gap:8px"><span style="width:60px;font-size:13px;text-align:right">' + t + '</span><span style="flex:1;background:#eee;border-radius:3px;height:18px"><span style="display:inline-block;height:18px;width:' + c + '%;background:' + r + ';border-radius:3px;min-width:2px"></span></span><span style="width:50px;font-size:12px;color:#888">' + e + ' (' + c + '%)</span></div>'; }, dialogHtml = '<div style="padding:10px 20px;height:100%;overflow:auto" class="jhs-scrollbar"><div style="display:flex;gap:10px;margin-bottom:15px;flex-wrap:wrap"><div style="flex:1;min-width:100px;background:#f8f9fa;border-radius:8px;padding:12px;text-align:center"><div style="font-size:24px;font-weight:bold;color:#333">' + a + '</div><div style="font-size:12px;color:#888">总数</div></div><div style="flex:1;min-width:100px;background:#f0f7ff;border-radius:8px;padding:12px;text-align:center"><div style="font-size:24px;font-weight:bold;color:#25b1dc">' + (i.favorite || 0) + '</div><div style="font-size:12px;color:#888">已收藏</div></div><div style="flex:1;min-width:100px;background:#f0fff4;border-radius:8px;padding:12px;text-align:center"><div style="font-size:24px;font-weight:bold;color:#7bc73b">' + (i.hasDown || 0) + '</div><div style="font-size:12px;color:#888">已下载</div></div><div style="flex:1;min-width:100px;background:#fff9f0;border-radius:8px;padding:12px;text-align:center"><div style="font-size:24px;font-weight:bold;color:#d7a80c">' + (i.hasWatch || 0) + '</div><div style="font-size:12px;color:#888">已观看</div></div><div style="flex:1;min-width:100px;background:#fff5f5;border-radius:8px;padding:12px;text-align:center"><div style="font-size:24px;font-weight:bold;color:#de3333">' + (i.filter || 0) + '</div><div style="font-size:12px;color:#888">已屏蔽</div></div><div style="flex:1;min-width:100px;background:#f5f5f5;border-radius:8px;padding:12px;text-align:center"><div style="font-size:24px;font-weight:bold;color:#666">' + l + '</div><div style="font-size:12px;color:#888">待鉴定</div></div></div><div style="display:flex;gap:10px;margin-bottom:15px;flex-wrap:wrap"><div style="flex:1;min-width:100px;background:#f8f0ff;border-radius:8px;padding:12px;text-align:center"><div style="font-size:24px;font-weight:bold;color:#6c5ce7">' + t.length + '</div><div style="font-size:12px;color:#888">收藏演员</div></div><div style="flex:1;min-width:100px;background:#fff0f0;border-radius:8px;padding:12px;text-align:center"><div style="font-size:24px;font-weight:bold;color:#b22222">' + n.length + '</div><div style="font-size:12px;color:#888">黑名单演员</div></div><div style="flex:1;min-width:100px;background:#fffef0;border-radius:8px;padding:12px;text-align:center"><div style="font-size:24px;font-weight:bold;color:#e8ab39">' + c + '</div><div style="font-size:12px;color:#888">新作品待看</div></div></div><div style="font-size:14px;font-weight:bold;margin-bottom:8px;color:#333">状态分布</div>' + barRow(i.favorite || 0, "已收藏", "#25b1dc") + barRow(i.hasDown || 0, "已下载", "#7bc73b") + barRow(i.hasWatch || 0, "已观看", "#d7a80c") + barRow(i.filter || 0, "已屏蔽", "#de3333") + barRow(l, "待鉴定", "#888") + (o.length > 0 ? '<div style="font-size:14px;font-weight:bold;margin:12px 0 8px;color:#333">Top10 演员</div>' + o.map(((e, t) => '<div style="display:flex;align-items:center;margin-bottom:4px;gap:8px"><span style="width:24px;font-size:12px;color:#888;text-align:right">' + (t + 1) + '</span><span style="width:100px;font-size:13px;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escapeHtml(e[0]) + '">' + escapeHtml(e[0]) + '</span>' + barPct(e[1]) + '<span style="width:36px;font-size:12px;color:#888">' + e[1] + '</span></div>')).join("") : "") + '</div>'; layer.open({ type: 1, title: "收藏统计", content: dialogHtml, scrollbar: !1, area: utils.getResponsiveArea(["60%", "80%"]), anim: -1, success: (e, t) => { utils.setupEscClose(t); } }); } } class MobileBottomBarPlugin extends X { constructor() { super(...arguments); this._fabGeneration = 0; } getName() { return "MobileBottomBarPlugin"; } shouldSkipOnMobile() { return false; // this plugin only runs on mobile } async initCss() { return ` /* FAB 浮动操作按钮 */ #jhs-fab { position: fixed; bottom: calc(24px + env(safe-area-inset-bottom, 0px)); right: 20px; width: 56px; height: 56px; border-radius: 50%; background: linear-gradient(135deg, #3b82f6, #1e40af); color: white; font-size: 26px; display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 10002; box-shadow: 0 4px 16px rgba(59,130,246,0.4); transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1), box-shadow 0.3s; -webkit-tap-highlight-color: transparent; user-select: none; -webkit-user-select: none; } #jhs-fab:active { transform: scale(0.9); } #jhs-fab.jhs-fab-open { transform: rotate(135deg); background: linear-gradient(135deg, #ef4444, #b91c1c); box-shadow: 0 4px 16px rgba(239,68,68,0.4); } /* FAB 遮罩 */ .jhs-fab-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.25); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); z-index: 10000; opacity: 0; pointer-events: none; transition: opacity 0.3s; } .jhs-fab-backdrop.jhs-fab-backdrop-visible { opacity: 1; pointer-events: auto; } /* FAB 菜单 */ .jhs-fab-menu { position: fixed; bottom: calc(92px + env(safe-area-inset-bottom, 0px)); right: 16px; z-index: 10001; display: flex; flex-direction: column; gap: 10px; align-items: flex-end; opacity: 0; transform: translateY(16px) scale(0.92); pointer-events: none; transition: opacity 0.25s, transform 0.25s cubic-bezier(0.32, 0.72, 0, 1); } .jhs-fab-menu.jhs-fab-menu-open { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; } /* FAB 菜单分组 */ .jhs-fab-group { display: flex; flex-direction: column; gap: 10px; align-items: flex-end; } .jhs-fab-divider { width: 32px; height: 2px; background: rgba(0,0,0,0.08); border-radius: 1px; align-self: flex-end; margin: 2px 0; } /* FAB 菜单项 */ .jhs-fab-menu-item { display: flex; align-items: center; gap: 10px; padding: 10px 16px; background: white; border-radius: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); cursor: pointer; white-space: nowrap; min-height: 44px; font-size: 14px; font-weight: 500; color: #333; opacity: 0; transform: translateY(8px) scale(0.92); transition: opacity 0.2s, transform 0.2s cubic-bezier(0.32, 0.72, 0, 1), background 0.15s; -webkit-tap-highlight-color: transparent; user-select: none; -webkit-user-select: none; } .jhs-fab-menu-item.jhs-fab-item-visible { opacity: 1; transform: translateY(0) scale(1); transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.32, 0.72, 0, 1), background 0.15s; } .jhs-fab-menu-item:active { transform: scale(0.95); background: #f3f4f6; } /* FAB 状态色块 */ .jhs-fab-status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; background: #e5e7eb; } @media (min-width: 769px) { #jhs-fab, .jhs-fab-menu, .jhs-fab-backdrop { display: none !important; } } `; } async handle() { if (!utils.isMobileMode()) return; // 添加遮罩 const backdrop = $('<div class="jhs-fab-backdrop"></div>').appendTo("body"); // 添加菜单 const menu = this.createMenu(); $("body").append(menu); // 添加 FAB 按钮 const fab = $('<div id="jhs-fab">+</div>').appendTo("body"); this.bindEvents(fab, backdrop); } /** 获取详情页番号 */ getCarNum() { try { const basePlugin = this.getBean("DetailPageButtonPlugin"); if (basePlugin?.parseMovieId) return basePlugin.parseMovieId(location.href); const el = document.querySelector(".header, #video_id, .video-id"); if (el) return el.textContent.trim(); } catch (e) {} return null; } createMenu() { const isList = window.isListPage; const isDetail = window.isDetailPage; let items = ""; if (isList) { const sortMethod = localStorage.getItem("jhs_sortMethod") || "default"; const sortLabel = { default: "默认", rateCount: "评价", date: "时间" }[sortMethod]; items = ` <div class="jhs-fab-group"> <div class="jhs-fab-menu-item" data-action="check">待鉴定</div> <div class="jhs-fab-menu-item" data-action="newVideo">新作品</div> <div class="jhs-fab-menu-item" data-action="blacklist">黑名单</div> <div class="jhs-fab-menu-item" data-action="sort">排序: ${sortLabel}</div> </div> <div class="jhs-fab-divider"></div> <div class="jhs-fab-group"> <div class="jhs-fab-menu-item" data-action="setting">设置</div> </div> `; } else if (isDetail) { const statusDefs = [ { action: "filter", icon: m, label: "屏蔽", key: "filter" }, { action: "fav", icon: v, label: "收藏", key: "fav" }, { action: "down", icon: y, label: "已下载", key: "down" }, { action: "watch", icon: k, label: "已观看", key: "watch" } ]; items = ` <div class="jhs-fab-group"> ${statusDefs.map(d => `<div class="jhs-fab-menu-item" data-action="${d.action}" data-label="${d.label}"><span class="jhs-fab-status-dot" data-status-key="${d.key}"></span>${d.icon}</div>`).join("")} </div> <div class="jhs-fab-divider"></div> <div class="jhs-fab-group"> <div class="jhs-fab-menu-item" data-action="magnet">磁力搜索</div> <div class="jhs-fab-menu-item" data-action="subtitle">字幕</div> </div> <div class="jhs-fab-divider"></div> <div class="jhs-fab-group"> <div class="jhs-fab-menu-item" data-action="setting">设置</div> </div> `; } else { items = ` <div class="jhs-fab-group"> <div class="jhs-fab-menu-item" data-action="setting">设置</div> </div> `; } return $(`<div class="jhs-fab-menu">${items}</div>`); } /** 刷新详情页菜单的状态指示 */ async refreshDetailStatus() { try { const carNum = this.getCarNum(); if (!carNum) return; const car = await storageManager.getCar(carNum); const menu = $(".jhs-fab-menu"); const colors = { filter: "#ef4444", fav: "#3b82f6", down: "#22c55e", watch: "#a855f7" }; const activeKey = { [d]: "filter", [h]: "fav", [g]: "down", [p]: "watch" }; let activeStatus = null; if (car?.status && activeKey[car.status]) activeStatus = activeKey[car.status]; menu.find(".jhs-fab-status-dot").each(function () { const key = $(this).data("status-key"); const item = $(this).closest(".jhs-fab-menu-item"); if (key === activeStatus) { $(this).css({ background: colors[key] || "#ccc" }); } else { $(this).css({ background: "#e5e7eb" }); } }); } catch (e) {} } bindEvents(fab, backdrop) { const menu = $(".jhs-fab-menu"); const closeMenu = () => { this._fabGeneration++; fab.removeClass("jhs-fab-open"); menu.removeClass("jhs-fab-menu-open"); backdrop.removeClass("jhs-fab-backdrop-visible"); menu.find(".jhs-fab-menu-item").removeClass("jhs-fab-item-visible"); }; const toggleMenu = () => { const isOpen = fab.hasClass("jhs-fab-open"); if (isOpen) { closeMenu(); } else { fab.addClass("jhs-fab-open"); menu.addClass("jhs-fab-menu-open"); backdrop.addClass("jhs-fab-backdrop-visible"); // 刷新排序标签 if (window.isListPage) { const sortMethod = localStorage.getItem("jhs_sortMethod") || "default"; const sortLabel = { default: "默认", rateCount: "评价", date: "时间" }[sortMethod]; menu.find('[data-action="sort"]').html(`排序: ${sortLabel}`); } // 刷新详情页状态 if (window.isDetailPage) this.refreshDetailStatus(); // stagger 动画:依次显示菜单项(generation counter 防竞态) const gen = ++this._fabGeneration; const self = this; const items = menu.find(".jhs-fab-menu-item"); items.each(function (i) { const el = $(this); setTimeout(() => { if (gen === self._fabGeneration) el.addClass("jhs-fab-item-visible"); }, 30 + i * 35); }); } }; // FAB 点击切换 fab.on("click", toggleMenu); // 遮罩点击关闭 backdrop.on("click", closeMenu); // 菜单项点击 menu.on("click", ".jhs-fab-menu-item", (e) => { const action = $(e.currentTarget).data("action"); this.handleAction(action); closeMenu(); }); } handleAction(action) { switch (action) { // 列表页操作 case "check": $("#waitCheckBtn").length && $("#waitCheckBtn").click(); break; case "newVideo": this.getBean("NewVideoPlugin")?.openDialog(); break; case "blacklist": this.getBean("BlacklistPlugin")?.openBlacklistDialog(); break; case "sort": { const cur = localStorage.getItem("jhs_sortMethod") || "default"; const next = cur === "default" ? "rateCount" : cur === "rateCount" ? "date" : "default"; localStorage.setItem("jhs_sortMethod", next); const btnPlugin = this.getBean("ListPageButtonPlugin"); btnPlugin?.sortItems?.(); const label = { default: "默认", rateCount: "评价", date: "时间" }[next]; show.info(`排序: ${label}`); break; } // 详情页操作: 原来是去 click 详情页那排按钮(#filterBtn 等), 那排按钮已随 createMenuBtn 一起删除, // 现在直接调插件方法。toggleStatus 读的是当前详情页的番号, 移动端菜单只在详情页显示, 用它正好。 case "filter": this.getBean("DetailPageButtonPlugin")?.toggleStatus(d); break; case "fav": this.getBean("DetailPageButtonPlugin")?.toggleStatus(h); break; case "down": this.getBean("DetailPageButtonPlugin")?.toggleStatus(g); break; case "watch": this.getBean("DetailPageButtonPlugin")?.toggleStatus(p); break; case "magnet": { const carNum = this.getCarNum(); if (!carNum) { show.error("番号为空!"); break; } const hub = this.getBean("MagnetHubPlugin")?.createMagnetHub(carNum); hub && layer.open({ type: 1, title: "磁力搜索 " + carNum, content: '<div id="magnetHubBox"></div>', area: utils.getResponsiveArea([ "60%", "80%" ]), scrollbar: !1, success: () => { $("#magnetHubBox").append(hub); } }); break; } case "subtitle": { const carNum = this.getCarNum(); carNum ? this.getBean("DetailPageButtonPlugin")?.searchSubtitle(carNum) : show.error("番号为空!"); break; } // 通用 case "setting": this.getBean("SettingPlugin")?.openSettingDialog(); break; } } } // 鉴定处理 / 外部跳转 两个图标的 SVG: 详情页图标栏和 FC2 弹层共用, 提出来免得两份拷贝走样 const QI_SVG_HANDLE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="2.5" width="14" height="11" rx="2" fill="#7c3aed"></rect><rect x="1" y="2.5" width="14" height="2.4" rx="1" fill="#a78bfa"></rect><path d="M4.6 9.4l2 2 4.8-4.4" fill="none" stroke="#fff" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"></path></svg>'; const QI_SVG_SITE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="2.5" width="14" height="11" rx="2" fill="#71bb59"></rect><path d="M6.6 5.4h4.2v4.2" fill="none" stroke="#fff" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"></path><path d="M10.6 5.6L5.4 10.8" fill="none" stroke="#fff" stroke-width="1.7" stroke-linecap="round"></path></svg>'; // 海报直传: 图片框 + 向上箭头(传到 115) const QI_SVG_POSTER = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="2.5" width="14" height="11" rx="2" fill="#ea580c"></rect><circle cx="5.2" cy="6.2" r="1.25" fill="#fde68a"></circle><path d="M2.4 12.2l3.1-3 2.1 1.8 2.4-2.9 3 4.1z" fill="#fff" opacity=".9"></path><path d="M12.1 9.6V5.4" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round"></path><path d="M10.6 6.8l1.5-1.5 1.5 1.5" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg>'; /** * 详情页番号信息面板底部的小图标按钮栏(字幕 / 预告片 / 预览图)。 * 详情页的操作入口现在只有这一栏: * 字幕 -> 复用迅雷字幕面板(DetailPageButtonPlugin 现在只是个方法库, 没有自己的 handle) * 预告片 -> JhsTrailer(javxy 聚合源 + 按需 hls.js) * 预览图 -> 长缩略图(ScreenShotPlugin + 图片查看器) */ class DetailQuickIconPlugin extends X { getName() { return "DetailQuickIconPlugin"; } async handle() { // 挂一个手动重试入口, 方便在控制台直接排查: jhsQuickIcons() try { unsafeWindow.jhsQuickIcons = window.jhsQuickIcons = () => this.injectQuickIconBar(); } catch (e) { window.jhsQuickIcons = () => this.injectQuickIconBar(); } if (!window.isDetailPage) return; this.injectQuickIconBar().catch((e => clog.error("详情页快捷图标栏注入失败", e))); } async injectQuickIconBar() { const carNum = this.getPageInfo().carNum; const st = await storageManager.getSetting(); const on = v => void 0 === v || null === v || v === _; clog.log("[快捷图标] 开始注入", { carNum: carNum, isDetailPage: window.isDetailPage, 字幕: st.enableIconSubtitle, 预告片: st.enableIconTrailer, 预览图: st.enableIconPreview, 鉴定处理: st.enableIconHandle, 海报直传: st.enableIconPoster, 外部跳转: st.enableIconSite }); if (!carNum) return void clog.warn("[快捷图标] 番号为空, 放弃注入"); // 彩色图标: 字幕(青) / 预告片(红, 沿用封面「播放视频」语义) / 预览图(蓝橙, 沿用封面「长缩略图」配色) / 鉴定处理(紫) const actions = [ { key: "subtitle", title: "字幕", color: "#0f766e", enabled: on(st.enableIconSubtitle), svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="2.5" width="14" height="11" rx="2" fill="#0f766e"></rect><rect x="3.2" y="9.4" width="5.2" height="1.5" rx=".75" fill="#fff"></rect><rect x="9.4" y="9.4" width="3.4" height="1.5" rx=".75" fill="#fff"></rect><rect x="3.2" y="6.2" width="7.6" height="1.5" rx=".75" fill="#fff" opacity=".55"></rect></svg>' }, { key: "trailer", title: "预告片", color: "#e11d48", enabled: on(st.enableIconTrailer), svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="2.5" width="14" height="11" rx="2" fill="#e11d48"></rect><rect x="1" y="2.5" width="14" height="2.4" rx="1" fill="#fb7185"></rect><path d="M6.4 6.7l4.1 1.9-4.1 1.9z" fill="#fff"></path></svg>' }, { key: "preview", title: "预览图", color: "#3a63d6", enabled: on(st.enableIconPreview), svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="2.5" width="14" height="11" rx="2" fill="#4078FD"></rect><circle cx="5.4" cy="6.4" r="1.35" fill="#FE9C23"></circle><path d="M2.6 12.4l3.3-3.2 2.2 1.9 2.6-3.1 3.1 4.4z" fill="#fff" opacity=".92"></path></svg>' }, { key: "handle", title: "鉴定处理", color: "#7c3aed", enabled: on(st.enableIconHandle), svg: QI_SVG_HANDLE }, { key: "poster", title: "海报直传", color: "#ea580c", enabled: on(st.enableIconPoster), svg: QI_SVG_POSTER }, { key: "site", title: "外部跳转", color: "#0e8a4f", enabled: on(st.enableIconSite), svg: QI_SVG_SITE } ].filter((e => e.enabled)); if (!actions.length) return void clog.warn("[快捷图标] 开关全部关闭(个性化设置 > 详情页快捷图标), 放弃注入"); // 详情页一打开就先把长缩略图地址抓好(和「加载长缩略图」格子共用 localStorage.jhs_screenShot), // 点「预览图」时直接命中缓存 —— 快就快在这里。关掉: localStorage.setItem("jhs_thumbPrefetch","off") try { actions.some((e => "preview" === e.key)) && "off" !== localStorage.getItem("jhs_thumbPrefetch") && "off" !== localStorage.getItem("jhs_fastThumb") && jhsFastThumb.prefetch(String(carNum).replace("FC2-", "")); } catch (e) { clog.debug("预抓长缩略图失败(忽略)", e); } // 图标+文字 胶囊按钮: 浅色底 + 描边 + 与图标同色的文字; 用 .panel-block 让左边距与「番號/演員」等行对齐 this._ensureQuickIconStyle(); const html = `<div id="jhs-quick-icons" class="jhs-quick-icons panel-block">${actions.map((e => `<span class="jhs-quick-icon" data-action="${e.key}" role="button" tabindex="0" title="${escapeHtml(e.title)} ${escapeHtml(carNum)}">${e.svg}<span class="jhs-qi-txt" style="color:${e.color}">${escapeHtml(e.title)}</span></span>`)).join("")}</div>`; // 信息面板容器: JavDB = .movie-panel-info, JavBus = .info。 // 兜底链与 #otherSiteBox 完全一致(JavDB 改版后可能没有 .movie-panel-info, 只剩 .panel-block) const findHost = () => { // 首选: 第三方站点栏已经落位, 直接跟它同一个父容器, 位置最稳 const $other = $("#otherSiteBox").first(); if ($other.length && $other.parent().length) return $other.parent(); if (l) { const $info = $(".info").first(); if ($info.length) return $info; const $busHeader = $("span.header").filter((function() { return /識別碼|识别码/.test(($(this).text() || "").trim()); })).first(); if ($busHeader.length) { const $p = $busHeader.closest("p"); if ($p.length && $p.parent().length) return $p.parent(); } } else { const $mpi = $(".movie-panel-info").first(); if ($mpi.length) return $mpi; const $vmp = $(".video-meta-panel").first(); if ($vmp.length) return $vmp; // JavDB 改版兜底: 番號/演員 所在 .panel-block 的父容器 let $blk = $(); $(".panel-block").each((function() { const label = $(this).find("strong").first().text().trim(); if (label.includes("番號") || label.includes("番号") || label.includes("演員") || label.includes("演员")) return $blk = $(this), !1; })); if ($blk.length && $blk.parent().length) return $blk.parent(); } return $(); }; const doInsert = () => { if ($("#jhs-quick-icons").length) return !0; // 有第三方站点栏就直接挂在它后面, 天然处于整栏最底部, 不需要后续再搬动(避免上下跳动) const $other = $("#otherSiteBox").first(); if ($other.length) $other.after(html); else { const $host = findHost(); if (!$host.length) return !1; $host.append(html); } const ok = $("#jhs-quick-icons").length > 0; return ok && clog.log("[快捷图标] 已注入", $other.length ? "紧随 #otherSiteBox" : "追加到信息面板", actions.map((e => e.key))), ok; }; const wireUp = () => { $("#jhs-quick-icons").off("click.jhsQuickIcon").on("click.jhsQuickIcon", ".jhs-quick-icon", (async e => { e.preventDefault(), e.stopPropagation(); const $btn = $(e.currentTarget); if ("1" === $btn.attr("data-loading")) return; $btn.attr("data-loading", "1").css({ pointerEvents: "none", opacity: .5 }); try { switch ($btn.data("action")) { case "subtitle": this.openXunLeiSubtitle(carNum); break; case "trailer": await JhsTrailer.show(carNum); break; case "preview": await this.showLongThumbnail(carNum); break; case "handle": await this.openHandleMenu($btn, carNum); break; case "poster": await this.pushPosterTo115(carNum); break; case "site": await this.openSiteMenu($btn, carNum); } } catch (t) { clog.error("快捷图标操作失败", t), show.error("操作失败: " + (t && t.message || t)); } finally { $btn.removeAttr("data-loading").css({ pointerEvents: "", opacity: "" }); } })); }; const tryOnce = () => { doInsert() ? wireUp() : utils.loopDetector((() => findHost().length > 0), (() => { doInsert() ? wireUp() : clog.warn("[快捷图标] 找到容器但插入失败"); }), 150, 1e4, !1); }; // 不再"先等 #otherSiteBox 落位再插入": 等待最多 6s, 且超时不回调(loopDetector 末参 false), // 只能靠下面 2800ms 的兜底才出现 —— JavBus 上这段等待最明显。 // 改为立刻插入, 之后再轮询一小段时间, #otherSiteBox 一出现就把图标栏挪到它后面, 顺序照旧。 tryOnce(); $("#otherSiteBox").length || utils.loopDetector((() => $("#otherSiteBox").length > 0), (() => { const $box = $("#jhs-quick-icons"), $other = $("#otherSiteBox"); $box.length && $other.length && $other[0].nextElementSibling !== $box[0] && $other.after($box); }), 150, 6e3, !1); // 演员维基等异步块可能晚于图标栏插入, 在 OtherSitePlugin 最后一次压底(2500ms)之后做一次收尾校正 setTimeout((() => { const $box = $("#jhs-quick-icons"); if (!$box.length) return void tryOnce(); const $p = $box.parent(); $p.length && $p.children().last()[0] !== $box[0] && $p.append($box); }), 2800); } /** 海报直传 115: 详情页大封面按「番号.jpg」传到「115匹配」搜到的影片所在目录。 * 和字幕直传同一条链路(fetchBuffer -> push115), 匹配到多个目录时每个目录各传一份。 */ async pushPosterTo115(carNum) { const code = String(carNum || "").trim(); if (!code) throw new Error("番号为空"); const url = this._findCoverUrl(); if (!url) throw new Error("没找到详情页封面图"); const matcher = this.getBean("WangPan115MatchPlugin"); if (!matcher) throw new Error("115 匹配插件不可用"); const matches = await matcher.searchFiles(code); if (!matches || !matches.length) throw new Error("115 里没搜到这个番号的影片, 请先确认已下载到 115"); // 同一目录只传一次: 一个目录里可能匹配到多个分片/版本 const cids = []; matches.forEach((m => { const cid = null == m.parentId ? "" : String(m.parentId); cid && !cids.includes(cid) && cids.push(cid); })); if (!cids.length) throw new Error("匹配结果里没有目录信息"); // 先查一遍哪些目录已经有海报了 —— 有的就跳过, 不重复传一份。 // 缓存清过(或刚传过)时这里会重新打接口, 拿到的是当前真实状态。 const missing = [], had = []; for (const cid of cids) { const names = await listPosterFiles115(cid, code); names && names.length ? had.push(names[0]) : missing.push(cid); } if (!missing.length) return void show.info(`该番号已经有上传海报(${had.join("、")})`); const buffer = await JhsSubtitle.fetchBuffer(url); const name = JhsSubtitle.safeName(`${code.toUpperCase()}.jpg`); const done = []; for (const cid of missing) { done.push(await JhsSubtitle.push115(buffer, name, cid)); clearDirFilesCache115(cid); } // 让「115匹配」表格的海报列重新查一次, 否则刚传完那格还写着「无」 matcher.bindPosterCells($(".jhs-match-detail")); const skipped = had.length ? `,另 ${had.length} 个目录已有海报、已跳过` : ""; show.ok(`海报已传 ${missing.length} 个目录: ${name}(${done.join("/")})${skipped}`); } /** 详情页大封面地址, 选择器与「保存封面(快捷键 q)」那套保持一致 */ _findCoverUrl() { const pick = sel => { const el = document.querySelector(sel); return el && (el.getAttribute("src") || el.getAttribute("poster")) || ""; }; let src = r ? pick(".column-video-cover img") : ""; if (!src && l) src = pick(".movie .bigImage img") || pick(".screencap img"); if (!src) src = pick(".column-video-cover img") || pick("img.video-cover") || pick("a.bigImage img"); if (!src) return ""; try { return new URL(src, location.href).href; } catch (e) { return src; } } /** 胶囊图标的样式。选择器用 .jhs-quick-icons 而不是 #jhs-quick-icons —— FC2 弹层里还有第二个容器。 */ _ensureQuickIconStyle() { if ($("#jhs-quick-icon-style").length) return; $("<style>").attr("id", "jhs-quick-icon-style").text(` .jhs-quick-icons{display:flex!important;align-items:center!important;gap:8px!important;flex-wrap:wrap!important;filter:none!important;box-shadow:none!important} .jhs-quick-icons .jhs-quick-icon{display:inline-flex!important;align-items:center!important;justify-content:center!important;gap:5px!important;height:28px!important;padding:0 7px!important;margin:0!important;border:1px solid #e2e6ea!important;border-radius:8px!important;background:#f6f8fa!important;box-shadow:none!important;line-height:1!important;text-decoration:none!important;cursor:pointer!important;user-select:none!important;transition:transform .14s ease,box-shadow .14s ease,background .14s ease!important} .jhs-quick-icons .jhs-quick-icon:hover{transform:translateY(-1px)!important;background:#fff!important;box-shadow:0 2px 6px rgba(0,0,0,.08)!important} .jhs-quick-icons .jhs-quick-icon:active{transform:scale(.96)!important} .jhs-quick-icons .jhs-quick-icon svg{width:18px!important;height:18px!important;display:block!important;flex:0 0 18px!important} .jhs-quick-icons .jhs-qi-txt{font-size:13px!important;font-weight:600!important;white-space:nowrap!important} html[data-theme="dark"] .jhs-quick-icons .jhs-quick-icon{background:#2a333f!important;border-color:#3d4a5a!important} html[data-theme="dark"] .jhs-quick-icons .jhs-quick-icon:hover{background:#323d4b!important} ${l ? "#jhs-quick-icons{margin-top:14px!important;padding-top:2px!important}" : ""} `).appendTo("head"); } /** FC2 弹层里的「鉴定处理 / 外部跳转」两个按钮, 插在 $anchor(字幕搜索按钮)之后。 * 样式跟同一行的磁力搜索/字幕搜索统一: .menu-btn + 渐变底 + 白字, 只是各自一套颜色。 * 番号来自弹层参数而不是当前页面, 所以鉴定动作不能复用 DetailPageButtonPlugin.toggleStatus * —— 那个方法读的是 getPageInfo(), 在列表页打开 FC2 弹层时拿不到正确番号。 * ctx: { carNum, url, names(), publishTime() } */ mountFc2QuickIcons($anchor, ctx) { if (!$anchor || !$anchor.length || !ctx || !ctx.carNum) return; const acts = [ { key: "handle", title: "鉴定处理", bg: "linear-gradient(to left, #8b5cf6, #b39cfb)" }, { key: "site", title: "外部跳转", bg: "linear-gradient(to left, #3aab68, #6fc98d)" } ]; const $box = $('<span class="jhs-fc2-quick-icons"></span>') .css({ display: "inline-flex", alignItems: "center", gap: "8px" }); acts.forEach((a => { $box.append(`<a class="menu-btn jhs-fc2-qi" data-action="${a.key}" style="background:${a.bg}" title="${escapeHtml(a.title)} ${escapeHtml(ctx.carNum)}"><span>${escapeHtml(a.title)}</span></a>`); })); $anchor.after($box); $box.on("click", ".jhs-fc2-qi", (async e => { e.preventDefault(), e.stopPropagation(); const $btn = $(e.currentTarget); try { "handle" === $btn.data("action") ? await this.openFc2HandleMenu($btn, ctx) : await this.openSiteMenu($btn, ctx.carNum); } catch (err) { clog.error("FC2 快捷图标操作失败", err), show.error("操作失败: " + (err && err.message || err)); } })); } /** FC2 弹层版「鉴定处理」: 四项与详情页一致(首次点击=标记, 再点同一项=取消, 屏蔽走二次确认), * 但直接按弹层里的番号写库, 不依赖当前页面。 */ async openFc2HandleMenu($btn, ctx) { if ($(".jhs-qi-pop-menu").data("jhsOwner") === $btn[0]) return void this._popMenu($btn, []); const cur = (await storageManager.getCar(ctx.carNum) || {}).status; const doSave = async type => { await storageManager.saveCar({ carNum: ctx.carNum, url: ctx.url, names: ctx.names ? ctx.names() : "", actionType: type, publishTime: ctx.publishTime ? ctx.publishTime() : "" }), window.refresh(), show.ok("操作成功"); }; const doRemove = async () => { await storageManager.removeCar(ctx.carNum), window.refresh(), show.ok("已取消鉴定"); }; const items = [ { type: p, label: k, color: S }, { type: g, label: y, color: x }, { type: h, label: v, color: w }, { type: d, label: m, color: f } ].map((it => ({ label: cur === it.type ? "取消" + it.label : it.label, color: it.color, onClick: ev => cur === it.type ? (it.type === d ? utils.q(ev, `是否取消屏蔽${ctx.carNum}?`, doRemove) : doRemove()) : (it.type === d ? utils.q(ev, `是否屏蔽${ctx.carNum}?`, (() => doSave(it.type))) : doSave(it.type)) }))); this._popMenu($btn, items); } /** 快捷图标的浮层菜单(鉴定处理 / 外部跳转 共用): * 固定在图标上方, 再次点击同一个图标即收起; 点菜单外或 Esc 也关。 * items: [{ label, color, onClick(ev) }] */ _popMenu($btn, items) { const $old = $(".jhs-qi-pop-menu"); const closeAll = () => { $(".jhs-qi-pop-menu").remove(), $(document).off("click.jhsQiPop keydown.jhsQiPop"), $(window).off("resize.jhsQiPop"); }; // 同一个图标再点一次 = 收起; 点另一个图标 = 换成它的菜单 if ($old.length) { const same = $old.data("jhsOwner") === $btn[0]; if (closeAll(), same) return; } if (!items.length) return; // FC2 弹层里也要用: layui 的 .layui-layer z-index 很高(约 19891000 起), 固定 10000060 会被盖住, // 所以取当前最高层再 +10。详情页没有弹层时仍是原来的值。 let zi = 10000060; try { $(".layui-layer").each((function() { const z = parseInt($(this).css("zIndex"), 10); Number.isFinite(z) && z + 10 > zi && (zi = z + 10); })); } catch (e) { /* 忽略 */ } const $menu = $('<div class="jhs-qi-pop-menu"></div>').data("jhsOwner", $btn[0]).css({ position: "absolute", left: "0", top: "0", visibility: "hidden", zIndex: zi, display: "flex", flexDirection: "column", gap: "5px" }); items.forEach((it => { $('<a class="menu-btn"></a>').css({ backgroundColor: it.color, color: "#fff", margin: 0, minWidth: "96px" }) .text(it.label) .on("click", (async ev => { ev.preventDefault(), ev.stopPropagation(), closeAll(), await it.onClick(ev); })).appendTo($menu); })); $("body").append($menu); // 贴在按钮上方: 用 absolute + 文档坐标(offset), 这样页面滚动时菜单跟着按钮走; // 用 fixed 会钉在视口上, 一滚就和按钮脱开。先量高度再定位, 避免先闪一下再跳位。 const place = () => { const rect = $btn[0].getBoundingClientRect(); const mh = $menu[0].offsetHeight, mw = $menu[0].offsetWidth; const above = rect.top - mh - 6; // 上方视口空间不够时才落到按钮下方 const viewTop = above >= 8 ? above : rect.bottom + 6; // 水平居中对齐按钮: 菜单比图标按钮宽(菜单项 minWidth:96px), 用 rect.left 会明显偏右。 // 再夹在视口内, 免得最左/最右的图标把菜单顶出屏幕。 const centered = rect.left + rect.width / 2 - mw / 2; const viewLeft = Math.min(Math.max(centered, 8), Math.max(8, document.documentElement.clientWidth - mw - 8)); $menu.offset({ top: viewTop + window.scrollY, left: viewLeft + window.scrollX }); }; place(), $menu.css("visibility", ""); // 按钮位置可能被异步内容(演员维基/第三方站点栏)挤动, 窗口尺寸变化时重算一次 $(window).on("resize.jhsQiPop", place); // 延一帧再绑: 否则触发本次点击的事件冒泡上来会立刻把菜单关掉 setTimeout((() => { $(document).on("click.jhsQiPop", (ev => { $(ev.target).closest(".jhs-qi-pop-menu").length || closeAll(); })).on("keydown.jhsQiPop", (ev => { "Escape" === ev.key && closeAll(); })); }), 0); } /** 鉴定处理: 与首页封面快捷按钮「鉴定处理」同款四项, 动作复用详情页 toggleStatus * (首次点击=标记, 再次点击=取消, 屏蔽仍走二次确认) */ async openHandleMenu($btn, carNum) { if ($(".jhs-qi-pop-menu").data("jhsOwner") === $btn[0]) return void this._popMenu($btn, []); const btnPlugin = this.getBean("DetailPageButtonPlugin"); if (!btnPlugin || "function" != typeof btnPlugin.toggleStatus) return void show.error("详情页按钮插件未注册"); const cur = (await storageManager.getCar(carNum) || {}).status; const items = [ { type: p, label: k, color: S }, { type: g, label: y, color: x }, { type: h, label: v, color: w }, { type: d, label: m, color: f } ].map((it => ({ label: cur === it.type ? "取消" + it.label : it.label, color: it.color, onClick: ev => btnPlugin.toggleStatus(it.type, ev) }))); this._popMenu($btn, items); } /** 外部跳转: 与首页封面快捷按钮「第三方网站」同一套站点列表/自定义域名/搜索地址 * (个性化设置 > 封面快捷按钮 里启用哪些站点, 这里就跟着显示哪些) */ async openSiteMenu($btn, carNum) { if ($(".jhs-qi-pop-menu").data("jhsOwner") === $btn[0]) return void this._popMenu($btn, []); const st = await storageManager.getSetting(); const hidden = jhsGetHiddenSites(); const keys = jhsGetCoverSites().filter((k => COVER_ALL_KEYS.includes(k) && !hidden.includes(k))); if (!keys.length) return void show.info("没有已启用的第三方站点, 请到 个性化设置 > 封面快捷按钮 里勾选"); this._popMenu($btn, keys.map((k => ({ label: st[COVER_NAME_KEY[k]] || COVER_DEFAULT_NAME[k] || k, color: "#71bb59", onClick: ev => { const url = jhsBuildCoverUrl(k, carNum, st); ev && (ev.ctrlKey || ev.metaKey) ? GM_openInTab(url, { insert: 0 }) : window.open(url); } })))); } /** 迅雷字幕面板: 复用 DetailPageButtonPlugin 里的实现(该类现在只作为方法库存在, 不再有 handle) */ openXunLeiSubtitle(carNum) { const btnPlugin = this.getBean("DetailPageButtonPlugin"); if (btnPlugin && "function" == typeof btnPlugin.searchXunLeiSubtitle) return void btnPlugin.searchXunLeiSubtitle(carNum); utils.openPage(`https://subtitlecat.com/index.php?search=${carNum}`, carNum, !1); } /** 长缩略图(封面快捷按钮的预览能力): 「快出图」模式——弹层先出来, 地址解析完再填图, 图片边下边渲染 */ async showLongThumbnail(carNum) { const code = String(carNum || "").replace("FC2-", ""); if (!code) return void show.error("番号为空"); const shot = this.getBean("ScreenShotPlugin"); if (!shot) return void show.error("截图预览插件未启用"); // 先把取图发起来(不 await), 再去读设置建切源配置, 两件事并行。 // 这里就地兜住失败: 弹层是在几个微任务之后才挂上处理器的, 不接会先冒一个 unhandled rejection。 // 默认用「快出图」实现(顺序试源, 不做图片校验); 想切回原实现: // localStorage.setItem("jhs_fastThumb","off") 刷新即可, 删掉该项恢复。 const useFast = "off" !== localStorage.getItem("jhs_fastThumb"); const imgPromise = (useFast ? jhsFastThumb.get(code) : shot.getScreenshotFast(code)) .catch((err => (clog.error("取长缩略图失败:", err), null))); let opts = null; try { if (await storageManager.getSetting("enableThumbSourceSwitch", C) === _) { const colorMap = { javfree: "#4caf50", javbest: "#ffc107", javstore: "#f44336", projectjav: "#ca8a04" }; const list = await shot.resolveThumbSources(); list.length >= 1 && (opts = { carNum: code, sources: list.map((e => ({ key: e.key, name: e.name, color: colorMap[e.key] || "#888" }))), fetchSource: async e => shot.getScreenshot(code, e) }); } } catch (e) { clog.debug("构建长缩略图切源配置失败", e); } showFastImageViewer(imgPromise, "缩略图", opts); } } /** * 磁力表格面板 —— 移植 laosiji 脚本的 FC2 磁力聚合表格 UI(#jav-nong-table)。 * 只换外观: 引擎列表、抓取和解析全部沿用 MagnetHubPlugin, 数据也是同一份缓存, * 复制/预览/115离线 三个动作直接复用原生磁力面板的 nmpAppendActions。 * 关掉这个开关, FC2 弹层就回到原来的 createMagnetHub 卡片列表。 */ class MagnetTablePlugin extends X { getName() { return "MagnetTablePlugin"; } /** 读的就是插件管理写入的 disabledPlugins, 和面板上的开关同一份数据 */ static async isEnabled() { try { const list = JSON.parse(await storageManager.getSetting("disabledPlugins", DEFAULT_DISABLED_PLUGINS)); return !list.includes("MagnetTablePlugin"); } catch (e) { return clog.warn("[磁力表格] 读取开关失败, 默认使用表格", e), !0; } } /** FC2 弹层用这个入口: 开关开着给表格, 关着退回原来的卡片列表。 * extraEngines: 额外的数据源(比如 FC2 弹层自带的 JavDB 接口), 排在引擎下拉最前面 */ static async createPanel(getBean, carNum, extraEngines) { return await MagnetTablePlugin.isEnabled() ? getBean("MagnetTablePlugin").createMagnetTable(carNum, extraEngines) : getBean("MagnetHubPlugin").createMagnetHub(carNum); } async initCss() { return ` <style> .jhs-nong-wrapper{display:block;width:100%;max-width:100%;box-sizing:border-box;padding:12px 12px 16px;margin:0 0 18px;background:#fafafa;border:1px solid #ebebeb;border-radius:6px;overflow:hidden} .jhs-nong-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:7px} .jhs-nong-head-title{color:#0066cc;font-size:14px;font-weight:600} .jhs-nong-origin{margin-left:auto;font-size:12px;color:#e74c3c} .jhs-nong-scroll{overflow-x:auto;overflow-y:hidden;scrollbar-width:thin} .jhs-nong-table{width:100%;min-width:404px;table-layout:fixed;margin:0;color:#666;font-size:13px;text-align:center;background:#f2f2f2;border-collapse:collapse} .jhs-nong-table th,.jhs-nong-table td{text-align:center;height:30px;line-height:1.35;vertical-align:middle;background:#fff;padding:0 6px;border:1px solid #efefef;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .jhs-nong-table td:first-child{text-align:left} .jhs-nong-table .jhs-nong-head-row th{background:#f8f8f8;font-weight:600} .jhs-nong-table .nong-size-head,.jhs-nong-table .nong-size-cell{width:78px} .jhs-nong-table .nong-op-head,.jhs-nong-table .nong-op-cell{width:112px} .jhs-nong-table .nong-115-head,.jhs-nong-table .nong-115-cell{width:56px} /* 按钮列不能裁: 固定表格里内容溢出会被 overflow:hidden 切掉半个字 */ .jhs-nong-table .nong-op-cell,.jhs-nong-table .nong-115-cell{padding:0 4px;overflow:visible} .jhs-nong-head-controls{display:flex;align-items:center;gap:5px} .jhs-nong-head-controls select{height:24px;max-width:130px;padding:0 4px;font-size:12px;color:#444;background:#fff;border:1px solid #dcdcdc;border-radius:3px;cursor:pointer} .jhs-nong-name{display:flex;align-items:center;gap:4px;min-width:0} .jhs-nong-name>a{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#0066cc} .jhs-nong-badge{display:inline-block;padding:1px 5px;font-size:11px;font-weight:800;color:#fff;border-radius:4px;flex-shrink:0} .jhs-nong-date{flex-shrink:0;margin-left:4px;color:#999;font-size:11px} .jhs-nong-notice{color:#888} .jhs-nong-refresh{margin-left:8px;color:#e74c3c;font-weight:bold;cursor:pointer} /* 动作按钮借用原生磁力面板的类名, 那个插件被禁用时这里兜个底 */ /* 按钮尺寸: 原生磁力面板给 .jhs-nmp-action 定的 min-height 是 30px, 正好等于行高, 会顶满整格没留白 */ .jhs-nong-table .nong-op-cell .jhs-nmp-action,.jhs-nong-table .nong-115-cell .jhs-nmp-action{min-height:0;height:20px;margin:0 2px;padding:0 6px;font-size:12px;font-weight:500;line-height:18px;border-radius:3px} .jhs-nong-table .nong-op-cell .jhs-nmp-action{color:#555;background:#f0f0f0;border:1px solid #ddd;cursor:pointer} .jhs-nong-table .nong-op-cell .jhs-nmp-action:hover{background:#e0e0e0;border-color:#ccc} </style> `; } /** 入口: 返回一个 jQuery 包装元素, 内部自己发起搜索 */ createMagnetTable(carNum, extraEngines) { const keyword = String(carNum || "").replace("FC2-", ""); const hub = this.getBean("MagnetHubPlugin"); const engines = [ ...(extraEngines || []), ...(hub && hub.searchEngines || []) ]; if (!engines.length) return $('<div class="jhs-nong-wrapper">磁力引擎未就绪</div>'); const savedId = localStorage.getItem("jhs_magnetHub_selectedEngine"); // 存的引擎不在当前列表里(比如 FC2 弹层多了一个 JavDB)就退回第一个 const engine = engines.find((e => e.id === savedId)) || engines[0]; const $wrap = $('<div class="jhs-nong-wrapper"></div>'); $wrap.append('<div class="jhs-nong-head"><span class="jhs-nong-head-title">磁力搜索</span><a class="jhs-nong-origin" target="_blank">原网页</a></div>'); const $scroll = $('<div class="jhs-nong-scroll"></div>').appendTo($wrap); $wrap.data("jhsNong", { keyword: keyword, engineId: engine.id, data: null }); this._render($wrap, $scroll, engines); this._search($wrap, $scroll, engines); return $wrap; } /** 抓数据: engine.fetch 自定义源优先; parseHtml 引擎走缓存请求; * parseJson 引擎(它自己会调 displayResults)用 sink 把结果接出来 */ async _fetch(engine, keyword) { const hub = this.getBean("MagnetHubPlugin"); if ("function" == typeof engine.fetch) return await engine.fetch(keyword); if (engine.parseHtml) { const url = engine.url.replace("{keyword}", encodeURIComponent(keyword)); return await storageManager.cachedRequest(`magnet:${engine.id}:${keyword}`, 216e5, (() => gmHttp.get(url).then((html => engine.parseHtml.call(hub, html, keyword))))); } if (engine.parseJson) { let received = null; const $sink = $("<div></div>").data("jhsNongSink", (data => { received = data; })); await engine.parseJson.call(hub, $sink, engine, keyword, `${engine.name}_${keyword}`); if (received) return received; // parseJson 出错时只往容器里写了一段 .magnet-error, 把它转成异常 const msg = $sink.find(".magnet-error").text(); if (msg) throw new Error(msg); return []; } return []; } async _search($wrap, $scroll, engines) { const ctx = $wrap.data("jhsNong"); const engine = engines.find((e => e.id === ctx.engineId)) || engines[0]; // 自定义源可能没有「原网页」可跳, 没有就把链接藏掉 const origin = engine.targetPage ? engine.targetPage.replace("{keyword}", encodeURIComponent(ctx.keyword)) : ""; $wrap.find(".jhs-nong-origin").attr("href", origin || "#").toggle(!!origin); ctx.data = null, this._render($wrap, $scroll, engines, `正在从 ${engine.name} 搜索 "${ctx.keyword}"...`); try { const data = await this._fetch(engine, ctx.keyword); ctx.data = Array.isArray(data) ? data : []; this._render($wrap, $scroll, engines); } catch (e) { clog.error("[磁力表格] 搜索失败", e), this._render($wrap, $scroll, engines, "搜索出错: " + (e && e.message || e), !0); } } /** 整表重画。notice 有值时只画表头 + 一行提示 */ _render($wrap, $scroll, engines, notice, showRefresh) { const ctx = $wrap.data("jhsNong"); const sortMode = localStorage.getItem("jhs_nongMagnetSort") || "size"; const $table = $('<table class="jhs-nong-table"></table>'); const $head = $('<tr class="jhs-nong-head-row"></tr>'); const $thName = $('<th style="text-align:left"></th>'); const $controls = $('<div class="jhs-nong-head-controls"></div>'); const $engineSel = $('<select aria-label="磁力引擎"></select>'); engines.forEach((e => $engineSel.append($("<option></option>").val(e.id).text(e.name)))); $engineSel.val(ctx.engineId); const $sortSel = $('<select aria-label="排序方式" title="排序方式"></select>'); [ [ "size", "大小" ], [ "newest", "最新" ], [ "oldest", "最旧" ] ].forEach((([v, t]) => $sortSel.append($("<option></option>").val(v).text(t)))); $sortSel.val(sortMode), $sortSel.prop("disabled", !ctx.data || !ctx.data.length); $controls.append($engineSel, $sortSel), $thName.append($controls), $head.append($thName); $head.append('<th class="nong-size-head">大小</th><th class="nong-op-head">操作</th><th class="nong-115-head">115</th>'); $table.append($head); if (notice || !ctx.data) { const $tr = $("<tr></tr>"), $td = $('<td class="jhs-nong-notice" colspan="4"></td>').text(notice || "Loading…"); showRefresh && $td.append($('<a class="jhs-nong-refresh" href="#">\ud83d\udd04 刷新</a>')); $table.append($tr.append($td)); } else if (!ctx.data.length) { $table.append($('<tr></tr>').append($('<td class="jhs-nong-notice" colspan="4"></td>') .text("无搜索结果 ").append($('<a class="jhs-nong-refresh" href="#">\ud83d\udd04 刷新</a>')))); } else { nmpSortData(ctx.data, sortMode).forEach((item => $table.append(this._buildRow(item)))); } $scroll.empty().append($table); $engineSel.on("change", (() => { ctx.engineId = $engineSel.val(), localStorage.setItem("jhs_magnetHub_selectedEngine", ctx.engineId), this._search($wrap, $scroll, engines); })); $sortSel.on("change", (() => { localStorage.setItem("jhs_nongMagnetSort", $sortSel.val()), this._render($wrap, $scroll, engines); })); $scroll.find(".jhs-nong-refresh").on("click", (e => { e.preventDefault(), this._search($wrap, $scroll, engines); })); } /** 单行: 名称(徽章 + 链接 + 日期) / 大小 / 文件 / 操作 / 115 */ _buildRow(item) { const maglink = item.magnet || item.maglink || ""; const $tr = $("<tr></tr>").attr("data-maglink", maglink); const $name = $('<span class="jhs-nong-name"></span>').attr("title", item.title || ""); // 徽章配色沿用 laosiji: 中字绿 / 4K 蓝 / 破解红, 顺序也保持 4K > 中字 > 破解 const palette = { "4K": "#2563eb", "中字": "#16a34a", "破解": "#be123c", "高清": "#0ea5e9" }; const tags = nmpQualityTags(item.title || ""); // JavDB 接口自带 hd/cnsub 标记, 标题里看不出来, 补进徽章 item.cnsub && !tags.includes("中字") && tags.push("中字"); item.hd && !tags.includes("4K") && tags.push("高清"); tags.forEach((t => $name.append($('<span class="jhs-nong-badge"></span>').css("background", palette[t] || "#64748b").text(`[${t}]`)))); $name.append($("<a></a>").attr("href", maglink).text(item.title || maglink)); item.date && $name.append($('<span class="jhs-nong-date"></span>').attr("title", "收录时间: " + item.date).text(item.date)); $tr.append($("<td></td>").append($name)); $tr.append($('<td class="nong-size-cell"></td>').text(item.size || "—")); // 三个动作直接复用原生磁力面板的实现, 生成后把 115 那颗挪到独立列里 const $bag = $("<div></div>"); nmpAppendActions($bag, { ...item, maglink: maglink }); // 独立成一列后列头已经写着 115, 按钮文字改成「离线」, 免得和列头重复 const $offline = $bag.find(".jhs-nmp-action-offline").detach().text("离线"); $tr.append($('<td class="nong-op-cell"></td>').append($bag.children())); $tr.append($('<td class="nong-115-cell"></td>').append($offline)); return $tr; } } /** * FC2 弹层那一行按钮(磁力搜索 / 字幕搜索 / 鉴定处理 / 外部跳转)的总开关。 * 本身不做任何事, 只为了在「插件管理 > 状态管理」里占一个开关位: * Fc2Plugin / Fc2By123AvPlugin 拼弹层前调 isEnabled(), 关掉就整行不渲染。 */ class Fc2ActionBarPlugin extends X { getName() { return "Fc2ActionBarPlugin"; } /** 读的就是插件管理写入的 disabledPlugins, 和面板上的开关同一份数据 */ static async isEnabled() { try { const list = JSON.parse(await storageManager.getSetting("disabledPlugins", DEFAULT_DISABLED_PLUGINS)); return !list.includes("Fc2ActionBarPlugin"); } catch (e) { return clog.warn("[FC2按钮栏] 读取开关失败, 默认显示", e), !0; } } } const DEFAULT_JAVDB_PLUGINS = [ Fc2ActionBarPlugin, MagnetTablePlugin, Ie, Be, le, de, Ce, xe, Ae, fe, pe, ue, Ee, Ue, Oe, Q, ye, ae, ke, he, be, Ze, ze, Re, Ve, Se, Xe, pt, et, mt, StatsPlugin, MobileBottomBarPlugin, WangPan115MatchPlugin, NativeMagnetPanelPlugin, NativeReviewPanelPlugin, NativeListPanelPlugin, DetailQuickIconPlugin ]; const DEFAULT_JAVBUS_PLUGINS = [ Fc2ActionBarPlugin, MagnetTablePlugin, Ie, Ce, Ae, xe, Be, Ee, Fe, Ue, Qe, we, ye, ke, je, Re, Ve, he, be, Ze, Se, et, StatsPlugin, MobileBottomBarPlugin, WangPan115MatchPlugin, NativeMagnetPanelPlugin, DetailQuickIconPlugin ]; const DEFAULT_SHARED_PLUGIN_RULES = [ { shouldRegister: hostname => hostname.includes("javtrailers"), plugins: [ oe ] }, { shouldRegister: hostname => hostname.includes("subtitlecat"), plugins: [ re ] } ]; function registerPluginGroup(pluginManager, plugins) { plugins.forEach((pluginClass => pluginManager.register(pluginClass))); } function registerSitePlugins(pluginManager, hostname = window.location.hostname) { DEFAULT_SHARED_PLUGIN_RULES.forEach((rule => { rule.shouldRegister(hostname) && registerPluginGroup(pluginManager, rule.plugins); })); r && registerPluginGroup(pluginManager, DEFAULT_JAVDB_PLUGINS); l && registerPluginGroup(pluginManager, DEFAULT_JAVBUS_PLUGINS); } const ut = layer.close; layer.close = function(e) { const t = ut.call(this, e); return function(e = 10) { setTimeout((() => { const e = document.querySelectorAll(".layui-layer-shade").length; document.documentElement.style.overflow = e > 0 ? "hidden" : ""; }), e); }(), t; }; const ft = layer.open; layer.open = function(e) { const t = (e = e || {}).success; return e.success = function(e, n) { "function" == typeof t && t.call(this, e, n), utils.setupEscClose(n); }, ft.call(this, e); }, utils.importResource("https://cdn.jsdelivr.net/npm/[email protected]/layer.min.css"), utils.importResource("https://cdn.jsdelivr.net/npm/[email protected]/src/toastify.min.css"), utils.importResource("https://cdn.jsdelivr.net/npm/[email protected]/dist/viewer.min.css"), utils.importResource("https://cdn.jsdelivr.net/npm/[email protected]/dist/css/tabulator_semanticui.min.css"); /** 插件注册中心: 按站点注册所有插件, 暴露到 unsafeWindow.pluginManager */ const vt = function() { const e = new Y; unsafeWindow.pluginManager = e; registerSitePlugins(e); return e; }(); vt.processCss().catch((e => console.error("[JHS] processCss failed:", e))), async function() { window.isDetailPage = function() { let e = window.location.href; return r ? e.split("?")[0].includes("/v/") : !!l && $("#magnet-table").length > 0; }(), window.isListPage = function() { let e = window.location.href; return r ? $(".movie-list").length > 0 || e.includes("advanced_search") : !!l && $(".masonry > div .item").length > 0; }(), window.isFc2Page = function() { let e = window.location.href; return e.includes("advanced_search?type=3") || e.includes("advanced_search?type=100"); }(), await (async () => { const e = await storageManager.getDataVersion(); e < CURRENT_DATA_VERSION && (await storageManager.merge_table_name(), await storageManager.clean_no_url_blacklist(), await storageManager.async_merge_other(), await storageManager.merge_blacklist(), await storageManager.merge_favoriteActress(), await storageManager.merge_tow_car_list_table(), await storageManager.setDataVersion(CURRENT_DATA_VERSION)); })(), r && /(^|;)\s*locale\s*=\s*en\s*($|;)/i.test(document.cookie) && show.error("请切换到中文语言下才可正常使用本脚本", { duration: -1 }), vt.processPlugins().catch((e => console.error("[JHS] processPlugins failed:", e))); // 屏蔽预览缩略图里那张「长图」。 // 实测: 真实剧照缩略图恒为 120x90 横版, 而第一张 _s_0.jpg 是封套图, 为 147x200 竖版; // 脚本的 .preview-images img{width:100%;height:auto} 会把竖图按满宽等比放大, 高度被拉得很长。 // 所以判据是「竖版」而不是某个比例区间(0.735 会落在常规区间里, 抓不到), 也不是固定 #gallery 序号。 if (r) (function jhsHidePortraitPreviewTiles() { // 判据要等 naturalWidth/naturalHeight, 也就是图片加载完才能定。在那之前竖版封套图被 // .preview-images img{width:100%;height:auto} 按满宽等比放大, 把整排撑高, 加载完才被隐藏收回去—— // 视觉上就是"预览那一栏先被拉长几秒再恢复正常"。 // 这里先给"尚未判定"的图套一个 4:3 的框(真实剧照缩略图恒为 120x90, 正好 4:3, 判定完放开时零跳变), // 竖图会被裁在框内而不是把整排顶高。.screen-container 里的长图不参与判定(sweep 会跳过), 必须排除, // 否则它永远等不到 data-jhs-portrait-checked, 会被永久限成 4:3。 if (!document.getElementById("jhs-preview-tile-stable")) { const st = document.createElement("style"); st.id = "jhs-preview-tile-stable"; st.textContent = '.preview-images .tile-item:not(.screen-container):not(.preview-video-container) img:not([data-jhs-portrait-checked="1"]){aspect-ratio:4/3;object-fit:cover}'; document.head.appendChild(st); } const check = img => { const w = img.naturalWidth, h = img.naturalHeight; if (!w || !h || w >= h) return; // 只处理竖版 const tile = img.closest(".tile-item, .sample-box"); if (!tile || tile.classList.contains("screen-container") || tile.classList.contains("preview-video-container")) return; tile.style.display = "none"; clog.debug(`[预览图] 隐藏竖版封套长图 ${w}x${h}:`, img.currentSrc || img.src); }; const sweep = () => { document.querySelectorAll(".preview-images img, #sample-waterfall img").forEach(img => { if ("1" === img.dataset.jhsPortraitChecked || img.closest(".screen-container")) return; img.complete && img.naturalWidth ? (img.dataset.jhsPortraitChecked = "1", check(img)) : img.addEventListener("load", (() => { img.dataset.jhsPortraitChecked = "1", check(img); }), { once: !0 }); }); }; let tries = 0; const start = () => { const box = document.querySelector(".preview-images, #sample-waterfall"); if (!box) return void (++tries < 20 && setTimeout(start, 300)); sweep(), new MutationObserver(sweep).observe(box, { childList: !0, subtree: !0 }); }; start(); })(); // 封面图无水印显示(个性化设置 > 封面图无水印显示, 默认开启) // 网站封面带水印, 源是同一张无水印原图(实测同为 800x538, 差异只集中在水印那条横带)。 // App 源返回的不是 JPEG: 首字节是异或密钥, 其余字节 XOR 该密钥后才是原始 JPEG(解密后首字节 0x00 丢弃)。 // 覆盖两处: 详情页大封面, 以及列表页/首页卡片(被 _replaceSingleHdImg 升级成 covers 后就带水印了)。 if (r) (function jhsCleanCoverSetup() { // 抓到新源后在这里加一行即可; %dir% = 影片 ID 前两位小写, %id% = 影片 ID // 多个源会并发竞速, 谁先返回可用图片就用谁, 其余立刻取消 const SOURCES = [ { name: "spfcas", url: "https://tp.spfcas.com/rhe951l4q/covers/%dir%/%id%.jpg" } ]; const CDN_RE = /jdbstatic\.com\/(?:covers|thumbs)\//i; const ID_RE = /\/(?:covers|thumbs)\/[^/]+\/([A-Za-z0-9]+)\.[a-z]+/i; const cache = new Map(); // 影片 ID -> Promise<blob url>, 同一 ID 只下一次 const api = { enabled: !1 }; window.jhsCleanCover = api; // JPEG 必须以 FFD9 收尾; 允许尾部少量填充字节。缺 FFD9 = 数据被截断, // 浏览器会把没解出来的 MCU 行填成灰色 => 封面"裂一半" const hasEoi = arr => { for (let i = arr.length - 2, stop = Math.max(0, arr.length - 64); i >= stop; i--) { if (255 === arr[i] && 217 === arr[i + 1]) return !0; } return !1; }; const toJpeg = buf => { const src = new Uint8Array(buf); if (src.length < 4) throw new Error("响应过短"); let out; if (255 === src[0] && 216 === src[1]) { out = src; // 某些源直接给明文 JPEG } else { const key = src[0]; out = new Uint8Array(src.length - 1); for (let i = 1; i < src.length; i++) out[i - 1] = src[i] ^ key; if (255 !== out[0] || 216 !== out[1]) throw new Error("解密后不是 JPEG"); } if (!hasEoi(out)) throw new Error("JPEG 数据不完整(缺 FFD9)"); return out; }; const grab = (tpl, id, signal) => { const url = tpl.replace("%dir%", id.slice(0, 2).toLowerCase()).replace("%id%", id); const viaGm = () => new Promise(((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url, responseType: "arraybuffer", timeout: 2e4, onload: res => 200 === res.status ? resolve(res.response) : reject(new Error("HTTP " + res.status)), onerror: () => reject(new Error("请求失败")), ontimeout: () => reject(new Error("超时")) }); })); // App 源带 Access-Control-Allow-Origin: *, 原生 fetch 比 GM 通道少一次跨进程搬运, 更快 // 只认 200: 206(Partial Content) 也能过 res.ok, 但只有半张图 —— 与 GM 分支的判断保持一致。 // 不用 force-cache: 缓存里若存着上次被中断的半截响应, 会一直拿到坏数据, 且不会自愈。 return fetch(url, { signal, credentials: "omit" }).then((res => { if (200 !== res.status) throw new Error("HTTP " + res.status); return res.arrayBuffer(); })).catch((e => { if (signal.aborted) throw e; return viaGm(); })).then(toJpeg); }; /** 按影片 ID 取无水印封面, 返回 blob url; 多源竞速 + 结果缓存 */ api.fetchById = id => { if (cache.has(id)) return cache.get(id); const t0 = Date.now(), controllers = SOURCES.map((() => new AbortController())); const tasks = SOURCES.map(((s, i) => grab(s.url, id, controllers[i].signal).then((jpeg => ({ name: s.name, jpeg })), (err => { controllers[i].signal.aborted || clog.debug(`[无水印封面] ${s.name} ${id} 失败: ${err.message}`); throw err; })))); const first = "function" == typeof Promise.any ? Promise.any(tasks) : new Promise(((resolve, reject) => { let left = tasks.length; tasks.forEach((t => t.then(resolve, (() => { --left || reject(new Error("全部源都失败")); })))); })); const task = first.then((win => { controllers.forEach(((c, i) => SOURCES[i].name !== win.name && c.abort())); clog.debug(`[无水印封面] ${id} 采用「${win.name}」 ${(win.jpeg.length / 1024).toFixed(0)}KB, 耗时 ${Date.now() - t0}ms`); return URL.createObjectURL(new Blob([ win.jpeg ], { type: "image/jpeg" })); }), (() => { throw new Error("所有源都失败"); })); return cache.set(id, task), task; }; /** 列表页/首页卡片: 影片 ID 从封面 URL 里取, 换成无水印版; 由 _replaceSingleHdImg 调用 */ api.applyTo = el => { if (!api.enabled || !el || el.dataset.jhsCleanCover) return; const cur = el.getAttribute("src") || ""; if (!CDN_RE.test(cur)) return; const m = cur.match(ID_RE); if (!m) return; el.dataset.jhsCleanCover = "pending"; api.fetchById(m[1]).then((url => { el.dataset.jhsCleanCover = "done", el.dataset.jhsOriginCover = cur, el.src = url; }), (() => { el.dataset.jhsCleanCover = "failed"; // App 源没有这张图, 保留原封面 })); }; (async () => { try { api.enabled = await storageManager.getSetting("enableCleanCover", _) === _; } catch (e) { return void clog.debug("[无水印封面] 读取配置失败", e); } if (!api.enabled) return; // 列表页: replaceHdImg 可能早于本段跑完, 已升级成 covers 的卡片这里补一遍; // 之后新出现的卡片(懒加载/自动翻页)由 _replaceSingleHdImg 里的钩子接管 window.isListPage && document.querySelectorAll(".cover img").forEach(api.applyTo); if (!window.isDetailPage) return; // 详情页 URL 自带影片 ID: /v/ZN3We7 -> covers/zn/ZN3We7.jpg, 不用等 DOM 也不用调 App API const movieId = (location.href.split("?")[0].match(/\/v\/([A-Za-z0-9]+)/) || [])[1]; if (!movieId) return; let blobUrl; try { blobUrl = await api.fetchById(movieId); } catch (e) { return void clog.warn("[无水印封面] 所有源都失败, 保留原封面"); } window.jhsCleanCoverUrl = blobUrl; const SEL = ".column-video-cover img, img.video-cover, .column-video-cover video[poster]"; const apply = () => { document.querySelectorAll(SEL).forEach((el => { if ("VIDEO" === el.tagName) { return void (CDN_RE.test(el.getAttribute("poster") || "") && (el.poster = blobUrl)); } const cur = el.getAttribute("src") || ""; CDN_RE.test(cur) && (el.dataset.jhsOriginCover = cur, el.src = blobUrl); })); }; apply(); // 封面(以及 3631 行用封面生成的「預告片」占位图)可能晚于本段插入, 站点脚本也可能把 src 改回去 let pending = !1; const mo = new MutationObserver((() => { pending || (pending = !0, requestAnimationFrame((() => { pending = !1, apply(); }))); })); mo.observe(document.documentElement, { childList: !0, subtree: !0, attributes: !0, attributeFilter: [ "src", "poster" ] }); setTimeout((() => mo.disconnect()), 15e3); })(); })(); // 界面模糊模式: 模糊封面/文字防窥屏。默认不开启, 在 个性化设置 里配置。 // 注意: 悬浮大图(.image-hover-preview)默认一起模糊, 否则悬浮就能看清、隐私模式形同虚设; // 只有开启「悬停时取消模糊」时, 悬浮大图才同步取消模糊。 // // 文字模糊统一由「模糊文字」一个开关控制(原「模糊标题翻译」「模糊番号页」已合并进来)。 // 关键设计: 直接对"区域容器"上 filter, 不再用 JS 把番号/标题/译文分别包成 span。 // - 番号、标题、译文本来就在同一个容器里(列表页 .video-title / 详情页 h2.title.is-4), // 糊容器天然就是"整块一起糊、整块一起恢复", 不会出现悬停时你清我糊的割裂。 // - 不依赖翻译的异步时机, 译文什么时候写进来都已经在模糊区域内, 不会再有"有的行糊有的行不糊"。 window.applyBlurMode = async function() { try { if ("yes" !== await storageManager.getSetting("enableBlurMode", "no")) { const root = document.documentElement; ["jhs-blur-image", "jhs-blur-title", "jhs-blur-trans", "jhs-blur-carnum", "jhs-blur-hover"].forEach(a => root.removeAttribute(a)); const old0 = document.getElementById("jhs-blur-style"); old0 && old0.remove(); return; } clog.log("[隐私模式] 开始初始化..."); const yesish = async (k, d) => { const v = await storageManager.getSetting(k, d); return void 0 === v || null === v ? "yes" === d : "yes" === v; }; const blurImage = await yesish("blurImage", "yes"); const blurTitle = await yesish("blurTitle", "yes"); const hoverReveal = await yesish("blurHoverReveal", "yes"); let level = parseInt(await storageManager.getSetting("blurLevel", 50)); if (isNaN(level)) level = 50; const px = (Math.max(0, Math.min(100, level)) / 100) * 10; // 只命中 <img> 本身, 绝不加"裸容器"选择器。 // 原因: filter 一旦作用在容器上, 整个子树都会被糊, 子元素无法再用 CSS 解糊 // (filter 不像 color 可被子元素覆盖)。若图片容器恰好是标题的祖先, 「模糊文字」开关就会彻底失效。 const IMG_SEL = [ ".movie-list .item .cover img", "img.video-cover", ".video-cover img", ".column-video-cover img", ".preview-images img", ".tile-images img", ".screen-container img", ".photo-frame img", "a.bigImage img", "#sample-waterfall img", ".movie-box img", ".masonry .movie-box .photo-frame img" ]; // 「模糊文字」的作用区域。每一项都是一个"区域容器", 里面的番号/标题/译文一起糊、一起恢复。 // TEXT_SEL 同时也是悬停恢复的触发器(见下方 join(A_HOVER...)), 所以区域划分即悬停粒度。 // 注意: 这里只放"纯文字区域"。像 #related-content 这种图文混排的容器不能整块放进来, // 否则里面的封面图会被文字开关连带糊掉(filter 会沿子树生效, 且和图片开关重复叠加)。 // 相关列表的文字靠 .video-title 命中即可 —— 它同时覆盖首页/列表页/详情页底部的相关推荐。 const TEXT_SEL = [ ".movie-list .item .video-title", // JavDB 列表: 番号 + 标题 + 译文 ".video-title", // 通用: 列表页 / 详情页底部「TA(们)还出演过」「你可能也喜欢」/ JavBus fixBusTitleBox ".movie-list .item .tags", // 列表条目上的标签名(含中字磁鏈/今日新種等) ".photo-info", // JavBus 列表条目信息区(番号/日期) "h2.title.is-4", // JavDB 详情标题栏: 番号 + 标题 + .translated-title ".container > h3", // JavBus 详情标题 ".translated-title", // 详情页译文块(万一被插到标题栏外面) ".movie-panel-info", // JavDB 详情信息栏: 日期/时长/导演/片商/系列/评分/类别(标签)/演员 ".movie .info", // JavBus 详情信息栏 // JavDB 详情页的磁链/短评/相关 是标签页结构: .tabs.no-bottom 是页签头, #tabs-container 是内容区。 // 之前用的 #reviews-content / #related-content / #magnet-table 都是脚本自建模板里的 id, // 在原生 JavDB 页上命中数为 0, 所以一直没效果。 // 页签头(磁链/短评/相关清单)也一起糊: 用 :has(~#tabs-container) 精确锁定详情页那一条, // 避免误伤列表页顶部的分类导航 .tabs。 ".tabs.no-bottom:has(~#tabs-container)", // 页签头: 磁链 / 短评 / 相关清单 "#tabs-container", // 磁链列表 / 短评 / 相关推荐(随当前页签切换) "#magnets-content", ".magnet-links", ".message.video-panel" // 脚本自建磁力面板 ]; const A_IMG = "jhs-blur-image", A_TITLE = "jhs-blur-title", A_HOVER = "jhs-blur-hover"; const join = (attr, list, extra = "") => list.map((s => `html[${attr}] ${s}${extra}`)).join(","); let css = `html{--jhs-blur:${px}px}` + `${join(A_IMG, IMG_SEL)}{filter:blur(var(--jhs-blur))!important;transition:filter .25s ease}` + `${join(A_TITLE, TEXT_SEL)}{filter:blur(var(--jhs-blur))!important;transition:filter .25s ease}` // 悬浮大图: 跟随图片模糊开关 + `html[${A_IMG}] .image-hover-preview img{filter:blur(var(--jhs-blur))!important;transition:filter .25s ease}` // 悬停取消模糊: 封面本身 + 悬浮大图 一起恢复清晰 + `${join(A_HOVER + "][" + A_IMG, IMG_SEL, ":hover")}{filter:none!important}` + `html[${A_HOVER}][${A_IMG}] .movie-list .item:hover .cover img,` + `html[${A_HOVER}][${A_IMG}] .movie-list .item:hover .cover,` + `html[${A_HOVER}][${A_IMG}] a.movie-box:hover .photo-frame img,` + `html[${A_HOVER}][${A_IMG}] a.movie-box:hover img,` + `html[${A_HOVER}][${A_IMG}] .image-hover-preview img{filter:none!important}` // 悬停取消模糊(文字): 悬停哪个区域, 那个区域整块恢复清晰 + `${join(A_HOVER + "][" + A_TITLE, TEXT_SEL, ":hover")}{filter:none!important}` // 列表页: 鼠标在条目任意位置(封面/标签)都恢复该条目的文字 + `html[${A_HOVER}][${A_TITLE}] .movie-list .item:hover .video-title,` + `html[${A_HOVER}][${A_TITLE}] a.movie-box:hover .photo-info{filter:none!important}` // 导航/页眉页脚/设置面板内不模糊 + `html nav .photo-frame img,html header .photo-frame img,` + `html #jhs-setting-panel img,html .layui-layer img{filter:none!important}`; const oldStyle = document.getElementById("jhs-blur-style"); oldStyle && oldStyle.remove(); document.head.insertAdjacentHTML("beforeend", `<style id="jhs-blur-style">${css}</style>`); const root = document.documentElement; blurImage ? root.setAttribute(A_IMG, "") : root.removeAttribute(A_IMG); blurTitle ? root.setAttribute(A_TITLE, "") : root.removeAttribute(A_TITLE); hoverReveal ? root.setAttribute(A_HOVER, "") : root.removeAttribute(A_HOVER); clog.log("[隐私模式] 已启用", { blurImage: blurImage, blurText: blurTitle, hoverReveal: hoverReveal, level: level, px: px }); } catch (err) { clog.warn("[隐私模式] 初始化失败", err && err.message || err); } }; window.applyBlurMode(); // 悬浮大图: 独立于 ListPagePlugin, 确保即使列表页插件被禁用也能生效 storageManager.getSetting("hoverBigImg", C).then((e => { if (e === _) { const sel = r ? ".cover img" : l ? ".masonry .movie-box .photo-frame img" : null; if (sel) window.imageHoverPreviewObj ? window.imageHoverPreviewObj.bindEvents() : window.imageHoverPreviewObj = new ImageHoverPreview({ selector: sel }); } })); // 下载图片快捷键 (列表页悬浮大图 + 详情页封面) storageManager.getSetting("saveHoverImageHotKey", "q").then((key => { if (key && HotkeyManager.isHotkeyFormat(key)) { HotkeyManager.registerHotkey(key, (() => { const ae = document.activeElement; if ("INPUT" === ae.tagName || "TEXTAREA" === ae.tagName || ae.isContentEditable) return; // 优先: 悬浮预览图 if (window.imageHoverPreviewObj && window.imageHoverPreviewObj.currentTarget) { window.imageHoverPreviewObj.saveCurrentImage(); return; } // 详情页: 保存封面大图 if (window.isDetailPage) { let coverSrc = ""; if (r) coverSrc = $(".column-video-cover img").attr("src") || ""; if (l) coverSrc = $(".movie .bigImage img, .screencap img").first().attr("src") || ""; if (!coverSrc) return; // 提取番号命名 let carNum = ""; const $clip = $("a[data-clipboard-text]").first(); if ($clip.length) carNum = ($clip.attr("data-clipboard-text") || "").trim(); if (!carNum) { const seg = location.href.split("?")[0].split("#")[0].split("/").filter(Boolean).pop() || ""; if (/^[A-Za-z0-9]+-?\d+/.test(seg)) carNum = seg; } if (!carNum) { const $id = $("span.header:contains('識別碼'), span.header:contains('识别码')").first().parent(); if ($id.length) carNum = $id.text().replace(/.*[::]/, "").trim(); } carNum = carNum ? carNum.toUpperCase().replace(/[\\/:*?"<>|]/g, "_") : ""; // 由 arraybuffer 判魔数定扩展名再触发下载 const saveBuf = (buf) => { if (!buf || buf.byteLength < 100) { show.error && show.error("封面下载失败"); return; } const arr = new Uint8Array(buf); let ext = "jpg", mime = "image/jpeg"; if (arr[0] === 0x89 && arr[1] === 0x50) { ext = "png"; mime = "image/png"; } else if (arr[0] === 0x47 && arr[1] === 0x49) { ext = "gif"; mime = "image/gif"; } else if (arr[0] === 0x52 && arr[1] === 0x49 && arr[8] === 0x57 && arr[9] === 0x45) { ext = "webp"; mime = "image/webp"; } const name = (carNum || "cover") + "." + ext; const url = URL.createObjectURL(new Blob([buf], { type: mime })); const a = document.createElement("a"); a.href = url; a.download = name; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1e3); show.ok && show.ok("封面已保存: " + name); }; // 开启「封面图无水印显示」后封面 src 是 blob:, GM 通道取不到, 改走原生 fetch if (coverSrc.startsWith("blob:")) { fetch(coverSrc).then((res => res.arrayBuffer())).then(saveBuf).catch((() => { show.error && show.error("封面下载失败"); })); } else if (typeof GM_xmlhttpRequest !== "undefined") { GM_xmlhttpRequest({ method: "GET", url: coverSrc, responseType: "arraybuffer", headers: { Referer: window.location.href }, onload: (res) => saveBuf(res.response), onerror: () => { show.error && show.error("封面下载失败"); } }); } } })); } })); }();