// ==UserScript== // @name 抖音精选页 SPA 视频跳转 B站模式 // @name:zh-CN 抖音精选页 SPA 视频跳转 B站模式 // @name:en Douyin Jingxuan Video Bilibili Mode // @namespace https://github.com/Saamliu // @version 1.0.1 // @description 将抖音网页版 jingxuan 页面的默认 SPA 弹窗播放改为新标签页打开 /video/XXX 标准视频页(类似 B 站播放逻辑)。 // @description:zh-CN 将抖音网页版 jingxuan 页面的默认 SPA 弹窗播放改为新标签页打开 /video/XXX 标准视频页(类似 B 站播放逻辑)。 // @description:en Open Douyin jingxuan videos in a new tab with standalone player mode like Bilibili, preventing SPA modal player. // @author Saamliu // @copyright 2026, Sam (https://github.com/Saamliu) // @license MIT // @icon data:image/svg+xml;utf8, // @match https://www.douyin.com/ // @match https://www.douyin.com/jingxuan* // @run-at document-start // @grant none // ==/UserScript== /* jshint esversion: 11 */ (() => { 'use strict'; /** * 校验当前路由是否处于目标页面(精选页) * 采用动态判断机制以兼容客户端 SPA 动态重定向与根路径加载场景 */ const isOnJingxuanPage = () => location.pathname.startsWith('/jingxuan') || location.pathname === '/'; /** 记录已打开的视频 ID 与时间戳,用于防抖降频 */ const openedVideos = new Map(); const THROTTLE_MS = 1500; /** 记录最后一次点击有效交互控件的时间戳(用于阻断冒泡引起的误触发) */ window.__lastControlClickTime = 0; /** * 在新标签页打开独立视频详情页(B站播放模式) * @param {string} videoId - 抖音视频唯一标识符 */ const openVideoPage = (videoId) => { const now = Date.now(); if (openedVideos.has(videoId) && (now - openedVideos.get(videoId)) < THROTTLE_MS) return; openedVideos.set(videoId, now); if (openedVideos.size > 50) { openedVideos.delete(openedVideos.keys().next().value); } window.open(`https://www.douyin.com/video/${videoId}`, '_blank'); }; /** * 判定目标元素是否属于交互控件(点赞、评论、进度条、小窗播放器等) * @param {Element} el - 触发事件的目标 DOM 节点 * @param {Event} e - 原生事件对象 * @returns {boolean} */ const isInteractiveElement = (el, e) => { if (!el || el.nodeType !== Node.ELEMENT_NODE) return false; // 1. 标准表单控件、无障碍角色与通用矢量图标放行 if (el.closest('button, input, textarea, select, label, [role="button"], [role="slider"], [role="switch"], svg, path, .semi-icon')) return true; // 2. 交互类名及 ARIA 标签语义正则匹配 let node = el; const interactRegex = /(like|comment|share|collect|follow|favorite|digg|progress|volume|mute|danmu|danmaku|\bpip\b|playpause|playbtn|speed-|fullscreen|control)/i; while (node && node !== document.body) { if (typeof node.className === 'string' && interactRegex.test(node.className)) return true; const label = node.getAttribute?.('aria-label'); if (label && /(播放|暂停|静音|音量|进度|全屏|小窗|画中画|倍速|弹幕|分享|收藏|关注|点赞|评论)/.test(label)) return true; node = node.parentElement; } // 3. 动态相对热区兜底:基于卡片底部渲染尺寸计算控制栏区域(自适应大卡、小卡及不同缩放比例) const videoCard = el.closest('.videoImage, .waterfall-videoCardContainer, .discover-video-card-item'); if (videoCard && typeof e?.clientY === 'number') { const rect = videoCard.getBoundingClientRect(); const cardHeight = rect.height; const offsetBottom = rect.bottom - e.clientY; const dynamicThreshold = Math.min(Math.max(cardHeight * 0.15, 36), 56); if (offsetBottom >= 0 && offsetBottom <= dynamicThreshold) { return true; } } return false; }; /** * 从目标 DOM 节点或其父级链中提取有效的视频 ID * @param {Element} el - 触发事件的 DOM 节点 * @returns {string|null} */ const extractVideoId = (el) => { if (!el || el.nodeType !== Node.ELEMENT_NODE) return null; const aTag = el.closest('a[href]'); if (aTag) { const m = aTag.href.match(/\/(?:video|.*[?&]modal_id=)(\d{10,30})/); if (m) return m[1]; } const dataNode = el.closest('[data-id], [data-video-id], [data-modal-id], [data-e2e-aweme-id], [data-aweme-id]'); if (dataNode) { for (const key in dataNode.dataset) { const val = dataNode.dataset[key]; if (val && /^\d{10,30}$/.test(val)) return val; } } const img = el.closest('img[src], img[data-src], video[poster]'); if (img) { const src = img.src || img.getAttribute('data-src') || img.getAttribute('poster') || ''; const match = src.match(/(\d{15,30})/); if (match) return match[1]; } const video = el.closest('video[src], video[data-src]'); if (video) { const src = video.src || video.getAttribute('data-src') || ''; const match = src.match(/(\d{15,30})/); if (match) return match[1]; } return null; }; /** * 从目标 URL 字符串中提取 modal_id 参数 * @param {string} url * @returns {string|null} */ const getModalId = (url) => { if (typeof url !== 'string') return null; const match = url.match(/[?&]modal_id=(\d{10,30})/); return match ? match[1] : null; }; /** * 清理原精选页 URL 中的 modal_id 参数,恢复干净路由 */ const cleanModalIdFromUrl = () => { const url = new URL(location.href); if (url.searchParams.has('modal_id')) { url.searchParams.delete('modal_id'); history.__originalReplaceState(history.state, '', url.pathname + url.search + url.hash); } }; /** * 策略一:事件捕获阶段拦截(阻断 SPA 手势响应链与默认行为) */ ['pointerdown', 'mousedown', 'click'].forEach(eventType => { window.addEventListener(eventType, (e) => { if (!isOnJingxuanPage()) return; if (isInteractiveElement(e.target, e)) { window.__lastControlClickTime = Date.now(); return; } const videoId = extractVideoId(e.target); if (videoId) { if (eventType === 'click') { e.preventDefault(); openVideoPage(videoId); } e.stopPropagation(); e.stopImmediatePropagation(); } }, true); }); /** * 策略二:Hook History API(阻断 SPA 弹窗的无刷新路由跳转) */ const hookHistoryMethod = (original) => { return function(...args) { if (!isOnJingxuanPage()) return original.apply(this, args); const url = typeof args[2] === 'string' ? args[2] : (args[2]?.url || ''); const modalId = getModalId(url); if (modalId) { if (Date.now() - window.__lastControlClickTime < 500) return; openVideoPage(modalId); cleanModalIdFromUrl(); return; } return original.apply(this, args); }; }; if (!history.pushState.__douyinHooked) { history.__originalReplaceState = history.replaceState; history.pushState = hookHistoryMethod(history.pushState); history.replaceState = hookHistoryMethod(history.replaceState); history.pushState.__douyinHooked = true; } /** * 策略三:检查当前页面 URL 状态(应对初次进入、前进/后退及 SPA 内部切页) */ const checkCurrentUrl = () => { if (!isOnJingxuanPage()) return; const modalId = getModalId(location.href); if (modalId) { openVideoPage(modalId); cleanModalIdFromUrl(); } }; window.addEventListener('popstate', checkCurrentUrl); let lastPathname = location.pathname; setInterval(() => { if (location.pathname !== lastPathname) { lastPathname = location.pathname; checkCurrentUrl(); } }, 500); document.addEventListener('DOMContentLoaded', checkCurrentUrl, { once: true }); })();