Sleazy Fork is available in English.
基于 JAV Blade 二次开发,配合 JAV老司机使用:原生顶栏入口 · 卡片状态按钮贴近封面底部左侧 · 屏蔽词直接输入 · 跨站数据互通 · 已鉴定标记 · Everything 本地片源匹配(支持 HTTP Basic 认证)· 点击「本地 N」标记时实时查询 Everything 并打开 · 本地播放页音量记忆(调过就记住,下次打开自动恢复)
// ==UserScript==
// @name JAV Wheel
// @name:zh-CN JAV Wheel
// @namespace https://github.com/jav-wheel/jav-wheel
// @version 1.3.1
// @author JAV Wheel
// @description 基于 JAV Blade 二次开发,配合 JAV老司机使用:原生顶栏入口 · 卡片状态按钮贴近封面底部左侧 · 屏蔽词直接输入 · 跨站数据互通 · 已鉴定标记 · Everything 本地片源匹配(支持 HTTP Basic 认证)· 点击「本地 N」标记时实时查询 Everything 并打开 · 本地播放页音量记忆(调过就记住,下次打开自动恢复)
// @match *://javdb.com/*
// @match *://*.javdb.com/*
// @match *://javdb*.com/*
// @match *://javbus.com/*
// @match *://*.javbus.com/*
// @match *://*.javbus.*/*
// @match *://javlibrary.com/*
// @match *://*.javlibrary.com/*
// @match *://*.javlibrary.*/*
// @match http://127.0.0.1:8080/*
// @match http://localhost:8080/*
// @include http://127.0.0.1:*/*
// @include http://localhost:*/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @grant GM_openInTab
// @grant GM_setClipboard
// @grant GM_registerMenuCommand
// @grant GM_addStyle
// @grant GM_addValueChangeListener
// @connect 127.0.0.1
// @connect localhost
// @connect *
// @run-at document-idle
// @noframes
// ==/UserScript==
(function () {
'use strict';
/* ======================================================================
* 0. 常量
* ==================================================================== */
const WHEEL_VERSION = '1.3.1';
const K = {
watched: 'jav_wheel_watched',
verified: 'jav_wheel_verified',
blockedVideos: 'jav_wheel_blocked_videos',
blockedSeries: 'jav_wheel_blocked_series',
blockedKeywords: 'jav_wheel_blocked_keywords',
filterWatched: 'jav_wheel_filter_watched',
filterVerified: 'jav_wheel_filter_verified',
blockSwitch: 'jav_wheel_block_switch',
autoVerified: 'jav_wheel_auto_verified',
// Everything
evEnabled: 'jav_wheel_ev_enabled',
evUrl: 'jav_wheel_ev_url',
evFolders: 'jav_wheel_ev_folders',
evIndex: 'jav_wheel_ev_index',
evSyncWatched: 'jav_wheel_ev_sync_watched',
evOpenMode: 'jav_wheel_ev_open_mode',
evUser: 'jav_wheel_ev_user',
evPass: 'jav_wheel_ev_pass',
// 同一番号最多入库的文件数(设置项)
evMaxPerCode: 'jav_wheel_ev_max_per_code',
// 右下角导航(回到顶部 / 回到底部,源自 JAV Blade)
scrollButtons: 'jav_wheel_scroll_buttons'
};
const DEFAULT_EV_URL = 'http://127.0.0.1:8080';
const DEFAULT_MAX_PER_CODE = 30; // 同一番号索引入库上限默认值
const MAX_PER_CODE_LIMIT = 500; // 上限的可配置范围上界
/** Everything HTTP 服务常见端口候选(按顺序自动探测,命中后写回配置) */
const EV_PORT_CANDIDATES = [80, 8080, 8000, 8888, 32123, 1234, 5000, 9000];
/* ======================================================================
* 1. 存储层(GM 优先,localStorage 兜底)—— 跨站点共享同一份数据
* ==================================================================== */
const hasGM = (typeof GM_getValue === 'function' && typeof GM_setValue === 'function');
/** 进程内缓存:避免卡片渲染时高频重复读取 */
const MEM = new Map();
function kvGet(key, def) {
if (MEM.has(key)) {
const v = MEM.get(key);
return v === undefined ? def : v;
}
try {
let v;
if (hasGM) {
v = GM_getValue(key, undefined);
} else {
const raw = localStorage.getItem(key);
v = raw === null ? undefined : JSON.parse(raw);
}
MEM.set(key, v);
return v === undefined ? def : v;
} catch (e) { return def; }
}
function kvSet(key, value) {
MEM.set(key, value);
try {
if (hasGM) { GM_setValue(key, value); return true; }
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (e) { return false; }
}
function kvDel(key) {
MEM.delete(key);
try {
if (hasGM && typeof GM_deleteValue === 'function') GM_deleteValue(key);
else localStorage.removeItem(key);
} catch (e) { /* ignore */ }
}
const memList = new Map();
const memFlag = new Map();
function readList(key) {
if (memList.has(key)) return memList.get(key);
let v = kvGet(key, []);
if (typeof v === 'string') { try { v = JSON.parse(v); } catch (e) { v = []; } }
if (!Array.isArray(v)) v = [];
memList.set(key, v);
return v;
}
/** 写前重读:丢弃内存缓存后直接读存储最新值。
* 多标签页 / iframe 弹层并发标记时,本页 memList 可能已落后于存储,
* 「先读旧值再整体覆盖写回」会丢掉其它标签页刚写入的条目,故改前先重读。 */
function readListFresh(key) {
try { MEM.delete(key); } catch (e) { /* ignore */ }
memList.delete(key);
return readList(key);
}
function commitList(key, arr) {
const uniq = Array.from(new Set(arr.filter(Boolean)));
memList.set(key, uniq);
kvSet(key, uniq);
return uniq;
}
function readFlag(key, def) {
if (memFlag.has(key)) return memFlag.get(key);
const v = kvGet(key, def);
const b = (v === true || v === 'true');
memFlag.set(key, b);
return b;
}
function writeFlag(key, val) {
memFlag.set(key, !!val);
kvSet(key, !!val);
}
/* ---------- 数据访问器 ---------- */
const getWatched = () => readList(K.watched);
const getVerified = () => readList(K.verified);
const getBlockedVideos = () => readList(K.blockedVideos);
const getBlockedSeries = () => readList(K.blockedSeries);
const getBlockedKeywords = () => readList(K.blockedKeywords);
const isFilterWatched = () => readFlag(K.filterWatched, true);
const isFilterVerified = () => readFlag(K.filterVerified, false);
const isBlockSwitch = () => readFlag(K.blockSwitch, true);
const isAutoVerified = () => readFlag(K.autoVerified, true);
const isScrollButtons = () => readFlag(K.scrollButtons, true);
/* ---------- 已下载 / 已鉴定:互斥写入(已下载优先) ---------- */
/** 标记已下载:加入 watched,同时从 verified 移除 */
function setWatched(code, on) {
if (!code) return false;
// 写前重读:已下载 / 已鉴定是一对互斥列表,必须基于最新存储快照改写
let w = readListFresh(K.watched).slice();
let v = readListFresh(K.verified).slice();
if (on) {
if (!w.includes(code)) w.push(code);
v = v.filter(x => x !== code);
} else {
w = w.filter(x => x !== code);
}
commitList(K.watched, w);
commitList(K.verified, v);
return on;
}
/** 标记已鉴定:若已下载则拒绝(已下载优先级更高) */
function setVerified(code, on) {
if (!code) return { ok: false, reason: 'no-code' };
const w = readListFresh(K.watched);
let v = readListFresh(K.verified).slice();
if (on) {
if (w.includes(code)) return { ok: false, reason: 'watched-first' };
if (!v.includes(code)) v.push(code);
} else {
v = v.filter(x => x !== code);
}
commitList(K.verified, v);
return { ok: true };
}
/** 自动标记已鉴定(详情页访问),不覆盖已下载 */
function autoMarkVerified(code) {
if (!code || !isAutoVerified()) return false;
// 重读存储后再判断:详情页常在新标签页打开,本页缓存可能还没有别的标签页
// 刚写入的「已下载 / 已鉴定」,用陈旧缓存判断会造成漏标记或重复覆盖
const w = readListFresh(K.watched);
const v = readListFresh(K.verified);
if (w.includes(code)) return false;
if (v.includes(code)) return false;
setVerified(code, true);
return true;
}
function toggleBlockedVideo(code) {
const list = readListFresh(K.blockedVideos).slice();
const i = list.indexOf(code);
if (i > -1) list.splice(i, 1); else list.push(code);
commitList(K.blockedVideos, list);
return list.includes(code);
}
function toggleBlockedSeries(series) {
const s = String(series || '').toLowerCase();
if (!s) return false;
const list = readListFresh(K.blockedSeries).slice();
const i = list.indexOf(s);
if (i > -1) list.splice(i, 1); else list.push(s);
commitList(K.blockedSeries, list);
return list.includes(s);
}
function toggleBlockedKeyword(kw, forceState) {
const s = String(kw || '').trim().toLowerCase();
if (!s) return false;
const list = readListFresh(K.blockedKeywords).slice();
const i = list.indexOf(s);
const want = (typeof forceState === 'boolean') ? forceState : (i === -1);
if (want && i === -1) list.push(s);
if (!want && i > -1) list.splice(i, 1);
commitList(K.blockedKeywords, list);
return list.includes(s);
}
// v1.2.0:不再提供 Blade -> Wheel 的旧数据迁移(旧键一律不再读取)
/* ======================================================================
* 2. 通用工具
* ==================================================================== */
function siteId() {
const h = (location.hostname || '').toLowerCase();
if (h.includes('javdb')) return 'javdb';
if (h.includes('javbus')) return 'javbus';
if (/(javlibrary|javlib|r86m|s87n)/.test(h)) return 'javlib';
return 'other';
}
/** 番号规范化:SSIS001 / ssis_001 / "SSIS 001" -> SSIS-001 */
function normalizeCode(raw) {
if (!raw) return '';
let s = String(raw).trim().toUpperCase();
s = s.replace(/[\uFF08\uFF09\u3010\u3011\[\]()]/g, ' ');
s = s.replace(/\s+/g, ' ');
const fc2ppv = s.match(/FC2[\s\-_]?PPV[\s\-_]?(\d{4,})/);
if (fc2ppv) return 'FC2-PPV-' + fc2ppv[1];
const fc2 = s.match(/FC2[\s\-_]?(\d{4,})/);
if (fc2) return 'FC2-' + fc2[1];
// v1.3.1:番头允许单字母(如 Y-438、C-2345),数字仍需至少 2 位避免误伤
const m = s.match(/([A-Z]{1,10})[\s\-_]?(\d{2,6})/);
if (m) return m[1] + '-' + m[2];
const d = s.match(/(\d{6})[\s\-_](\d{3,4})/);
if (d) return d[1] + '-' + d[2];
return '';
}
/** 从任意文本中提取番号 */
function extractCode(text) {
if (!text) return '';
const s = String(text);
let m = s.match(/FC2[\s\-_]?PPV[\s\-_]?(\d{4,})/i);
if (m) return 'FC2-PPV-' + m[1];
m = s.match(/FC2[\s\-_]?(\d{5,})/i);
if (m) return 'FC2-' + m[1];
m = s.match(/\b([A-Z]{1,10})[\s\-_]?(\d{2,6})\b/i);
if (m) return (m[1] + '-' + m[2]).toUpperCase();
m = s.match(/\b(\d{6})[\s\-_](\d{3,4})\b/);
if (m) return m[1] + '-' + m[2];
return '';
}
/** 番头(系列) */
function extractSeries(code) {
const m = String(code || '').toUpperCase().match(/^([A-Z][A-Z0-9]*?)(?=-\d)/);
if (m) return m[1].toLowerCase();
const m2 = String(code || '').toUpperCase().match(/^(FC2)/);
return m2 ? 'fc2' : '';
}
/** 匹配键:忽略所有分隔符 */
function matchKey(code) {
return String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
}
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
}
function escapeRegExp(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function debounce(fn, wait) {
let t = 0;
return function () {
const args = arguments, self = this;
clearTimeout(t);
t = setTimeout(() => fn.apply(self, args), wait);
};
}
function bytesText(n) {
n = Number(n) || 0;
if (n <= 0) return '';
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return (i === 0 ? n : n.toFixed(n >= 100 ? 0 : 1)) + ' ' + u[i];
}
/* ======================================================================
* 3. 卡片识别与信息读取(仅针对老司机生成/装饰过的卡片)
* ==================================================================== */
const CARD_SELECTOR = '.jav-card, .javbus-grid-card, .javdb-grid-card, .javlib-grid-card';
function isLaosijiCard(el) {
if (!el || el.nodeType !== 1) return false;
if (typeof el.matches !== 'function') return false;
if (!el.matches(CARD_SELECTOR)) return false;
return !!el.querySelector('.jav-card-cover');
}
function getCards() {
const out = [];
document.querySelectorAll(CARD_SELECTOR).forEach(el => {
if (isLaosijiCard(el)) out.push(el);
});
return out;
}
function getCardCover(card) {
return card.querySelector('.jav-card-cover');
}
function getCardCode(card) {
// 1) 老司机明确标注的番号节点
const ready = card.querySelector('.javbus-card-code, .javlib-card-code');
if (ready) {
const c = extractCode(ready.textContent);
if (c) return c;
}
const strong = card.querySelector('.javdb-card-headline strong, .javdb-card-title strong, .video-title strong, .id');
if (strong) {
const c = extractCode(strong.getAttribute('data-code') || '') || extractCode(strong.textContent);
if (c) return c;
}
// 2) 卡片链接
const a = card.querySelector('a.jav-card-link[href], a.movie-box[href], a.box[href], a[href]');
if (a) {
const href = a.getAttribute('href') || '';
const c = extractCode(href);
if (c) return c;
}
// 3) 标题文本
const title = card.querySelector('.javdb-card-headline, .javbus-card-headline, .javlib-card-headline, .video-title');
if (title) {
const c = extractCode(title.textContent);
if (c) return c;
}
// 4) 不做全文匹配,避免把 FHD 1080 之类的文本误判成番号
return '';
}
function getCardTitleEl(card) {
return card.querySelector('.video-title')
|| card.querySelector('.javdb-card-headline')
|| card.querySelector('.javbus-card-headline')
|| card.querySelector('.javlib-card-headline')
// 兜底:不同版本/主题下 headline 类可能缺失,退化为老司机卡片容器本身
|| card.querySelector('.jav-card-title')
|| card.querySelector('.photo-info')
|| card.querySelector('.title');
}
function isBlockedByKeyword(carNum, title) {
const kws = getBlockedKeywords();
if (!kws.length) return false;
const text = (carNum + ' ' + (title || '')).toLowerCase();
for (let i = 0; i < kws.length; i++) {
if (kws[i] && text.includes(kws[i])) return true;
}
return false;
}
/* ======================================================================
* 4. 过滤引擎
* ==================================================================== */
function ensureFilterStyle() {
if (document.getElementById('js-filter-style')) return;
const style = document.createElement('style');
style.id = 'js-filter-style';
style.textContent = `
.js-filter-hidden {
display: none !important;
visibility: hidden !important;
height: 0 !important;
min-height: 0 !important;
padding: 0 !important;
margin: 0 !important;
border: none !important;
overflow: hidden !important;
position: absolute !important;
pointer-events: none !important;
opacity: 0 !important;
}
`;
document.head.appendChild(style);
}
function applyFilter() {
const cards = getCards();
if (!cards.length) { updateStats(); return; }
ensureFilterStyle();
const blockOn = isBlockSwitch();
const filterWatched = isFilterWatched();
const filterVerified = isFilterVerified();
const blockedVideos = getBlockedVideos();
const blockedSeries = getBlockedSeries();
const watched = getWatched();
const verified = getVerified();
let total = 0, hidden = 0;
cards.forEach(card => {
total++;
const code = getCardCode(card);
const series = extractSeries(code);
const titleEl = getCardTitleEl(card);
const title = titleEl ? titleEl.textContent : '';
let hide = false;
if (blockOn && code) {
if (series && blockedSeries.includes(series)) hide = true;
if (!hide && blockedVideos.includes(code)) hide = true;
if (!hide && isBlockedByKeyword(code, title)) hide = true;
}
if (!hide && filterWatched && code && watched.includes(code)) hide = true;
if (!hide && filterVerified && code && verified.includes(code)) hide = true;
if (hide) {
card.classList.add('js-filter-hidden');
hidden++;
} else {
card.classList.remove('js-filter-hidden');
}
});
fixWaterfallLayout();
updateStats(total, hidden);
}
function fixWaterfallLayout() {
try {
if (window.jQuery && typeof window.jQuery.fn.masonry === 'function') {
window.jQuery('.masonry').masonry('layout');
}
if (window.jQuery && typeof window.jQuery.fn.isotope === 'function') {
window.jQuery('.masonry').isotope('layout');
}
} catch (e) { /* ignore */ }
window.dispatchEvent(new Event('resize'));
document.querySelectorAll('.masonry, #waterfall, .movie-list').forEach(el => {
el.style.height = 'auto';
});
}
function updateStats(total, hidden) {
const el = document.getElementById('js-stats');
if (!el) return;
const t = (typeof total === 'number') ? total : getCards().length;
const h = (typeof hidden === 'number') ? hidden : document.querySelectorAll('.js-filter-hidden').length;
el.textContent = `显示 ${t - h}/${t} · 屏蔽 ${getBlockedVideos().length}部/${getBlockedSeries().length}番头/${getBlockedKeywords().length}词 · 下载 ${getWatched().length} · 鉴定 ${getVerified().length}`;
}
/* ======================================================================
* 5. 卡片操作按钮(贴封面底部左侧)
* ==================================================================== */
function cardBtnCss(bg) {
return `padding:2px 10px;font-size:11px;border:none;border-radius:4px;background:${bg};color:#fff;cursor:pointer;font-weight:bold;`;
}
/** 状态变化通知:派发到 window,便于其它脚本(如 JAV老司机)联动 */
function emitStateChange(code, card) {
try {
const detail = {
code: code,
watched: getWatched().includes(code),
verified: getVerified().includes(code),
blocked: getBlockedVideos().includes(code),
series: extractSeries(code) || ''
};
window.dispatchEvent(new CustomEvent('jav-wheel:state-change', { detail: detail }));
if (card) card.dispatchEvent(new CustomEvent('jav-wheel:state-change', { detail: detail, bubbles: false }));
broadcastStateChange(code);
} catch (e) { /* ignore */ }
}
/** 卡片状态徽标:已下载 / 已鉴定(与已下载互斥),随标记动作实时刷新。
* 传入卡片或封面容器(详情页主图装饰时直接传主图容器)均可。 */
function applyStateBadge(card, code) {
const cover = getCardCover(card) || card;
if (!cover) return;
const watched = getWatched().includes(code);
const verified = !watched && getVerified().includes(code);
const state = watched ? 'watched' : (verified ? 'verified' : '');
let badge = cover.querySelector(':scope > .js-state-badge');
if (!state) { if (badge) badge.remove(); return; }
if (!badge) {
badge = document.createElement('span');
badge.className = 'js-state-badge';
try { if (getComputedStyle(cover).position === 'static') cover.style.position = 'relative'; } catch (e) { }
cover.appendChild(badge);
}
if (badge.dataset.state === state) return;
badge.dataset.state = state;
badge.textContent = state === 'watched' ? '已下载' : '已鉴定';
// 用 important 内联样式,避免被站点 CSS(如 *{color:#999!important})覆盖成灰字
try {
badge.style.setProperty('background', state === 'watched' ? '#2ecc71' : '#16a085', 'important');
badge.style.setProperty('color', '#ffffff', 'important');
badge.style.setProperty('font-size', '11px', 'important');
badge.style.setProperty('font-weight', '700', 'important');
badge.style.setProperty('line-height', '1.6', 'important');
} catch (e) {
badge.style.background = state === 'watched' ? '#2ecc71' : '#16a085';
}
badge.title = state === 'watched' ? '已标记为已下载' : '已标记为已鉴定';
}
/** 标记动作后立即刷新当前卡片(按钮 + 徽标),不等全局重绘。
* 传入卡片或主图封面容器均可(主图容器没有 .jav-card-cover,退化为容器自身)。 */
function updateCardState(card, code) {
if (!card) return;
const cover = getCardCover(card) || card;
if (cover) {
const group = cover.querySelector(':scope > .dy-card-actions');
if (group) group.remove();
const g = buildCardActions(card, code);
g.dataset.sig = cardStateSig(card, code);
cover.appendChild(g);
}
applyStateBadge(card, code);
emitStateChange(code, card);
updateMenuStat();
}
function buildCardActions(card, code) {
const group = document.createElement('div');
group.className = 'dy-card-actions';
group.dataset.code = code;
const series = extractSeries(code);
const blockOn = isBlockSwitch();
const isSeriesBlocked = !!series && getBlockedSeries().includes(series);
const isBlocked = getBlockedVideos().includes(code);
const isWatched = getWatched().includes(code);
const isVerified = getVerified().includes(code);
const titleEl = getCardTitleEl(card);
const title = titleEl ? titleEl.textContent : '';
const isKwBlocked = isBlockedByKeyword(code, title);
// 屏蔽番头
const seriesBtn = document.createElement('button');
seriesBtn.type = 'button';
seriesBtn.dataset.act = 'series';
seriesBtn.textContent = isSeriesBlocked ? '✅ 番头已屏蔽' : '📦 屏蔽番头';
seriesBtn.style.cssText = cardBtnCss(isSeriesBlocked ? '#95a5a6' : '#e67e22');
seriesBtn.title = blockOn ? '屏蔽整个系列(所有同番头影片)' : '屏蔽总开关已关闭';
seriesBtn.style.opacity = blockOn ? '1' : '0.55';
seriesBtn.addEventListener('click', e => {
e.preventDefault(); e.stopPropagation();
if (!blockOn) { toast('屏蔽总开关已关闭,请先开启'); return; }
if (!series) { toast('无法识别番头'); return; }
const now = toggleBlockedSeries(series);
toast(now ? `已屏蔽番头:${series.toUpperCase()}*` : `已解除番头屏蔽:${series.toUpperCase()}*`);
updateCardState(card, code);
refreshAll();
});
// 屏蔽这一部
const blockBtn = document.createElement('button');
blockBtn.type = 'button';
blockBtn.dataset.act = 'block';
blockBtn.textContent = isBlocked ? '✅ 已屏蔽' : '🚫 屏蔽这部';
blockBtn.style.cssText = cardBtnCss(isBlocked ? '#95a5a6' : '#e74c3c');
blockBtn.title = blockOn ? '屏蔽 / 解除屏蔽当前这部影片' : '屏蔽总开关已关闭';
if (isKwBlocked) blockBtn.title = '当前影片命中屏蔽关键字';
blockBtn.style.opacity = blockOn ? '1' : '0.55';
blockBtn.addEventListener('click', e => {
e.preventDefault(); e.stopPropagation();
if (!blockOn) { toast('屏蔽总开关已关闭,请先开启'); return; }
const now = toggleBlockedVideo(code);
toast(now ? `已屏蔽:${code}` : `已解除屏蔽:${code}`);
updateCardState(card, code);
refreshAll();
});
// 标记已下载
const watchedBtn = document.createElement('button');
watchedBtn.type = 'button';
watchedBtn.dataset.act = 'watched';
watchedBtn.textContent = isWatched ? '📥 已下载' : '📥 标记下载';
watchedBtn.style.cssText = cardBtnCss(isWatched ? '#2ecc71' : '#3498db');
watchedBtn.title = '标记 / 取消「已下载」(与已鉴定互斥,已下载优先)';
watchedBtn.addEventListener('click', e => {
e.preventDefault(); e.stopPropagation();
const now = setWatched(code, !isWatched);
toast(now ? `已标记下载:${code}` : `已取消下载标记:${code}`);
updateCardState(card, code);
refreshAll();
});
// 标记已鉴定
const verifyBtn = document.createElement('button');
verifyBtn.type = 'button';
verifyBtn.dataset.act = 'verify';
verifyBtn.textContent = isVerified ? '🩺 已鉴定' : '🩺 标记鉴定';
verifyBtn.style.cssText = cardBtnCss(isVerified ? '#16a085' : '#7f8c8d');
verifyBtn.title = '标记 / 取消「已鉴定」(与已下载互斥,已下载优先)';
verifyBtn.addEventListener('click', e => {
e.preventDefault(); e.stopPropagation();
const res = setVerified(code, !isVerified);
if (!res.ok && res.reason === 'watched-first') {
toast(`已下载状态优先,${code} 保持为「已下载」`);
} else {
toast(isVerified ? `已取消鉴定标记:${code}` : `已标记鉴定:${code}`);
}
updateCardState(card, code);
refreshAll();
});
// v1.0.5:卡片操作组不再放「📂 打开本地」按钮(仅保留 4 个状态按钮),
// 本地文件打开入口统一收敛到标题前的绿色「本地 N」标记。
group.appendChild(seriesBtn);
group.appendChild(blockBtn);
group.appendChild(watchedBtn);
group.appendChild(verifyBtn);
return group;
}
/** 卡片当前状态签名:仅当状态变化时才重建按钮,避免无谓重排 */
function cardStateSig(card, code) {
const series = extractSeries(code);
const titleEl = getCardTitleEl(card);
const title = titleEl ? titleEl.textContent : '';
return [
code,
series || '',
isBlockSwitch() ? 1 : 0,
series && getBlockedSeries().includes(series) ? 1 : 0,
getBlockedVideos().includes(code) ? 1 : 0,
getWatched().includes(code) ? 1 : 0,
getVerified().includes(code) ? 1 : 0,
isBlockedByKeyword(code, title) ? 1 : 0
].join('|');
}
function decorateCards() {
getCards().forEach(card => {
const cover = getCardCover(card);
if (!cover) return;
const code = getCardCode(card);
if (!code) return;
// 标注番号,便于与站点/其它脚本(如 JAV老司机)互通
try { if (card.dataset.code !== code) card.dataset.code = code; } catch (e) { }
const sig = cardStateSig(card, code);
let group = cover.querySelector(':scope > .dy-card-actions');
if (group && (group.dataset.code !== code || group.dataset.sig !== sig)) {
group.remove();
group = null;
}
if (!group) {
group = buildCardActions(card, code);
group.dataset.sig = sig;
cover.appendChild(group);
}
applyLocalBadge(card, code);
applyStateBadge(card, code);
});
}
/* ======================================================================
* 6. UI 基础:样式 / Toast / 弹窗(视觉规范对齐 JAV老司机)
* ==================================================================== */
const UI_CSS = `
.js-wheel-entry{cursor:pointer!important;}
.js-wheel-entry .js-wheel-dot{display:inline-block;margin-left:4px;font-size:11px;color:#0ea5e9;}
#navbar-menu-user .js-wheel-entry{color:#2563eb!important;font-weight:700!important;}
#navbar-menu-user .js-wheel-entry:hover{color:#1d4ed8!important;background:rgba(37,99,235,.08)!important;}
#navbar .js-wheel-nav .js-wheel-entry a{color:#2563eb!important;font-weight:700!important;}
#navbar .js-wheel-nav .js-wheel-entry a:hover{color:#1d4ed8!important;background:rgba(37,99,235,.08)!important;}
#topmenu .menutext .js-wheel-entry{color:#2563eb!important;font-weight:700!important;text-decoration:none!important;cursor:pointer;}
#topmenu .menutext .js-wheel-entry:hover{color:#1d4ed8!important;text-decoration:underline!important;}
.js-menu{position:absolute;top:calc(100% + 6px);right:0;z-index:2147482000;min-width:264px;max-width:320px;
background:#fff;border:1px solid #dbe3ef;border-radius:10px;box-shadow:0 12px 30px rgba(15,23,42,.18);
padding:8px;color:#1e293b;text-align:left;
font:13px/1.55 -apple-system,BlinkMacSystemFont,"Microsoft YaHei","PingFang SC","Noto Sans CJK SC","Segoe UI",sans-serif;}
.js-menu-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 10px 8px;font-weight:700;color:#0f172a;}
.js-menu-head small{font-weight:400;color:#64748b;font-size:11px;}
.js-menu-stat{padding:0 10px 8px;font-size:11px;color:#64748b;line-height:1.6;}
.js-menu-sep{height:1px;background:#e8eef7;margin:6px 4px;}
.js-menu-item{display:flex;align-items:center;gap:8px;padding:7px 10px;border-radius:7px;cursor:pointer;color:#1e293b;font-size:13px;white-space:nowrap;}
.js-menu-item:hover{background:#eef4ff;}
.js-menu-item .js-mi-sub{color:#94a3b8;font-size:11px;margin-left:auto;}
.js-menu-inputrow{display:flex;gap:6px;padding:4px 10px 8px;}
.js-menu-input{flex:1;min-width:0;padding:6px 8px;border:1px solid #cbd5e1;border-radius:6px;font-size:12px;color:#1e293b;background:#f8fafc;}
.js-menu-input:focus{outline:none;border-color:#0ea5e9;background:#fff;}
.js-menu-add{padding:6px 10px;border:none;border-radius:6px;background:#334155;color:#fff;font-size:12px;cursor:pointer;}
.js-menu-type{flex:1;padding:5px 8px;border:1px solid #334155;border-radius:6px;background:#0f172a;color:#e2e8f0;font-size:12px;}
.js-switch{margin-left:auto;flex:none;width:34px;height:18px;border-radius:9px;background:#cbd5e1;position:relative;transition:background .18s;}
.js-switch.on{background:#0ea5e9;}
.js-switch::after{content:'';position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;transition:left .18s;}
.js-switch.on::after{left:18px;}
.js-mask{position:fixed;inset:0;background:rgba(15,23,42,.45);z-index:2147483000;display:flex;align-items:center;justify-content:center;padding:18px;}
.js-dialog{display:flex;flex-direction:column;max-height:88vh;background:#fff;border:1px solid #dbe3ef;border-radius:10px;
box-shadow:0 20px 45px rgba(15,23,42,.28);color:#1e293b;overflow:hidden;
font:13px/1.65 -apple-system,BlinkMacSystemFont,"Microsoft YaHei","PingFang SC","Noto Sans CJK SC","Segoe UI",sans-serif;}
.js-dialog-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:13px 18px;border-bottom:1px solid #e8eef7;font-size:15px;font-weight:700;color:#0f172a;}
.js-dialog-head .js-x{border:none;background:transparent;color:#94a3b8;font-size:18px;line-height:1;cursor:pointer;padding:2px 4px;}
.js-dialog-head .js-x:hover{color:#334155;}
.js-dialog-body{padding:16px 18px;overflow:auto;}
.js-dialog-foot{display:flex;justify-content:flex-end;gap:8px;padding:12px 18px;border-top:1px solid #e8eef7;background:#f8fafc;}
.js-btn{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;border:1px solid #cbd5e1;border-radius:6px;background:#fff;color:#334155;font-size:13px;cursor:pointer;}
.js-btn:hover{background:#eef2f7;}
.js-btn.primary{background:#334155;border-color:#334155;color:#fff;}
.js-btn.primary:hover{background:#1e293b;}
.js-btn.ok{background:#0f766e;border-color:#0f766e;color:#fff;}
.js-btn.ok:hover{background:#0b5c56;}
.js-btn.danger{background:#fff;border-color:#fca5a5;color:#dc2626;}
.js-btn.danger:hover{background:#fef2f2;}
.js-btn.sm{padding:3px 9px;font-size:12px;border-radius:5px;}
.js-field{width:100%;box-sizing:border-box;padding:7px 9px;border:1px solid #cbd5e1;border-radius:6px;background:#f8fafc;color:#1e293b;font-size:13px;}
.js-field:focus{outline:none;border-color:#0ea5e9;background:#fff;}
textarea.js-field{min-height:110px;resize:vertical;font-family:ui-monospace,Consolas,"Courier New",monospace;font-size:12px;line-height:1.55;}
.js-label{display:block;margin:12px 0 6px;font-weight:700;color:#0f172a;font-size:13px;}
.js-hint{color:#64748b;font-size:12px;line-height:1.7;}
.js-card-box{border:1px solid #e2e8f0;border-radius:8px;background:#f8fafc;padding:12px;margin-top:6px;}
.js-chip{display:inline-flex;align-items:center;gap:6px;margin:4px 4px 0 0;padding:3px 8px;border-radius:6px;
font-size:12px;background:#eef2f7;color:#334155;border:1px solid #dbe3ef;}
.js-chip b{font-weight:600;}
.js-chip .js-chip-x{cursor:pointer;color:#94a3b8;font-weight:700;}
.js-chip .js-chip-x:hover{color:#dc2626;}
.js-chip.red{background:#fef2f2;border-color:#fecaca;color:#b91c1c;}
.js-chip.orange{background:#fff7ed;border-color:#fed7aa;color:#c2410c;}
.js-chip.blue{background:#eff6ff;border-color:#bfdbfe;color:#1d4ed8;}
.js-chip.green{background:#ecfdf5;border-color:#a7f3d0;color:#047857;}
.js-row{display:flex;flex-wrap:wrap;gap:8px;align-items:center;}
.js-kv{display:flex;align-items:center;gap:8px;padding:6px 0;}
.js-kv .js-kv-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.js-setrow{display:flex;align-items:center;gap:8px;padding:5px 0;cursor:pointer;font-size:13px;color:#1e293b;}
.js-setrow input[type=checkbox]{width:15px;height:15px;accent-color:#0f766e;cursor:pointer;flex:none;margin:0;}
.js-setrow-hint{margin:-2px 0 4px 23px;}
.js-state-badge{position:absolute!important;top:6px!important;left:6px!important;z-index:6!important;
padding:1px 6px!important;border-radius:4px!important;color:#fff!important;
font:700 11px/1.6 -apple-system,BlinkMacSystemFont,"Microsoft YaHei","PingFang SC","Segoe UI",sans-serif!important;
background:#16a085!important;pointer-events:none!important;letter-spacing:normal!important;
text-shadow:none!important;text-transform:none!important;opacity:1!important;
box-shadow:0 2px 6px rgba(15,23,42,.28)!important;}
.dy-card-actions{position:absolute;left:6px;bottom:6px;z-index:6;display:flex;flex-wrap:wrap;gap:5px;max-width:calc(100% - 12px);}
.jav-card-cover{position:relative!important;}
.js-local-badge{display:inline-flex!important;align-items:center;gap:3px;margin-right:5px;padding:1px 6px;border-radius:4px;
background:#0f766e!important;color:#fff!important;font-size:11px!important;font-weight:700!important;
cursor:pointer;vertical-align:middle;opacity:1!important;letter-spacing:normal!important;text-shadow:none!important;
font-family:-apple-system,BlinkMacSystemFont,"Microsoft YaHei","PingFang SC","Segoe UI",sans-serif!important;}
.js-local-badge:hover{background:#115e59!important;}
.js-toast{position:fixed;left:50%;bottom:34px;transform:translateX(-50%);z-index:2147483600;max-width:86vw;
padding:10px 20px;border-radius:8px;background:rgba(15,23,42,.92);color:#fff;font-size:13px;
box-shadow:0 8px 24px rgba(15,23,42,.35);text-align:center;
font:13px/1.5 -apple-system,BlinkMacSystemFont,"Microsoft YaHei","PingFang SC","Segoe UI",sans-serif;}
`;
function injectStyle() {
if (document.getElementById('js-style')) return;
const style = document.createElement('style');
style.id = 'js-style';
style.textContent = UI_CSS;
document.head.appendChild(style);
}
/* ======================================================================
* 6.1 右下角导航(回到顶部 / 回到底部)
* 功能移植自 JAV Blade 的 addScrollButtons,可在设置中开关
* ==================================================================== */
function applyScrollButtons() {
const on = isScrollButtons();
let topBtn = document.getElementById('dy-scroll-top');
let bottomBtn = document.getElementById('dy-scroll-bottom');
if (!on) {
if (topBtn) topBtn.remove();
if (bottomBtn) bottomBtn.remove();
return;
}
const mk = (id, text, toTop) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.id = id;
btn.textContent = text;
btn.title = toTop ? '回到顶部' : '回到底部';
Object.assign(btn.style, {
position: 'fixed',
right: '18px',
zIndex: '2147482000',
width: '46px',
height: '46px',
borderRadius: '50%',
border: 'none',
cursor: 'pointer',
fontWeight: '700',
fontSize: '13px',
color: '#fff',
background: 'rgba(15,23,42,.78)',
boxShadow: '0 4px 14px rgba(15,23,42,.28)'
});
btn.style.bottom = toTop ? '110px' : '54px';
btn.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
window.scrollTo({ top: toTop ? 0 : document.documentElement.scrollHeight, behavior: 'smooth' });
});
return btn;
};
if (!topBtn) { topBtn = mk('dy-scroll-top', '▲', true); document.body.appendChild(topBtn); }
if (!bottomBtn) { bottomBtn = mk('dy-scroll-bottom', '▼', false); document.body.appendChild(bottomBtn); }
}
let toastTimer = null;
function toast(msg, duration) {
const old = document.getElementById('js-toast');
if (old) old.remove();
const el = document.createElement('div');
el.id = 'js-toast';
el.className = 'js-toast';
el.textContent = msg;
document.body.appendChild(el);
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
el.style.transition = 'opacity .25s';
el.style.opacity = '0';
setTimeout(() => el.remove(), 260);
}, duration || 2200);
}
function openDialog(opts) {
const mask = document.createElement('div');
mask.className = 'js-mask';
const box = document.createElement('div');
box.className = 'js-dialog';
box.style.width = (opts.width || 620) + 'px';
box.style.maxWidth = '100%';
const head = document.createElement('div');
head.className = 'js-dialog-head';
const title = document.createElement('div');
title.textContent = opts.title || '';
const x = document.createElement('button');
x.type = 'button';
x.className = 'js-x';
x.textContent = '\u00D7';
head.appendChild(title);
head.appendChild(x);
const body = document.createElement('div');
body.className = 'js-dialog-body';
if (typeof opts.content === 'string') body.innerHTML = opts.content;
else if (opts.content) body.appendChild(opts.content);
box.appendChild(head);
box.appendChild(body);
if (opts.footer) {
const foot = document.createElement('div');
foot.className = 'js-dialog-foot';
opts.footer.forEach(btn => foot.appendChild(btn));
box.appendChild(foot);
}
mask.appendChild(box);
document.body.appendChild(mask);
// closed 幂等 + 在 close 内解绑 ESC:无论走 X / 遮罩 / ESC / 业务按钮,
// 都不会在 document 上残留 keydown 监听(旧版只在按 ESC 时解绑,会累积)
let closed = false;
const close = () => {
if (closed) return;
closed = true;
document.removeEventListener('keydown', esc);
mask.remove();
if (opts.onClose) opts.onClose();
};
function esc(e) {
if (e.key === 'Escape') { e.stopPropagation(); close(); }
}
x.addEventListener('click', close);
// 遮罩关闭:只有当「按下的落点」和「抬起的落点」都在遮罩本身时才算点击遮罩。
// 旧实现只判断 click 的 target(= mousedown / mouseup 目标的最近公共祖先),
// 于是在输入框内按下、把鼠标拖到窗口外(遮罩)再松开时,target 会变成遮罩而被
// 误判为「点击遮罩」,正在拖动选择文本的窗口被直接关闭。
let maskDown = false;
mask.addEventListener('mousedown', e => { maskDown = (e.target === mask); });
mask.addEventListener('mouseup', e => {
const hitMask = maskDown && e.target === mask;
maskDown = false;
if (hitMask && !opts.locked) close();
});
document.addEventListener('keydown', esc);
return { mask, box, body, close };
}
function makeBtn(text, cls, onClick) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'js-btn' + (cls ? ' ' + cls : '');
b.textContent = text;
if (onClick) b.addEventListener('click', onClick);
return b;
}
/* ======================================================================
* 7. 顶栏入口 + 下拉列表(收进站点原生浮动顶栏)
* ==================================================================== */
/** javdb:插进 #navbar-menu-user .navbar-end 最前(原生右侧按钮左侧) */
function mountJavdbEntry() {
const end = document.querySelector('#navbar-menu-user .navbar-end')
|| document.querySelector('.navbar .navbar-end')
|| document.querySelector('.navbar-menu .navbar-end');
if (!end) return null;
let entry = end.querySelector('.js-wheel-entry');
if (!entry) {
entry = document.createElement('a');
entry.className = 'navbar-item js-wheel-entry';
entry.href = 'javascript:;';
entry.innerHTML = '<span class="icon is-small"><i class="fas fa-dharmachakra"></i></span><span>JAV Wheel</span><span class="js-wheel-dot"></span>';
const profile = end.querySelector('a[href="/users/profile"]');
const userItem = profile ? profile.closest('.navbar-item.has-dropdown') : null;
const lsBtn = end.querySelector('.javdb-top-settings-btn');
if (lsBtn) lsBtn.insertAdjacentElement('afterend', entry);
else end.insertBefore(entry, userItem || null);
}
const host = entry.parentElement;
if (host && getComputedStyle(host).position === 'static') host.style.position = 'relative';
return entry;
}
/** javbus:新建同 class 的 navbar-right ul,插到原生右侧 ul 之后(视觉在其左侧) */
function mountJavbusEntry() {
const nav = document.getElementById('navbar') || document.querySelector('.navbar');
if (!nav) return null;
const candidates = Array.from(nav.querySelectorAll('ul.nav.navbar-nav.navbar-right'))
.filter(ul => !ul.classList.contains('js-wheel-nav'));
const rightUl = candidates.find(ul => ul.querySelector('.glyphicon-magnet') || /已有磁力/.test(ul.textContent || ''))
|| candidates[candidates.length - 1];
if (!rightUl) return null;
let ourUl = nav.querySelector('ul.js-wheel-nav');
if (!ourUl) {
ourUl = document.createElement('ul');
ourUl.className = 'nav navbar-nav navbar-right js-wheel-nav';
const li = document.createElement('li');
li.className = 'js-wheel-entry';
const a = document.createElement('a');
a.href = 'javascript:;';
a.innerHTML = '<span>JAV Wheel</span><span class="js-wheel-dot"></span>';
li.appendChild(a);
ourUl.appendChild(li);
rightUl.insertAdjacentElement('afterend', ourUl);
}
const li = ourUl.querySelector('.js-wheel-entry');
if (li && getComputedStyle(li).position === 'static') li.style.position = 'relative';
return li || ourUl;
}
/** javlib:插到 #topmenu .menutext 的账号链接之后 */
function mountJavlibEntry() {
const menu = document.querySelector('#topmenu .menutext') || document.querySelector('.menutext');
if (!menu) return null;
let entry = menu.querySelector('.js-wheel-entry');
if (!entry) {
entry = document.createElement('a');
entry.className = 'js-wheel-entry';
entry.href = 'javascript:;';
entry.textContent = 'JAV Wheel';
const accountLink = menu.querySelector('a[href*="myaccount.php"]');
const sep = document.createTextNode(' | ');
if (accountLink) accountLink.after(sep, entry);
else menu.append(sep, entry);
}
if (getComputedStyle(entry).position === 'static') entry.style.position = 'relative';
return entry;
}
let menuEl = null;
function closeMenu() {
if (menuEl) { menuEl.remove(); menuEl = null; }
document.removeEventListener('click', outsideCloseMenu, true);
document.removeEventListener('mousedown', outsideCloseMenu, true);
}
/**
* 点击菜单外部才关闭。
* 注意:原生 select 展开后的 option 点击在部分内核下 target 会落到
* option/select 之外,用 closest 判断容器可避免「点下拉列表把菜单关掉」。
*/
function outsideCloseMenu(e) {
if (!menuEl) return;
const t = e.target;
if (!t || t.nodeType !== 1) return;
if (t.closest && (t.closest('.js-menu') || t.closest('.js-wheel-entry') || t.closest('select.js-menu-type'))) return;
if (menuEl.contains(t)) return;
closeMenu();
}
function menuItem(icon, text, onClick, sub) {
const item = document.createElement('div');
item.className = 'js-menu-item';
const ic = document.createElement('span');
ic.textContent = icon;
const tx = document.createElement('span');
tx.textContent = text;
item.appendChild(ic);
item.appendChild(tx);
if (sub) {
const s = document.createElement('span');
s.className = 'js-mi-sub';
s.textContent = sub;
item.appendChild(s);
}
item.addEventListener('click', e => { e.stopPropagation(); onClick(item); });
return item;
}
let menuStatEl = null;
// 统计文案惰性缓存:签名 = 各列表长度 + 索引时间戳 + 入库文件数。
// 签名不变时直接复用上次结果并跳过 DOM 写入,因此标记动作后频繁调用的
// updateMenuStat() 不再重复遍历索引(索引可能有上千条)与重复渲染。
let menuStatCache = { sig: '', text: '' };
function fillMenuStat(el) {
if (!el) return;
const evIndex = getEvIndex();
const nbv = getBlockedVideos().length, nbs = getBlockedSeries().length,
nbk = getBlockedKeywords().length, nw = getWatched().length, nv = getVerified().length;
const sig = [nbv, nbs, nbk, nw, nv, evIndex.map ? 1 : 0, evIndex.ts || 0, evIndex.files || 0].join('|');
if (sig !== menuStatCache.sig) {
const hit = evIndex.map ? Object.keys(evIndex.map).length : 0;
menuStatCache = {
sig: sig,
text: `屏蔽 ${nbv} 部 · ${nbs} 番头 · ${nbk} 词`
+ ` | 已下载 ${nw} · 已鉴定 ${nv}`
+ (evIndex.map ? ` | 本地索引 ${hit}` : '')
};
}
if (el.textContent !== menuStatCache.text) el.textContent = menuStatCache.text;
}
function updateMenuStat() { fillMenuStat(menuStatEl); }
function openMenu(anchor) {
if (menuEl) { closeMenu(); return; }
const menu = document.createElement('div');
menu.className = 'js-menu';
menuEl = menu;
// 头部
const head = document.createElement('div');
head.className = 'js-menu-head';
head.innerHTML = '<span>JAV Wheel</span><small>v' + WHEEL_VERSION + '</small>';
menu.appendChild(head);
if (!hasGM) {
const warn = document.createElement('div');
warn.className = 'js-menu-stat';
warn.style.color = '#f59e0b';
warn.textContent = '⚠️ 未检测到 GM 存储,数据仅当前域名可用(跨站不互通)';
menu.appendChild(warn);
}
const stat = document.createElement('div');
stat.className = 'js-menu-stat';
menuStatEl = stat;
fillMenuStat(stat);
menu.appendChild(stat);
menu.appendChild(Object.assign(document.createElement('div'), { className: 'js-menu-sep' }));
// 全部配置项已收进统一「设置」窗口,菜单只保留入口(见下方条目区)
// 屏蔽词直接输入
const inputRow = document.createElement('div');
inputRow.className = 'js-menu-inputrow';
const input = document.createElement('input');
input.className = 'js-menu-input';
input.type = 'text';
input.placeholder = '输入番号 / 关键字,回车添加';
const addBtn = document.createElement('button');
addBtn.type = 'button';
addBtn.className = 'js-menu-add';
addBtn.textContent = '添加';
const topup = document.createElement('div');
topup.style.cssText = 'display:flex;gap:6px;padding:0 10px 8px;';
const typeSel = document.createElement('select');
typeSel.className = 'js-menu-type';
typeSel.title = '选择要加入的屏蔽类型,默认自动识别';
[['auto', '自动识别'], ['video', '单部屏蔽'], ['series', '番头屏蔽'], ['keyword', '关键字屏蔽']].forEach(([v, t]) => {
const o = document.createElement('option');
o.value = v; o.textContent = t;
typeSel.appendChild(o);
});
topup.appendChild(typeSel);
const doAdd = () => {
const raw = input.value.trim();
if (!raw) { toast('请输入内容'); return; }
let type = typeSel.value;
if (type === 'auto') {
// 自动识别:番号 -> 单部屏蔽;纯番头(如 SSIS)-> 番头屏蔽;其它 -> 关键字屏蔽
const plain = raw.replace(/[^A-Za-z0-9]/g, '');
// v1.3.1:番头允许单字母(如 Y、C),可单独添加为番头屏蔽
if (/^[A-Za-z]{1,6}$/.test(plain)) type = 'series';
else if (normalizeCode(raw)) type = 'video';
else type = 'keyword';
}
if (type === 'keyword') {
if (toggleBlockedKeyword(raw, true)) toast(`已添加屏蔽关键字:${raw}`);
} else if (type === 'series') {
const s = extractSeries(normalizeCode(raw)) || raw.toLowerCase();
if (toggleBlockedSeries(s)) toast(`已添加番头屏蔽:${s.toUpperCase()}*`);
} else {
const code = normalizeCode(raw) || raw.toUpperCase();
const cur = getBlockedVideos().slice();
if (!cur.includes(code)) { cur.push(code); commitList(K.blockedVideos, cur); toast(`已添加单部屏蔽:${code}`); }
else toast(`${code} 已在屏蔽列表`);
}
input.value = '';
writeFlag(K.blockSwitch, true);
refreshAll();
openMenuRefresh(anchor);
};
addBtn.addEventListener('click', e => { e.stopPropagation(); doAdd(); });
input.addEventListener('click', e => e.stopPropagation());
// 下拉在展开 / 选择时会派发点击,必须拦截,否则会被菜单的「点击外部关闭」逻辑误关
['click', 'mousedown', 'pointerdown', 'change'].forEach(ev => {
typeSel.addEventListener(ev, e => e.stopPropagation());
});
input.addEventListener('keydown', e => {
e.stopPropagation();
if (e.key === 'Enter') doAdd();
});
inputRow.appendChild(input);
inputRow.appendChild(addBtn);
menu.appendChild(inputRow);
menu.appendChild(topup);
menu.appendChild(Object.assign(document.createElement('div'), { className: 'js-menu-sep' }));
menu.appendChild(menuItem('⚙️', '设置', () => { closeMenu(); openSettingsDialog(); }, '全部配置项'));
menu.appendChild(menuItem('📋', '管理列表', () => { closeMenu(); openManageDialog(); }));
menu.appendChild(menuItem('📥', '导入数据', () => { closeMenu(); openImportDialog(); }));
menu.appendChild(menuItem('📤', '导出数据', () => { closeMenu(); openExportDialog(); }));
menu.appendChild(menuItem('💾', '重建本地索引', () => { closeMenu(); buildEvIndex(false); }));
menu.appendChild(menuItem('🧹', '清空全部数据', () => { closeMenu(); clearAllData(); }));
menu.appendChild(menuItem('🔄', '修复布局', () => { closeMenu(); fixWaterfallLayout(); refreshAll(); toast('已修复布局'); }));
// 定位
const host = anchor.closest('li, .navbar-item, .js-wheel-entry') || anchor;
if (host !== menu && !host.contains(menu)) host.appendChild(menu);
setTimeout(() => {
document.addEventListener('click', outsideCloseMenu, true);
document.addEventListener('mousedown', outsideCloseMenu, true);
}, 0);
setTimeout(() => { try { input.focus(); } catch (e) {} }, 60);
}
function openMenuRefresh(anchor) {
closeMenu();
openMenu(anchor);
}
function clearAllData() {
const dlg = openDialog({
title: '清空全部数据',
width: 460,
locked: true, // 不可逆操作:禁止误点遮罩关闭
content: '<div class="js-hint">即将清空:单部屏蔽、番头屏蔽、屏蔽关键字、已下载、已鉴定、本地片源索引。<br><b style="color:#dc2626;">此操作不可撤销。</b></div>',
footer: [
makeBtn('取消', '', () => dlg.close()),
makeBtn('确认清空', 'danger', () => {
[K.watched, K.verified, K.blockedVideos, K.blockedSeries, K.blockedKeywords, K.evIndex].forEach(kvDel);
memList.clear();
memFlag.clear();
dlg.close();
refreshAll();
toast('已清空全部数据');
})
]
});
}
/* ======================================================================
* 8. 数据管理 / 导入 / 导出
* ==================================================================== */
function buildChipRow(arr, cls, toText, onRemove) {
const wrap = document.createElement('div');
if (!arr.length) {
wrap.innerHTML = '<span class="js-hint">(空)</span>';
return wrap;
}
arr.forEach(item => {
const chip = document.createElement('span');
chip.className = 'js-chip' + (cls ? ' ' + cls : '');
const txt = document.createElement('b');
txt.textContent = toText ? toText(item) : item;
const x = document.createElement('span');
x.className = 'js-chip-x';
x.textContent = '\u00D7';
x.title = '删除';
x.addEventListener('click', () => { onRemove(item); chip.remove(); });
chip.appendChild(txt);
chip.appendChild(x);
wrap.appendChild(chip);
});
return wrap;
}
function openManageDialog() {
const body = document.createElement('div');
const makeSection = (title, hint, arr, cls, onRemove) => {
const h = document.createElement('div');
h.className = 'js-label';
h.textContent = title + '(' + arr.length + ')';
body.appendChild(h);
if (hint) {
const p = document.createElement('div');
p.className = 'js-hint';
p.textContent = hint;
body.appendChild(p);
}
const box = document.createElement('div');
box.className = 'js-card-box';
box.appendChild(buildChipRow(arr, cls, null, item => {
onRemove(item);
refreshAll();
h.textContent = title + '(' + box.querySelectorAll('.js-chip').length + ')';
}));
body.appendChild(box);
};
// 快捷输入
const quickBox = document.createElement('div');
quickBox.className = 'js-card-box';
quickBox.style.marginTop = '0';
quickBox.innerHTML = '<div class="js-hint" style="margin-bottom:6px;">快捷添加(回车提交;单部屏蔽填完整番号,番头屏蔽填前缀如 SSIS,关键字屏蔽填任意词)</div>';
const row = document.createElement('div');
row.className = 'js-row';
const sel = document.createElement('select');
sel.className = 'js-field';
sel.style.width = '130px';
[['video', '单部屏蔽'], ['series', '番头屏蔽'], ['keyword', '关键字屏蔽']].forEach(([v, t]) => {
const o = document.createElement('option');
o.value = v; o.textContent = t;
sel.appendChild(o);
});
const input = document.createElement('input');
input.className = 'js-field';
input.style.flex = '1';
input.placeholder = '输入后回车 / 点击添加';
const addBtn = makeBtn('添加', 'primary');
const doAdd = () => {
const raw = input.value.trim();
if (!raw) return;
if (sel.value === 'keyword') { toggleBlockedKeyword(raw, true); toast('已添加关键字:' + raw); }
else if (sel.value === 'series') { toggleBlockedSeries(extractSeries(normalizeCode(raw)) || raw.toLowerCase()); toast('已添加番头:' + raw.toUpperCase()); }
else {
const code = normalizeCode(raw) || raw.toUpperCase();
const cur = getBlockedVideos().slice();
if (!cur.includes(code)) { cur.push(code); commitList(K.blockedVideos, cur); }
toast('已添加单部:' + code);
}
input.value = '';
writeFlag(K.blockSwitch, true);
refreshAll();
dlg.close();
openManageDialog();
};
addBtn.addEventListener('click', doAdd);
input.addEventListener('keydown', e => { if (e.key === 'Enter') doAdd(); });
row.appendChild(sel); row.appendChild(input); row.appendChild(addBtn);
quickBox.appendChild(row);
body.appendChild(quickBox);
makeSection('单部屏蔽', '', getBlockedVideos().slice(), 'red', code => {
const list = getBlockedVideos().slice().filter(x => x !== code);
commitList(K.blockedVideos, list);
});
makeSection('番头屏蔽', '同番头影片全部隐藏', getBlockedSeries().slice(), 'orange', s => {
const list = getBlockedSeries().slice().filter(x => x !== s);
commitList(K.blockedSeries, list);
});
makeSection('关键字屏蔽', '命中番号或标题即隐藏', getBlockedKeywords().slice(), 'red', kw => {
const list = getBlockedKeywords().slice().filter(x => x !== kw);
commitList(K.blockedKeywords, list);
});
makeSection('已下载', '与已鉴定互斥,已下载优先', getWatched().slice(), 'blue', code => {
setWatched(code, false);
});
makeSection('已鉴定', '与已下载互斥,已下载优先', getVerified().slice(), 'green', code => {
setVerified(code, false);
});
const dlg = openDialog({
title: 'JAV Wheel · 数据管理',
width: 720,
content: body,
footer: [
makeBtn('导出数据', '', () => { dlg.close(); openExportDialog(); }),
makeBtn('导入数据', '', () => { dlg.close(); openImportDialog(); }),
makeBtn('关闭', 'primary', () => dlg.close())
]
});
}
/** 文本批量解析:一行一条,同时兼容逗号 / 空格 / 分号分隔 */
function parseBulkText(text) {
if (!text) return [];
const out = [];
String(text).split(/[\r\n]+/).forEach(line => {
line.split(/[,,;;\t]+/).forEach(part => {
const s = part.trim();
if (s) out.push(s);
});
});
return out;
}
function openImportDialog() {
const body = document.createElement('div');
body.innerHTML = `
<div class="js-label">导入类型</div>
<select class="js-field" id="js-imp-type">
<option value="blocked">屏蔽列表(单部)</option>
<option value="series">番头屏蔽</option>
<option value="keyword">关键字屏蔽</option>
<option value="watched">已下载</option>
<option value="verified">已鉴定</option>
</select>
<div class="js-label">粘贴内容</div>
<textarea class="js-field" id="js-imp-text" placeholder="每行一个;也支持逗号 / 分号分隔"></textarea>
<div class="js-label">或选择本地文件(.txt / .csv)</div>
<input type="file" id="js-imp-file" accept=".txt,.csv,text/plain" class="js-field" style="padding:5px;">
<div class="js-hint" id="js-imp-hint" style="margin-top:8px;">已导入数据会与现有数据合并去重。</div>
`;
const fileInput = body.querySelector('#js-imp-file');
const textarea = body.querySelector('#js-imp-text');
fileInput.addEventListener('change', () => {
const f = fileInput.files && fileInput.files[0];
if (!f) return;
const reader = new FileReader();
reader.onload = () => { textarea.value = String(reader.result || ''); };
reader.readAsText(f, 'utf-8');
});
const dlg = openDialog({
title: '导入数据',
width: 620,
content: body,
footer: [
makeBtn('取消', '', () => dlg.close()),
makeBtn('开始导入', 'primary', () => {
const type = body.querySelector('#js-imp-type').value;
const items = parseBulkText(textarea.value);
if (!items.length) { toast('没有可导入的内容'); return; }
let added = 0;
if (type === 'keyword') {
const list = getBlockedKeywords().slice();
items.forEach(x => { const s = x.toLowerCase(); if (s && !list.includes(s)) { list.push(s); added++; } });
commitList(K.blockedKeywords, list);
} else if (type === 'series') {
const list = getBlockedSeries().slice();
items.forEach(x => { const s = x.toLowerCase().replace(/[^a-z0-9]/g, ''); if (s && !list.includes(s)) { list.push(s); added++; } });
commitList(K.blockedSeries, list);
} else {
const key = type === 'watched' ? K.watched : type === 'verified' ? K.verified : K.blockedVideos;
const list = readList(key).slice();
items.forEach(x => {
const code = normalizeCode(x) || x.toUpperCase();
if (!list.includes(code)) { list.push(code); added++; }
});
// 已下载 / 已鉴定互斥
if (type === 'watched') {
const v = getVerified().slice().filter(x => !list.includes(x));
commitList(K.verified, v);
} else if (type === 'verified') {
const w = getWatched().slice();
const filtered = list.filter(x => !w.includes(x));
commitList(K.verified, filtered);
}
commitList(key, list);
}
writeFlag(K.blockSwitch, true);
refreshAll();
dlg.close();
toast(`导入完成,新增 ${added} 条`);
})
]
});
}
function exportText() {
const lines = [];
lines.push('# JAV Wheel 数据导出 ' + new Date().toLocaleString());
lines.push('');
lines.push('[已下载] ' + getWatched().length);
getWatched().forEach(x => lines.push(x));
lines.push('');
lines.push('[已鉴定] ' + getVerified().length);
getVerified().forEach(x => lines.push(x));
lines.push('');
lines.push('[单部屏蔽] ' + getBlockedVideos().length);
getBlockedVideos().forEach(x => lines.push(x));
lines.push('');
lines.push('[番头屏蔽] ' + getBlockedSeries().length);
getBlockedSeries().forEach(x => lines.push(x));
lines.push('');
lines.push('[关键字屏蔽] ' + getBlockedKeywords().length);
getBlockedKeywords().forEach(x => lines.push(x));
return lines.join('\n');
}
function openExportDialog() {
const body = document.createElement('div');
const ta = document.createElement('textarea');
ta.className = 'js-field';
ta.style.minHeight = '320px';
ta.value = exportText();
body.appendChild(ta);
const dlg = openDialog({
title: '导出数据',
width: 680,
content: body,
footer: [
makeBtn('复制全部', '', () => {
const text = ta.value;
if (typeof GM_setClipboard === 'function') { GM_setClipboard(text, 'text'); toast('已复制到剪贴板'); }
else { ta.select(); document.execCommand('copy'); toast('已复制到剪贴板'); }
}),
makeBtn('下载为文本', 'primary', () => {
const blob = new Blob([ta.value], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'jav-wheel-' + new Date().toISOString().slice(0, 10) + '.txt';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 4000);
}),
makeBtn('关闭', '', () => dlg.close())
]
});
}
/* ======================================================================
* 9. Everything 本地片源匹配
* ==================================================================== */
// v1.3.1:番头允许单字母(如 Y-438、C-2345),Everything 正则同步放宽
const EV_CODE_REGEX = '[A-Za-z]{1,8}[-_ ]?[0-9]{2,5}';
/**
* 传给 Everything 的正则表达式。
* 注意:Everything 的搜索串以「空格」作为 AND 分隔符,正则里一旦含空格(如 [-_ ]),
* 不加引号就会被拆成两个搜索词,导致结果恒为 0 —— 必须整体用英文双引号包住。
*/
const EV_SEARCH_REGEX = '"' + EV_CODE_REGEX + '"';
/** 视频文件扩展名白名单:本地匹配只认视频,排除字幕(.srt/.ass/.sub 等)及其它附属文件 */
const VIDEO_EXT_RE = /\.(mp4|mkv|avi|wmv|flv|ts|m2ts|mts|mov|webm|rmvb|rm|mpg|mpeg|m4v|3gp|ogv|vob|divx|f4v)$/i;
function isVideoFile(name) {
return VIDEO_EXT_RE.test(String(name || ''));
}
/**
* v1.3.1:浏览器可内嵌播放的视频扩展名(Chromium 系)。
* 不在此列的格式(mkv/avi/wmv/ts/rmvb 等)经 Everything HTTP 打开时
* 浏览器无法播放、只会直接触发下载,因此打开前先拦截。
*/
const BROWSER_PLAY_EXT_RE = /\.(mp4|webm|m4v|mov|ogv|3gp)$/i;
function isBrowserPlayable(name) {
return BROWSER_PLAY_EXT_RE.test(String(name || ''));
}
function extOf(path) {
const m = String(path || '').match(/\.([A-Za-z0-9]{2,5})$/);
return m ? m[1].toLowerCase() : '';
}
function getEvUrl() {
return String(kvGet(K.evUrl, DEFAULT_EV_URL) || DEFAULT_EV_URL).replace(/\/+$/, '');
}
function getEvUser() { return String(kvGet(K.evUser, '') || ''); }
function getEvPass() { return String(kvGet(K.evPass, '') || ''); }
const isEvAuth = () => !!getEvUser();
/** UTF-8 安全的 Base64,用于 HTTP Basic 认证头 */
function b64(str) {
try {
const bytes = new TextEncoder().encode(String(str));
let bin = '';
bytes.forEach(b => { bin += String.fromCharCode(b); });
return btoa(bin);
} catch (e) { try { return btoa(str); } catch (e2) { return ''; } }
}
/** Everything HTTP 服务可能开启「用户名 / 密码」认证:填了用户名就统一带凭据 */
function evAuthHeaders() {
if (!isEvAuth()) return {};
const token = b64(getEvUser() + ':' + getEvPass());
return token ? { Authorization: 'Basic ' + token } : {};
}
function getEvFolders() {
const v = kvGet(K.evFolders, []);
if (Array.isArray(v)) return v.filter(Boolean);
if (typeof v === 'string') return v.split(/[\r\n]+/).map(s => s.trim()).filter(Boolean);
return [];
}
function setEvFolders(list) {
kvSet(K.evFolders, list.map(s => String(s).trim()).filter(Boolean));
}
const isEvEnabled = () => readFlag(K.evEnabled, true);
const getEvOpenMode = () => {
const v = String(kvGet(K.evOpenMode, 'http') || 'http');
// v1.1.0 起只有两种方式:http(Everything HTTP 打开)/ copy(仅复制路径)。
// 旧配置残留的 system(javsword:// 打开助手)与 file(file://,https 页面会被
// 浏览器拦截)一律回落为 http,保证升级后行为一致
return v === 'copy' ? 'copy' : 'http';
};
const isEvSyncWatched = () => readFlag(K.evSyncWatched, true);
/** 同一番号的索引入库上限(设置项):非法值回落默认值,并夹在 1 - 500 之间 */
function getMaxPerCode() {
const n = Number(kvGet(K.evMaxPerCode, DEFAULT_MAX_PER_CODE));
if (!isFinite(n) || n < 1) return DEFAULT_MAX_PER_CODE;
return Math.min(Math.floor(n), MAX_PER_CODE_LIMIT);
}
function getEvIndex() {
const v = kvGet(K.evIndex, null);
if (v && typeof v === 'object' && v.map) return v;
if (typeof v === 'string') {
try { const p = JSON.parse(v); if (p && p.map) return p; } catch (e) { /* ignore */ }
}
return { ts: 0, folders: [], map: null };
}
function evRequest(url, timeout) {
const headers = evAuthHeaders();
return new Promise((resolve, reject) => {
if (typeof GM_xmlhttpRequest === 'function') {
GM_xmlhttpRequest({
method: 'GET',
url: url,
headers: headers,
timeout: timeout || 15000,
onload: r => resolve({ status: r.status, text: r.responseText }),
onerror: () => reject(new Error('无法连接 Everything HTTP 服务')),
ontimeout: () => reject(new Error('Everything 请求超时'))
});
} else {
fetch(url, { method: 'GET', mode: 'cors', headers: headers })
.then(r => r.text().then(t => resolve({ status: r.status, text: t })))
.catch(e => reject(e));
}
});
}
/** 会话内已探测可用的 base 地址 */
let evBaseCache = '';
/** 上次全端口探测失败的时间戳(失败后 30s 内不再重复探测,避免页面长时间转圈) */
let evProbeFailAt = 0;
/** v1.3.1:最近一次探测是否因认证失败(401/403)。服务可达但缺凭据时不进冷却,填完凭据立即可重探 */
let evLastAuthFail = false;
function evBaseOf(url) {
return String(url || '').trim().replace(/\/+$/, '');
}
/**
* 探测单个 base 是否是可用的 Everything HTTP 服务。
* 返回 true=可用;'auth'=服务可达但认证未通过(401/403);false=不可达/异常
*/
async function evPing(base, timeout) {
const b = evBaseOf(base);
if (!b) return false;
try {
const res = await evRequest(`${b}/?json=1&count=1`, timeout || 4000);
if (res.status === 401 || res.status === 403) return 'auth';
if (res.status && (res.status < 200 || res.status >= 300)) return false;
JSON.parse(res.text);
return true;
} catch (e) {
return false;
}
}
/**
* 依次探测:用户配置地址 → 本机常见端口候选。
* 命中即缓存并写回配置,避免下次重复探测。
*/
async function probeEvBase() {
const configured = getEvUrl();
const tried = new Set();
const candidates = [configured].concat(EV_PORT_CANDIDATES.map(p => `http://127.0.0.1:${p}`));
let authHit = false;
for (let i = 0; i < candidates.length; i++) {
const b = evBaseOf(candidates[i]);
if (!b || tried.has(b)) continue;
tried.add(b);
const r = await evPing(b);
if (r === 'auth') { authHit = true; continue; }
if (r === true) {
evBaseCache = b;
evProbeFailAt = 0;
evLastAuthFail = false;
if (b !== configured) kvSet(K.evUrl, b);
return b;
}
}
// 全部失败:认证失败(服务可达但缺凭据)不进入冷却,填完凭据可立即重探;
// 真正不可达才按原策略冷却 30s
evLastAuthFail = authHit;
evProbeFailAt = authHit ? 0 : Date.now();
return '';
}
/** 取当前可用的 Everything 地址;未命中返回空串(失败后 30s 内不重复探测) */
async function resolveEvBase(force) {
if (evBaseCache && !force) return evBaseCache;
if (!force && evProbeFailAt && Date.now() - evProbeFailAt < 30000) return '';
return await probeEvBase();
}
/** 供设置面板「测试连接」使用,返回可读结果 */
async function testEvConnection() {
const configured = getEvUrl();
const authTip = isEvAuth() ? '(已使用 Basic 认证)' : '';
if (await evPing(configured)) {
evBaseCache = configured;
return { ok: true, base: configured, msg: `连接成功:${configured}${authTip}` };
}
// 区分「服务未开启」与「账号密码不正确」
try {
const probe = await evRequest(`${configured}/?json=1&count=1`, 4000);
if (probe.status === 401 || probe.status === 403) {
return {
ok: false,
base: '',
msg: `Everything HTTP 服务已响应,但认证未通过(HTTP ${probe.status})。`
+ '请核对「用户名 / 密码」是否与 Everything「工具 → 选项 → HTTP 服务器」中的设置一致。'
};
}
} catch (e) { /* ignore */ }
const found = await probeEvBase();
if (found) return { ok: true, base: found, msg: `连接成功:${found}(已自动写入配置)${authTip}` };
if (evLastAuthFail) {
return {
ok: false,
base: '',
msg: 'Everything HTTP 服务已响应,但认证未通过(401/403)。'
+ '请核对「用户名 / 密码」是否与 Everything「工具 → 选项 → HTTP 服务器」中的设置一致。'
};
}
return {
ok: false,
base: '',
msg: `无法连接 Everything HTTP 服务(配置:${configured},已尝试端口:${EV_PORT_CANDIDATES.join('/')})。`
+ '请确认 Everything 正在运行,且已勾选「工具 → 选项 → HTTP 服务器 → 启用 HTTP 服务器」。'
};
}
/** 单文件夹抓取:用 Everything 正则只取含番号的文件,避免全量传输 */
async function evFetchFolder(folder) {
const base = evBaseCache || await resolveEvBase();
if (!base) {
// v1.3.1:区分「服务未开启」与「认证未通过」
if (evLastAuthFail) {
throw new Error('Everything HTTP 服务要求认证,请核对「设置 → 本地片源匹配」中的用户名/密码(401/403)');
}
throw new Error(`无法连接 Everything HTTP 服务(${getEvUrl()} 及端口 ${EV_PORT_CANDIDATES.join('/')} 均无响应)`);
}
const norm = String(folder).trim().replace(/[\\\/]+$/, '') + '\\';
const search = `path:"${norm}" regex:${EV_SEARCH_REGEX}`;
const pageSize = 8000;
const out = [];
let offset = 0;
// 注意:URL 里不能再带 c=1 —— 它会覆盖 count 参数,导致每页只返回 1 条
for (let page = 0; page < 20; page++) {
const url = `${base}/?search=${encodeURIComponent(search)}&json=1&count=${pageSize}&offset=${offset}`
+ `&path_column=1&size_column=1&date_modified_column=1`;
const res = await evRequest(url);
if (res.status && (res.status < 200 || res.status >= 300)) throw new Error('Everything 返回状态码 ' + res.status);
let data;
try { data = JSON.parse(res.text); } catch (e) { throw new Error('Everything 返回内容不是合法 JSON,请确认 HTTP 服务已开启'); }
const results = data.results || [];
results.forEach(r => {
const name = r.name || '';
const dir = r.path || '';
if (!name) return;
out.push({
name: name,
dir: dir,
full: (dir.endsWith('\\') || dir.endsWith('/')) ? dir + name : dir + '\\' + name,
size: Number(r.size) || 0,
mtime: r.date_modified || ''
});
});
const total = Number(data.totalResults) || 0;
offset += results.length;
if (!results.length || offset >= total || results.length < pageSize) break;
}
return out;
}
function evExtractCode(name) {
let s = String(name).replace(/\.[A-Za-z0-9]{2,5}$/, '');
s = s.replace(/[\[\]【】()()]/g, ' ');
return extractCode(s);
}
let evBuilding = false;
async function buildEvIndex(silent) {
if (evBuilding) return;
if (!isEvEnabled()) { if (!silent) toast('本地片源匹配未启用'); return; }
const folders = getEvFolders();
if (!folders.length) {
if (!silent) { toast('请先在「设置」中添加本地片源文件夹路径'); openSettingsDialog(); }
return;
}
evBuilding = true;
if (!silent) toast('正在建立本地索引,请稍候…');
try {
const map = {};
const maxPerCode = getMaxPerCode(); // 设置项:同一番号最多入库的文件数
let fileCount = 0;
for (let i = 0; i < folders.length; i++) {
const folder = folders[i];
let files = [];
try {
files = await evFetchFolder(folder);
} catch (e) {
if (!silent) toast(`「${folder}」抓取失败:${e.message}`);
continue;
}
if (!files.length && !silent) {
toast(`「${folder}」在 Everything 中查不到含番号的文件,请确认该路径真实存在且已被 Everything 索引`);
}
files.forEach(f => {
// 只匹配视频文件,排除字幕(.srt/.ass/.sub 等)与图片等附属文件
if (!isVideoFile(f.name)) return;
const code = evExtractCode(f.name);
if (!code) return;
const key = matchKey(code);
if (!key) return;
if (!map[key]) map[key] = [];
// 同一番号最多保留 maxPerCode(设置项,默认 30)个文件;超出的不入
// 索引、也不计入文件数,否则「索引状态」显示的文件数会高于入库数量
if (map[key].length >= maxPerCode) return;
map[key].push({ n: f.name, p: f.full, s: f.size, m: f.mtime, c: code });
fileCount++;
});
}
const index = { ts: Date.now(), folders: folders, map: map, files: fileCount };
kvSet(K.evIndex, index);
const synced = syncLocalToWatched(map);
refreshAll();
const hit = Object.keys(map).length;
if (!silent) {
if (!fileCount) {
toast('本地索引完成:未匹配到任何文件,请检查文件夹路径是否为 Everything 已索引的真实目录');
} else {
toast(`本地索引完成:${fileCount} 个文件 / ${hit} 个番号${synced ? ',新增已下载 ' + synced + ' 部' : ''}`);
}
}
} catch (e) {
if (!silent) toast('建立本地索引失败:' + (e.message || e));
} finally {
evBuilding = false;
}
}
let evIndexTimer = null;
function scheduleEvIndex() {
clearTimeout(evIndexTimer);
const idx = getEvIndex();
const stale = !idx.ts || (Date.now() - idx.ts > 6 * 3600 * 1000) || String(idx.folders) !== String(getEvFolders());
if (!stale) return;
evIndexTimer = setTimeout(() => { buildEvIndex(true); }, 4000);
}
/** 索引命中的番号批量并入「已下载」(只增不删,避免误清用户手动标记) */
function syncLocalToWatched(map) {
if (!isEvSyncWatched() || !map) return 0;
const cur = getWatched().slice();
const set = new Set(cur);
let added = 0;
Object.keys(map).forEach(k => {
const arr = map[k];
const code = (arr && arr[0] && arr[0].c) || '';
if (code && !set.has(code)) { set.add(code); cur.push(code); added++; }
});
if (!added) return 0;
commitList(K.watched, cur);
commitList(K.verified, getVerified().filter(x => !set.has(x)));
return added;
}
function findLocalFiles(code) {
const idx = getEvIndex();
if (!idx.map || !code) return [];
return idx.map[matchKey(code)] || [];
}
/**
* 打开本地文件。
* mode = 'copy' 复制路径;其余情况按设置打开(当前仅 Everything HTTP)。
* v1.1.0 起不再支持 file:// 与自定义协议:https 页面会被浏览器拦截,
* 自定义协议依赖已移除的本地助手;旧配置的 system / file 由
* getEvOpenMode() 统一回落为 http。
*/
function openLocalFile(path, mode) {
const p = String(path).replace(/\\/g, '/');
const m = mode === 'copy' ? 'copy' : getEvOpenMode();
try {
if (m === 'copy') {
if (typeof GM_setClipboard === 'function') GM_setClipboard(String(path), 'text');
else { const ta = document.createElement('textarea'); ta.value = String(path); document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); }
toast('路径已复制:' + path);
return;
}
// Everything HTTP 打开:向本机 Everything HTTP 服务器请求该文件。
// 优先使用已探测通过的地址(evBaseCache),避免用户改了配置但服务
// 实际在其它端口时打开失败
let base = evBaseCache || getEvUrl();
if (isEvAuth()) {
base = base.replace('://', '://' + encodeURIComponent(getEvUser()) + ':' + encodeURIComponent(getEvPass()) + '@');
}
// v1.3.1:浏览器无法内嵌播放的格式(mkv/avi/wmv/ts/rmvb 等)经 HTTP 打开
// 只会触发浏览器下载,直接改为复制路径,避免误下载
if (!isBrowserPlayable(p)) {
const ext = extOf(p);
if (typeof GM_setClipboard === 'function') GM_setClipboard(String(path), 'text');
else { const ta = document.createElement('textarea'); ta.value = String(path); document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); }
toast('浏览器无法直接播放' + (ext ? ' .' + ext : '该格式') + ',已复制路径');
return;
}
// UNC 路径转正斜杠后以 // 开头,拼成 base + '/' + '//host/...' 恰为三斜杠,
// Everything 才认;单斜杠 / 双斜杠都会 404
const url = base + '/' + encodeURI(p).replace(/[?#]/g, c => c === '?' ? '%3F' : '%23');
if (typeof GM_openInTab === 'function') GM_openInTab(url, { active: true });
else window.open(url, '_blank');
toast('已向 Everything 发起 HTTP 打开:' + p);
} catch (e) {
toast('打开失败:' + (e.message || e));
}
}
function openLocalFilesDialog(code, files, sourceText) {
const body = document.createElement('div');
const head = document.createElement('div');
head.className = 'js-hint';
head.style.marginBottom = '8px';
head.textContent = `番号 ${code} 命中 ${files.length} 个本地文件`
+ (sourceText ? `(来源:${sourceText})` : '')
+ ';打开方式见「设置 → 本地片源匹配」';
body.appendChild(head);
const list = document.createElement('div');
list.className = 'js-card-box';
files.forEach(f => {
const row = document.createElement('div');
row.className = 'js-kv';
const dir = String(f.p || '').replace(/[\\/][^\\/]*$/, '');
const name = document.createElement('div');
name.className = 'js-kv-name';
name.innerHTML = `<b>${escapeHtml(f.n)}</b> <span class="js-hint">${escapeHtml(f.c || '')}${f.s ? ' · ' + bytesText(f.s) : ''}</span>`
+ (dir ? `<div class="js-hint" style="overflow:hidden;text-overflow:ellipsis;">${escapeHtml(dir)}</div>` : '');
name.title = f.p;
row.appendChild(name);
row.appendChild(makeBtn('打开', 'sm ok', () => { dlg.close(); openLocalFile(f.p); }));
row.appendChild(makeBtn('复制路径', 'sm', () => { dlg.close(); openLocalFile(f.p, 'copy'); }));
list.appendChild(row);
});
body.appendChild(list);
const dlg = openDialog({
title: 'JAV Wheel · 本地片源',
width: 760,
content: body,
footer: [makeBtn('关闭', 'primary', () => dlg.close())]
});
}
/** 番号 → Everything 搜索用正则片段(整体加英文双引号,避免空格被当作 AND 分隔符) */
function evCodeRegexTerm(code) {
const raw = String(code == null ? '' : code).trim();
if (!raw) return '';
return 'regex:"' + escapeRegExp(raw).replace(/-/g, '[-_ ]?') + '"';
}
/**
* 文件夹范围限定:<path:"D:\A" | path:"D:\B">;未配置文件夹时返回空串(全局搜索)。
* 注意:必须用尖括号 < > 分组 —— Everything 1.4 不支持圆括号分组,
* 写成 ( ... | ... ) 会被当作字面字符,导致整条查询命中数为 0
*(表现为单击标记永远提示「实时查询未命中」,而单文件夹的索引抓取却正常)。
*/
function evScopeTerm() {
const folders = getEvFolders();
if (!folders.length) return '';
const parts = folders.map(f => 'path:"' + String(f).trim().replace(/[\\/]+$/, '') + '"');
return parts.length > 1 ? '<' + parts.join(' | ') + '>' : parts[0];
}
/**
* 实时查询本地文件(v1.1.0 核心):单击「本地 N」标记时调用。
* 直接向 Everything HTTP 搜索接口现查一次,结果是磁盘当前状态,
* 因此文件新增 / 移动 / 删除后无需重建索引即可反映。
*/
async function queryLocalFilesLive(code) {
const key = matchKey(code);
if (!key) throw new Error('无法解析番号:' + code);
// 用户主动点击:强制重探地址,跳过失败冷却
const base = await resolveEvBase(true);
if (!base) {
// v1.3.1:区分「服务未开启」与「认证未通过」,给出针对性提示
throw new Error(evLastAuthFail
? 'Everything HTTP 服务要求认证,请核对「设置 → 本地片源匹配」中的用户名/密码(401/403)'
: '无法连接 Everything HTTP 服务,请确认 Everything 已运行且已开启 HTTP 服务器');
}
const search = [evScopeTerm(), evCodeRegexTerm(code)].filter(Boolean).join(' ');
const url = base + '/?search=' + encodeURIComponent(search)
+ '&json=1&count=500&path_column=1&size_column=1&date_modified_column=1';
const res = await evRequest(url, 8000);
if (res.status && (res.status < 200 || res.status >= 300)) throw new Error('Everything 返回状态码 ' + res.status);
let data;
try { data = JSON.parse(res.text); } catch (e) { throw new Error('Everything 返回内容不是合法 JSON'); }
const out = [];
(data.results || []).forEach(r => {
const name = r.name || '';
const dir = r.path || '';
if (!name) return;
// 只匹配视频文件,排除字幕(.srt/.ass/.sub 等)与图片等附属文件
if (!isVideoFile(name)) return;
// 正则可能顺带命中 0010 这类近似番号,按标准化番号再做一次精确过滤
if (matchKey(evExtractCode(name)) !== key) return;
out.push({
n: name,
p: (dir.endsWith('\\') || dir.endsWith('/')) ? dir + name : dir + '\\' + name,
s: Number(r.size) || 0,
m: r.date_modified || '',
c: code
});
});
out.sort((a, b) => String(a.n).localeCompare(String(b.n), 'zh-Hans-CN'));
return out;
}
/** 实时查询命中后回写索引,让卡片上的「本地 N」计数随渲染保持接近实时 */
function cacheLiveResult(code, files) {
const idx = getEvIndex();
if (!idx.map || !files || !files.length) return;
const key = matchKey(code);
if (!key) return;
idx.map[key] = files.slice(0, getMaxPerCode());
// 同步重算入库文件总数,保证「索引状态」与真实索引一致;该计数同时参与
// 顶栏统计的惰性缓存签名,变化时会自动触发统计重算
idx.files = Object.keys(idx.map).reduce((n, k) => n + (idx.map[k] ? idx.map[k].length : 0), 0);
kvSet(K.evIndex, idx);
refreshAll();
}
/** 命中文件的打开策略:单个直接打开,多个弹选择列表 */
function handleLocalHit(code, files, sourceText) {
if (!files || !files.length) return false;
if (files.length === 1) openLocalFile(files[0].p);
else openLocalFilesDialog(code, files, sourceText);
return true;
}
// 按番号去重:同一番号查询进行中时忽略重复点击,不同番号可以并行查询
//(旧版用单个全局布尔值,连点两个不同番号时第二次点击会被静默丢弃)
const evQueryingCodes = new Set();
const evQueryKey = code => matchKey(code) || String(code || '').toUpperCase();
/** 单击「本地 N」标记:先向 Everything 实时查询,再打开;失败回落缓存索引 */
function onLocalBadgeClick(e, code) {
e.preventDefault();
e.stopPropagation();
const qk = evQueryKey(code);
if (evQueryingCodes.has(qk)) { toast('「' + code + '」正在查询中,请稍候…'); return; }
evQueryingCodes.add(qk);
const cached = findLocalFiles(code);
toast('正在向 Everything 实时查询「' + code + '」…');
queryLocalFilesLive(code).then(files => {
evQueryingCodes.delete(qk);
if (!files.length) {
toast(cached.length
? `Everything 实时查询未命中 ${code}(缓存索引有 ${cached.length} 个,文件可能已移动或删除,可在设置内重建索引)`
: `Everything 实时查询未命中:${code}`, 3200);
return;
}
cacheLiveResult(code, files);
handleLocalHit(code, files, '实时查询');
}).catch(err => {
evQueryingCodes.delete(qk);
if (cached.length) {
toast('实时查询失败(' + (err.message || err) + '),已回退缓存索引', 3200);
handleLocalHit(code, cached, '缓存索引');
return;
}
toast('Everything 实时查询失败:' + (err.message || err), 4200);
});
}
/** 在卡片标题前插入「本地」标记(计数取自索引;单击时改为实时查询) */
function applyLocalBadge(card, code) {
const titleEl = getCardTitleEl(card);
if (!titleEl) return;
const host = titleEl.closest('.video-title, .javdb-card-headline, .javbus-card-headline, .javlib-card-headline') || titleEl;
const existed = host.querySelector(':scope > .js-local-badge');
if (!isEvEnabled() || !getEvIndex().map) {
if (existed) existed.remove();
return;
}
const files = findLocalFiles(code);
if (!files.length) {
if (existed) existed.remove();
return;
}
if (existed) {
// 计数变化时必须连可见文本一起刷新:旧版只改 dataset.count,
// 导致实时查询回写索引后「本地 N」仍停留在旧数字
if (existed.dataset.count !== String(files.length)) {
existed.dataset.count = String(files.length);
existed.textContent = files.length > 1 ? `本地 ${files.length}` : '本地';
}
existed.title = '单击:向 Everything 实时查询并打开本地文件\n'
+ files.map(f => f.n).slice(0, 8).join('\n')
+ (files.length > 8 ? `\n…索引缓存共 ${files.length} 个` : '');
return;
}
const badge = document.createElement('span');
badge.className = 'js-local-badge';
badge.dataset.count = String(files.length);
badge.textContent = files.length > 1 ? `本地 ${files.length}` : '本地';
badge.title = '单击:向 Everything 实时查询并打开本地文件\n'
+ files.map(f => f.n).slice(0, 8).join('\n')
+ (files.length > 8 ? `\n…索引缓存共 ${files.length} 个` : '');
badge.addEventListener('click', e => onLocalBadgeClick(e, code));
host.insertBefore(badge, host.firstChild);
}
/* ======================================================================
* 10. 统一设置面板(全部配置项集中于此,入口位于列表菜单内)
* ==================================================================== */
function switchLine(id, text, checked, hint) {
return `<label class="js-setrow"><input type="checkbox" id="${id}"${checked ? ' checked' : ''}>`
+ `<span>${text}</span></label>`
+ (hint ? `<div class="js-hint js-setrow-hint">${hint}</div>` : '');
}
function openSettingsDialog() {
const body = document.createElement('div');
const idx = getEvIndex();
body.innerHTML = `
<div class="js-label" style="margin-top:0;">过滤与标记</div>
<div class="js-card-box">
${switchLine('js-set-block', '屏蔽总开关', isBlockSwitch(), '关闭后屏蔽按钮失效,卡片不再被过滤')}
${switchLine('js-set-fwatched', '隐藏已下载', isFilterWatched())}
${switchLine('js-set-fverified', '隐藏已鉴定', isFilterVerified())}
${switchLine('js-set-autoverify', '打开详情页自动标记「已鉴定」', isAutoVerified(), '静默执行,不弹提示')}
</div>
<div class="js-label">界面</div>
<div class="js-card-box">
${switchLine('js-set-scrollbtns', '右下角导航按钮', isScrollButtons(), '页面右下角显示「回到顶部 / 回到底部」按钮(源自 JAV Blade)')}
</div>
<div class="js-label">本地片源匹配(Everything)</div>
<div class="js-card-box">
${switchLine('js-set-ev', '启用本地片源匹配', isEvEnabled())}
<div class="js-label" style="margin-top:10px;">Everything HTTP 服务地址</div>
<div class="js-row" style="flex-wrap:nowrap;">
<input class="js-field" id="js-ev-url" style="flex:1;min-width:0;" placeholder="http://127.0.0.1:8080" value="${escapeHtml(getEvUrl())}">
<button type="button" class="js-btn sm" id="js-ev-detect" style="flex:none;">自动检测</button>
<button type="button" class="js-btn sm ok" id="js-ev-test" style="flex:none;">测试连接</button>
</div>
<div class="js-hint" id="js-ev-testmsg">需在 Everything 中开启:工具 → 选项 → HTTP 服务器 → 启用 HTTP 服务器。</div>
<div class="js-label">HTTP 认证(用户名 / 密码)</div>
<div class="js-row" style="flex-wrap:nowrap;">
<input class="js-field" id="js-ev-user" style="flex:1;min-width:0;" placeholder="用户名(留空 = 不使用认证)" value="${escapeHtml(getEvUser())}">
<input class="js-field" id="js-ev-pass" type="password" style="flex:1;min-width:0;" placeholder="密码" value="${escapeHtml(getEvPass())}">
</div>
<div class="js-hint">与 Everything「工具 → 选项 → HTTP 服务器 → 用户名 / 密码」保持一致;填写后测试连接、建立索引与「HTTP 打开」都会自动带 Basic 认证。</div>
<div class="js-label">只匹配以下文件夹(每行一个绝对路径)</div>
<textarea class="js-field" id="js-ev-folders" placeholder="D:\\Video\\AV E:\\Media\\Movies" style="min-height:80px;"></textarea>
<div class="js-hint">留空表示不限制;填写后仅在这些路径内匹配,可显著提升索引速度。</div>
<div class="js-hint">索引只用于在卡片标题前显示绿色「本地 N」标记;单击该标记会重新向 Everything 实时查询(命中多个文件时弹出选择列表),因此索引过期也不影响打开结果。</div>
<div class="js-label">同一番号最多入库文件数</div>
<div class="js-row" style="flex-wrap:nowrap;">
<input class="js-field" id="js-ev-maxpercode" type="number" min="1" max="500" step="1" style="flex:none;width:110px;" value="${getMaxPerCode()}">
<span class="js-hint" style="margin:0;">默认 30,可填 1 - 500</span>
</div>
<div class="js-hint">同一番号文件较多时(多版本 / 多字幕 / 分片),只把前 N 个纳入索引与卡片上的「本地 N」计数;修改后需点「立即重建索引」生效。单击标记走的是实时查询,不受该上限影响。</div>
<div class="js-label">打开本地文件的方式</div>
<select class="js-field" id="js-ev-mode">
<option value="http">Everything HTTP 打开(推荐)</option>
<option value="copy">仅复制文件路径</option>
</select>
<div class="js-hint">「Everything HTTP 打开」:单击卡片上的「本地 N」标记时,先向本机 Everything 的 HTTP 服务器按番号实时查询一次,拿到实时路径后打开该文件的 HTTP 地址(mp4 等在浏览器内播放);不使用任何自定义协议,也不需要安装外部助手。需在 Everything「工具 → 选项 → HTTP 服务器」中开启服务。</div>
${switchLine('js-set-evsync', '索引命中的番号自动并入「已下载」', isEvSyncWatched(), '只增不删,可被「隐藏已下载」过滤')}
<div class="js-label">索引状态(仅供卡片标记)</div>
<div class="js-card-box" id="js-ev-status"></div>
</div>
<div class="js-label">数据</div>
<div class="js-card-box" id="js-set-data"></div>
`;
body.querySelector('#js-ev-folders').value = getEvFolders().join('\n');
body.querySelector('#js-ev-mode').value = getEvOpenMode();
body.querySelector('#js-set-evsync').checked = isEvSyncWatched();
const statusBox = body.querySelector('#js-ev-status');
const renderStatus = () => {
const cur = getEvIndex();
const keys = cur.map ? Object.keys(cur.map).length : 0;
statusBox.innerHTML = cur.ts
? `<div>已索引番号:<b>${keys}</b> 个 · 文件:<b>${cur.files || 0}</b> 个</div>
<div class="js-hint">更新时间:${new Date(cur.ts).toLocaleString()} · 文件夹:${escapeHtml((cur.folders || []).join(' ; ') || '(未限制)')}</div>`
: '<span class="js-hint">尚未建立索引(不影响「本地 N」标记的实时查询)</span>';
};
renderStatus();
const collect = () => {
kvSet(K.evUrl, body.querySelector('#js-ev-url').value.trim() || DEFAULT_EV_URL);
kvSet(K.evUser, body.querySelector('#js-ev-user').value.trim());
kvSet(K.evPass, body.querySelector('#js-ev-pass').value);
setEvFolders(parseBulkLines(body.querySelector('#js-ev-folders').value));
kvSet(K.evOpenMode, body.querySelector('#js-ev-mode').value);
const mp = parseInt(body.querySelector('#js-ev-maxpercode').value, 10);
kvSet(K.evMaxPerCode, isFinite(mp) ? Math.min(Math.max(mp, 1), MAX_PER_CODE_LIMIT) : DEFAULT_MAX_PER_CODE);
writeFlag(K.evSyncWatched, body.querySelector('#js-set-evsync').checked);
writeFlag(K.evEnabled, body.querySelector('#js-set-ev').checked);
writeFlag(K.blockSwitch, body.querySelector('#js-set-block').checked);
writeFlag(K.filterWatched, body.querySelector('#js-set-fwatched').checked);
writeFlag(K.filterVerified, body.querySelector('#js-set-fverified').checked);
writeFlag(K.autoVerified, body.querySelector('#js-set-autoverify').checked);
writeFlag(K.scrollButtons, body.querySelector('#js-set-scrollbtns').checked);
};
// 测试连接 / 自动检测端口
const testMsg = body.querySelector('#js-ev-testmsg');
const collectAuth = () => {
kvSet(K.evUser, body.querySelector('#js-ev-user').value.trim());
kvSet(K.evPass, body.querySelector('#js-ev-pass').value);
};
body.querySelector('#js-ev-test').addEventListener('click', async () => {
const input = body.querySelector('#js-ev-url');
kvSet(K.evUrl, input.value.trim() || DEFAULT_EV_URL);
collectAuth();
testMsg.style.color = '#64748b';
testMsg.textContent = '正在测试连接…';
const r = await testEvConnection();
testMsg.style.color = r.ok ? '#0f766e' : '#dc2626';
testMsg.textContent = r.msg;
if (r.ok) input.value = r.base;
});
body.querySelector('#js-ev-detect').addEventListener('click', async () => {
const input = body.querySelector('#js-ev-url');
collectAuth();
testMsg.style.color = '#64748b';
testMsg.textContent = `正在探测本机常见端口:${EV_PORT_CANDIDATES.join(' / ')} …`;
const found = await probeEvBase();
if (found) {
input.value = found;
testMsg.style.color = '#0f766e';
testMsg.textContent = `已找到 Everything HTTP 服务:${found}(已写入配置)${isEvAuth() ? '(已使用 Basic 认证)' : ''}`;
} else {
testMsg.style.color = '#dc2626';
testMsg.textContent = '未探测到 Everything HTTP 服务,请确认 Everything 已运行,并在「工具 → 选项 → HTTP 服务器」中启用。';
}
});
const refreshBtn = makeBtn('立即重建索引', 'ok', async () => {
collect();
refreshBtn.disabled = true;
refreshBtn.textContent = '正在建立…';
await buildEvIndex(false);
refreshBtn.disabled = false;
refreshBtn.textContent = '立即重建索引';
renderStatus();
renderData();
refreshAll();
});
const clearBtn = makeBtn('清空索引', '', () => {
kvDel(K.evIndex);
renderStatus();
renderData();
refreshAll();
toast('已清空本地索引');
});
const row = document.createElement('div');
row.className = 'js-row';
row.style.marginTop = '10px';
row.appendChild(refreshBtn);
row.appendChild(clearBtn);
statusBox.appendChild(row);
const dataBox = body.querySelector('#js-set-data');
const renderData = () => {
dataBox.innerHTML = `<div>已下载 <b>${getWatched().length}</b> · 已鉴定 <b>${getVerified().length}</b> · `
+ `单部屏蔽 <b>${getBlockedVideos().length}</b> · 番头 <b>${getBlockedSeries().length}</b> · 关键字 <b>${getBlockedKeywords().length}</b></div>`;
const r = document.createElement('div');
r.className = 'js-row';
r.style.marginTop = '10px';
r.appendChild(makeBtn('管理列表', '', () => { dlg.close(); openManageDialog(); }));
r.appendChild(makeBtn('导入数据', '', () => { dlg.close(); openImportDialog(); }));
r.appendChild(makeBtn('导出数据', '', () => { dlg.close(); openExportDialog(); }));
r.appendChild(makeBtn('清空全部数据', 'danger', () => { dlg.close(); clearAllData(); }));
dataBox.appendChild(r);
};
const dlg = openDialog({
title: 'JAV Wheel · 设置',
width: 720,
content: body,
footer: [
makeBtn('取消', '', () => dlg.close()),
makeBtn('保存', 'primary', () => {
const prevFolders = String(getEvIndex().folders || []);
const wasEvEnabled = isEvEnabled();
collect();
const foldersChanged = String(getEvFolders()) !== prevFolders;
dlg.close();
refreshAll();
applyScrollButtons();
toast('设置已保存');
if (foldersChanged || (!wasEvEnabled && isEvEnabled())) buildEvIndex(false);
})
]
});
renderData();
}
function parseBulkLines(text) {
return String(text || '').split(/[\r\n]+/).map(s => s.trim()).filter(Boolean);
}
/* ======================================================================
* 11. 详情页自动标记「已鉴定」
* ==================================================================== */
function getDetailCode() {
const site = siteId();
if (site === 'javdb') {
const btn = document.querySelector('a.button.is-white.copy-to-clipboard');
let code = extractCode(btn && btn.dataset ? btn.dataset.clipboardText || '' : '');
if (code) return code;
const t = document.querySelector('h2.title, .javdb-api-detail-title');
if (t) { code = extractCode(t.textContent); if (code) return code; }
const m = location.pathname.match(/\/v\/([A-Za-z0-9_-]+)/);
if (m) return normalizeCode(m[1]);
return '';
}
if (site === 'javbus') {
const kw = document.querySelector('meta[name="keywords"]');
if (kw && kw.content) {
const code = extractCode(kw.content.split(',')[0] || '');
if (code) return code;
}
const m = location.pathname.match(/\/([A-Za-z]{1,10}[-_]?\d{2,6})/);
if (m) return normalizeCode(m[1]);
const h3 = document.querySelector('#wrapper h3, .container h3');
if (h3) return extractCode(h3.textContent);
return '';
}
if (site === 'javlib') {
const el = document.querySelector('#video_id .text');
if (el) { const code = extractCode(el.textContent); if (code) return code; }
const m = document.title.match(/([A-Z0-9]{1,10}-\d{2,6})/i);
if (m) return normalizeCode(m[1]);
return '';
}
return '';
}
function isDetailPage() {
const site = siteId();
if (site === 'javdb') return /^\/v\//.test(location.pathname);
if (site === 'javbus') {
return /^\/[A-Za-z]{1,10}[-_]?\d{2,6}(?:[-_][a-z]+)?\/?$/i.test(location.pathname)
|| !!document.querySelector('#magnet-search, .movie-magnet');
}
if (site === 'javlib') return !!document.querySelector('#video_id .text');
return false;
}
function autoVerifyDetailPage() {
if (!isAutoVerified()) return;
if (!isDetailPage()) return;
const code = getDetailCode();
if (!code) return;
// 静默标记:不弹提示;标记成功后再刷新卡片按钮 / 状态徽标
if (autoMarkVerified(code)) {
updateMenuStat();
refreshAll();
emitStateChange(code);
}
}
/* ======================================================================
* 11.1 详情页主图装饰(与卡片一致的状态按钮组 / 状态徽标 / 本地徽标)
* 仅操作站点详情页主图封面容器,避免误伤页面内其它图片(截图列表 / 头像等)
* ==================================================================== */
function getMainCover() {
const site = siteId();
// 站点 DOM 结构实测(老司机脚本布局改造后):
// javdb 主图列 = div.column.column-video-cover(旧版曾为 .movie-preview,兜底保留)
// javbus 主图列 = div.col-md-9.screencap > a.bigImage > img(旧版曾为 #video_jacket,兜底保留)
// javlib 仍为 #video_jacket
if (site === 'javdb') {
return document.querySelector('.column.column-video-cover')
|| document.querySelector('.column.movie-preview, .movie-preview');
}
if (site === 'javbus') {
return document.querySelector('.screencap')
|| document.querySelector('#video_jacket');
}
if (site === 'javlib') return document.querySelector('#video_jacket');
return null;
}
/** 主图本地徽标:样式与卡片 .js-local-badge 一致,位置改为主图右上角(主图无标题行) */
function applyMainLocalBadge(cover, code) {
let badge = cover.querySelector(':scope > .js-local-badge');
if (!isEvEnabled() || !getEvIndex().map) {
if (badge) badge.remove();
return;
}
const files = findLocalFiles(code);
if (!files.length) {
if (badge) badge.remove();
return;
}
if (badge) {
if (badge.dataset.count !== String(files.length)) {
badge.dataset.count = String(files.length);
badge.textContent = files.length > 1 ? `本地 ${files.length}` : '本地';
}
badge.title = '单击:向 Everything 实时查询并打开本地文件\n'
+ files.map(f => f.n).slice(0, 8).join('\n')
+ (files.length > 8 ? `\n…索引缓存共 ${files.length} 个` : '');
return;
}
badge = document.createElement('span');
badge.className = 'js-local-badge';
badge.dataset.count = String(files.length);
badge.textContent = files.length > 1 ? `本地 ${files.length}` : '本地';
badge.title = '单击:向 Everything 实时查询并打开本地文件\n'
+ files.map(f => f.n).slice(0, 8).join('\n')
+ (files.length > 8 ? `\n…索引缓存共 ${files.length} 个` : '');
// 主图没有标题行:徽标改为封面右上角绝对定位,其余样式 / 点击行为与卡片一致
badge.style.cssText = 'position:absolute;right:6px;top:6px;z-index:7;margin:0;';
badge.addEventListener('click', e => onLocalBadgeClick(e, code));
cover.appendChild(badge);
}
/** 详情页主图装饰入口:按钮组(贴封面底部左侧)+ 状态徽标 + 本地徽标 */
function decorateDetailMain() {
if (!isDetailPage()) return;
const cover = getMainCover();
if (!cover) return;
const code = getDetailCode();
if (!code) return;
try { if (getComputedStyle(cover).position === 'static') cover.style.position = 'relative'; } catch (e) { }
const sig = cardStateSig(cover, code);
let group = cover.querySelector(':scope > .dy-card-actions');
if (group && (group.dataset.code !== code || group.dataset.sig !== sig)) {
group.remove();
group = null;
}
if (!group) {
group = buildCardActions(cover, code);
group.dataset.sig = sig;
cover.appendChild(group);
}
applyMainLocalBadge(cover, code);
applyStateBadge(cover, code);
}
/* ======================================================================
* 11.5 跨上下文联动
* 详情页可能在新标签页或同源 iframe 弹层中打开;详情页内的自动标记 / 手动标记
* 必须让列表页卡片同步变成「已鉴定」(按钮 + 徽标),否则表现为“标记没生效”
* ==================================================================== */
const WHEEL_SYNC_MSG = 'jav-wheel:state-change';
/** 向同源父窗口 / 子 iframe 广播状态变化(iframe 弹层详情页场景) */
function broadcastStateChange(code) {
const payload = { type: WHEEL_SYNC_MSG, code: code || '' };
try { if (window.parent && window.parent !== window) window.parent.postMessage(payload, location.origin); } catch (e) { }
try { if (window.top && window.top !== window) window.top.postMessage(payload, location.origin); } catch (e) { }
try {
for (let i = 0; i < window.frames.length; i++) {
try { window.frames[i].postMessage(payload, location.origin); } catch (e) { }
}
} catch (e) { }
}
/** 清空进程内缓存:其它标签页 / 弹层写入存储后,本页缓存已过期,必须重读
* (只清状态类键,本地索引等大对象缓存保留,避免每次标记都重新解析) */
function invalidateMemCache() {
const keys = [K.watched, K.verified, K.blockedVideos, K.blockedSeries, K.blockedKeywords, K.blockSwitch];
keys.forEach(k => {
try { MEM.delete(k); } catch (e) { /* ignore */ }
try { memList.delete(k); } catch (e) { /* ignore */ }
try { memFlag.delete(k); } catch (e) { /* ignore */ }
});
}
/** 外部上下文发生状态变化 → 丢弃过期缓存并立即重绘列表卡片 */
function onExternalStateChange() {
invalidateMemCache();
try { updateMenuStat(); } catch (e) { }
refreshAll();
}
function syncAcrossContexts() {
// 1) 脚本存储变化:新标签页 / iframe 内标记后,GM 存储跨实例共享
const watchKey = key => {
try {
if (typeof GM_addValueChangeListener === 'function') {
GM_addValueChangeListener(key, (name, oldV, newV) => {
if (String(oldV) === String(newV)) return;
onExternalStateChange();
});
}
} catch (e) { /* ignore */ }
};
watchKey(K.verified);
watchKey(K.watched);
watchKey(K.blockedVideos);
watchKey(K.blockedSeries);
// 2) localStorage 兜底(无 GM 存储或 GM 关闭时的降级路径)
window.addEventListener('storage', e => {
if (e.key && /^jav_(sword|blade)_/.test(e.key)) onExternalStateChange();
});
// 3) iframe 弹层详情页 → 父页面 的广播
window.addEventListener('message', e => {
const d = e.data;
if (!d || d.type !== WHEEL_SYNC_MSG) return;
onExternalStateChange();
});
// 4) 切回本标签页 / 窗口重新获得焦点时兜底刷新(详情页在新标签页打开的场景)
let lastKick = 0;
const kick = () => {
const now = Date.now();
if (now - lastKick < 400) return;
lastKick = now;
onExternalStateChange();
};
document.addEventListener('visibilitychange', () => { if (!document.hidden) kick(); });
window.addEventListener('focus', kick);
window.addEventListener('pageshow', kick);
}
/* ======================================================================
* 12. 刷新调度 / 观察者 / 初始化
* ==================================================================== */
let refreshTimer = null;
function refreshAll() {
clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => {
decorateCards();
decorateDetailMain();
applyFilter();
}, 60);
}
function isOurNode(n) {
if (!n || n.nodeType !== 1) return false;
const cl = n.classList;
if (!cl) return false;
return cl.contains('dy-card-actions') || cl.contains('js-local-badge')
|| cl.contains('js-filter-hidden') || cl.contains('js-menu') || cl.contains('js-toast');
}
function mutationRelevant(m) {
if (m.target && m.target.nodeType === 1) {
if (m.target.closest && (m.target.closest('.dy-card-actions') || m.target.closest('.js-menu') || m.target.closest('.js-mask'))) return false;
}
const nodes = [];
m.addedNodes && m.addedNodes.forEach(n => nodes.push(n));
m.removedNodes && m.removedNodes.forEach(n => nodes.push(n));
if (!nodes.length) return false;
return !nodes.every(isOurNode);
}
const onDomChange = debounce(() => {
mountTopEntry();
decorateCards();
decorateDetailMain();
applyFilter();
applyScrollButtons();
autoVerifyDetailPage();
}, 280);
function observe() {
if (typeof MutationObserver === 'undefined') return;
const ob = new MutationObserver(muts => {
let relevant = false;
for (let i = 0; i < muts.length; i++) {
if (mutationRelevant(muts[i])) { relevant = true; break; }
}
if (relevant) onDomChange();
});
ob.observe(document.body, { childList: true, subtree: true });
}
function mountTopEntry() {
const site = siteId();
let anchor = null;
if (site === 'javdb') anchor = mountJavdbEntry();
else if (site === 'javbus') anchor = mountJavbusEntry();
else if (site === 'javlib') anchor = mountJavlibEntry();
if (anchor && !anchor.dataset.jsBound) {
anchor.dataset.jsBound = '1';
anchor.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
openMenu(anchor);
});
}
return anchor;
}
function registerMenuCommands() {
if (typeof GM_registerMenuCommand !== 'function') return;
try {
// v1.0.5:油猴菜单只保留「设置」一项,
// 数据管理 / 导入导出 / 重建索引等入口统一放在设置面板与顶栏入口菜单里
GM_registerMenuCommand('JAV Wheel · 设置', () => openSettingsDialog());
} catch (e) { /* ignore */ }
}
/* ---------- 本地播放页(Everything HTTP 直开视频):音量记忆 ---------- */
const LOCAL_PLAY_HOSTS = ['127.0.0.1', 'localhost', '::1', '[::1]'];
function isLocalPlayPage() {
const h = (location.hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
return LOCAL_PLAY_HOSTS.indexOf(h) !== -1;
}
/** 原生 video 播放器没有音量记忆,这里补一层:
* 页面加载后把音量恢复到上次保存值,用户调整时实时写入。 */
function initLocalPlayerVolume() {
const KEY = 'jav_wheel_ev_volume';
let initialized = false;
let bound = false;
function apply() {
if (initialized) return true;
const vid = document.querySelector('video');
if (!vid) return false;
let saved = kvGet(KEY, 1);
if (typeof saved !== 'number' || !isFinite(saved)) saved = 1;
const val = Math.min(1, Math.max(0, saved));
if (vid.volume !== val) vid.volume = val;
initialized = true;
if (!bound) {
bound = true;
vid.addEventListener('volumechange', () => kvSet(KEY, vid.volume));
}
return true;
}
const t = setInterval(apply, 300);
setTimeout(() => clearInterval(t), 8000);
}
function init() {
// 本地 Everything 播放页:只做音量记忆,不执行站点逻辑
if (isLocalPlayPage()) {
initLocalPlayerVolume();
return;
}
try {
injectStyle();
mountTopEntry();
registerMenuCommands();
syncAcrossContexts();
decorateCards();
decorateDetailMain();
applyFilter();
applyScrollButtons();
observe();
autoVerifyDetailPage();
if (isEvEnabled()) scheduleEvIndex();
// 页面较慢加载时补挂顶栏入口
setTimeout(mountTopEntry, 1200);
setTimeout(mountTopEntry, 3000);
setTimeout(() => { decorateCards(); applyFilter(); }, 1500);
} catch (e) {
console.error('[JAV Wheel] 初始化失败:', e);
}
}
window.__JAV_WHEEL__ = {
version: WHEEL_VERSION,
refreshAll: refreshAll,
buildEvIndex: buildEvIndex,
getEvIndex: getEvIndex,
store: { K, getWatched, getVerified, getBlockedVideos, getBlockedSeries, getBlockedKeywords }
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();