// ==UserScript== // @name Media Ad Skip (B站 + 抖音) // @namespace https://github.com/hualeide/media-ad-skip // @version 1.5.74 // @description 像绯红之王一样删除广告时间 · B站/抖音片内与信息流跳过 // @author media-ad-skip // @homepageURL https://github.com/hualeide/media-ad-skip // @supportURL https://github.com/hualeide/media-ad-skip/issues // @updateURL https://raw.githubusercontent.com/hualeide/media-ad-skip/master/media-ad-skip.user.js // @downloadURL https://raw.githubusercontent.com/hualeide/media-ad-skip/master/media-ad-skip.user.js // @match *://www.bilibili.com/video/* // @match *://www.bilibili.com/list/* // @match *://www.bilibili.com/watchlater/* // @match *://www.bilibili.com/bangumi/* // @match *://m.bilibili.com/video/* // @match *://www.douyin.com/* // @match *://www.iesdouyin.com/* // @icon https://www.bilibili.com/favicon.ico // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @connect bsbsb.top // @connect api.bilibili.com // @connect comment.bilibili.com // @run-at document-idle // @license MIT // ==/UserScript== (function () { 'use strict'; const HOST = location.hostname; const IS_BILI = HOST.includes('bilibili.com'); const IS_DOUYIN = HOST.includes('douyin.com'); const IS_EXT = typeof chrome !== 'undefined' && !!(chrome.runtime && chrome.runtime.id); // 扩展 / 油猴分桶,避免共用 __MAS_VER__ 互相挡住 const MAS_VER_KEY = IS_EXT ? '__MAS_VER_EXT__' : '__MAS_VER_GM__'; if (window[MAS_VER_KEY] === '1.5.74') return; window[MAS_VER_KEY] = '1.5.74'; window.__MAS_VER__ = '1.5.74'; // 非目标站一律不跑(双重保险;扩展/油猴 match 已限定) if (!IS_BILI && !IS_DOUYIN) return; window.__MAS_LOADED__ = true; // --- 阈值(与 src/config.mjs 对齐;改配置后请跑 build)--- const MIN_AD_SEC = 8; // 广告段最短秒数 const MAX_AD_SEC = 420; // 广告段最长秒数 const MIN_SEG_START_SEC = 3; // 非作者自标不允许更早起点 const SEG_TAIL_GUARD_SEC = 3; // 终点须距片尾 const JUMP_VOTE_MIN_SCORE = 1.8;// 弹幕时间戳投票门槛 const CONF_LANG_TIP = 1.5; // 「谢谢X分Y郎」 const CONF_COLON_JUMP = 1.25; // 带空降词的 mm:ss const CONF_COLON_WEAK = 0.35; // 裸 mm:ss const MAX_FEED_BODY = 350_000; // feed 响应体上限,防崩 const MAX_DANMAKU_XML = 600_000; const MAX_RENDER_DATA = 250_000; const MAX_PAGE_FETCH = 600_000; const MAX_SUBTITLE_AD_SEC = 75; // 字幕估段最长;过宽会早跳进正片 const AD_CTX_RE = /(赞助|恰饭|商单|广告|软广|金主|优惠券|领券|下单|购买|带货|橱窗|链接|折扣|满减|安利)/; // 品牌默认表:构建时由 src/config.mjs 同步;勿手改中间内容 /* BRAND_KW_START */ const DEFAULT_BRAND_KW = [ "转转", "爱回收", "闲鱼", "瓜子", "萤石", "山楂树下", "真我", "神奇小鹿", "小鹿冰被", "躺岛", "蓝盒子", "半日闲", "时光存折", "栖作", "甜秘密", "华味坊", "酸汤面叶", "劲仔", "卫龙", "盐津铺子", "三只松鼠", "良品铺子", "王小卤", "认养一头牛", "妙界", "赫恩", "海洋至尊", "溪木源", "博乐达", "蜜丝婷", "盖世小鸡", "飞智", "北通", "黑白调", "骁骑", "瑞幸", "安克", "酷态科", "得物", ]; /* BRAND_KW_END */ const DEFAULTS = { autoSkip: true, showPanel: false, douyinFeedAd: true, douyinFeedLive: false, douyinFeedShop: true, douyinInVideo: true, biliInVideo: true, feedPollMs: 1400, countdownSec: 3, softOralSkipSec: 35, softOralAuto: false, useSponsorBlock: true, useDanmakuDetect: false, // 弹幕空降/关键词误伤太多,默认关 useCreatorMarks: false, // 简介很少写恰饭轴,默认关 showUndoToast: true, statsEnabled: false, brandKeywords: DEFAULT_BRAND_KW.slice(), blockBvids: [], blockMids: [], }; const AD_START = ['广告开始', '开始恰饭', '恰饭开始', '广告来了', '开始推广', '金主来了', '广告时间']; const AD_END = ['广告结束', '欢迎回来', '恰饭结束', '回来了', '广告完了', '正片开始', '回归正片']; /* 弹幕泛词 + 品牌:见 generalKw() */ const SKIP_BTN_TEXT = ['跳过广告', '关闭广告', 'Skip Ad', 'Skip Ads']; /** 章节/时间轴是否广告:须与 src/detect-core.mjs#labelLooksAd 保持同构 */ function labelLooksAd(text) { const raw = String(text || ''); if (/(广告|广告时间|恰饭|赞助|商单|推广|软广)/.test(raw)) return true; return /\bads?\b|\bsponsors?\b|\bsponsored\b|\badvert(?:s|ising|isement)?\b/i.test(raw); } const ZH_NUM = { 零: 0, 〇: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10, }; /** @type {typeof DEFAULTS} */ let cfg = loadCfg(); let panelEl = null; let panelDragHandlers = null; let statusText = '待命'; let activeSeg = null; // { start, end, source } 当前优先段 let activeSegs = []; // 多段(SponsorBlock) let skippedKeys = new Set(); /** 用户撤销过的段 key:本片内禁止再自动跳(重分析清空 skippedKeys 后仍生效) */ let undoneKeys = new Set(); /** 撤销段时间窗,兜底 key 因重检略变 */ let undoneRange = null; let lastKey = ''; let lastSkip = null; let softOralReady = false; /** 换片/重置时递增;异步分析结束后若对不上则丢弃,避免串台误跳 */ let analyzeGen = 0; function brandList() { // null/undefined → 默认;显式 [] 表示用户清空;过短残缺列表 → 补全默认词 if (!Array.isArray(cfg.brandKeywords)) return DEFAULT_BRAND_KW.slice(); const cur = cfg.brandKeywords.map((x) => String(x).trim()).filter(Boolean); if (!cur.length) return []; if (cur.length < Math.ceil(DEFAULT_BRAND_KW.length * 0.5)) { const set = new Set(cur); for (const k of DEFAULT_BRAND_KW) set.add(k); return [...set]; } return cur; } function generalKw() { return ['已买', '购买', '购入', '接广', '广告', '广子', '感谢金主', '商单', '恰饭', '下单', '买买买', ...brandList()]; } /** 弹幕稠密簇专用:去掉裸「广告/购买」,防吐槽弹幕把正片簇成广告段 */ function generalCueKw() { return ['已买', '购入', '接广', '广子', '感谢金主', '商单', '恰饭', '下单', '买买买', ...brandList()]; } function isWeakAdGossip(text) { const t = String(text || ''); return /不[是算]?广告|没有(?:这么短的)?广告|广告吗|广告吧|隔壁|删了|剪了|别的平台|感觉不像|没法判断|玩梗/.test(t); } function currentVideoId() { if (IS_BILI) { const m = location.pathname.match(/\/video\/(BV[\w]+)/i); return m ? m[1] : null; } if (IS_DOUYIN) { return (typeof getDouyinAwemeId === 'function' ? getDouyinAwemeId() : null) || (location.pathname.match(/\/video\/(\d+)/) || [])[1] || null; } return null; } function isBlockedVideo() { const id = currentVideoId(); if (!id) return false; return (cfg.blockBvids || []).map(String).includes(String(id)); } function isBlockedUp(mid) { if (mid == null || mid === '') return false; return (cfg.blockMids || []).map(String).includes(String(mid)); } async function appendFeedback(entry) { const item = { at: Date.now(), href: location.href, videoId: currentVideoId(), ...entry, }; try { if (typeof chrome !== 'undefined' && chrome.storage?.local) { const { feedbackLog = [] } = await chrome.storage.local.get(['feedbackLog']); const next = [...feedbackLog, item].slice(-50); await chrome.storage.local.set({ feedbackLog: next }); } else { const raw = localStorage.getItem('mas-feedback') || '[]'; const arr = JSON.parse(raw); arr.push(item); localStorage.setItem('mas-feedback', JSON.stringify(arr.slice(-50))); } } catch (e) { log('feedback save fail', e); } return item; } async function reportWrongMark() { const seg = lastSkip?.seg || activeSeg; const item = await appendFeedback({ reason: 'wrong_skip', seg: seg ? { start: seg.start, end: seg.end, source: seg.source } : null, fromTime: lastSkip?.fromTime ?? null, }); const skipped = lastSkip || (seg ? { key: segKey(seg), seg } : null); if (lastSkip) undoLastSkip(); else if (seg) skippedKeys.add(segKey(seg)); if (skipped) rejectSegForever(skipped); if (typeof recordStat === 'function') recordStat({ wrong: 1 }); const text = JSON.stringify(item, null, 2); try { await navigator.clipboard.writeText(text); setStatus('已记录标错并复制到剪贴板'); } catch { setStatus('已记录标错(复制失败,见设置页导出)'); } } function blockCurrentVideo(forcedId) { const id = forcedId || currentVideoId(); if (!id) { setStatus('无法识别视频 ID'); return false; } const set = new Set((cfg.blockBvids || []).map(String)); set.add(String(id)); cfg.blockBvids = [...set]; saveCfg(); activeSeg = null; activeSegs = []; skippedKeys = new Set(); // 换片/「本视频已禁用」才该清撤销记忆;重分析不清(见 runBilibili) undoneKeys = new Set(); undoneRange = null; lastSkip = null; setStatus('本视频已禁用跳过'); return true; } function unblockCurrentVideo(forcedId) { const id = forcedId || currentVideoId(); if (!id) { setStatus('无法识别视频 ID'); return false; } cfg.blockBvids = (cfg.blockBvids || []).map(String).filter((x) => x !== String(id)); saveCfg(); setStatus('已恢复本集跳过'); return true; } function isCurrentVideoBlocked() { const id = currentVideoId(); if (!id) return false; return (cfg.blockBvids || []).map(String).includes(String(id)); } function undoLastSkip() { if (!lastSkip) { setStatus('无可撤销跳过'); return; } const skipped = lastSkip; // 先作废进行中的跳转确认 / 延迟 seek,再撤回 if (pendingSkip?.timer) clearInterval(pendingSkip.timer); pendingSkip = null; clearTimeout(doSkip._toastT); if (seekTo(skipped.fromTime)) { markSegUndone(skipped); const seg = skipped.seg; if (seg && seg.start != null && seg.end != null) { setStatus(`已撤销 ${formatTime(seg.start)} → ${formatTime(seg.end)}`); } else { setStatus(`已撤销,回到 ${formatTime(skipped.fromTime)}`); } lastSkip = null; dismissMasToast(document.getElementById('mas-undo-toast')); if (typeof recordStat === 'function') recordStat({ undo: 1 }); } } function dismissMasToast(el) { if (!el) return; clearTimeout(el._hideT); clearTimeout(el._hideAnim); clearTimeout(el._outT); el.style.display = 'none'; el.classList.remove('mas-toast-in', 'mas-toast-out'); } /** 右下角 toast:纯色瞬显,无 transform/will-change(dump 显示 GPU/合成层 FATAL) */ function ensureToastStyles() { let style = document.getElementById('mas-toast-style'); if (!style) { style = document.createElement('style'); style.id = 'mas-toast-style'; (document.head || document.documentElement).appendChild(style); } style.textContent = ` #mas-undo-toast,#mas-skip-toast,#mas-feed-toast{ position:fixed;right:16px;bottom:20px;z-index:2147483646; padding:10px 12px;border-radius:10px; font:500 12.5px/1.35 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif; color:rgba(245,245,247,.9);display:none; max-width:min(260px,calc(100vw - 32px)); background:rgba(28,28,30,.88);border:0; box-shadow:0 4px 12px rgba(0,0,0,.2); } #mas-feed-toast{ padding:7px 10px;border-radius:8px;font-weight:500;font-size:12px; background:rgba(22,22,24,.78);box-shadow:0 2px 10px rgba(0,0,0,.16); max-width:min(220px,calc(100vw - 32px)); } #mas-undo-toast .mas-toast-title,#mas-skip-toast .mas-toast-title{font-weight:600;font-size:13px} #mas-feed-toast .mas-toast-title{font-weight:500;font-size:12px;opacity:.92} #mas-undo-toast .mas-toast-sub,#mas-skip-toast .mas-toast-sub{ margin-top:3px;font-weight:400;font-size:11.5px;opacity:.7; } #mas-feed-toast .mas-toast-sub{margin-top:2px;font-weight:400;font-size:11px;opacity:.62} #mas-undo-toast .mas-toast-actions,#mas-skip-toast .mas-toast-actions,#mas-feed-toast .mas-toast-actions{ margin-top:8px;display:flex;gap:6px;flex-wrap:wrap; } #mas-undo-toast .mas-btn,#mas-skip-toast .mas-btn,#mas-feed-toast .mas-btn{ appearance:none;-webkit-appearance:none;cursor:pointer;border:0;border-radius:7px; padding:5px 10px;font:600 12px/1.2 inherit; } #mas-undo-toast .mas-btn-primary,#mas-skip-toast .mas-btn-primary,#mas-feed-toast .mas-btn-primary{background:#0a84ff;color:#fff} #mas-undo-toast .mas-btn-ghost,#mas-skip-toast .mas-btn-ghost,#mas-feed-toast .mas-btn-ghost{ background:rgba(44,44,46,.85);color:#f5f5f7;border:0; } `; } function revealToast(toast) { ensureToastStyles(); clearTimeout(toast._outT); toast.classList.remove('mas-toast-out', 'mas-toast-in'); toast.style.display = 'block'; } /** 即将跳过(自动跳前 / 手动确认) */ function showUpcomingToast(seg, opts = {}) { if (!seg || cfg.showUndoToast === false) return; ensureToastStyles(); dismissMasToast(document.getElementById('mas-undo-toast')); let toast = document.getElementById('mas-skip-toast'); if (!toast) { toast = document.createElement('div'); toast.id = 'mas-skip-toast'; document.documentElement.appendChild(toast); } const range = `${formatTime(seg.start)} → ${formatTime(seg.end)}`; const label = seg.label ? (' · ' + seg.label) : ''; const withActions = !!opts.withActions; toast.innerHTML = `
即将跳过广告
${withActions ? `
` : ''}`; toast.querySelector('.mas-toast-sub').textContent = range + label; revealToast(toast); if (withActions) { bindMasActions(toast, { go: () => { doSkip(seg, true); dismissMasToast(toast); }, no: () => { dismissMasToast(toast); rejectSegForever({ key: segKey(seg), seg }); }, }); } armToastAutoHide(toast, toastStayMs(withActions)); } function showUndoToast() { if (!cfg.showUndoToast || !lastSkip) return; ensureToastStyles(); dismissMasToast(document.getElementById('mas-skip-toast')); dismissMasToast(document.getElementById('mas-feed-toast')); let toast = document.getElementById('mas-undo-toast'); if (!toast) { toast = document.createElement('div'); toast.id = 'mas-undo-toast'; document.documentElement.appendChild(toast); } const end = lastSkip.seg ? formatTime(lastSkip.seg.end) : formatTime(lastSkip.toTime); const start = lastSkip.seg ? formatTime(lastSkip.seg.start) : formatTime(lastSkip.fromTime); // 偶尔玩梗:约 1/4 次显示「时间已被删除」 const title = Math.random() < 0.25 ? '时间已被删除。' : '已跳过广告'; toast.innerHTML = `
${title}
${start} → ${end}
`; revealToast(toast); bindMasActions(toast, { undo: () => undoLastSkip(), never: () => { if (lastSkip) rejectSegForever(lastSkip); dismissMasToast(toast); }, block: () => { blockCurrentVideo(); dismissMasToast(toast); }, wrong: () => { reportWrongMark(); dismissMasToast(toast); }, }); armToastAutoHide(toast, toastStayMs(true)); } // -------------------- storage -------------------- function loadCfg() { try { const raw = typeof GM_getValue === 'function' ? GM_getValue('cfg', null) : localStorage.getItem('mas-cfg'); const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; return { ...DEFAULTS, ...(parsed || {}) }; } catch { return { ...DEFAULTS }; } } function saveCfg() { const raw = JSON.stringify(cfg); if (typeof GM_setValue === 'function') GM_setValue('cfg', raw); else localStorage.setItem('mas-cfg', raw); } // -------------------- utils -------------------- function log(...args) { console.log('[MAS]', ...args); } function formatTime(sec) { sec = Math.max(0, Math.floor(sec || 0)); const m = Math.floor(sec / 60); const s = sec % 60; return `${m}:${String(s).padStart(2, '0')}`; } /** Toast 可点时长:默认至少 10s,悬停暂停关闭 */ function toastStayMs(withActions) { if (!withActions) return 1800; return Math.max(10, Number(cfg.countdownSec) || 3) * 1000; } function armToastAutoHide(toast, ms) { if (!toast) return; const hide = () => dismissMasToast(toast); const clear = () => clearTimeout(toast._hideT); clear(); toast._hideT = setTimeout(hide, ms); toast.onmouseenter = clear; toast.onmouseleave = () => { clear(); toast._hideT = setTimeout(hide, ms); }; } /** * 捕获阶段绑定 data-mas-act,挡住站点抢点击(抖音/B站常见)。 * handlers: { undo(){}, block(){}, ... } */ function bindMasActions(root, handlers) { if (!root || !handlers) return; const run = (e) => { const btn = e.target?.closest?.('[data-mas-act]'); if (!btn || !root.contains(btn)) return; const act = btn.getAttribute('data-mas-act'); if (!act || typeof handlers[act] !== 'function') return; e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation?.(); try { handlers[act](); } catch (err) { log('mas-act fail', act, err); } }; if (root._masActBound) { root.removeEventListener('pointerdown', root._masActBound, true); root.removeEventListener('click', root._masActBound, true); } root._masActBound = run; root.addEventListener('pointerdown', run, true); root.addEventListener('click', run, true); } /** 本地轻量计数:成就看板默认记账,防抖写盘,不存明细 */ const EMPTY_STATS = { skipCount: 0, undoCount: 0, wrongCount: 0, feedSwipeCount: 0, feedAdCount: 0, feedLiveCount: 0, feedShopCount: 0, savedSec: 0, }; let statsMem = { ...EMPTY_STATS }; let statsLoaded = false; let statsQueue = []; let statsDirty = false; let statsFlushTimer = 0; let statsLoading = false; function applyStatDelta(s, delta) { if (delta.skip) s.skipCount += delta.skip; if (delta.undo) s.undoCount += delta.undo; if (delta.wrong) s.wrongCount += delta.wrong; if (delta.feed) s.feedSwipeCount += delta.feed; if (delta.feedAd) s.feedAdCount += delta.feedAd; if (delta.feedLive) s.feedLiveCount += delta.feedLive; if (delta.feedShop) s.feedShopCount += delta.feedShop; if (delta.savedSec) s.savedSec += Math.max(0, Number(delta.savedSec) || 0); if (s.savedSec > 1e9) s.savedSec = 1e9; } function feedSwipeStatDelta(reason) { const d = { feed: 1 }; if (reason === '直播卡') d.feedLive = 1; else if (reason === '购物卡') d.feedShop = 1; else d.feedAd = 1; return d; } function flushStats() { if (!statsDirty) return; if (!statsLoaded) { ensureStatsLoad(); return; } statsDirty = false; const snap = { ...statsMem }; try { if (typeof chrome !== 'undefined' && chrome.storage?.local?.set) { chrome.storage.local.set({ masStats: snap }, () => { try { void chrome.runtime?.lastError; } catch { /* ignore */ } }); return; } localStorage.setItem('mas-stats', JSON.stringify(snap)); } catch { /* ignore */ } } function scheduleStatsFlush(delayMs) { const ms = Math.max(0, Number(delayMs) || 250); clearTimeout(statsFlushTimer); statsFlushTimer = setTimeout(() => { statsFlushTimer = 0; flushStats(); }, ms); } function ensureStatsLoad() { if (statsLoaded || statsLoading) return; statsLoading = true; const finish = (base) => { statsMem = { ...EMPTY_STATS, ...(base && typeof base === 'object' ? base : {}) }; for (const d of statsQueue) applyStatDelta(statsMem, d); statsQueue = []; statsLoaded = true; statsLoading = false; statsDirty = true; flushStats(); // 读完立刻落盘,弹窗才能看到 }; try { if (typeof chrome !== 'undefined' && chrome.storage?.local?.get) { chrome.storage.local.get(['masStats'], (data) => { try { if (chrome.runtime?.lastError) { finish({}); return; } } catch { /* ignore */ } finish(data?.masStats); }); return; } finish(JSON.parse(localStorage.getItem('mas-stats') || '{}')); } catch { finish({}); } } /** @param {{ skip?: number, undo?: number, wrong?: number, feed?: number, feedAd?: number, feedLive?: number, feedShop?: number, savedSec?: number }} delta */ function recordStat(delta) { if (!delta) return; applyStatDelta(statsMem, delta); statsDirty = true; if (!statsLoaded) { statsQueue.push(delta); if (statsQueue.length > 40) statsQueue = statsQueue.slice(-20); ensureStatsLoad(); return; } // 划走/跳过马上写盘;其它稍抖 const urgent = !!(delta.feed || delta.feedAd || delta.feedLive || delta.feedShop || delta.skip || delta.savedSec); scheduleStatsFlush(urgent ? 80 : 400); } try { document.addEventListener('visibilitychange', () => { if (document.hidden) flushStats(); }); window.addEventListener('pagehide', () => flushStats()); } catch { /* ignore */ } /** 信息流划走后的 toast(放 utils 后,避免扩展构建剥掉) */ function showFeedSwipeToast(reason) { if (cfg.showUndoToast === false) return; ensureToastStyles(); dismissMasToast(document.getElementById('mas-skip-toast')); dismissMasToast(document.getElementById('mas-undo-toast')); let toast = document.getElementById('mas-feed-toast'); if (!toast) { toast = document.createElement('div'); toast.id = 'mas-feed-toast'; document.documentElement.appendChild(toast); } const title = reason === '直播卡' ? '已划走直播' : (reason === '购物卡' ? '已划走购物' : '已划走广告'); let sub = ''; if (reason === '直播卡') { sub = `累计 ${Math.max(0, Number(statsMem.feedLiveCount) || 0)}`; } else if (reason === '购物卡') { sub = `累计 ${Math.max(0, Number(statsMem.feedShopCount) || 0)}`; } else { sub = `累计 ${Math.max(0, Number(statsMem.feedAdCount) || 0)}`; } toast.innerHTML = `
${title}
${sub}
`; revealToast(toast); clearTimeout(showFeedSwipeToast._t); showFeedSwipeToast._t = setTimeout(() => dismissMasToast(toast), 1400); } /** 先滑走,确认换卡后再记账+轻提示(提示晚于划走动画) */ function afterFeedSwiped(beforeId, reason) { const done = (afterId) => { if (afterId) lastSkippedAwemeId = afterId; else if (beforeId) lastSkippedAwemeId = beforeId; recordStat(feedSwipeStatDelta(reason)); setStatus(`已划走${reason}`); // 等画面切走再弹,避免「提示先出、还没滑」 setTimeout(() => { if (typeof showFeedSwipeToast === 'function') showFeedSwipeToast(reason); }, 260); }; setTimeout(() => { const afterId = typeof getDouyinAwemeId === 'function' ? (getDouyinAwemeId() || '') : ''; const moved = !beforeId || (!!afterId && afterId !== beforeId); if (moved) { done(afterId); return; } if (typeof swipeToNextFeed === 'function') swipeToNextFeed({ hard: true }); setTimeout(() => { const after2 = typeof getDouyinAwemeId === 'function' ? (getDouyinAwemeId() || '') : ''; if (!beforeId || (!!after2 && after2 !== beforeId)) { done(after2); return; } lastFeedSkipKey = ''; lastSkippedAwemeId = ''; setStatus(`检测到${reason}·未滑出,将重试`); }, 380); }, 300); } /** 检测到直播但开关关着:一键开启 */ function showEnableLiveSwipeToast() { if (cfg.showUndoToast === false) return; ensureToastStyles(); dismissMasToast(document.getElementById('mas-skip-toast')); dismissMasToast(document.getElementById('mas-undo-toast')); let toast = document.getElementById('mas-feed-toast'); if (!toast) { toast = document.createElement('div'); toast.id = 'mas-feed-toast'; document.documentElement.appendChild(toast); } toast.innerHTML = `
检测到直播卡
「划走直播」当前是关的
`; revealToast(toast); bindMasActions(toast, { enable: () => { cfg.douyinFeedLive = true; try { saveCfg(); } catch { /* ignore */ } dismissMasToast(toast); setStatus('已开启划走直播'); lastFeedSkipKey = ''; lastFeedSkipAt = 0; const beforeId = typeof getDouyinAwemeId === 'function' ? (getDouyinAwemeId() || '') : ''; swipeToNextFeed(); afterFeedSwiped(beforeId, '直播卡'); }, no: () => dismissMasToast(toast), }); armToastAutoHide(toast, 8000); } function softSkipSec() { const n = Number(cfg.softOralSkipSec); return Math.max(15, Math.min(90, Number.isFinite(n) ? n : 35)); } /** 无看点终点时:从当前进度软跳一段(需用户听到口播再点) */ function softSkipForward() { const v = getVideoEl(); if (!v || !Number.isFinite(v.currentTime)) { setStatus('没有可跳的播放器'); return; } const from = v.currentTime; const sec = softSkipSec(); const dur = v.duration && Number.isFinite(v.duration) ? v.duration : 0; const end = dur > 0 ? Math.min(dur - 0.5, from + sec) : from + sec; if (end <= from + 2) { setStatus('已接近片尾,无法再跳'); return; } const seg = { start: from, end, source: 'soft-oral', label: `约${Math.round(end - from)}s` }; activeSeg = seg; activeSegs = [seg]; softOralReady = true; doSkip(seg, true); } function showSoftOralHint() { if (cfg.showUndoToast === false) return; ensureToastStyles(); dismissMasToast(document.getElementById('mas-undo-toast')); let toast = document.getElementById('mas-skip-toast'); if (!toast) { toast = document.createElement('div'); toast.id = 'mas-skip-toast'; document.documentElement.appendChild(toast); } const sec = softSkipSec(); const auto = !!(cfg.softOralAuto && cfg.autoSkip); let left = Math.max(3, Number(cfg.countdownSec) || 3); const render = () => { toast.innerHTML = auto ? `
疑似口播 · ${left}s 后软跳约${sec}秒
无看点/字幕终点时,从当前进度跳过。可取消。
` : `
口播无时间戳
听到广告时点下方,从当前进度跳过约 ${sec} 秒
`; revealToast(toast); bindMasActions(toast, { soft: () => { clearInterval(showSoftOralHint._tick); dismissMasToast(toast); softSkipForward(); }, no: () => { clearInterval(showSoftOralHint._tick); dismissMasToast(toast); }, }); }; clearInterval(showSoftOralHint._tick); render(); if (auto) { showSoftOralHint._tick = setInterval(() => { left -= 1; if (left <= 0) { clearInterval(showSoftOralHint._tick); dismissMasToast(toast); softSkipForward(); return; } render(); }, 1000); } else { armToastAutoHide(toast, 12000); } } function zhNumToInt(str) { if (!str) return 0; if (/^\d+$/.test(str)) return parseInt(str, 10); if (str === '十') return 10; if (str.includes('十')) { const [a, b] = str.split('十'); const tens = a ? ZH_NUM[a] ?? 1 : 1; const ones = b ? ZH_NUM[b] ?? 0 : 0; return tens * 10 + ones; } return ZH_NUM[str] ?? 0; } function convertChineseNumbersToArabic(text) { let converted = String(text || ''); converted = converted.replace(/([一二三四五六七八九])?十([一二三四五六七八九])?/g, (_, tens, ones) => { const tensValue = tens ? (ZH_NUM[tens] ?? 1) : 1; const onesValue = ones ? (ZH_NUM[ones] ?? 0) : 0; return String(tensValue * 10 + onesValue); }); for (const [c, n] of Object.entries(ZH_NUM)) { if (c === '十') continue; converted = converted.split(c).join(String(n)); } return converted; } function extractTimeFromText(text) { if (!text) return null; let t = text.trim(); if (t.startsWith('[') || t.startsWith('{')) return null; if (/发布.{0,4}[分秒钟]|打\s*\d+\s*分|^\d+(\.\d+)?\s*(米|km)/i.test(t)) return null; if (/\d+\.\d+.*(更新|版本|预告|我的世界|minecraft)/i.test(t)) return null; if (/minecraft|我的世界/i.test(t) && /\d+\.\d+/.test(t)) return null; if (/\d{4}\s*年|\d{1,2}\s*月\s*\d{1,2}\s*日|考古/.test(t)) return null; if (/\d{4}\s*[-/,.]\s*\d{1,2}/.test(t)) return null; const raw = t; t = convertChineseNumbersToArabic(t); let m = t.match(/(?:谢谢|感谢|谢|多谢|感恩)?\s*(\d{1,2})\s*分\s*(\d{1,2})\s*(?:郎|君|酱|哥|姐|侠|总|大佬|大神)/); if (m) return { time: parseInt(m[1], 10) * 60 + parseInt(m[2], 10), conf: CONF_LANG_TIP }; m = t.match(/(\d{1,2})[:;:;](\d{2})(?!\d)/); if (m) { const sec = parseInt(m[2], 10); if (sec < 60) { const time = parseInt(m[1], 10) * 60 + sec; const hasJump = /(空降(?!兵)|跳过|快进|广告|恰饭|指路|进度条|mark|标记|正片|指挥部)/i.test(raw + t); return { time, conf: hasJump ? CONF_COLON_JUMP : CONF_COLON_WEAK }; } } m = t.match(/(\d{1,3})\s*分\s*半/); if (m) return { time: parseInt(m[1], 10) * 60 + 30, conf: 1.15 }; m = t.match(/(\d{1,3})\s*分\s*(\d{1,2})\s*秒/); if (m) return { time: parseInt(m[1], 10) * 60 + parseInt(m[2], 10), conf: 1.1 }; m = t.match(/(\d{1,3})\s*分钟?(?!\s*[半\d])/); if (m && /(空降(?!兵)|跳过|快进|广告|恰饭|指路|进度条|跳到|直达)/.test(raw + t)) { return { time: parseInt(m[1], 10) * 60, conf: 1.0 }; } m = t.match(/0?(\d{1,2})(\d{2})\s*工程/); if (m) { const sec = parseInt(m[2], 10); if (sec < 60) return { time: parseInt(m[1], 10) * 60 + sec, conf: 1.3 }; } m = t.match(/(?:空降(?!兵)|跳过|快进|指路|进度条|跳到|直达)[^\d]{0,8}(\d{2,4})\s*[秒sS]/); if (m) return { time: parseInt(m[1], 10), conf: 1.4 }; m = (raw + t).match(/(\d{1,3}[::]\d{2}).{0,6}(?:mark|标记)/i) || (raw + t).match(/(?:mark|标记).{0,6}(\d{1,3}[::]\d{2})/i); if (m) { const p = m[1].match(/(\d{1,3})[::](\d{2})/); if (p) return { time: parseInt(p[1], 10) * 60 + parseInt(p[2], 10), conf: 1.2 }; } return null; } function findDenseCluster(times, windowSec, minSize) { if (!times || times.length < minSize) return null; const sorted = [...times].sort((a, b) => a - b); let best = null; let i = 0; for (let j = 0; j < sorted.length; j++) { while (sorted[j] - sorted[i] > windowSec) i++; const count = j - i + 1; if (count >= minSize && (!best || count > best.count)) { best = { count, start: sorted[i], end: sorted[j], center: (sorted[i] + sorted[j]) / 2 }; } } return best; } /** 段是否可跳:长度 / 起点 / 片尾守卫(阈值见文件顶 MIN_*) */ function validSeg(seg, duration) { if (!seg || seg.start == null || seg.end == null) return false; const len = seg.end - seg.start; if (len < MIN_AD_SEC || len > MAX_AD_SEC) return false; if (seg.start < 0) return false; // 作者自标允许片头广告 if (!String(seg.source || '').startsWith('creator') && seg.start < MIN_SEG_START_SEC) return false; if (duration > 0 && seg.end >= duration - SEG_TAIL_GUARD_SEC) return false; return true; } let pendingSkip = null; // { key, target, timer } /** 每次 seek 递增;抖音延迟重试若对不上则作废,避免撤销后又被拽回终点 */ let seekSerial = 0; function getActiveFeedRoot() { if (!IS_DOUYIN) return null; return document.querySelector( '[data-e2e="feed-active-video"], .swiper-slide-active', ); } function getVideoEl() { const root = getActiveFeedRoot(); if (root) { const v = root.querySelector('video, bwp-video'); if (v) return v; } if (/\/video\//.test(location.pathname)) { const main = document.querySelector( '[data-e2e="feed-active-video"] video, .xgplayer video, video, bwp-video', ); if (main) return main; } return document.querySelector('video, bwp-video'); } /** 轻量 seek:只改 currentTime,禁止全页 query + 禁止狂点进度条 */ function seekTo(sec) { const v = getVideoEl(); if (!v) return false; const target = Math.max(0, sec); const serial = ++seekSerial; try { if (Math.abs((v.currentTime || 0) - target) < 0.35) return true; v.currentTime = target; if (IS_DOUYIN) { const retry = () => { if (serial !== seekSerial) return; const v2 = getVideoEl(); if (v2 && Math.abs(v2.currentTime - target) > 1.5) { try { v2.currentTime = target; } catch { /* ignore */ } } }; setTimeout(retry, 300); setTimeout(retry, 750); } return true; } catch { return false; } } function seekCloseEnough(target, slack = 2.5) { const v = getVideoEl(); return !!(v && Number.isFinite(v.currentTime) && Math.abs(v.currentTime - target) <= slack); } // 字幕硬 CTA(已去掉「合作/限时/入手/安利」等游戏口播高频误伤词) const AD_CONTENT_KW = [ '赞助', '冠名', '商单', '蓝链', '二维码', '口令', '领券', '优惠券', '优惠码', '兑换码', '首充', '应用商店', ]; /** 字幕聚类:按间隙拆簇 + 轻 padding,防早段误命中导致还没到广告就跳 */ function detectFromSubtitles(lines, danmaku, duration) { if (!lines || lines.length < 5) return null; const hits = []; for (const line of lines) { const text = String(line.content || ''); const lower = text.toLowerCase(); const brands = brandList().filter((k) => text.includes(k) || lower.includes(k.toLowerCase())); const matched = AD_CONTENT_KW.filter((k) => lower.includes(k.toLowerCase()) || text.includes(k)); if (!brands.length && !matched.length) continue; hits.push({ from: line.from, to: line.to, matched, brands, text, weight: brands.length ? 2 + brands.length : 1, }); } if (!hits.length) return null; const brandHits = hits.filter((h) => h.brands.length); const hasAdCtx = hits.some((h) => h.matched.length || AD_CTX_RE.test(h.text || '')); const longBrand = hits.some((h) => h.brands.some((b) => b.length >= 4)); const hasBrand = brandHits.length > 0 && (hasAdCtx || brandHits.length >= 2 || longBrand); if (hits.length < 2 && !hasBrand) return null; const PAD_START = 2; const PAD_END = 8; const CLUSTER_GAP = 28; function splitClusters(list, gapSec) { const out = []; let cur = []; for (const h of list) { if (cur.length && h.from - cur[cur.length - 1].to > gapSec) { out.push(cur); cur = []; } cur.push(h); } if (cur.length) out.push(cur); return out; } function densestSubspan(list, maxSpan) { if (!list.length) return null; let bestW = -1; let bestI = 0; let bestJ = 0; let i = 0; for (let j = 0; j < list.length; j++) { while (list[j].from - list[i].from > maxSpan) i += 1; let w = 0; for (let k = i; k <= j; k++) w += list[k].weight; const span = list[j].to - list[i].from; const bestSpan = list[bestJ].to - list[bestI].from; const better = w > bestW || (w === bestW && span < bestSpan) || (w === bestW && span === bestSpan && list[i].from > list[bestI].from); if (better) { bestW = w; bestI = i; bestJ = j; } } return { weight: bestW, start: list[bestI].from, end: list[bestJ].to }; } const clampSub = (seg) => { if (!seg || seg.end <= seg.start) return null; if (seg.end - seg.start > MAX_SUBTITLE_AD_SEC) { seg.start = Math.max(0, seg.end - MAX_SUBTITLE_AD_SEC); } return validSeg(seg, duration) ? seg : null; }; const pool = hasBrand && brandHits.length ? brandHits : hits; let best = null; for (const c of splitClusters(pool, CLUSTER_GAP)) { const d = densestSubspan(c, MAX_SUBTITLE_AD_SEC); if (!d || d.weight < 2) continue; if (!best || d.weight > best.weight || (d.weight === best.weight && (d.end - d.start) < (best.end - best.start))) { best = d; } } if (best && best.end > best.start) { let start = Math.max(0, best.start - (hasBrand ? PAD_START : 0)); let end = best.end + (hasBrand ? PAD_END : 0); if (duration > 0) end = Math.min(duration - SEG_TAIL_GUARD_SEC - 0.1, end); return clampSub({ start, end, source: hasBrand ? 'subtitle-brand' : 'subtitle', }); } const strong = hits.find((h) => (hasBrand && h.brands.length) || h.matched.length >= 2); if (strong) { let start = Math.max(0, strong.to - 45); if (danmaku?.length) { const win = danmaku.filter((d) => d.time >= strong.to - 90 && d.time <= strong.to); for (const d of win) { if (AD_START.some((k) => d.text.includes(k)) || generalKw().some((k) => d.text.includes(k))) { start = Math.min(start, d.time); } } } let end = strong.to; if (hasBrand && strong.brands.length) { start = Math.max(0, strong.from - PAD_START); end = duration > 0 ? Math.min(duration - SEG_TAIL_GUARD_SEC - 0.1, strong.to + PAD_END) : strong.to + PAD_END; } return clampSub({ start, end, source: (hasBrand && strong.brands.length) ? 'subtitle-brand' : 'subtitle-cta', }); } return null; } function skipIfPlayingInSegs() { const v = getVideoEl(); if (!v || !Number.isFinite(v.currentTime)) return false; const t = v.currentTime; const pool = activeSegs.length ? activeSegs : (activeSeg ? [activeSeg] : []); for (const seg of pool) { // 勿 force:否则会清掉 skippedKeys / 绕过撤销禁跳 if (isAutoSkipBlocked(seg)) continue; if (t >= seg.start - 0.6 && t < seg.end - 0.4) { doSkip(seg, false); return true; } } return false; } function statusForUi() { // 撤销后以 statusText 为准,勿再被 lastSkip 盖成「已跳过」 if (/^已撤销|^无可撤销/.test(statusText)) return statusText; if (lastSkip?.seg) { const s = lastSkip.seg; return `已跳过 ${formatTime(s.start)} → ${formatTime(s.end)} (${s.source || ''})`; } return statusText; } function setStatus(text) { statusText = text; const el = document.getElementById('mas-status'); if (el) el.textContent = text; } function hidePanel() { if (panelDragHandlers) { window.removeEventListener('mousemove', panelDragHandlers.move); window.removeEventListener('mouseup', panelDragHandlers.up); panelDragHandlers = null; } if (!panelEl) return; panelEl.remove(); panelEl = null; } function resetPlaybackState() { analyzeGen += 1; seekSerial += 1; lastKey = ''; activeSeg = null; activeSegs = []; skippedKeys = new Set(); // 撤销语义:本片内禁止再自动跳;仅「本集不跳」会连同 undoneKeys 一起清空 lastSkip = null; softOralReady = false; if (pendingSkip?.timer) clearTimeout(pendingSkip.timer); pendingSkip = null; dismissMasToast(document.getElementById('mas-undo-toast')); dismissMasToast(document.getElementById('mas-skip-toast')); } function currentBvidFromUrl() { const m = location.pathname.match(/\/video\/(BV[\w]+)/i); return m ? m[1] : ''; } /** SPA 监听键:B站忽略 t=/追踪参数,避免播放进度改 URL 时反复清空跳点 */ function spaWatchKey() { if (IS_BILI) { const id = currentBvidFromUrl() || (String(location.hash || '').match(/BV[\w]+/i) || [])[0] || (location.pathname.match(/\/bangumi\/play\/([^/?#]+)/i) || [])[1] || location.pathname; const p = new URLSearchParams(location.search).get('p') || ''; return `${String(id).toUpperCase()}|${p}`; } return location.pathname + location.search; } function analysisStillCurrent(gen, expectBvid) { if (gen !== analyzeGen) return false; if (!expectBvid || !IS_BILI) return true; const live = currentBvidFromUrl(); if (!live) return true; return live.toUpperCase() === String(expectBvid).toUpperCase(); } // -------------------- time-text clustering (shared) -------------------- /** * 从文本列表里找「跳到某时刻」的共识终点,再估广告段。 * @param {{time?:number,text:string}[]} items time=弹幕出现时刻;评论可无 time * @param {number} duration */ function detectFromJumpTexts(items, duration) { const votes = new Map(); // endSec -> score const ctxScore = new Map(); const pairs = []; const seenText = new Set(); for (const it of items) { const info = extractTimeFromText(it.text); if (!info) continue; const end = Math.round(info.time); if (end <= 0 || (duration > 0 && end >= duration - 5)) continue; const text = String(it.text || '').replace(/\s+/g, ''); const dedupeKey = `${end}|${text}`; if (seenText.has(dedupeKey)) continue; seenText.add(dedupeKey); let score = info.conf; const adCtx = /(广告|恰饭|赞助|商单|正片|金主|软广)/.test(text); const langTip = /(?:谢谢|感谢).{0,12}分|(?:\d)\s*分\s*\d{1,2}\s*(?:郎|君)/.test(text); if (/(空降(?!兵)|跳过|快进|广告|恰饭|指路|谢谢|感谢)/.test(text)) { score += adCtx || langTip ? 0.8 : 0.25; } votes.set(end, (votes.get(end) || 0) + score); if (adCtx || langTip) ctxScore.set(end, (ctxScore.get(end) || 0) + score); if (typeof it.time === 'number' && !Number.isNaN(it.time)) { pairs.push({ start: it.time, end, score }); } } if (votes.size === 0) return null; const ranked = [...votes.entries()].sort((a, b) => b[1] - a[1]); const [bestEnd, bestScore] = ranked[0]; // 弱票(裸时间戳)再多也难过门槛;要空降词/郎体等抬分 if (bestScore < JUMP_VOTE_MIN_SCORE) return null; // 用弹幕出现时刻估广告起点;否则默认 end-60 let start = Math.max(0, bestEnd - 60); const related = pairs.filter((p) => Math.abs(p.end - bestEnd) <= 2 && p.end - p.start >= 20); if (related.length) { related.sort((a, b) => a.start - b.start); start = related[0].start + 2; } // 纯剧透空降(无广告/郎语境)禁止拉超长段 if ((ctxScore.get(bestEnd) || 0) < 0.9 && bestEnd - start > MAX_SUBTITLE_AD_SEC) return null; const seg = { start, end: bestEnd, source: 'timestamp' }; return validSeg(seg, duration) ? seg : null; } function detectFromKeywords(items, duration) { const starts = []; const ends = []; const generals = []; const cueKw = generalCueKw(); for (const it of items) { const t = (it.text || '').trim(); const time = typeof it.time === 'number' ? it.time : null; if (time == null) continue; if (AD_START.some((k) => t.includes(k))) starts.push(time); if (AD_END.some((k) => t.includes(k))) ends.push(time); if (isWeakAdGossip(t)) continue; if (cueKw.some((k) => t.includes(k))) generals.push(time); } const sc = findDenseCluster(starts, 20, 2); const ec = findDenseCluster(ends, 20, 2); if (sc && ec && ec.center > sc.center) { const seg = { start: sc.start, end: ec.end, source: 'keyword' }; if (validSeg(seg, duration)) return seg; } // 泛词簇:窗口收紧 + 至少 4 条,避免「广告吗」吐槽刷屏误跳 const gc = findDenseCluster(generals, 50, 4); if (gc && gc.end - gc.start >= 20) { const seg = { start: Math.max(0, gc.start - 3), end: gc.end, source: 'keyword-general' }; if (validSeg(seg, duration)) return seg; } return null; } // -------------------- UI -------------------- function ensurePanel() { if (!cfg.showPanel || panelEl) return; panelEl = document.createElement('div'); panelEl.id = 'mas-panel'; panelEl.innerHTML = `
Ad Skip
${statusText}
${IS_DOUYIN ? ` ` : ''}
`; const style = document.createElement('style'); // 不用 backdrop-filter:盖在抖音/B站视频上会触发 Chrome 渲染崩溃 style.textContent = ` #mas-panel{ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; color:#f5f5f7; background:#1c1c1e; border:1px solid #3a3a3c; box-shadow:0 8px 24px rgba(0,0,0,.35); letter-spacing:-0.01em; position:fixed;right:16px;top:96px;z-index:2147483646; width:212px;font-size:12px;line-height:1.4; border-radius:12px;user-select:none;overflow:hidden; } #mas-panel .mas-head{ display:flex;align-items:center;justify-content:space-between; padding:10px 12px;border-bottom:1px solid #3a3a3c;cursor:move; font-weight:600;font-size:13px; } #mas-panel .mas-body{padding:10px 12px;display:flex;flex-direction:column;gap:7px} #mas-panel.collapsed .mas-body{display:none} #mas-panel label{display:flex;gap:6px;align-items:center;opacity:.95} #mas-panel #mas-status{opacity:.78;min-height:1.2em;word-break:break-all;font-weight:400} #mas-panel .mas-btn{ appearance:none;-webkit-appearance:none;cursor:pointer; border:0;border-radius:8px;padding:6px 12px;font:600 12px/1.2 inherit; } #mas-panel .mas-btn-primary{background:#0a84ff;color:#fff} #mas-panel .mas-btn-ghost{ background:#2c2c2e;color:#f5f5f7;border:1px solid #3a3a3c; } #mas-panel button#mas-toggle{ background:transparent;border:0;padding:2px 6px;color:#f5f5f7;cursor:pointer;border-radius:8px; } @media (prefers-reduced-motion: reduce){ #mas-panel .mas-btn{transition:none} } `; document.documentElement.appendChild(style); document.documentElement.appendChild(panelEl); panelEl.querySelectorAll('input[data-k]').forEach((input) => { const k = input.getAttribute('data-k'); input.checked = !!cfg[k]; input.addEventListener('change', () => { cfg[k] = input.checked; saveCfg(); setStatus(`已更新: ${k}`); }); }); panelEl.querySelector('#mas-toggle').addEventListener('click', () => { panelEl.classList.toggle('collapsed'); }); bindMasActions(panelEl, { skip: () => { if (activeSeg) doSkip(activeSeg, true); else if (softOralReady) softSkipForward(); else setStatus(statusText.includes('分析') ? '还在分析,稍后再点' : '暂无跳点(本片未识别到广告段)'); }, reanalyze: () => { resetPlaybackState(); if (IS_BILI) runBilibili(); else douyinAnalyzeInVideo(); }, undo: () => undoLastSkip(), block: () => blockCurrentVideo(), wrong: () => { reportWrongMark(); }, }); // 简易拖拽(window 监听挂引用,hidePanel 时移除,防反复开关堆积) const head = panelEl.querySelector('.mas-head'); let dragging = false; let ox = 0; let oy = 0; head.addEventListener('mousedown', (e) => { dragging = true; const r = panelEl.getBoundingClientRect(); ox = e.clientX - r.left; oy = e.clientY - r.top; e.preventDefault(); }); panelDragHandlers = { move: (e) => { if (!dragging || !panelEl) return; panelEl.style.left = `${Math.max(0, e.clientX - ox)}px`; panelEl.style.top = `${Math.max(0, e.clientY - oy)}px`; panelEl.style.right = 'auto'; }, up: () => { dragging = false; }, }; window.addEventListener('mousemove', panelDragHandlers.move); window.addEventListener('mouseup', panelDragHandlers.up); } function showSkipToast(seg) { showUpcomingToast(seg, { withActions: true }); } function segKey(seg) { return `${Math.round(seg.start)}-${Math.round(seg.end)}`; } function markSegUndone(skipped) { if (!skipped) return; const key = skipped.key || (skipped.seg ? segKey(skipped.seg) : ''); if (key) { skippedKeys.add(key); undoneKeys.add(key); } const seg = skipped.seg; if (seg && seg.start != null && seg.end != null) { undoneRange = { start: seg.start - 1, end: seg.end + 1 }; } } /** 本片永久拒绝的段 key:chrome.storage.local / localStorage,按视频 ID 分桶 */ let rejectSegMap = null; let rejectMapLoading = false; let rejectMapLoadingAt = 0; function writeRejectMap(map) { rejectSegMap = map; try { if (typeof chrome !== 'undefined' && chrome.storage?.local) { chrome.storage.local.set({ masRejectSegs: map }); return; } localStorage.setItem('mas-reject-segs', JSON.stringify(map)); } catch { /* ignore */ } } function ensureRejectMap(cb) { if (rejectSegMap) { cb?.(rejectSegMap); return; } if (rejectMapLoading) { // storage 永不回调时兜底空表,防 80ms 无限重试 if (Date.now() - rejectMapLoadingAt > 4000) { rejectSegMap = {}; rejectMapLoading = false; cb?.(rejectSegMap); return; } setTimeout(() => ensureRejectMap(cb), 80); return; } rejectMapLoading = true; rejectMapLoadingAt = Date.now(); const finish = (raw) => { rejectSegMap = raw && typeof raw === 'object' ? raw : {}; rejectMapLoading = false; cb?.(rejectSegMap); }; try { if (typeof chrome !== 'undefined' && chrome.storage?.local) { chrome.storage.local.get(['masRejectSegs'], (data) => finish(data?.masRejectSegs)); return; } finish(JSON.parse(localStorage.getItem('mas-reject-segs') || '{}')); } catch { finish({}); } } function applyRejectsToSession(videoId) { if (!videoId) return; ensureRejectMap((map) => { const list = map[String(videoId)] || []; for (const k of list) { undoneKeys.add(k); skippedKeys.add(k); } }); } function rejectSegForever(skipped) { markSegUndone(skipped); const vid = currentVideoId(); const key = skipped?.key || (skipped?.seg ? segKey(skipped.seg) : ''); if (!vid || !key) { setStatus('已标记本段不再跳(仅本次)'); return; } ensureRejectMap((map) => { const id = String(vid); const list = Array.isArray(map[id]) ? map[id].slice() : []; if (!list.includes(key)) list.push(key); map[id] = list.slice(-40); const ids = Object.keys(map); if (ids.length > 80) { for (const old of ids.slice(0, ids.length - 80)) delete map[old]; } writeRejectMap(map); setStatus('已记住:本段不再自动跳'); }); } function forgetRejectKey(videoId, key) { if (!videoId || !key) return; ensureRejectMap((map) => { const id = String(videoId); const list = Array.isArray(map[id]) ? map[id].filter((k) => k !== key) : []; if (list.length) map[id] = list; else delete map[id]; writeRejectMap(map); }); } function isAutoSkipBlocked(seg) { if (!seg) return false; const key = segKey(seg); if (skippedKeys.has(key) || undoneKeys.has(key)) return true; if (undoneRange && seg.end > undoneRange.start && seg.start < undoneRange.end) return true; return false; } function doSkip(seg, force) { if (!seg) return; const key = segKey(seg); if (!force && isAutoSkipBlocked(seg)) return; if (force) { skippedKeys.delete(key); undoneKeys.delete(key); if (undoneRange && seg.end > undoneRange.start && seg.start < undoneRange.end) { undoneRange = null; } forgetRejectKey(currentVideoId(), key); } if (pendingSkip?.key === key) { const target = pendingSkip.target; if (pendingSkip.timer) clearInterval(pendingSkip.timer); if (seekTo(target)) { const fromTime = getVideoEl()?.currentTime ?? seg.start; skippedKeys.add(key); lastSkip = { fromTime, toTime: target, key, seg }; pendingSkip = null; setStatus(`已跳过 ${formatTime(seg.start)} → ${formatTime(seg.end)}`); } return; } // 换段前清掉上一次确认轮询,避免定时器叠多层 if (pendingSkip?.timer) { clearInterval(pendingSkip.timer); pendingSkip = null; } const v = getVideoEl(); const fromTime = v ? v.currentTime : seg.start; const target = seg.end + 0.3; // 先 seek,绝不与 toast 同帧;自动跳不弹「即将跳过」 seekTo(target); log('skip try', seg, '→', target); const commit = () => { skippedKeys.add(key); lastSkip = { fromTime, toTime: target, key, seg }; const startLabel = formatTime(seg.start ?? fromTime); setStatus(`已跳过 ${startLabel} → ${formatTime(seg.end)} (${seg.source || ''})`); const saved = Math.max(0, (seg.end ?? target) - (seg.start ?? fromTime)); recordStat({ skip: 1, savedSec: saved }); clearTimeout(doSkip._toastT); doSkip._toastT = setTimeout(() => showUndoToast(), 450); }; if (!IS_DOUYIN) { commit(); return; } setStatus(`正在跳到 ${formatTime(target)}…`); let tries = 0; if (pendingSkip?.timer) clearInterval(pendingSkip.timer); const timer = setInterval(() => { tries += 1; if (seekCloseEnough(target)) { clearInterval(timer); pendingSkip = null; commit(); return; } if (tries <= 2) seekTo(target); if (tries >= 3) { clearInterval(timer); pendingSkip = null; if (seekCloseEnough(target, 3)) commit(); else { const now = getVideoEl()?.currentTime; setStatus(`跳转未生效(仍在 ${formatTime(now || 0)})`); } } }, 450); pendingSkip = { key, target, timer }; } function watchPlayback() { const tick = () => { if (document.hidden) return; if (!activeSegs.length && !activeSeg) return; const v = getVideoEl(); if (!v) return; const t = v.currentTime; const pool = activeSegs.length ? activeSegs : (activeSeg ? [activeSeg] : []); for (const seg of pool) { const key = segKey(seg); if (isAutoSkipBlocked(seg) || pendingSkip?.key === key) continue; if (t >= seg.start - 0.4 && t < seg.end - 1) { activeSeg = seg; if (cfg.autoSkip) doSkip(seg, false); else showSkipToast(seg); break; } } }; setInterval(tick, IS_DOUYIN ? 1200 : 800); } // ==================== Bilibili ==================== async function biliFetchJson(url) { const res = await fetch(url, { credentials: 'include' }); return res.json(); } async function biliGetVideoMeta() { const state = window.__INITIAL_STATE__ || {}; const p = Math.max(1, parseInt(new URLSearchParams(location.search).get('p') || '1', 10) || 1); let bvid = (location.pathname.match(/\/video\/(BV[\w]+)/i) || [])[1] || (String(location.hash || '').match(/BV[\w]+/i) || [])[0] || state.bvid || state.videoData?.bvid || state.epInfo?.bvid || null; // 番剧 ep/ss:优先 INITIAL_STATE,再打 pgc API if (!bvid) { const epId = (location.pathname.match(/\/bangumi\/play\/ep(\d+)/i) || [])[1] || state.epInfo?.id || state.epInfo?.ep_id; if (epId) { try { const epJson = await biliFetchJson( `https://api.bilibili.com/pgc/view/web/season?ep_id=${encodeURIComponent(epId)}`, ); const view = epJson?.result || epJson?.data; const ep = view?.episodes?.find((e) => String(e.id) === String(epId)) || view?.episodes?.find((e) => String(e.ep_id) === String(epId)) || state.epInfo; bvid = ep?.bvid || view?.bvid || null; if (ep?.cid && bvid) { return { bvid, cid: ep.cid, mid: view?.up_info?.mid || ep.up_info?.mid || null, desc: view?.evaluate || view?.alias || '', duration: ep.duration ? ep.duration / 1000 : (ep.durationSeconds || 0), title: ep.long_title || ep.share_copy || view?.title || '', }; } } catch (e) { log('bangumi meta fail', e); } } } if (!bvid && state.epInfo?.cid) { return { bvid: state.epInfo.bvid || `ep${state.epInfo.id || state.epInfo.ep_id || ''}`, cid: state.epInfo.cid, mid: state.mediaInfo?.up_info?.mid || null, desc: state.mediaInfo?.evaluate || '', duration: (state.epInfo.duration || 0) / (state.epInfo.duration > 10000 ? 1000 : 1), title: state.epInfo.long_title || state.mediaInfo?.title || '', }; } if (!bvid) return null; const json = await biliFetchJson(`https://api.bilibili.com/x/web-interface/view?bvid=${encodeURIComponent(bvid)}`); if (json.code !== 0 || !json.data) return null; const d = json.data; const pages = Array.isArray(d.pages) ? d.pages : []; const page = pages[p - 1] || pages[0]; return { bvid, cid: page?.cid || d.cid, aid: d.aid || null, mid: d.owner?.mid ?? d.owner_mid ?? null, desc: d.desc || '', duration: page?.duration || d.duration || 0, title: d.title || '', page: p, }; } async function biliFetchDanmaku(cid, duration) { // 优先 XML(兼容性好) try { const res = await fetch(`https://comment.bilibili.com/${cid}.xml`); const text = await res.text(); // 超大弹幕 XML 同步 parse 会卡死主线程 → 标签页崩溃 if (text.length > MAX_DANMAKU_XML) { log('danmaku xml too large, skip', text.length); return []; } const doc = new DOMParser().parseFromString(text, 'text/xml'); const nodes = doc.getElementsByTagName('d'); const max = Math.min(nodes.length, 1800); const list = []; for (let i = 0; i < max; i += 1) { const d = nodes[i]; list.push({ time: parseFloat((d.getAttribute('p') || '0').split(',')[0]), text: d.textContent || '', }); } if (list.length) return list; } catch (e) { log('xml danmaku fail', e); } // protobuf 分段兜底 try { const segs = Math.max(1, Math.ceil((duration || 600) / 360)); const all = []; for (let i = 1; i <= segs; i++) { const res = await fetch(`https://api.bilibili.com/x/v2/dm/web/seg.so?type=1&oid=${cid}&segment_index=${i}`); if (!res.ok) continue; all.push(...decodeDanmakuProto(await res.arrayBuffer())); } return all; } catch (e) { log('proto danmaku fail', e); return []; } } function decodeDanmakuProto(buffer) { const view = new DataView(buffer); const results = []; let offset = 0; function readVarint() { let result = 0; let shift = 0; while (offset < view.byteLength) { const byte = view.getUint8(offset++); result |= (byte & 0x7f) << shift; if ((byte & 0x80) === 0) return result; shift += 7; } return result; } function readBytes() { const len = readVarint(); const bytes = new Uint8Array(buffer, offset, len); offset += len; return bytes; } function decodeElem(elemBuffer) { const elem = { progress: 0, content: '' }; const elemView = new DataView(elemBuffer.buffer, elemBuffer.byteOffset, elemBuffer.byteLength); let pos = 0; const readEV = () => { let result = 0; let shift = 0; while (pos < elemBuffer.byteLength) { const byte = elemView.getUint8(pos++); result |= (byte & 0x7f) << shift; if ((byte & 0x80) === 0) return result; shift += 7; } return result; }; while (pos < elemBuffer.byteLength) { const tag = readEV(); const fieldNum = tag >>> 3; const wireType = tag & 0x7; if (wireType === 0) { const val = readEV(); if (fieldNum === 2) elem.progress = val; } else if (wireType === 2) { const len = readEV(); if (fieldNum === 7) { elem.content = new TextDecoder().decode( new Uint8Array(elemBuffer.buffer, elemBuffer.byteOffset + pos, len), ); } pos += len; } else if (wireType === 5) pos += 4; else if (wireType === 1) pos += 8; else break; } return elem; } while (offset < view.byteLength) { const tag = readVarint(); const fieldNum = tag >>> 3; const wireType = tag & 0x7; if (wireType === 2) { const bytes = readBytes(); if (fieldNum === 1) { const elem = decodeElem(bytes); if (elem.content) results.push({ time: elem.progress / 1000, text: elem.content }); } } else if (wireType === 0) readVarint(); else if (wireType === 5) offset += 4; else if (wireType === 1) offset += 8; else break; } return results; } async function biliFetchPlayer(bvid, cid) { try { const json = await biliFetchJson( `https://api.bilibili.com/x/player/wbi/v2?bvid=${bvid}&cid=${cid}`, ); return { viewPoints: json?.data?.view_points || [], subtitles: json?.data?.subtitle?.subtitles || [], }; } catch { return { viewPoints: [], subtitles: [] }; } } async function biliFetchSubtitleBody(subtitleUrl) { try { const url = subtitleUrl.startsWith('//') ? `https:${subtitleUrl}` : subtitleUrl; const res = await fetch(url); const json = await res.json(); return json.body || []; } catch (e) { log('subtitle fetch fail', e); return []; } } function detectFromChapters(viewPoints) { for (const ch of viewPoints || []) { // type 1 = UP主官方报备的广告时段(播放器左上角「广告」标),最高精度 if (ch.type === 1 || labelLooksAd(ch.content)) { return { start: ch.from, end: ch.to, source: ch.type === 1 ? 'chapter-official-ad' : 'chapter' }; } } return null; } function detectFromDesc(desc, duration) { return detectFromCreatorMarks(desc, duration); } /** * 作者自标:简介/标题/置顶评论里的恰饭时间轴 * 例: * 0:00 广告 / 1:20 正片 * 广告 0:00-1:30 * 恰饭到 2:15 * 【赞助】1:00~2:00 */ function detectFromCreatorMarks(text, duration, opts) { if (!text) return null; const raw = String(text); // timelineOnly:只认结构化时间轴列表(评论用),不跑单行松散正则 const timelineOnly = !!opts?.timelineOnly; const AD = /(广告|恰饭|赞助|商单|推广|软广|合作方|金主|片头广告)/i; const OK = /(正片|正文|开始|开讲|上车|回归|谢谢收看)/i; if (!timelineOnly) { // 区间:广告 1:00-2:30 / 1:00~2:30 恰饭 / 【广告】0:00—1:20 let m = raw.match(/(?:广告|恰饭|赞助|商单|推广|软广)[^0-9]{0,12}(\d{1,2})[::](\d{2})\s*[-~—~至到]+\s*(\d{1,2})[::](\d{2})/i) || raw.match(/(\d{1,2})[::](\d{2})\s*[-~—~至到]+\s*(\d{1,2})[::](\d{2})[^\n]{0,12}(?:广告|恰饭|赞助|商单|推广)/i); if (m) { const start = parseInt(m[1], 10) * 60 + parseInt(m[2], 10); const end = parseInt(m[3], 10) * 60 + parseInt(m[4], 10); const seg = { start, end, source: 'creator-range' }; if (end - start > MAX_AD_SEC) return null; if (validSeg(seg, duration) || (end > start && end - start >= 8)) return seg; } // 结束点:恰饭到 2:15 / 广告结束 1:30 → 片头到该点(勿臆造中插) m = raw.match(/(?:广告|恰饭|赞助|商单)[^0-9]{0,8}(?:到|至|结束(?:于|在)?|完(?:于|在)?)[^0-9]{0,6}(\d{1,2})[::](\d{2})/i); if (m) { const end = parseInt(m[1], 10) * 60 + parseInt(m[2], 10); const seg = { start: 0.1, end, source: 'creator-end' }; if (end >= 8 && end <= MAX_AD_SEC && end < (duration || 99999) - 3) return seg; } // 正片从 2:00 / 正文开始于 1:30(连接词必填,防评论误伤) m = raw.match(/(?:正片|正文)\s*(?:从|自|开始于?|起于?|开始)\s*(\d{1,2})[::](\d{2})/i); if (m) { const end = parseInt(m[1], 10) * 60 + parseInt(m[2], 10); const seg = { start: 0.1, end, source: 'creator-end' }; if (end >= 8 && end <= MAX_AD_SEC && end < (duration || 99999) - 3) return seg; } } // 时间轴列表 const entries = []; const re = /(?:^|\n)\s*(?:[【\[]?(?:广告|恰饭|赞助)?[】\]]?)?\s*(\d{1,2})[::](\d{2})(?:\s*[-~—~]\s*(\d{1,2})[::](\d{2}))?\s*[【\[]?(.*?)[】\]]?(?=\n|$)/g; let mm; while ((mm = re.exec(raw)) !== null) { const start = parseInt(mm[1], 10) * 60 + parseInt(mm[2], 10); const end = mm[3] != null ? parseInt(mm[3], 10) * 60 + parseInt(mm[4], 10) : null; entries.push({ start, end, label: (mm[5] || '').trim() }); } // 同行内联:0:00 广告 1:20 正片 if (entries.length < 2) { const inline = [...raw.matchAll(/(\d{1,2})[::](\d{2})\s*([^\d\n]{1,16})/g)]; for (const it of inline) { entries.push({ start: parseInt(it[1], 10) * 60 + parseInt(it[2], 10), end: null, label: it[3].trim(), }); } } const END_MARK = /(跳过广告|广告结束|广告完了?|正片|回归正片|正片开始)/; for (let i = 0; i < entries.length; i++) { const label = entries[i].label; const isEndMark = END_MARK.test(label); const isAd = !isEndMark && (AD.test(label) || labelLooksAd(label)); // 评论时间轴:标签必须自带广告词,不吃「首行兜底」 if (!isAd && !isEndMark && (timelineOnly || !(i === 0 && AD.test(raw.slice(0, 80)) && entries[i].start <= 5))) continue; // 「01:51 跳过广告」:T 是广告结束点,不是起点 if (isEndMark) { const end = entries[i].start; const prev = entries[i - 1]; const prevIsAdStart = prev && !END_MARK.test(prev.label) && (AD.test(prev.label) || labelLooksAd(prev.label)); const start = prevIsAdStart ? prev.start : Math.max(0, end - 60); const seg = { start, end, source: 'creator-timeline', startEstimated: !prevIsAdStart }; if (end - start > MAX_AD_SEC) continue; if (end > start && end - start >= 8) return seg; continue; } let start = entries[i].start; let end = entries[i].end; if (end == null) { if (i + 1 < entries.length) end = entries[i + 1].start; else end = Math.min(duration || start + 90, start + 90); } // 若下一段标正片,用其起点作 end if (i + 1 < entries.length && OK.test(entries[i + 1].label)) { end = entries[i + 1].start; } const seg = { start, end, source: 'creator-timeline' }; if (end - start > MAX_AD_SEC) continue; if (end > start && end - start >= 8) return seg; } return null; } function gmFetchJson(url) { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest === 'function') { GM_xmlhttpRequest({ method: 'GET', url, onload: (res) => { try { resolve(JSON.parse(res.responseText)); } catch (e) { reject(e); } }, onerror: reject, }); } else { fetch(url).then((r) => r.json()).then(resolve).catch(reject); } }); } /** * 置顶/UP自评/热评里的时间轴(「01:51 跳过广告」式)。 * 高精度:只跑结构化时间轴,不跑松散正则;不受 useCreatorMarks 开关影响。 */ async function biliFetchTimelineComment(aid, ownerMid) { if (!aid) return ''; try { const res = await fetch(`https://api.bilibili.com/x/v2/reply/main?type=1&oid=${aid}&mode=3&ps=10`); const json = await res.json(); const replies = [ ...(json?.data?.top_replies || []), ...(json?.data?.replies || []), ]; const picked = []; for (const r of replies.slice(0, 10)) { const msg = String(r?.content?.message || ''); if (!msg || msg.length > 600) continue; const isUp = ownerMid && String(r?.member?.mid) === String(ownerMid); const hot = (r?.like || 0) >= 20; if (!isUp && !hot && !r?.reply_control?.is_top && !r?.is_top) continue; if (!/\d{1,2}[::]\d{2}/.test(msg)) continue; if (!/(广告|恰饭|赞助|商单|正片|跳过)/.test(msg)) continue; picked.push(msg); } return picked.join('\n'); } catch (e) { log('timeline comment fail', e); return ''; } } async function fetchSponsorBlock(bvid) { try { if (cfg.useSponsorBlock === false) return []; const url = `https://bsbsb.top/api/skipSegments?videoID=${encodeURIComponent(bvid)}&categories=${encodeURIComponent(JSON.stringify(['sponsor']))}`; const data = await gmFetchJson(url); if (!Array.isArray(data)) return []; // 镜像库常见 votes=0;丢掉差评与过短碎片(曾出现 5s「三段」噪音) return data .filter((s) => s.actionType === 'skip' && s.category === 'sponsor' && (s.votes ?? 0) >= 0) .map((s) => ({ start: s.segment[0], end: s.segment[1], source: 'sponsorblock', votes: s.votes ?? 0, })) .filter((s) => s.end - s.start >= MIN_AD_SEC && s.end - s.start <= MAX_AD_SEC) .sort((a, b) => a.start - b.start); } catch (e) { log('sponsorblock fail', e); return []; } } /** * B站主流程:SB → 章节 → 评论时间轴 → 字幕;(可选)简介自标 / 弹幕。 * 简介与弹幕默认关:多数视频不写恰饭轴,弹幕则易剧透/吐槽误伤。 * 评论时间轴(置顶/UP/热评的 mm:ss 列表)高精度,始终启用。 * 全程用 analyzeGen:换片后旧请求回来必须丢弃,否则会串台误跳。 */ async function runBilibili(forceKey) { const gen = analyzeGen; if (isBlockedVideo()) { setStatus('本视频已禁用'); activeSeg = null; activeSegs = []; return; } if (!cfg.biliInVideo) { setStatus('片内检测已关闭'); return; } setStatus('分析中…'); const meta = await biliGetVideoMeta(); if (!analysisStillCurrent(gen, meta?.bvid)) return; if (!meta?.cid) { setStatus('未获取到视频信息'); return; } if (isBlockedUp(meta.mid)) { setStatus(`该 UP 已禁用 (mid ${meta.mid})`); activeSeg = null; activeSegs = []; return; } const key = forceKey || `${meta.bvid}:${meta.cid}`; if (key === lastKey && (activeSeg || activeSegs.length)) { setStatus(`跳点就绪 ${activeSegs.length || 1} 段`); return; } lastKey = key; skippedKeys = new Set(); // 重分析不得抹掉本片已撤销段,否则点过撤销会再自动跳 activeSeg = null; activeSegs = []; applyRejectsToSession(meta.bvid); const wantDm = cfg.useDanmakuDetect === true; const [danmaku, player, sbSegs, timelineComment] = await Promise.all([ wantDm ? biliFetchDanmaku(meta.cid, meta.duration) : Promise.resolve([]), biliFetchPlayer(meta.bvid, meta.cid), fetchSponsorBlock(meta.bvid), biliFetchTimelineComment(meta.aid, meta.mid), ]); if (!analysisStillCurrent(gen, meta.bvid)) return; const { viewPoints, subtitles } = player; log('bili', meta.bvid, 'dm', danmaku.length, 'sb', sbSegs.length, 'dmDetect', wantDm, 'tl', !!timelineComment); if (sbSegs.length) { activeSegs = sbSegs; activeSeg = sbSegs[0]; setStatus(`SB ${sbSegs.length} 段 · 首段 ${formatTime(activeSeg.start)}→${formatTime(activeSeg.end)}`); if (!cfg.autoSkip) showSkipToast(activeSeg); return; } let subtitleLines = []; if (subtitles.length) { const zh = subtitles.find((s) => s.lan === 'zh-CN' || s.lan === 'ai-zh') || subtitles[0]; subtitleLines = await biliFetchSubtitleBody(zh.subtitle_url); } if (!analysisStillCurrent(gen, meta.bvid)) return; const pipeline = [ () => detectFromChapters(viewPoints), ...(cfg.useCreatorMarks === true ? [() => detectFromDesc(meta.desc, meta.duration)] : []), // 置顶/UP/热评时间轴:高精度,独立开关外 () => (timelineComment ? detectFromCreatorMarks(timelineComment, meta.duration, { timelineOnly: true }) : null), () => detectFromSubtitles(subtitleLines, wantDm ? danmaku : [], meta.duration), ...(wantDm ? [ () => detectFromJumpTexts(danmaku, meta.duration), () => detectFromKeywords(danmaku, meta.duration), ] : []), ]; for (const step of pipeline) { let seg = await step(); if (!analysisStillCurrent(gen, meta.bvid)) return; // 结束点时间轴的起点是估的:用「恭喜接广」弹幕校准(此时才按需拉弹幕) if (seg?.startEstimated) { try { const dm = danmaku.length ? danmaku : await biliFetchDanmaku(meta.cid, meta.duration); if (!analysisStillCurrent(gen, meta.bvid)) return; const hits = dm .filter((d) => d.time >= seg.end - 180 && d.time < seg.end && /(恭喜接广|恭喜.{0,4}恰饭|接到广|接广了)/.test(d.text || '')) .sort((a, b) => a.time - b.time); if (hits.length) { seg = { ...seg, start: Math.max(0, hits[0].time - 5), startEstimated: false }; } } catch { /* ignore */ } } if (seg && validSeg(seg, meta.duration)) { activeSeg = seg; activeSegs = [seg]; setStatus(`跳点 ${formatTime(seg.start)}→${formatTime(seg.end)} · ${seg.source}`); if (!cfg.autoSkip) showSkipToast(seg); return; } } setStatus('未发现广告段'); } // ==================== Douyin ==================== function clickIfVisible(el) { if (!el) return false; const r = el.getBoundingClientRect(); if (r.width < 2 || r.height < 2) return false; el.click(); return true; } function findClickableByText(texts) { // 绝不扫 document:抖音全页 button 数量巨大,广告 UI 出现时尤甚 const root = document.querySelector('.xgplayer') || document.querySelector('[data-e2e="feed-active-video"]') || document.querySelector('xg-controls, .xgplayer-controls'); if (!root) return null; let nodes; try { nodes = root.querySelectorAll('button, [role="button"]'); } catch { return null; } const max = Math.min(nodes.length, 40); for (let i = 0; i < max; i += 1) { const el = nodes[i]; const t = lightText(el, 20, 4).replace(/\s+/g, ' ').trim(); if (!t || t.length > 16) continue; if (texts.some((x) => t === x || t.includes(x))) return el; } return null; } /** 信息流下一则:先点官方下一条;未换卡再补方向键(勿对 video 派发 wheel,易触 GPU 崩) */ function swipeToNextFeed(opts = {}) { const noRetry = !!opts.noRetry; const hard = !!opts.hard; const before = typeof getDouyinAwemeId === 'function' ? (getDouyinAwemeId() || '') : ''; const nextBtn = document.querySelector( '[data-e2e="video-switch-next-arrow"], [data-e2e="feed-scroll-down"],' + '.xgplayer-playswitch-next, button[aria-label*="下"]', ); let clicked = false; if (nextBtn) { try { nextBtn.click(); clicked = true; } catch { /* ignore */ } } if (!clicked || hard) pressFeedNextKey(); if (noRetry) return; setTimeout(() => { const after = typeof getDouyinAwemeId === 'function' ? (getDouyinAwemeId() || '') : ''; if (before && after === before) swipeToNextFeed({ hard: true, noRetry: true }); }, 280); } function pressFeedNextKey() { const opts = { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40, which: 40, bubbles: true, cancelable: true, }; // 只打 body,避免对 video 合成层狂发事件 try { document.body.dispatchEvent(new KeyboardEvent('keydown', opts)); document.body.dispatchEvent(new KeyboardEvent('keyup', opts)); } catch { /* ignore */ } } /** 有预算地摘文本 */ function lightText(root, maxLen = 160, maxNodes = 18) { if (!root) return ''; let out = ''; let n = 0; try { const tw = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); let node; while ((node = tw.nextNode()) && out.length < maxLen && n < maxNodes) { n += 1; const t = String(node.nodeValue || '').replace(/\s+/g, ' ').trim(); if (!t) continue; out += `${t}\n`; } } catch { return ''; } return out.slice(0, maxLen); } /** 当前卡是否还有正片短视频(非 LivePlayer 内)——只查节点,不碰 getBoundingClientRect */ function feedHasMainShortVideo(root) { try { const scope = root || getActiveFeedRoot(); if (!scope?.querySelectorAll) return false; const videos = scope.querySelectorAll('video'); const max = Math.min(videos.length, 4); for (let i = 0; i < max; i += 1) { if (!videos[i].closest?.('[class*="LivePlayer"]')) return true; } } catch { /* ignore */ } return false; } /** 整卡直播:有 LivePlayer 且无并列正片 video(不读布局,防合成层崩) */ function livePlayerLooksFullFeed(root) { if (!root?.querySelector) return false; try { if (feedHasMainShortVideo(root)) return false; return !!root.querySelector('[class*="LivePlayer"]'); } catch { return false; } } /** * 弹窗直播闸:正片 video 还在就不划。 * Crashpad:renderer FATAL raw_hash_map + video-capture GPU;禁止布局探测。 */ function isPopupLiveOverlay(root) { try { return feedHasMainShortVideo(root); } catch { /* ignore */ } return false; } /** * 已禁用:elementFromPoint 打在视频合成层上会触发 Chrome renderer FATAL *(Crashpad: raw_hash_map<>::at / end() iterator + gpu video)。 */ function probeViewportText() { return []; } const AD_BADGE_SVG_PREFIX = 'M9.492 2.004'; function detectAdSvgInRoot(root) { if (!root?.querySelector) return false; try { return !!root.querySelector(`path[d^="${AD_BADGE_SVG_PREFIX}"]`); } catch { return false; } } /** * 推荐流分类:只走选择器 / SVG,禁止 elementFromPoint。 */ function classifyActiveFeed(needLive) { const out = { ad: false, live: false, shop: false }; const root = getActiveFeedRoot(); if (!root) return out; if (detectAdSvgInRoot(root)) out.ad = true; try { if (root.querySelector('[data-e2e="video-cart-entry"]')) out.shop = true; } catch { /* ignore */ } if (out.ad && !needLive) return out; if (needLive && !isPopupLiveOverlay(root) && livePlayerLooksFullFeed(root)) { out.live = true; } try { const info = root.querySelector?.('[data-e2e="video-info"], .account'); if (info) { const blob = lightText(info, 80, 10); if (/查看详情|立即购买|去购买/.test(blob)) out.shop = true; } } catch { /* ignore */ } if (out.live && feedHasMainShortVideo(root)) out.live = false; return out; } /** feed 接口标记缓存(主路径) */ const awemeMeta = new Map(); function rememberAwemeFlags(id, ad, shop, live) { if (!id) return; awemeMeta.set(id, { ad: !!ad, shop: !!shop, live: !!live, ts: Date.now() }); if (awemeMeta.size > 180) { let n = 0; for (const k of awemeMeta.keys()) { awemeMeta.delete(k); if (++n >= 40) break; } } } function ingestFeedJsonLight(text) { // 只抽 aweme_id 附近片段打标,禁止整包 JSON.parse if (!text || text.length < 40 || text.length > MAX_FEED_BODY) return; if (!/"aweme_id"/.test(text)) return; const idRe = /"aweme_id"\s*:\s*"?(\d{6,})"?/g; let m; let count = 0; while ((m = idRe.exec(text)) && count < 16) { count += 1; const id = m[1]; const i = m.index; const slice = text.slice(Math.max(0, i - 280), Math.min(text.length, i + 900)); rememberAwemeFlags( id, /"is_ads"\s*:\s*true/.test(slice) || /"raw_ad_data"\s*:/.test(slice), /"title_tag"\s*:\s*"购物"/.test(slice) || /brand_ad/.test(slice), // 仅整卡直播 aweme_type=101;cell_room 常见于「作者在播」挂件,勿当直播卡 /"aweme_type"\s*:\s*101/.test(slice), ); } } function ingestFeedJson(text) { if (!text || text.length < 40 || text.length > MAX_FEED_BODY) return; const run = () => { try { ingestFeedJsonLight(text); } catch { /* ignore */ } }; // 尽快打标,划走才跟得上;仍异步,不堵 fetch try { setTimeout(run, 0); } catch { try { run(); } catch { /* ignore */ } } } function looksLikeFeedApiUrl(url) { return /\/aweme\/v\d+\/web\/(?:tab\/)?feed/i.test(String(url || '')); } function feedResponseTooLarge(headersLike) { try { const raw = headersLike?.get?.('content-length') || headersLike?.['content-length'] || headersLike?.getResponseHeader?.('content-length'); const n = parseInt(raw, 10); return Number.isFinite(n) && n > MAX_FEED_BODY; } catch { return false; } } function installDouyinFeedHooks() { if (installDouyinFeedHooks._done) return; if (!cfg.douyinFeedAd && !cfg.douyinFeedLive && !cfg.douyinFeedShop) return; installDouyinFeedHooks._done = true; try { const xo = XMLHttpRequest.prototype.open; const xs = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function (method, url, ...rest) { try { this.__masFeed = looksLikeFeedApiUrl(url); } catch { this.__masFeed = false; } return xo.call(this, method, url, ...rest); }; XMLHttpRequest.prototype.send = function (...args) { if (this.__masFeed && !this.__masFeedHooked) { this.__masFeedHooked = true; // 同一 XHR 复用 send 时只挂一次 this.addEventListener('load', () => { try { if (feedResponseTooLarge(this)) return; const t = this.responseText; if (typeof t === 'string' && t.length > 40 && t.length <= MAX_FEED_BODY) ingestFeedJson(t); } catch { /* ignore */ } }); } return xs.apply(this, args); }; } catch { /* ignore */ } try { const ofetch = window.fetch; if (typeof ofetch !== 'function') return; window.fetch = function (...args) { const p = ofetch.apply(this, args); try { const u = String(args[0]?.url || args[0] || ''); if (looksLikeFeedApiUrl(u)) { p.then((r) => { try { if (!r || feedResponseTooLarge(r.headers)) return ''; return r.clone().text(); } catch { return ''; } }) .then((t) => { if (typeof t === 'string' && t.length > 40 && t.length <= MAX_FEED_BODY) ingestFeedJson(t); }) .catch(() => {}); } } catch { /* ignore */ } return p; }; } catch { /* ignore */ } } let lastFeedSkipAt = 0; let lastFeedSkipKey = ''; let lastSkippedAwemeId = ''; let lastFeedAwemeId = ''; let feedTickBusy = false; function scheduleFeedRecheck(awemeId) { if (!awemeId) return; clearTimeout(scheduleFeedRecheck._t1); clearTimeout(scheduleFeedRecheck._t2); // 短+中两次:DOM/徽章晚出现时别干等一轮长轮询 scheduleFeedRecheck._t1 = setTimeout(() => { if (getDouyinAwemeId() === awemeId) douyinFeedTick({ urgent: true }); }, 140); scheduleFeedRecheck._t2 = setTimeout(() => { if (getDouyinAwemeId() === awemeId && awemeId !== lastSkippedAwemeId) { douyinFeedTick({ urgent: true }); } }, 400); } function douyinFeedTick(opts = {}) { if (!IS_DOUYIN || document.hidden || feedTickBusy) return; if (!cfg.douyinFeedAd && !cfg.douyinFeedLive && !cfg.douyinFeedShop && !cfg.douyinInVideo) return; const now = Date.now(); const urgent = !!opts.urgent; const awemeId = getDouyinAwemeId(); if (awemeId && awemeId === lastSkippedAwemeId && now - lastFeedSkipAt < 700) return; if (!urgent && now - lastFeedSkipAt < 450) return; if (awemeId && awemeId !== lastFeedAwemeId) { lastFeedAwemeId = awemeId; if (!urgent) scheduleFeedRecheck(awemeId); } feedTickBusy = true; try { // 官方「跳过广告」按钮:仅详情/长视频上下文才扫,推荐流免扫 if (cfg.douyinInVideo && shouldAnalyzeDouyinInVideo()) { const btn = findClickableByText(SKIP_BTN_TEXT); if (btn && clickIfVisible(btn)) { lastFeedSkipAt = now; setStatus('已点跳过广告'); return; } } if (!cfg.douyinFeedAd && !cfg.douyinFeedLive && !cfg.douyinFeedShop) return; const meta = awemeId ? awemeMeta.get(awemeId) : null; let adHit = !!(meta?.ad); let liveHit = !!(meta?.live); let shopHit = !!(meta?.shop); const needDom = (!adHit && cfg.douyinFeedAd) || (!shopHit && cfg.douyinFeedShop) || (cfg.douyinFeedLive && !liveHit); // 有 API 标记时优先信缓存,减少 elementFromPoint if (needDom && !(adHit || shopHit || liveHit)) { const card = classifyActiveFeed(!!cfg.douyinFeedLive); adHit = adHit || card.ad; liveHit = liveHit || card.live; shopHit = shopHit || card.shop; } else if (needDom) { // 缺某一类时只补缺的,且 live 关则不打 live 点 const card = classifyActiveFeed(!!cfg.douyinFeedLive && !liveHit); if (!adHit) adHit = card.ad; if (!liveHit) liveHit = card.live; if (!shopHit) shopHit = card.shop; } // 弹窗/浮层/正片仍在:绝不按直播划走 if (liveHit) { const feedRoot = getActiveFeedRoot(); if (isPopupLiveOverlay(feedRoot) || feedHasMainShortVideo(feedRoot)) { liveHit = false; } } let reason = ''; if (cfg.douyinFeedAd && adHit) reason = '信息流广告'; else if (cfg.douyinFeedShop && shopHit) reason = '购物卡'; else if (cfg.douyinFeedLive && liveHit) reason = '直播卡'; else if (!cfg.douyinFeedLive && liveHit) { if (!douyinFeedTick._liveHintAt || now - douyinFeedTick._liveHintAt > 15000) { douyinFeedTick._liveHintAt = now; setStatus('直播卡 · 请打开「划走直播」'); showEnableLiveSwipeToast(); } return; } else return; const key = `${awemeId || ''}:${reason}`; if (key === lastFeedSkipKey && now - lastFeedSkipAt < 1600) return; lastFeedSkipKey = key; lastFeedSkipAt = now; const beforeId = awemeId || ''; // 先滑,确认换卡后再提示 swipeToNextFeed(); afterFeedSwiped(beforeId, reason); } catch { /* ignore */ } finally { feedTickBusy = false; } } /** 抖音官方「广告看点」索引 → 跳过区间(口播略加缓冲,勿吞太多正片) */ function chapterLabelLooksAd(label) { const t = String(label || ''); if (/(广告|恰饭|赞助|商单|推广|软广)/.test(t)) return true; return brandList().some((k) => t.includes(k)); } function detectFromDouyinAdChapters(chapterList, adIndexes, durationSec) { if (!chapterList?.length) return []; const segs = []; const dur = durationSec || 0; for (const idx of adIndexes || []) { const ch = chapterList[idx]; if (!ch) continue; const start = (ch.timestamp || 0) / 1000; let endIdx = idx + 1; if (endIdx < chapterList.length) { const nextLab = String(chapterList[endIdx].desc || chapterList[endIdx].detail || ''); const gap = endIdx + 1 < chapterList.length ? (chapterList[endIdx + 1].timestamp - chapterList[endIdx].timestamp) / 1000 : 99; // 下一看点标题仍像广告话术、且间隔短 → 再吞一段 if (gap <= 45 && (chapterLabelLooksAd(nextLab) || /(压力|旗舰|性能|续航|优惠|下单|品牌|真我|一加|红米|小米|华为|vivo|OPPO|手机|冰被|回收|零食|方便面|咖啡|联名)/i.test(nextLab))) { endIdx += 1; } } let end; if (endIdx < chapterList.length) { // 跳到下一看点起点后只留短缓冲,避免 +28s 吞正片 end = (chapterList[endIdx].timestamp || 0) / 1000 + 5; } else { end = Math.min(dur || start + 75, start + 75); } // 王路翔类口播:官方下一段常偏早,保证至少约 32s if (end - start < 32) { let stretch = start + 40; if (endIdx + 1 < chapterList.length) { stretch = Math.min(stretch, (chapterList[endIdx + 1].timestamp || 0) / 1000 + 5); } else if (dur > 0) { stretch = Math.min(stretch, dur - 2.5); } end = Math.max(end, stretch); } if (dur > 0) end = Math.min(dur - 2.5, end); if (end - start < 8) continue; segs.push({ start, end, source: 'douyin-ad-chapter', label: String(ch.desc || ch.detail || ''), }); } if (!segs.length) { for (let i = 0; i < chapterList.length; i++) { const label = String(chapterList[i].desc || chapterList[i].detail || ''); if (!chapterLabelLooksAd(label)) continue; const start = (chapterList[i].timestamp || 0) / 1000; let end = chapterList[i + 1] ? (chapterList[i + 1].timestamp || 0) / 1000 + 5 : Math.min(dur || start + 60, start + 75); if (dur > 0) end = Math.min(dur - 2.5, end); segs.push({ start, end, source: 'douyin-chapter-kw', label }); } } return segs.filter((s) => s.end > s.start + 5); } function collectDouyinSubtitleInfos(detail) { if (!detail || typeof detail !== 'object') return []; const out = []; const push = (info, langHint) => { if (!info || typeof info !== 'object') return; const url = info.url || info.Url || (Array.isArray(info.url_list) && info.url_list[0]) || (info.url && info.url.url_list && info.url.url_list[0]); if (!url || typeof url !== 'string') return; const lang = String( langHint || info.language_code || info.language || info.lang || info.LanguageCodeName || info.LanguageID || '', ); out.push({ url, lang, format: String(info.caption_format || info.Format || info.format || '') }); }; const video = detail.video || {}; for (const info of (video.subtitleInfos || video.subtitle_infos || detail.subtitle_infos || [])) { push(info); } for (const info of (video.cla_info?.caption_infos || [])) { push(info, info.lang); } const stickers = detail.interaction_stickers || []; for (const st of stickers) { const caps = st?.auto_video_caption_info?.auto_captions || []; for (const cap of caps) { push(cap, cap.language); } } // 去重 URL const seen = new Set(); return out.filter((x) => { if (seen.has(x.url)) return false; seen.add(x.url); return true; }); } function parseClockToSec(raw) { const t = String(raw || '').trim().replace(',', '.'); const parts = t.split(':').map((x) => parseFloat(x)); if (parts.some((n) => !Number.isFinite(n))) return NaN; if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; if (parts.length === 2) return parts[0] * 60 + parts[1]; return parts[0]; } function normalizeSubtitleLines(payload) { if (!payload) return []; const cap = (lines) => (lines.length > 800 ? lines.slice(0, 800) : lines); if (Array.isArray(payload)) { return cap(payload.map((x) => { let from = x.from ?? x.start ?? x.start_time ?? x.begin; let to = x.to ?? x.end ?? x.end_time ?? x.finish; if (from > 1000 || to > 1000) { from /= 1000; to /= 1000; } return { from: Number(from) || 0, to: Number(to) || (Number(from) || 0) + 2, content: String(x.content || x.text || x.utterance || ''), }; }).filter((l) => l.content)); } if (typeof payload === 'object') { if (Array.isArray(payload.body)) return normalizeSubtitleLines(payload.body); if (Array.isArray(payload.utterances)) { return normalizeSubtitleLines(payload.utterances.map((u) => ({ start_time: u.start_time, end_time: u.end_time, text: u.text, }))); } if (Array.isArray(payload.sentences)) { return normalizeSubtitleLines(payload.sentences); } } const text = String(payload); if (text.length > 500_000 || !text.includes('-->')) return []; const lines = []; const re = /(\d{1,2}:\d{2}(?::\d{2})?[.,]\d{1,3})\s*-->\s*(\d{1,2}:\d{2}(?::\d{2})?[.,]\d{1,3})[^\n]*\n([\s\S]*?)(?=\n\s*\n|\n\d{1,2}:\d{2}|\n\d+\s*\n|$)/g; let m; while ((m = re.exec(text)) !== null && lines.length < 800) { const from = parseClockToSec(m[1]); const to = parseClockToSec(m[2]); const content = String(m[3] || '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); if (!content || !Number.isFinite(from) || !Number.isFinite(to)) continue; lines.push({ from, to, content }); } return lines; } async function fetchDouyinSubtitleLines(infos) { if (!infos?.length) return []; const ranked = [...infos].sort((a, b) => { const score = (x) => (/zh|cn|chi/i.test(x.lang || '') ? 0 : 1); return score(a) - score(b); }); for (const info of ranked.slice(0, 4)) { try { let url = info.url; if (url.startsWith('//')) url = `https:${url}`; const data = await pageFetchJson(url); const lines = normalizeSubtitleLines(data); if (lines.length >= 5) { log('douyin subtitle lines', lines.length, info.lang || '', info.format || ''); return lines; } } catch (e) { log('douyin subtitle fail', e); } } return []; } function packDouyinDetail(detail, fromApi) { if (!detail) return null; return { chapterList: detail.chapter_list || [], adIndexes: detail.chapter_data?.ad_chapter_index_list || [], durationMs: detail.duration || 0, desc: detail.desc || '', subtitleInfos: collectDouyinSubtitleInfos(detail), fromApi: !!fromApi, }; } async function fetchDouyinChapters(awemeId) { try { const url = `https://www.douyin.com/aweme/v1/web/aweme/detail/?device_platform=webapp&aid=6383&aweme_id=${encodeURIComponent(awemeId)}`; const data = await pageFetchJson(url); const detail = data?.aweme_detail || data?.aweme_detail_list?.[0] || null; if (!detail) { const embedded = tryReadDouyinEmbedded(awemeId); if (embedded) return embedded; return null; } return packDouyinDetail(detail, true); } catch (e) { log('douyin chapters fail', e); return tryReadDouyinEmbedded(awemeId); } } function tryReadDouyinEmbedded(awemeId) { if (!awemeId) return null; // 推荐流首页 RENDER_DATA 极大,未登录也常有;禁止在非详情上下文解析 if (!isDouyinDetailContext()) return null; try { const scripts = document.querySelectorAll('script#RENDER_DATA, script[id*="RENDER"]'); for (const s of scripts) { let raw = s.textContent || ''; // 超过约 MAX_RENDER_DATA 直接放弃,避免 decode+JSON.parse 卡死 if (raw.length > MAX_RENDER_DATA) continue; if (raw.includes('%')) { try { raw = decodeURIComponent(raw); } catch { /* keep raw */ } } if (raw.length > MAX_RENDER_DATA) continue; if (!raw.includes(String(awemeId))) continue; if (!raw.includes('chapter_list') && !raw.includes('subtitle')) continue; const j = JSON.parse(raw); const found = findAwemeInObj(j, awemeId); if (found) return packDouyinDetail(found, false); } } catch (e) { log('embedded douyin fail', e); } return null; } function findAwemeInObj(obj, awemeId, depth = 0, state = { n: 0 }) { if (!obj || depth > 8 || state.n > 6000) return null; if (typeof obj !== 'object') return null; state.n += 1; const id = obj.aweme_id || obj.awemeId || obj.group_id || obj.awemeIdStr; if (awemeId && id && String(id) === String(awemeId)) { if (obj.chapter_list || obj.chapter_data || obj.desc != null || obj.video || obj.subtitle_infos) return obj; } for (const v of Object.values(obj)) { if (v && typeof v === 'object') { const hit = findAwemeInObj(v, awemeId, depth + 1, state); if (hit) return hit; } } return null; } /** 扩展隔离世界无页面 Cookie;经 page-bridge 在主世界请求 */ function ensurePageBridge() { if (!IS_EXT || typeof chrome === 'undefined' || !chrome.runtime?.getURL) { return Promise.resolve(); } if (window.__MAS_BRIDGE_INJECTED__) return Promise.resolve(); return new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = chrome.runtime.getURL('content/page-bridge.js'); s.onload = () => { window.__MAS_BRIDGE_INJECTED__ = true; resolve(); }; s.onerror = () => reject(new Error('page-bridge inject fail')); (document.head || document.documentElement).appendChild(s); }); } async function pageFetchJson(url) { if (!IS_EXT || typeof chrome === 'undefined' || !chrome.runtime?.id) { const res = await fetch(url, { credentials: 'include' }); const text = await res.text(); if (text.length > MAX_PAGE_FETCH) throw new Error('response too large'); const trimmed = (text || '').trim(); if (trimmed.startsWith('{') || trimmed.startsWith('[')) { try { return JSON.parse(text); } catch { /* fallthrough */ } } return text; } await ensurePageBridge(); return new Promise((resolve, reject) => { const id = `mas_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const timer = setTimeout(() => { window.removeEventListener('message', onMsg); reject(new Error('page fetch timeout')); }, 15000); function onMsg(ev) { if (ev.source !== window || !ev.data || ev.data.source !== 'mas-page-bridge') return; if (ev.data.id !== id) return; clearTimeout(timer); window.removeEventListener('message', onMsg); if (ev.data.ok) resolve(ev.data.data); else reject(new Error(ev.data.error || 'page fetch fail')); } window.addEventListener('message', onMsg); window.postMessage({ source: 'mas-content', type: 'MAS_PAGE_FETCH', id, url }, '*'); }); } function getDouyinAwemeId() { // 作者主页弹窗:必须优先 modal_id,否则会抓到侧边推荐视频 const modal = location.href.match(/[?&#]modal_id=(\d+)/); if (modal) return modal[1]; const mNum = location.pathname.match(/\/video\/(\d+)/); if (mNum) return mNum[1]; const m2 = location.href.match(/aweme_id=(\d+)/); if (m2) return m2[1]; // 推荐流:当前卡 class 上的 video_(比链接更稳) const active = document.querySelector('[data-e2e="feed-active-video"], .swiper-slide-active'); const cm = String(active?.className || '').match(/video_(\d{6,})/); if (cm) return cm[1]; // 仅当前激活卡片,不用满页 a[href*=/video/](会串台) const activeSelectors = [ '[data-e2e="feed-active-video"] a[href*="/video/"]', '.swiper-slide-active a[href*="/video/"]', ]; for (const sel of activeSelectors) { const a = document.querySelector(sel); const hm = (a?.getAttribute('href') || a?.href || '').match(/\/video\/(\d+)/); if (hm) return hm[1]; } const v = getVideoEl(); const root = v?.closest('[data-e2e="feed-active-video"], .swiper-slide-active, li, article') || null; const near = root?.querySelector?.('a[href*="/video/"]'); const hm2 = (near?.getAttribute('href') || near?.href || '').match(/\/video\/(\d+)/); if (hm2) return hm2[1]; return null; } /** 详情页 / 弹窗详情:才允许重分析与读 RENDER_DATA */ function isDouyinDetailContext() { return /\/video\/\d+/.test(location.pathname) || /[?&#]modal_id=\d+/.test(location.href); } /** 仅详情/弹窗做片内分析;推荐流(?recommend=1)只划走,避免 creator-end 误跳 */ function shouldAnalyzeDouyinInVideo() { if (!cfg.douyinInVideo) return false; return isDouyinDetailContext(); } /** 进度条附近的「第N章:标题」 */ function readDomChapterHint() { const root = document.querySelector('.xgplayer') || document.querySelector('[data-e2e="feed-active-video"]'); const text = lightText(root, 240, 24); const m = text.match(/第\s*(\d+)\s*章\s*[::]\s*([^\n\r]{1,24})/); if (!m) return null; return { n: parseInt(m[1], 10), label: m[2].replace(/\s+/g, ' ').trim() }; } function chaptersMatchPage(chapterList, pageHint) { if (!pageHint?.label || !chapterList?.length) return true; const lab = pageHint.label; return chapterList.some((c) => { const d = String(c.desc || c.detail || ''); return d.includes(lab) || lab.includes(d) || d.replace(/\s/g, '') === lab.replace(/\s/g, ''); }); } /** 抖音跳点文案:只用标题/简介。评论区平时不可信,易误伤。 */ function collectDouyinJumpTexts() { const items = []; const push = (text, time) => { const t = String(text || '').trim(); if (!t || t.length > 80) return; items.push({ text: t, time: typeof time === 'number' ? time : undefined }); }; const title = document.querySelector('h1')?.textContent || ''; const desc = document.querySelector('[data-e2e="browse-video-desc"], [data-e2e="video-desc"]')?.textContent || ''; push(title); push(desc); return items; } async function douyinAnalyzeInVideo() { const gen = analyzeGen; const awemeAtStart = typeof getDouyinAwemeId === 'function' ? (getDouyinAwemeId() || '') : ''; const stillDouyin = () => { if (gen !== analyzeGen) return false; if (!awemeAtStart) return true; const now = typeof getDouyinAwemeId === 'function' ? (getDouyinAwemeId() || '') : ''; return !now || now === awemeAtStart; }; if (isBlockedVideo()) { setStatus('本视频已禁用'); activeSeg = null; activeSegs = []; return; } if (!cfg.douyinInVideo) return; if (!shouldAnalyzeDouyinInVideo()) { setStatus(isDouyinDetailContext() ? '等待播放器…' : '推荐流待命(片内分析需进视频页;直播划走默认关)'); return; } const v = getVideoEl(); const duration = v?.duration && Number.isFinite(v.duration) ? v.duration : 0; if (duration && duration < 45) { setStatus('短视频,仅信息流/跳过按钮'); return; } setStatus('分析看点/作者标注…'); const awemeId = awemeAtStart || getDouyinAwemeId(); const domHint = readDomChapterHint(); let chapterSegs = []; let detailPack = null; if (awemeId) { detailPack = await fetchDouyinChapters(awemeId); if (!stillDouyin()) return; if (detailPack) { // 按 aweme_id 拉到的官方详情可信;仅内嵌兜底数据才用 DOM 防串台 const matched = detailPack.fromApi || !!(detailPack.adIndexes && detailPack.adIndexes.length) || chaptersMatchPage(detailPack.chapterList, domHint); log('douyin chapters', detailPack.chapterList?.length, 'adIdx', detailPack.adIndexes, 'subs', detailPack.subtitleInfos?.length, 'id', awemeId, 'fromApi', detailPack.fromApi, 'domHint', domHint, 'match', matched); if (!matched) { setStatus(`看点与本片不符(id ${awemeId}),已忽略串台数据`); } else { chapterSegs = detectFromDouyinAdChapters( detailPack.chapterList, detailPack.adIndexes, (detailPack.durationMs || 0) / 1000 || duration, ) || []; const timeline = (detailPack.chapterList || []) .map((c) => `${formatTime((c.timestamp || 0) / 1000)} ${c.desc || ''}`) .join('\n'); if (cfg.useCreatorMarks === true) { const fromMarks = detectFromCreatorMarks(`${detailPack.desc || ''}\n${timeline}`, duration); if (fromMarks) chapterSegs.push(fromMarks); } } } else { log('douyin chapters empty', awemeId); } } if (chapterSegs.length) { if (!stillDouyin()) return; activeSegs = chapterSegs; activeSeg = chapterSegs[0]; for (const k of undoneKeys) skippedKeys.add(k); applyRejectsToSession(awemeId || currentVideoId()); const tip = chapterSegs .map((s) => `${formatTime(s.start)}→${formatTime(s.end)}${s.label ? '(' + s.label + ')' : ''}`) .join(' · '); setStatus(`看点广告 ${tip}`); if (cfg.autoSkip) { const kick = () => skipIfPlayingInSegs(); if (!kick()) { setTimeout(kick, 400); setTimeout(kick, 1200); } } else { showSkipToast(activeSeg); } return; } const title = lightText(document.querySelector('h1'), 200, 12); const descNode = document.querySelector( '[data-e2e="browse-video-desc"], [data-e2e="video-desc"]', ); const desc = `${lightText(descNode, 500, 30)}\n${title}`; const jumpItems = collectDouyinJumpTexts(); const creatorText = [title, desc].filter(Boolean).join('\n'); // 无看点时:用定时字幕估口播段 let subtitleLines = []; if (detailPack?.subtitleInfos?.length) { setStatus('分析字幕轨…'); subtitleLines = await fetchDouyinSubtitleLines(detailPack.subtitleInfos); if (!stillDouyin()) return; } if (subtitleLines.length >= 5) { if (!stillDouyin()) return; const subSeg = detectFromSubtitles(subtitleLines, jumpItems, duration); if (subSeg && subSeg.end - subSeg.start <= MAX_AD_SEC && (validSeg(subSeg, duration || 9999) || subSeg.end - subSeg.start >= 8)) { activeSeg = subSeg; activeSegs = [subSeg]; for (const k of undoneKeys) skippedKeys.add(k); setStatus(`跳点 ${formatTime(subSeg.start)}→${formatTime(subSeg.end)} · ${subSeg.source}`); if (cfg.autoSkip) { if (!skipIfPlayingInSegs()) setTimeout(() => skipIfPlayingInSegs(), 400); } else { showSkipToast(subSeg); } return; } } // 官方看点 / 字幕优先;简介自标默认关 const pipeline = [ ...(cfg.useCreatorMarks === true ? [() => detectFromCreatorMarks(creatorText, duration)] : []), () => detectFromJumpTexts(jumpItems, duration), () => detectFromKeywords( jumpItems.filter((x) => typeof x.time === 'number'), duration, ), ]; for (const step of pipeline) { if (!stillDouyin()) return; const seg = step(); if (seg && seg.end - seg.start <= MAX_AD_SEC && (validSeg(seg, duration || 9999) || seg.end - seg.start >= 8)) { activeSeg = seg; activeSegs = [seg]; for (const k of undoneKeys) skippedKeys.add(k); setStatus(`跳点 ${formatTime(seg.start)}→${formatTime(seg.end)} · ${seg.source}`); if (!cfg.autoSkip) showSkipToast(seg); else if (!skipIfPlayingInSegs()) setTimeout(() => skipIfPlayingInSegs(), 400); return; } } if (!stillDouyin()) return; const shopBlob = title + desc + jumpItems.map((x) => x.text).join(''); const looksShop = brandList().some((k) => shopBlob.includes(k)) || /(苹果|手机|ProMax|回收|山楂|购物|橱窗|优惠券|广告|恰饭|赞助|3100|甲方)/i.test(shopBlob); if (!awemeId) { setStatus(looksShop ? '未拿到视频ID(推荐流?点进视频页再试)' : '未识别视频ID,无法拉看点'); return; } if (looksShop) { softOralReady = true; const sec = softSkipSec(); const noSub = !subtitleLines.length; setStatus(noSub ? `疑似口播 · 无看点/无字幕可估 · 听到时点「跳过约${sec}秒」` : `疑似口播 · 字幕未命中品牌 · 听到时点「跳过约${sec}秒」`); showSoftOralHint(); return; } setStatus('未发现片内跳点(仍监视跳过按钮)'); } // -------------------- boot -------------------- function registerMenu() { if (typeof GM_registerMenuCommand !== 'function') return; GM_registerMenuCommand('重新分析广告', () => { resetPlaybackState(); if (IS_BILI) runBilibili(); else douyinAnalyzeInVideo(); }); GM_registerMenuCommand('显示/隐藏浮动面板', () => { cfg.showPanel = !cfg.showPanel; saveCfg(); if (cfg.showPanel) ensurePanel(); else hidePanel(); }); GM_registerMenuCommand('切换自动跳过', () => { cfg.autoSkip = !cfg.autoSkip; saveCfg(); setStatus(`自动跳过: ${cfg.autoSkip ? '开' : '关'}`); if (cfg.showPanel) { ensurePanel(); const input = panelEl?.querySelector('input[data-k="autoSkip"]'); if (input) input.checked = cfg.autoSkip; } }); } /** * SPA 换片监听。 * 只轮询 BV/p=(及抖音 aweme),禁止劫持 history:B站播放中会狂刷 replaceState, * 旧逻辑每次都重拉弹幕 → 标签页崩。 */ function observeSpa(cb) { let key = spaWatchKey(); let lastAweme = ''; let awemeProbeAt = 0; const fireIfChanged = () => { if (document.hidden) return; const nextKey = spaWatchKey(); let changed = nextKey !== key; // 抖音:不在推荐流狂扫 DOM;详情页低频探 aweme if (IS_DOUYIN && isDouyinDetailContext() && Date.now() - awemeProbeAt > 2000) { awemeProbeAt = Date.now(); const id = typeof getDouyinAwemeId === 'function' ? (getDouyinAwemeId() || '') : ''; if (id && id !== lastAweme) { lastAweme = id; changed = true; } } if (!changed) return; key = nextKey; cb(); }; // 只轮询:禁止劫持 history.pushState/replaceState。 // B站播放中会狂刷 replaceState(进度/埋点),旧逻辑每次都 kick→重拉弹幕→标签页直接崩。 setInterval(fireIfChanged, IS_DOUYIN ? 1600 : 1200); window.addEventListener('popstate', fireIfChanged); } function bindExtCommands() { if (!IS_EXT || !chrome.runtime?.onMessage) return; chrome.runtime.onMessage.addListener((msg, _s, sendResponse) => { if (!msg || msg.type !== 'MAS_CMD') return false; (async () => { try { switch (msg.cmd) { case 'status': sendResponse({ ok: true, status: statusForUi(), seg: lastSkip?.seg || activeSeg, videoId: IS_DOUYIN && typeof getDouyinAwemeId === 'function' ? getDouyinAwemeId() : currentVideoId(), blocked: isCurrentVideoBlocked(), }); break; case 'reanalyze': resetPlaybackState(); if (IS_BILI) await runBilibili(); else await douyinAnalyzeInVideo(); sendResponse({ ok: true, status: statusText, blocked: isCurrentVideoBlocked() }); break; case 'undo': undoLastSkip(); sendResponse({ ok: true, status: statusText }); break; case 'block': blockCurrentVideo(msg.videoId); sendResponse({ ok: true, status: statusText, blocked: true }); break; case 'unblock': unblockCurrentVideo(msg.videoId); sendResponse({ ok: true, status: statusText, blocked: isCurrentVideoBlocked(), }); break; case 'skipNow': if (activeSeg) doSkip(activeSeg, true); else if (softOralReady) softSkipForward(); else setStatus('暂无跳点'); sendResponse({ ok: true, status: statusText }); break; case 'wrong': await reportWrongMark(); sendResponse({ ok: true, status: statusText }); break; default: sendResponse({ ok: false, error: 'unknown cmd' }); } } catch (e) { sendResponse({ ok: false, error: String(e && e.message ? e.message : e) }); } })(); return true; }); } let feedTimer = null; function startFeedPoll() { if (feedTimer) { clearInterval(feedTimer); feedTimer = null; } if (!IS_DOUYIN) return; let lastId = ''; let lastBackupAt = 0; const pollMs = Math.max(900, Math.min(4000, Number(cfg.feedPollMs) || 1400)); // 单定时器:多数循环只比 id;换卡才 urgent;约 1.5×poll 保底重检 feedTimer = setInterval(() => { if (document.hidden) return; if (!cfg.douyinFeedAd && !cfg.douyinFeedLive && !cfg.douyinFeedShop && !cfg.douyinInVideo) return; let id = ''; try { id = getDouyinAwemeId() || ''; } catch { return; } if (id && id !== lastId) { lastId = id; douyinFeedTick({ urgent: true }); scheduleFeedRecheck(id); return; } const now = Date.now(); if (now - lastBackupAt < pollMs * 1.5) return; lastBackupAt = now; douyinFeedTick({ urgent: false }); }, pollMs); } function warnDualInstall() { // 油猴与扩展共用 window 标记;同开会导致版本互挡 / 行为重复 const other = IS_EXT ? window.__MAS_VER_GM__ : window.__MAS_VER_EXT__; if (!other) return; const tip = IS_EXT ? `检测到油猴脚本 v${other},建议只留扩展或油猴其中一个` : `检测到浏览器扩展 v${other},建议只留扩展或油猴其中一个`; setTimeout(() => setStatus(tip), 800); log(tip); } function boot() { bindExtCommands(); warnDualInstall(); if (IS_BILI) { ensureToastStyles(); ensurePanel(); registerMenu(); watchPlayback(); setStatus('B站模式'); let kickTimer = 0; const kick = () => { clearTimeout(kickTimer); kickTimer = setTimeout(() => { resetPlaybackState(); setStatus('切换视频…'); setTimeout(() => runBilibili(), 600); }, 200); }; kick(); observeSpa(kick); return; } if (IS_DOUYIN) { // 必须延后:首页水合期装 hooks / 轮询易触发 renderer FATAL(见 Crashpad) setStatus('抖音模式'); const kick = () => { resetPlaybackState(); if (!shouldAnalyzeDouyinInVideo()) { setStatus('推荐流待命(点进视频页再分析片内广告)'); return; } setStatus('切换视频…'); setTimeout(() => douyinAnalyzeInVideo(), 1500); }; const start = () => { try { ensureStatsLoad(); } catch { /* ignore */ } try { installDouyinFeedHooks(); } catch { /* ignore */ } ensureToastStyles(); ensurePanel(); registerMenu(); watchPlayback(); startFeedPoll(); kick(); observeSpa(kick); }; if (typeof requestIdleCallback === 'function') { requestIdleCallback(() => setTimeout(start, 2200), { timeout: 5000 }); } else { setTimeout(start, 2800); } return; } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { boot(); } })();