字幕搜索/加载/下载 · 静默自动匹配(≥60%) · 四源聚合 · 相似度排序 · 可拖拽小工具条
// ==UserScript==
// @name 字幕-手机版-AI
// @namespace missav-sub
// @version 3.3.0
// @description 字幕搜索/加载/下载 · 静默自动匹配(≥60%) · 四源聚合 · 相似度排序 · 可拖拽小工具条
// @author xiaoxiongweihu (v3)
// @match *://*.missav123.com/*
// @match *://*.missav.ws/*
// @match *://*.missav.ai/*
// @match *://*.missav.com/*
// @match *://*.missav.live/*
// @match *://*.javdb.com/*
// @match *://*.javdb.tv/*
// @match *://*.jable.tv/*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_openInTab
// @grant unsafeWindow
// @connect *
// @connect www.avsubtitles.com
// @connect avsubtitles.com
// @run-at document-end
// @license MIT
// ==/UserScript==
(function () {
'use strict';
if (window.__MISSAV_SUB_V3__) { console.debug('[MissavSub] 已加载,跳过'); return; }
window.__MISSAV_SUB_V3__ = true;
const TAG = '[MissavSub]';
const log = (...a) => console.debug(TAG, ...a);
const NL = '\n';
const BS = String.fromCharCode(92);
const REPL = String.fromCharCode(0xFFFD);
/* 1. 配置 */
const NS = 'missavSub.v3.';
const DEFAULTS = {
accelRate: 3, skipTime: 5, offset: 0, posPercent: 15,
fontSize: 26, fontFamily: 'Microsoft YaHei, PingFang SC, Hiragino Sans GB, sans-serif',
color: '#ffffff', outline: 2, opacity: 1,
keys: { accel: 'z', fwd: 'x', back: 'c' },
panel: null, fab: null, listWidth: 340,
cacheHours: 6, source: 'xunlei',
collapsed: true,
autoMatch: true,
silentThreshold: 0.6,
assrtToken: ''
};
const store = {
get(k, d) { try { const v = GM_getValue(NS + k); return (v === undefined || v === null) ? d : v; } catch (e) { return d; } },
set(k, v) { try { GM_setValue(NS + k, v); } catch (e) { log('保存失败', e); } },
del(k) { try { GM_deleteValue(NS + k); } catch (e) {} }
};
let cfg = Object.assign({}, DEFAULTS, store.get('config', {}));
cfg.keys = Object.assign({}, DEFAULTS.keys, cfg.keys || {});
let saveTimer = 0;
function saveCfg() { clearTimeout(saveTimer); saveTimer = setTimeout(() => store.set('config', cfg), 300); }
/* 2. 工具 */
function h(tag, props, ...kids) {
const e = document.createElement(tag);
if (props) for (const k in props) {
const v = props[k];
if (v === null || v === undefined || v === false) continue;
if (k === 'style') { if (typeof v === 'string') e.style.cssText = v; else Object.assign(e.style, v); }
else if (k === 'text') e.textContent = v;
else if (k.indexOf('on') === 0) e.addEventListener(k.slice(2).toLowerCase(), v);
else e.setAttribute(k, v);
}
kids.forEach(c => {
if (c === null || c === undefined || c === false) return;
e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
});
return e;
}
function sanitizeName(n) {
return String(n || 'subtitle').replace(/[^\w\u4e00-\u9fa5.\-]+/g, '_').slice(0, 120) || 'subtitle';
}
function similarity(a, b) {
const sa = new Set(String(a || '').toUpperCase().replace(/[^A-Z0-9]/g, '').split(''));
const sb = new Set(String(b || '').toUpperCase().replace(/[^A-Z0-9]/g, '').split(''));
if (!sa.size || !sb.size) return 0;
let inter = 0;
sa.forEach(c => { if (sb.has(c)) inter++; });
return inter / (sa.size + sb.size - inter);
}
const toastBox = h('div', { style: 'position:fixed;right:16px;bottom:16px;z-index:2147483647;display:flex;flex-direction:column;gap:8px;align-items:flex-end;pointer-events:none;' });
function toast(msg, ms) {
ms = ms || 4000;
if (!toastBox.isConnected) document.body.appendChild(toastBox);
const t = h('div', { text: String(msg), style: 'pointer-events:auto;max-width:380px;background:rgba(18,18,24,.78);color:#fff;padding:12px 18px;border-radius:9px;font:15px/1.5 system-ui,sans-serif;box-shadow:0 6px 28px rgba(0,0,0,.45);border:1px solid rgba(120,160,255,.3);opacity:0;transform:translateY(6px);transition:opacity .22s,transform .22s;' });
toastBox.appendChild(t);
requestAnimationFrame(() => { t.style.opacity = '1'; t.style.transform = 'none'; });
setTimeout(() => {
t.style.opacity = '0'; t.style.transform = 'translateY(6px)';
setTimeout(() => t.remove(), 260);
}, ms);
while (toastBox.children.length > 4) toastBox.firstChild.remove();
}
function forceDownload(content, filename) {
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = h('a', { href: url, download: sanitizeName(filename) });
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 4000);
}
/* 3. 网络 */
const pending = new Set();
function request(opts) {
return new Promise((resolve, reject) => {
const req = GM_xmlhttpRequest({
method: opts.method || 'GET',
url: opts.url,
headers: opts.headers || {},
timeout: opts.timeout || 20000,
onload: (r) => { pending.delete(req); if (r.status >= 200 && r.status < 300) resolve(r.responseText); else reject(new Error('HTTP ' + r.status)); },
onerror: () => { pending.delete(req); reject(new Error('网络错误或被阻止')); },
ontimeout: () => { pending.delete(req); reject(new Error('请求超时')); },
onabort: () => { pending.delete(req); reject(new Error('已取消')); }
});
pending.add(req);
});
}
function abortAll() { pending.forEach(r => { try { r.abort(); } catch (e) {} }); pending.clear(); }
function fetchText(url) {
if (!/^https?:/i.test(url)) return Promise.reject(new Error('非法地址'));
return request({ url: url, headers: { Accept: 'text/plain,*/*', Referer: location.href } });
}
async function fetchJSON(url, opts) {
const txt = await request({ url: url, headers: (opts && opts.headers) || { Accept: 'application/json, text/plain, */*' } });
return JSON.parse(txt);
}
/* 4. 解码 */
function decodeBuffer(buf) {
const u8 = new Uint8Array(buf);
const td = (enc) => new TextDecoder(enc);
try {
if (u8.length >= 3 && u8[0] === 0xEF && u8[1] === 0xBB && u8[2] === 0xBF) return td('utf-8').decode(u8.subarray(3));
if (u8.length >= 2 && u8[0] === 0xFF && u8[1] === 0xFE) return td('utf-16le').decode(u8.subarray(2));
if (u8.length >= 2 && u8[0] === 0xFE && u8[1] === 0xFF) return td('utf-16be').decode(u8.subarray(2));
try { return new TextDecoder('utf-8', { fatal: true }).decode(u8); } catch (e) {}
const candidates = ['gb18030', 'big5', 'shift_jis', 'euc-kr'];
for (let i = 0; i < candidates.length; i++) {
try { const t = td(candidates[i]).decode(u8); if (t.indexOf(REPL) === -1) return t; } catch (e) {}
}
return td('utf-8').decode(u8);
} catch (e) { return td('utf-8').decode(u8); }
}
/* 5. 解析 */
function parseTimeSrt(s) {
const parts = String(s).trim().split(/[,.]/);
const hms = parts[0].split(':');
if (hms.length < 3) return NaN;
const ms = parts[1] ? parseInt(parts[1].padEnd(3, '0').slice(0, 3), 10) : 0;
const hh = parseInt(hms[0], 10) || 0, mm = parseInt(hms[1], 10) || 0, ss = parseInt(hms[2], 10) || 0;
const v = hh * 3600 + mm * 60 + ss + ms / 1000;
return isNaN(v) ? NaN : v;
}
function parseTimeAss(s) {
const t = String(s).trim().split(/[,.]/);
const hms = t[0].split(':');
if (hms.length < 3) return NaN;
const cs = t[1] ? parseInt(t[1].padEnd(2, '0').slice(0, 2), 10) : 0;
return (parseInt(hms[0], 10) || 0) * 3600 + (parseInt(hms[1], 10) || 0) * 60 + (parseInt(hms[2], 10) || 0) + cs / 100;
}
function cleanCue(t) {
return String(t).split(BS + 'N').join(NL).split(BS + 'n').join(NL).split(BS + 'h').join(' ').trim();
}
function splitLines(text) { return String(text).split('\r').join(NL).split(NL); }
function parseSRT(text) {
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const out = [];
text.split(/\n\n+/).forEach(block => {
const lines = block.split(NL).map(l => l.replace(/\r$/, '')).filter(l => l.length);
if (!lines.length) return;
let ti = lines[0].indexOf('-->') >= 0 ? 0 : (lines[1] && lines[1].indexOf('-->') >= 0 ? 1 : -1);
if (ti < 0) return;
const seg = lines[ti].split('-->');
const s = parseTimeSrt(seg[0]), e = parseTimeSrt(seg[1]);
const txt = lines.slice(ti + 1).join(NL).trim();
if (!isNaN(s) && !isNaN(e) && e > s && txt) out.push({ s: s, e: e, text: txt });
});
return out;
}
function parseVTT(text) {
const body = text.replace(/^WEBVTT[^\n]*\n/, '');
return parseSRT(body.replace(/\n\d+\n/g, NL));
}
function parseASS(text) {
const out = [];
const lines = splitLines(text);
let inEvents = false, fmt = null;
lines.forEach(raw => {
const line = raw.trim();
if (!line) return;
if (line.indexOf('[') === 0) { inEvents = line.toLowerCase().indexOf('[events]') === 0; return; }
if (!inEvents) return;
if (line.toLowerCase().indexOf('format:') === 0) { fmt = line.slice(7).split(',').map(s => s.trim().toLowerCase()); return; }
if (line.toLowerCase().indexOf('dialogue:') !== 0) return;
const f = fmt || ['layer', 'start', 'end', 'style', 'name', 'marginl', 'marginr', 'marginv', 'effect', 'text'];
const ti = f.indexOf('text'), si = f.indexOf('start'), ei = f.indexOf('end');
const parts = line.slice(9).split(',');
const head = parts.slice(0, ti);
const bodyText = parts.slice(ti).join(',');
const s = parseTimeAss(head[si]), e = parseTimeAss(head[ei]);
const txt = cleanCue(bodyText.replace(/\{[^}]*\}/g, ''));
if (!isNaN(s) && !isNaN(e) && e > s && txt) out.push({ s: s, e: e, text: txt });
});
return out;
}
function parseMicroDVD(text) {
const out = [];
const fps = 23.976;
splitLines(text).forEach(line => {
const m = line.match(/^\{(\d+)\}\{(\d+)\}(.*)$/);
if (!m) return;
const s = parseInt(m[1], 10) / fps, e = parseInt(m[2], 10) / fps;
const txt = cleanCue(m[3].split('|').join(NL));
if (e > s && txt) out.push({ s: s, e: e, text: txt });
});
return out;
}
function parseSubtitles(text) {
const head = text.slice(0, 2000);
let cues = [];
if (head.indexOf('[Script Info]') >= 0 || head.indexOf('Dialogue:') >= 0) cues = parseASS(text);
else if (head.indexOf('WEBVTT') >= 0) cues = parseVTT(text);
else if (/^\{\d+\}\{\d+\}/m.test(text)) cues = parseMicroDVD(text);
else cues = parseSRT(text);
if (!cues.length) cues = parseSRT(text);
cues.sort((a, b) => a.s - b.s);
return cues;
}
/* 6. 字幕状态机 */
const state = { cues: [], off: 0, cursor: 0, lastCue: null, name: '', raw: '' };
function findCue(t) {
const c = state.cues, n = c.length;
if (!n) return null;
const off = state.off;
let i = state.cursor;
if (i >= n) i = n - 1;
if (i < 0) i = 0;
while (i < n - 1 && c[i].e + off < t) i++;
if (i - state.cursor > 60) {
let lo = 0, hi = n - 1;
while (lo < hi) { const mid = (lo + hi) >> 1; if (c[mid].e + off < t) lo = mid + 1; else hi = mid; }
i = lo;
}
while (i > 0 && c[i].s + off > t) i--;
state.cursor = i;
const cur = c[i];
if (t >= cur.s + off && t <= cur.e + off) return cur;
const nx = c[i + 1];
if (nx && t >= nx.s + off && t <= nx.e + off) { state.cursor = i + 1; return nx; }
return null;
}
let subEl = null;
function applySubStyle() {
if (!subEl) return;
const s = subEl.style;
s.fontSize = cfg.fontSize + 'px';
s.fontFamily = cfg.fontFamily;
s.color = cfg.color;
s.opacity = String(cfg.opacity);
s.textShadow = '0 0 ' + cfg.outline + 'px #000, 0 0 ' + (cfg.outline * 2) + 'px rgba(0,0,0,.85), 2px 2px 5px rgba(0,0,0,.9)';
s.bottom = Math.max(0, Math.min(90, cfg.posPercent)) + '%';
}
function showCue(cue) {
if (!subEl) return;
const txt = cue ? cue.text : '';
if (subEl.textContent !== txt) subEl.textContent = txt;
subEl.style.display = (txt && !subHidden) ? 'block' : 'none';
}
let rafId = 0, lastTime = -1;
function renderLoop() {
rafId = requestAnimationFrame(renderLoop);
if (document.hidden || !state.cues.length) return;
if (!Player.ready()) return;
const t = Player.time;
if (t === lastTime) return;
lastTime = t;
const cue = findCue(t);
if (cue !== state.lastCue) { state.lastCue = cue; showCue(cue); }
}
function setSubtitle(text, name, silent) {
try {
const cues = parseSubtitles(text);
if (!cues.length) throw new Error('未解析到有效条目');
state.cues = cues; state.cursor = 0; state.lastCue = null;
state.raw = text; state.name = name || 'subtitle';
if (subEl) { subEl.style.display = 'block'; applySubStyle(); }
if (!silent) toast('字幕加载成功(' + cues.length + ' 条)', 4000);
} catch (e) { toast('字幕加载失败:' + e.message, 4000); }
}
function clearSubtitle(silent) {
state.cues = []; state.cursor = 0; state.lastCue = null; state.raw = ''; state.name = '';
state.off = cfg.offset || 0;
if (subEl) { subEl.textContent = ''; subEl.style.display = 'none'; }
if (!silent) toast('字幕已清除', 2000);
}
/* 6.5 字幕显隐 */
let subHidden = false;
function toggleSubtitle() {
subHidden = !subHidden;
if (subEl) {
if (subHidden) subEl.style.display = 'none';
else if (state.cues.length) subEl.style.display = 'block';
}
toast(subHidden ? '字幕已隐藏' : '字幕已显示', 2000);
}
/* 7. 播放器适配 */
const Player = {
raw: null, video: null, target: null,
detect() {
const w = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
const p = w.player || w.plyr || null;
if (p && typeof p.currentTime === 'number') this.raw = p;
else this.raw = null;
const v = document.querySelector('video');
if (v) this.video = v;
const t = this.raw || this.video;
const changed = t !== this.target;
this.target = t;
return changed;
},
ready() { return !!this.target; },
get time() {
if (this.raw && typeof this.raw.currentTime === 'number') return this.raw.currentTime;
return this.video ? this.video.currentTime : 0;
},
set time(v) {
const val = Math.max(0, v);
if (this.raw && typeof this.raw.currentTime === 'number') { try { this.raw.currentTime = val; return; } catch (e) {} }
if (this.video) this.video.currentTime = val;
},
get speed() {
if (this.raw && typeof this.raw.speed === 'number') return this.raw.speed;
return this.video ? this.video.playbackRate : 1;
},
set speed(v) {
const val = Math.max(0.1, v);
if (this.raw && typeof this.raw.speed === 'number') { try { this.raw.speed = val; return; } catch (e) {} }
if (this.video) this.video.playbackRate = val;
}
};
/* 8. 字幕层挂载 */
function ensureSubtitleEl() {
if (!subEl) {
subEl = h('div', { style: 'position:absolute;left:50%;transform:translateX(-50%);z-index:2147483000;max-width:100%;text-align:center;padding:4px 10px;border-radius:5px;pointer-events:none;background:transparent;white-space:pre-wrap;line-height:1.35;display:none;' });
}
return subEl;
}
function mountSubtitle() {
const el = ensureSubtitleEl();
const v = Player.video || document.querySelector('video');
let box = document.querySelector('.plyr__video-wrapper') || document.querySelector('.player-container') || (v && v.parentElement);
if (!box) return false;
if (getComputedStyle(box).position === 'static') box.style.position = 'relative';
if (el.parentElement !== box) box.appendChild(el);
applySubStyle();
return true;
}
/* 9. UI */
const ui = { host: null, root: null, wrap: null, panel: null, list: null, fab: null };
const SHADOW_CSS = ':host{all:initial}.wrap{position:fixed;inset:0;pointer-events:none;z-index:2147482000;font-family:system-ui,-apple-system,sans-serif}.panel{position:fixed;left:12px;top:12px;pointer-events:auto;width:352px;box-sizing:border-box;background:linear-gradient(135deg,rgba(28,28,38,.97),rgba(38,38,52,.97));color:#eaeaf2;border:1px solid rgba(120,160,255,.28);border-radius:12px;padding:10px 12px 12px;box-shadow:0 10px 36px rgba(0,0,0,.5);font-size:12px;user-select:none}.head{display:flex;justify-content:space-between;align-items:center;cursor:move;padding:2px 0 8px;border-bottom:1px solid rgba(255,255,255,.12);margin-bottom:8px;font-weight:600;font-size:13px}.ver{opacity:.5;font-weight:400;font-size:11px}.grid{display:grid;grid-template-columns:1fr 1fr;gap:6px 10px}.row{display:flex;align-items:center;gap:6px}.row label{flex:0 0 auto;min-width:52px;text-align:right;opacity:.82;font-size:11px}input[type=number],input[type=text]{width:100%;min-width:0;box-sizing:border-box;background:rgba(255,255,255,.09);border:1px solid rgba(120,160,255,.28);border-radius:6px;padding:4px 7px;color:#fff;font-size:12px;outline:none}input[type=number]:focus,input[type=text]:focus{border-color:rgba(120,160,255,.7);background:rgba(255,255,255,.15)}input[type=color]{width:100%;height:26px;padding:0;border:1px solid rgba(120,160,255,.28);border-radius:6px;background:transparent}.btns{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:10px;padding-top:10px;border-top:1px solid rgba(255,255,255,.12)}button{background:linear-gradient(135deg,#4a90e2,#357abd);border:none;color:#fff;padding:7px 9px;border-radius:7px;cursor:pointer;font-size:12px;font-weight:500;transition:filter .15s,transform .1s}button:hover{filter:brightness(1.12)}button:active{transform:translateY(1px)}button.ghost{background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.16)}button.wide{grid-column:1/-1}.chk{display:flex;align-items:center;gap:6px;grid-column:1/-1;opacity:.9;font-size:11px;padding:2px 0}.list{position:fixed;pointer-events:auto;background:linear-gradient(135deg,rgba(28,28,38,.97),rgba(38,38,52,.97));color:#eaeaf2;border:1px solid rgba(120,160,255,.28);border-radius:12px;box-shadow:0 10px 36px rgba(0,0,0,.5);padding:10px;display:none;flex-direction:column;max-height:60vh}.list input.f{width:100%;box-sizing:border-box;margin-bottom:8px}.items{overflow-y:auto;max-height:46vh}.item{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:7px 8px;border-radius:6px;cursor:pointer;transition:background .15s}.item:hover{background:rgba(120,160,255,.2)}.item .nm{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}.item .nm small{opacity:.55;margin-left:4px}.item .dl{flex:0 0 auto;padding:3px 8px;font-size:11px;background:linear-gradient(135deg,#28a745,#1f8a37)}.empty{padding:10px;opacity:.6;font-size:12px;text-align:center}.fab{position:fixed;left:12px;top:70px;pointer-events:auto;display:none;flex-direction:column;gap:5px;background:rgba(28,28,38,.95);padding:7px;border-radius:12px;border:1px solid rgba(120,160,255,.3);box-shadow:0 8px 24px rgba(0,0,0,.45);backdrop-filter:blur(8px);cursor:move;touch-action:none}.fab button{padding:8px 10px;font-size:15px;min-width:40px;line-height:1;text-align:center;cursor:pointer}';
/* 安卓/触屏适配样式:禁止拖拽时浏览器把手势当滚动/缩放;窄屏防溢出;触屏设备放大点击区并显示播放控制按钮 */
const MOBILE_CSS = ':host{all:initial}.head,.fab,.fab button{touch-action:none}.wrap{touch-action:manipulation}.panel,.fab,.list,.head,button,input{-webkit-tap-highlight-color:transparent}.panel{max-width:calc(100vw - 24px)}.list{max-width:calc(100vw - 16px)}@media (pointer:coarse){.fab{flex-wrap:wrap;max-width:calc(100vw - 24px)}.fab button{min-width:42px;min-height:42px;font-size:17px}.panel button{min-height:34px}.list .dl{min-width:38px;min-height:38px;font-size:13px}.item{padding:8px 6px}.head{padding-top:6px;padding-bottom:10px}}.items{overflow-y:auto;max-height:none;flex:1 1 auto;min-height:0;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;touch-action:pan-y}.list{touch-action:auto}';
function ensureHost() {
if (ui.host && document.documentElement.contains(ui.host)) return ui.root;
const host = h('div', { id: 'missav-sub-host' });
const root = host.attachShadow({ mode: 'open' });
root.appendChild(h('style', { text: SHADOW_CSS }));
root.appendChild(h('style', { text: MOBILE_CSS }));
const wrap = h('div', { class: 'wrap' });
root.appendChild(wrap);
// 关键修复:面板内按键不再向页面冒泡,避免站点播放器(YouTube 式数字键跳百分比/方向键快进)把输入操作当成快捷键
['keydown', 'keyup', 'keypress'].forEach((t) => root.addEventListener(t, (ev) => ev.stopPropagation(), false));
(document.body || document.documentElement).appendChild(host);
ui.host = host; ui.root = root; ui.wrap = wrap;
return root;
}
function numRow(label, key, min, max, step, onChange) {
const input = h('input', { type: 'number', value: cfg[key], min: min, max: max, step: step || 'any',
oninput: (e) => { const v = parseFloat(e.target.value); if (isNaN(v)) return; cfg[key] = v; if (onChange) onChange(v); saveCfg(); } });
return h('div', { class: 'row' }, h('label', { text: label }), input);
}
function buildPanel() {
ensureHost();
if (ui.panel) { applyCollapsed(); return; }
const grid = h('div', { class: 'grid' },
numRow('字幕偏移', 'offset', -600, 600, 0.1, (v) => { state.off = v; state.cursor = 0; }),
numRow('位置%', 'posPercent', 0, 90, 1, () => applySubStyle()),
numRow('字号', 'fontSize', 8, 90, 1, () => applySubStyle()),
numRow('描边', 'outline', 0, 8, 0.5, () => applySubStyle()),
numRow('不透明度', 'opacity', 0.1, 1, 0.05, () => applySubStyle()),
h('div', { class: 'row' }, h('label', { text: '颜色' }),
h('input', { type: 'color', value: cfg.color, oninput: (e) => { cfg.color = e.target.value; applySubStyle(); saveCfg(); } }))
);
const fileInput = h('input', { type: 'file', accept: '.srt,.vtt,.ass,.ssa,.sub', style: 'display:none', onchange: onLocalFile });
const importInput = h('input', { type: 'file', accept: '.json', style: 'display:none', onchange: onImportCfg });
const tokenRow = h('div', { class: 'chk' },
h('label', { text: '伪射手Token:' }),
h('input', { type: 'text', value: cfg.assrtToken || '', placeholder: '可留空', style: 'flex:1;font-size:11px',
oninput: (e) => { cfg.assrtToken = e.target.value.trim(); saveCfg(); } })
);
const btns = h('div', { class: 'btns' },
h('button', { text: '加载本地字幕', onclick: () => fileInput.click() }),
h('button', { text: '搜索全部源', onclick: () => searchAll(false) }),
h('button', { class: 'ghost', text: '源1·迅雷', onclick: () => searchOnline('xunlei') }),
h('button', { class: 'ghost', text: '源2·字幕猫', onclick: () => searchOnline('subtitlecat') }),
h('button', { class: 'ghost', text: '源3·伪射手', onclick: () => searchOnline('assrt') }),
h('button', { class: 'ghost', text: '源4·AV字幕库', onclick: () => searchOnline('avsubtitles') }),
h('button', { class: 'ghost', text: '隐藏/显示字幕', onclick: toggleSubtitle }),
h('button', { class: 'ghost', text: '下载当前字幕', onclick: downloadCurrent }),
h('button', { class: 'ghost', text: '清除字幕', onclick: () => clearSubtitle() }),
h('label', { class: 'chk' },
h('input', { type: 'checkbox', checked: cfg.autoMatch ? 'checked' : null, onchange: (e) => { cfg.autoMatch = e.target.checked; saveCfg(); } }),
'进入视频自动匹配字幕'),
numRow('静默阈值%', 'silentThreshold', 0.3, 1, 0.05, () => {}),
tokenRow,
h('button', { class: 'ghost', text: '导出配置', onclick: exportCfg }),
h('button', { class: 'ghost', text: '导入配置', onclick: () => importInput.click() }),
h('button', { class: 'wide ghost', text: '收起面板', onclick: hidePanel })
);
const head = h('div', { class: 'head' }, h('span', { text: '字幕增强 v3' }), h('span', { class: 'ver', text: 'v3.3.0' }));
const panel = h('div', { class: 'panel' }, head, grid, btns, fileInput, importInput);
if (cfg.panel && typeof cfg.panel.x === 'number') { panel.style.left = cfg.panel.x + 'px'; panel.style.top = cfg.panel.y + 'px'; }
makeDraggable(panel, head, 'panel');
ui.wrap.appendChild(panel);
ui.panel = panel;
ui.fab = h('div', { class: 'fab' },
h('button', { title: '搜索字幕(全部源)', onclick: () => searchAll(false) }, '🔍'),
h('button', { class: 'ghost', title: '关闭字幕', onclick: () => clearSubtitle() }, '✕'),
h('button', { class: 'ghost', title: '打开设置面板', onclick: showPanel }, '⚙')
);
if (cfg.fab && typeof cfg.fab.x === 'number') {
ui.fab.style.left = cfg.fab.x + 'px';
ui.fab.style.top = cfg.fab.y + 'px';
}
makeFabDraggable(ui.fab);
ui.wrap.appendChild(ui.fab);
applyCollapsed();
}
function applyCollapsed() {
if (!ui.panel || !ui.fab) return;
if (cfg.collapsed) {
ui.panel.style.display = 'none';
ui.fab.style.display = 'flex';
} else {
ui.panel.style.display = 'block';
ui.fab.style.display = 'none';
}
}
function makeDraggable(el, handle, key) {
let sx = 0, sy = 0, ox = 0, oy = 0, dragging = false;
handle.addEventListener('pointerdown', (e) => {
dragging = true; sx = e.clientX; sy = e.clientY;
const r = el.getBoundingClientRect(); ox = r.left; oy = r.top;
handle.setPointerCapture(e.pointerId); e.preventDefault();
});
handle.addEventListener('pointermove', (e) => {
if (!dragging) return;
const x = Math.max(0, Math.min(innerWidth - 60, ox + e.clientX - sx));
const y = Math.max(0, Math.min(innerHeight - 30, oy + e.clientY - sy));
el.style.left = x + 'px'; el.style.top = y + 'px';
if (ui.list && ui.list.style.display === 'flex') positionList();
});
const up = () => {
if (!dragging) return;
dragging = false;
cfg[key] = { x: parseInt(el.style.left, 10) || 12, y: parseInt(el.style.top, 10) || 12 };
saveCfg();
};
handle.addEventListener('pointerup', up);
handle.addEventListener('pointercancel', up);
}
/* 小工具条拖拽(按钮点击优先,移动超过 5px 才算拖拽) */
function makeFabDraggable(el) {
let sx = 0, sy = 0, ox = 0, oy = 0, dragging = false, moved = false;
let downTarget = null;
el.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
dragging = true; moved = false;
downTarget = e.target;
sx = e.clientX; sy = e.clientY;
const r = el.getBoundingClientRect(); ox = r.left; oy = r.top;
});
document.addEventListener('pointermove', (e) => {
if (!dragging) return;
const dx = e.clientX - sx, dy = e.clientY - sy;
if (!moved && Math.abs(dx) + Math.abs(dy) < 5) return;
moved = true;
const x = Math.max(0, Math.min(innerWidth - 50, ox + dx));
const y = Math.max(0, Math.min(innerHeight - 50, oy + dy));
el.style.left = x + 'px';
el.style.top = y + 'px';
if (ui.list && ui.list.style.display === 'flex') positionList();
e.preventDefault();
});
document.addEventListener('pointerup', () => {
if (!dragging) return;
dragging = false;
if (moved && downTarget) {
const swallow = (ev) => { ev.stopPropagation(); ev.preventDefault(); };
downTarget.addEventListener('click', swallow, { capture: true, once: true });
setTimeout(() => { try { downTarget.removeEventListener('click', swallow, true); } catch (e) {} }, 50);
cfg.fab = { x: parseInt(el.style.left, 10) || 12, y: parseInt(el.style.top, 10) || 70 };
saveCfg();
}
downTarget = null;
});
}
function positionList() {
if (!ui.list) return;
const w = cfg.listWidth;
const anchor = cfg.collapsed ? ui.fab : ui.panel;
let x = 72, y = 12;
if (anchor) {
const r = anchor.getBoundingClientRect();
x = r.right + 10;
y = Math.max(8, r.top);
if (x + w > innerWidth - 8) x = Math.max(8, r.left - w - 10);
}
// 夹到视口内,并按剩余空间动态定高,保证列表整体可见、内部可滚动
y = Math.max(8, Math.min(y, Math.max(8, innerHeight - 180)));
ui.list.style.left = x + 'px';
ui.list.style.top = y + 'px';
ui.list.style.width = Math.min(w, innerWidth - 16) + 'px';
ui.list.style.maxHeight = Math.max(160, innerHeight - y - 10) + 'px';
}
function hidePanel() { cfg.collapsed = true; saveCfg(); applyCollapsed(); closeList(); }
function showPanel() { cfg.collapsed = false; saveCfg(); applyCollapsed(); }
function closeList() { if (ui.list) { ui.list.remove(); ui.list = null; } document.removeEventListener('click', handleClickOutside, true); abortAll(); }
function handleClickOutside(e) {
if (!ui.list) return;
const path = (e.composedPath && e.composedPath()) || [e.target];
for (const t of path) {
if (t === ui.list) return;
if (t === ui.fab || (t.classList && t.classList.contains('fab'))) return;
if (t === ui.panel || (t.classList && t.classList.contains('panel'))) return;
}
closeList();
}
function showList(items, title) {
closeList();
const box = h('div', { class: 'list' });
box.style.display = 'flex';
const filter = h('input', { class: 'f', type: 'text', placeholder: '过滤…' });
const itemsBox = h('div', { class: 'items' });
function render(list) {
itemsBox.textContent = '';
if (!list.length) { itemsBox.appendChild(h('div', { class: 'empty', text: '未找到匹配字幕' })); return; }
list.forEach(it => {
const srcLabel = sourceLabel(it.source);
const simLabel = it.similarity ? ' (' + Math.round(it.similarity * 100) + '%)' : '';
const nm = h('div', { class: 'nm' }, it.name,
it.extra ? h('small', { text: '(' + it.extra + ')' }) : null,
srcLabel ? h('small', { text: '·' + srcLabel }) : null,
simLabel ? h('small', { text: simLabel }) : null);
const dl = h('button', { class: 'dl', text: '下载', onclick: (e) => { e.stopPropagation(); downloadItem(it); } });
itemsBox.appendChild(h('div', { class: 'item', onclick: () => loadItem(it) }, nm, dl));
});
}
filter.addEventListener('input', () => {
const q = filter.value.toLowerCase();
render(items.filter(i => (i.name + ' ' + (i.extra || '')).toLowerCase().indexOf(q) >= 0));
});
render(items);
box.appendChild(h('div', { style: 'font-weight:600;margin-bottom:6px;font-size:12px;opacity:.85', text: title || '选择字幕' }));
box.appendChild(filter);
box.appendChild(itemsBox);
box.appendChild(h('button', { class: 'ghost', style: 'margin-top:8px', text: '关闭 (Esc)', onclick: closeList }));
ui.wrap.appendChild(box);
ui.list = box;
// 安卓:列表内的滑动不再冒泡到页面,避免被站点播放器的 touchmove 处理吞掉导致滑不动
['touchstart', 'touchmove'].forEach((t) => box.addEventListener(t, (ev) => ev.stopPropagation(), { passive: true }));
positionList();
setTimeout(() => document.addEventListener('click', handleClickOutside, true), 100);
setTimeout(() => filter.focus(), 30);
}
/* 10. 字幕源 */
function fullUrl(base, href) {
if (!href) return '';
if (href.indexOf('http') === 0) return href;
if (href.charAt(0) === '/') return base + href;
return base + '/' + href;
}
const SOURCES = {
xunlei: {
label: '迅雷',
async search(id) {
const url = 'https://api-shoulei-ssl.xunlei.com/oracle/subtitle?name=' + encodeURIComponent(id);
const data = await fetchJSON(url);
if (!data || data.code !== 0 || !Array.isArray(data.data)) return [];
return data.data
.filter(i => i && typeof i.url === 'string' && /^https?:/i.test(i.url))
.filter(i => /\.(srt|ass|ssa|vtt|sub)($|\?)/i.test(i.url))
.map(i => ({ name: i.name || 'subtitle', extra: i.extra_name || i.language || '', url: i.url, source: 'xunlei' }));
}
},
subtitlecat: {
label: '字幕猫',
link: (id) => 'https://www.subtitlecat.com/index.php?search=' + encodeURIComponent(id),
async search(id) {
const url = 'https://www.subtitlecat.com/index.php?search=' + encodeURIComponent(id);
const html = await fetchText(url);
if (/cf-browser-verification|Just a moment|challenge-platform/i.test(html)) {
throw new Error('Cloudflare 拦截');
}
const doc = new DOMParser().parseFromString(html, 'text/html');
const results = [];
const kw = id.toLowerCase();
const kwClean = kw.replace(/[^a-z0-9]/g, '');
doc.querySelectorAll('tr').forEach(row => {
const a = row.querySelector('a[href]');
if (!a) return;
const href = a.getAttribute('href') || '';
if (href.indexOf('/subs/') < 0 && href.indexOf('/view.php?') < 0) return;
const title = (a.textContent || '').trim();
if (title && kwClean) {
const titleClean = title.toLowerCase().replace(/[^a-z0-9]/g, '');
if (titleClean.indexOf(kwClean) < 0 && title.toLowerCase().indexOf(kw) < 0) return;
}
const cells = row.querySelectorAll('td');
const lang = cells.length > 1 ? (cells[1].textContent || '').trim() : '';
const full = fullUrl('https://www.subtitlecat.com', href);
if (full && !results.some(r => r.url === full)) {
results.push({ name: title || id, extra: lang, url: full, source: 'subtitlecat' });
}
});
return results;
}
},
assrt: {
label: '伪射手',
async search(id) {
if (!cfg.assrtToken) throw new Error('未配置 Assrt Token(面板设置)');
const url = 'https://api.assrt.net/v1/sub/search?token=' + encodeURIComponent(cfg.assrtToken) + '&q=' + encodeURIComponent(id) + '&cnt=15';
const data = await fetchJSON(url);
if (!data || data.status !== 0 || !data.sub || !Array.isArray(data.sub.subs)) return [];
return data.sub.subs
.filter(s => s && s.id)
.map(s => ({ name: s.native_name || s.videoname || id, extra: (s.subtype || '') + (s.vote_score ? ' 👍' + s.vote_score : ''), id: s.id, source: 'assrt' }));
}
},
avsubtitles: {
label: 'AV字幕库',
async search(id) {
const searchHtml = await request({ url: 'https://www.avsubtitles.com/search_results.php?search=' + encodeURIComponent(id), timeout: 8000 });
const movieLinks = [...new Set(Array.from(searchHtml.matchAll(/href="(\/movie\d+\/[^"#]+)"/gi)).map(m => m[1]))];
if (!movieLinks.length) return [];
const cleanId = id.replace(/[^a-z0-9]/gi, '').toLowerCase();
const matched = movieLinks.find(l => l.replace(/[^a-z0-9]/gi, '').toLowerCase().indexOf(cleanId) >= 0) || movieLinks[0];
const movieHtml = await request({ url: fullUrl('https://www.avsubtitles.com', matched), timeout: 8000 });
const out = [];
Array.from(movieHtml.matchAll(/href="(\/movie\d+\/[^/]+\/subtitles\/([a-z]{2})\/(\d+))"/gi)).forEach(m => {
const langCode = m[2].toLowerCase();
const lang = langCode === 'zh' ? '中文' : (langCode === 'en' ? '英文' : (langCode === 'ja' ? '日文' : '其他'));
out.push({
name: '[' + id + '] ' + lang + '字幕',
extra: 'AVSubtitles · ' + lang,
url: fullUrl('https://www.avsubtitles.com', m[1]),
lang: langCode,
source: 'avsubtitles'
});
});
return out;
}
}
};
async function resolveSubtitlecat(detailUrl) {
const html = await fetchText(detailUrl);
const doc = new DOMParser().parseFromString(html, 'text/html');
const links = Array.from(doc.querySelectorAll('a'));
const pick = (tag) => {
for (const a of links) {
const href = a.getAttribute('href') || '';
const id = a.getAttribute('id') || '';
if (id.indexOf(tag) >= 0 || href.indexOf(tag) >= 0) {
return fullUrl('https://www.subtitlecat.com', href);
}
}
return null;
};
return pick('zh-CN') || pick('zh-TW');
}
async function resolveAssrt(subId) {
const url = 'https://api.assrt.net/v1/sub/detail?token=' + encodeURIComponent(cfg.assrtToken) + '&id=' + encodeURIComponent(subId);
const data = await fetchJSON(url);
if (!data || data.status !== 0 || !data.sub || !data.sub.subs || !data.sub.subs.length) throw new Error('Assrt 详情获取失败');
const sub = data.sub.subs[0];
const file = (sub.filelist && sub.filelist[0]) || {};
if (!file.url) throw new Error('Assrt 未返回下载链接');
return file.url;
}
/* AV字幕库需要先访问字幕页拿 subid/revid 令牌,预热 download_page 后才能取 download_sub */
async function resolveAvsubtitles(pageUrl) {
const pageHtml = await fetchText(pageUrl);
const subid = (pageHtml.match(/name="subid"\s+value="(\d+)"/i) || [])[1];
const revid = (pageHtml.match(/name="revid"\s+value="(\d+)"/i) || [])[1];
if (!subid || !revid) throw new Error('AV字幕库令牌获取失败,请到网页端手动下载');
await fetchText('https://www.avsubtitles.com/download_page.php?subid=' + subid + '&revid=' + revid);
return 'https://www.avsubtitles.com/download_sub.php?subid=' + subid + '&revid=' + revid;
}
/* 统一解析:各源条目地址 → 真实字幕文件地址 */
async function resolveItemUrl(it) {
if (it.source === 'subtitlecat') {
const real = await resolveSubtitlecat(it.url);
if (!real) throw new Error('该条目没有中文(简/繁)字幕');
return real;
}
if (it.source === 'assrt') return resolveAssrt(it.id);
if (it.source === 'avsubtitles') return resolveAvsubtitles(it.url);
return it.url;
}
function cacheKey(src, id) { return 'cache.' + src + '.' + id; }
function cacheGet(src, id) {
const c = store.get(cacheKey(src, id), null);
if (!c || !c.items) return null;
if (Date.now() - c.t > cfg.cacheHours * 3600e3) { store.del(cacheKey(src, id)); return null; }
return c.items;
}
async function searchOnline(src) {
const id = videoId();
if (!id) { toast('无法识别当前番号'); return; }
const S = SOURCES[src];
if (!S) { toast('未配置该字幕源'); return; }
const cached = cacheGet(src, id);
if (cached && cached.length) { showList(cached, S.label + ' · ' + id + '(缓存)'); return; }
toast('正在搜索:' + id + ' …');
try {
const items = await S.search(id);
if (!items.length) { toast(S.label + ' 未找到 ' + id + ' 的字幕', 4000); return; }
store.set(cacheKey(src, id), { t: Date.now(), items: items });
showList(items, S.label + ' · ' + id);
} catch (e) {
if (e.message === '已取消') return;
if (src === 'subtitlecat' && S.link && /Cloudflare|拦截|challenge/i.test(e.message)) {
toast('内嵌被拦,已打开字幕猫网页', 4000);
GM_openInTab(S.link(id), { active: true });
return;
}
toast('搜索失败:' + e.message, 4000);
}
}
function handleMerged(merged, silent, id, fromCache) {
if (!merged.length) {
if (!silent) toast('全部源都没找到 ' + id + ' 的字幕', 4000);
else log('静默匹配:全部源都没找到 ' + id);
return;
}
if (silent) {
const th = (typeof cfg.silentThreshold === 'number') ? cfg.silentThreshold : 0.6;
const best = merged[0];
if (best && (best.similarity || 0) >= th) {
log('静默匹配命中:' + best.name + ' 相似度 ' + best.similarity + ' ≥ ' + th + (fromCache ? '(缓存)' : ''));
loadItem(best, true);
} else {
log('静默匹配:最高 ' + Math.round((best ? best.similarity : 0) * 100) + '% < 阈值 ' + Math.round(th * 100) + '%,不加载');
}
return;
}
const highConf = merged.filter(m => (m.similarity || 0) >= 0.3);
const lowConf = merged.filter(m => (m.similarity || 0) < 0.3);
const finalList = highConf.concat(lowConf);
showList(finalList, '全部源 · ' + id + '(' + finalList.length + ' 条' + (fromCache ? ',缓存' : '') + ',按匹配度排序)');
}
async function searchAll(silent) {
const id = videoId();
if (!id) { if (!silent) toast('无法识别当前番号'); return; }
/* v3.3:静默匹配也复用 6 小时搜索缓存 */
const cached = cacheGet('all', id);
if (cached && cached.length) { handleMerged(cached, silent, id, true); return; }
if (!silent) toast('正在搜索全部源:' + id + ' …');
const keys = Object.keys(SOURCES);
const tasks = keys.map(k => {
const S = SOURCES[k];
return S.search(id)
.then(items => items.map(i => {
const sim = similarity(id, (i.name || '') + ' ' + (i.extra || ''));
return { name: i.name, extra: i.extra, url: i.url, id: i.id, source: k, similarity: sim };
}))
.catch(e => {
log(k + ' 搜索失败', e);
if (k === 'subtitlecat' && S.link && /Cloudflare|拦截|challenge/i.test(e.message)) {
return [{ name: '→ 浏览器打开字幕猫搜索', extra: '内嵌被拦·点此跳转', url: S.link(id), source: 'open-url' }];
}
return [];
});
});
const results = await Promise.all(tasks);
let merged = [];
results.forEach(arr => arr.forEach(x => merged.push(x)));
merged.sort((a, b) => (b.similarity || 0) - (a.similarity || 0));
if (merged.length) store.set(cacheKey('all', id), { t: Date.now(), items: merged });
handleMerged(merged, silent, id, false);
}
function sourceLabel(src) {
return src === 'xunlei' ? '迅雷' : (src === 'subtitlecat' ? '字幕猫' : (src === 'assrt' ? '伪射手' : (src === 'avsubtitles' ? 'AV字幕库' : (src === 'open-url' ? '跳转' : ''))));
}
function isSubtitleText(txt) {
return txt.indexOf('-->') >= 0 || txt.indexOf('[Events]') >= 0 || txt.indexOf('WEBVTT') >= 0 || /^\{\d+\}\{\d+\}/m.test(txt);
}
async function loadItem(it, silent) {
if (!silent) closeList();
if (it.source === 'open-url') { GM_openInTab(it.url, { active: true }); return; }
if (!silent) toast('正在加载字幕…', 4000);
try {
const url = await resolveItemUrl(it);
const txt = await fetchText(url);
if (!isSubtitleText(txt)) throw new Error('内容不是有效字幕');
setSubtitle(txt, it.name, true);
const sim = it.similarity ? ' (' + Math.round(it.similarity * 100) + '%)' : '';
const srcName = sourceLabel(it.source);
toast('已自动加载 [' + srcName + ']:' + it.name + sim, 4000);
} catch (e) {
if (e.message !== '已取消') {
if (!silent) toast('加载失败:' + e.message, 4000);
else log('静默加载失败:' + e.message);
}
}
}
async function downloadItem(it) {
if (it.source === 'open-url') { GM_openInTab(it.url, { active: true }); return; }
toast('正在下载…');
try {
const url = await resolveItemUrl(it);
const txt = await fetchText(url);
let name = sanitizeName(it.name);
if (!/\.(srt|ass|ssa|vtt|sub)$/i.test(name)) name += '.srt';
forceDownload(txt, name);
toast('已下载:' + name);
} catch (e) { if (e.message !== '已取消') toast('下载失败:' + e.message, 4000); }
}
function downloadCurrent() {
if (!state.raw) { toast('当前没有已加载的字幕'); return; }
const id = videoId() || 'subtitle';
forceDownload(state.raw, id + '.srt');
toast('已下载当前字幕');
}
/* 11. 本地文件 / 配置导入导出 */
async function onLocalFile(e) {
const f = e.target.files && e.target.files[0];
e.target.value = null;
if (!f) return;
try {
const buf = await f.arrayBuffer();
const text = decodeBuffer(buf);
setSubtitle(text, f.name.replace(/\.(srt|ass|ssa|vtt|sub)$/i, ''));
} catch (err) { toast('读取失败:' + err.message, 4000); }
}
function exportCfg() {
const data = { config: cfg, exportedAt: new Date().toISOString(), version: '3.3.0' };
forceDownload(JSON.stringify(data, null, 2), 'missav-sub-config.json');
toast('配置已导出');
}
function onImportCfg(e) {
const f = e.target.files && e.target.files[0];
e.target.value = null;
if (!f) return;
f.text().then(t => {
try {
const obj = JSON.parse(t);
const inc = obj.config || obj;
cfg = Object.assign({}, DEFAULTS, inc);
cfg.keys = Object.assign({}, DEFAULTS.keys, cfg.keys || {});
saveCfg();
if (ui.panel) { ui.panel.remove(); ui.panel = null; }
if (ui.fab) { ui.fab.remove(); ui.fab = null; }
buildPanel();
applySubStyle();
toast('配置已导入,建议刷新页面');
} catch (err) { toast('配置解析失败:' + err.message, 4000); }
});
}
/* 12. 番号识别(v3.3:支持 FC2 系列,剥离 -c/-uc/chinese-subtitle 等中文版后缀) */
function stripCodeSuffixes(raw) {
let cleaned = String(raw || '').trim();
const suffixPattern = /[\s-_]+(?:chinese[\s-_]?subtitle|uncensored[\s-_]?leak|uncensored|leak|subtitle|chinese|sub|ch|uc|hd|4k|c|中文字幕|双语字幕|无码破解|流出|破解|字幕|中文)$/i;
while (suffixPattern.test(cleaned)) cleaned = cleaned.replace(suffixPattern, '').trim();
return cleaned;
}
function codeFromSegment(seg) {
let s = String(seg || '').toLowerCase().trim();
if (!s) return '';
try { s = decodeURIComponent(s); } catch (e) {}
s = stripCodeSuffixes(s);
let m = s.match(/^fc2(?:-ppv)?-\d{4,10}$/);
if (m) return m[0].toUpperCase();
m = s.match(/^[a-z][a-z0-9]{1,9}-\d{2,8}$/);
if (m) return m[0].toUpperCase();
m = s.match(/^[a-z]{1,6}\d{2,7}$/);
if (m) return m[0].toUpperCase();
return '';
}
function extractCodeFromText(text) {
if (!text) return '';
// 从一段正文里抠出 JAV 番号:FC2-PPV 系列,或 字母前缀-数字(数字≥3 位,降低误判)
const patterns = [
/fc2[-_ ]?ppv[-_ ]?\d{4,10}/i,
/[a-z]{2,6}[-_ ]\d{3,8}/i
];
for (const p of patterns) {
const m = text.match(p);
if (m) {
const candidate = m[0].replace(/[\s_]/g, '-');
const norm = codeFromSegment(candidate);
if (norm) return norm;
}
}
return '';
}
function videoId() {
try {
const segs = location.pathname.split('/').filter(Boolean);
if (segs.length) { const a = codeFromSegment(segs[segs.length - 1]); if (a) return a; }
if (segs.length >= 2) { const b = codeFromSegment(segs[segs.length - 2]); if (b) return b; }
const fromPath = codeFromSegment(location.pathname);
if (fromPath) return fromPath;
// DOM 兜底:适配 JavDB 等「番号不在 URL 路径、而在页面标题/详情里」的站点
const selectors = ['.movie-title', 'h1', '.video-title', '.title', '[itemprop="name"]'];
let domText = document.title || '';
for (const sel of selectors) {
const node = document.querySelector(sel);
if (node && node.textContent) domText += ' ' + node.textContent;
}
return extractCodeFromText(domText);
} catch (e) { return ''; }
}
/* 14. 生命周期 */
let lastId = '';
let uiReady = false;
function onTargetChanged() { mountSubtitle(); state.cursor = 0; state.lastCue = null; lastTime = -1; }
function tick() {
const changed = Player.detect();
if (changed) onTargetChanged();
if (!uiReady) { buildPanel(); uiReady = true; }
const id = videoId();
if (id && id !== lastId) {
if (lastId) clearSubtitle(true);
lastId = id;
subHidden = false;
if (cfg.autoMatch) {
setTimeout(() => {
if (!state.cues.length && videoId() === id) {
log('静默自动匹配:' + id);
searchAll(true);
}
}, 2000);
}
}
}
function hookRoute() {
const w = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
try {
const H = w.history;
if (H && !H.__missavHooked) {
['pushState', 'replaceState'].forEach(fn => {
const orig = H[fn];
H[fn] = function () { const r = orig.apply(this, arguments); setTimeout(onTargetChanged, 0); return r; };
});
H.__missavHooked = true;
}
} catch (e) { log('hook history 失败', e); }
window.addEventListener('popstate', onTargetChanged);
}
/* 15. 启动 */
function boot() {
state.off = cfg.offset || 0;
hookRoute();
ensureHost();
tick();
setInterval(tick, 1500);
requestAnimationFrame(renderLoop);
/* v3.3:DOM 变动检测加 300ms 防抖,避免播放页频繁 DOM 变动时反复跑 detect */
let moTimer = 0;
new MutationObserver(() => {
clearTimeout(moTimer);
moTimer = setTimeout(() => { if (Player.detect()) onTargetChanged(); }, 300);
}).observe(document.documentElement, { childList: true, subtree: true });
log('已启动 v3.3.0');
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
else boot();
})();