// ==UserScript== // @name 抖音私信内嵌浮窗 // @namespace https://github.com/douyin-chat-float-window // @version 2.0.0 // @description 拦截抖音 Web 私信独立窗口,在当前页面内以内嵌浮窗打开,支持拖动、缩放、关闭和状态记忆 // @author you // @match https://www.douyin.com/* // @match https://*.douyin.com/* // @run-at document-start // @grant none // @noframes // ==/UserScript== (function () { 'use strict'; const SCRIPT_ID = 'douyin-chat-float-window'; const STORE_KEY = 'dy-chat-float-window-v1'; const CHAT_URL_RE = /(?:^|[/?#&=._-])(im|message|chat|conversation|letter|webcast_im)(?:$|[/?#&=._-])/i; const nativeOpen = window.open ? window.open.bind(window) : null; let floatWin = null; let iframe = null; let fallbackLink = null; let fallbackTimer = null; let currentUrl = ''; const state = Object.assign({ width: Math.round(window.innerWidth * 0.52), height: Math.round(window.innerHeight * 0.78), x: 0, y: 56, minimized: false, }, readState()); function readState() { try { return JSON.parse(localStorage.getItem(STORE_KEY)) || {}; } catch { return {}; } } function saveState() { try { localStorage.setItem(STORE_KEY, JSON.stringify(state)); } catch { // ignore } } function ready(fn) { if (document.body) { fn(); return; } document.addEventListener('DOMContentLoaded', fn, { once: true }); } function normalizeUrl(url) { if (!url) return null; try { return new URL(String(url), location.href); } catch { return null; } } function isDouyinHost(hostname) { return hostname === 'douyin.com' || hostname.endsWith('.douyin.com'); } function isChatWindowUrl(url) { const u = normalizeUrl(url); if (!u || !isDouyinHost(u.hostname)) return false; const text = `${u.pathname}${u.search}${u.hash}`; return CHAT_URL_RE.test(text); } function fakeOpenedWindow(url) { return { closed: false, focus() {}, close() { this.closed = true; }, location: { href: String(url || '') }, }; } function interceptWindowOpen() { if (!nativeOpen) return; window.open = function patchedWindowOpen(url, target, features) { if (isChatWindowUrl(url)) { openFloatingChat(url); return fakeOpenedWindow(url); } return nativeOpen(url, target, features); }; } function interceptAnchorClicks() { document.addEventListener('click', (event) => { const anchor = event.target && event.target.closest ? event.target.closest('a[href]') : null; if (!anchor || !isChatWindowUrl(anchor.href)) return; event.preventDefault(); event.stopPropagation(); openFloatingChat(anchor.href); }, true); } function injectStyle() { if (document.getElementById(SCRIPT_ID)) return; const style = document.createElement('style'); style.id = SCRIPT_ID; style.textContent = ` .dycf-window { position: fixed !important; left: 0; top: 0; width: 720px; height: 620px; min-width: 360px; min-height: 280px; max-width: calc(100vw - 16px); max-height: calc(100vh - 16px); background: #1f202b; border: 1px solid rgba(255,255,255,.12); border-radius: 12px; box-shadow: 0 18px 56px rgba(0,0,0,.38); overflow: hidden; z-index: 2147483646 !important; display: flex; flex-direction: column; color: #fff; } .dycf-window.dycf-minimized { height: 42px !important; min-height: 42px !important; } .dycf-header { flex: 0 0 42px; height: 42px; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 14px; background: #171821; border-bottom: 1px solid rgba(255,255,255,.08); cursor: move; user-select: none; } .dycf-title { flex: 1 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 14px; color: rgba(255,255,255,.92); } .dycf-actions { flex: 0 0 auto; display: flex; align-items: center; gap: 4px; } .dycf-btn { width: 28px; height: 28px; border: 0; border-radius: 7px; background: transparent; color: rgba(255,255,255,.78); cursor: pointer; font-size: 14px; line-height: 28px; text-align: center; } .dycf-btn:hover { background: rgba(255,255,255,.12); color: #fff; } .dycf-body { position: relative; flex: 1 1 auto; min-height: 0; background: #1f202b; } .dycf-window.dycf-minimized .dycf-body, .dycf-window.dycf-minimized .dycf-resize { display: none; } .dycf-frame { display: block; width: 100%; height: 100%; border: 0; background: #1f202b; } .dycf-fallback { position: absolute; inset: 0; display: none; align-items: center; justify-content: center; padding: 24px; background: #1f202b; color: rgba(255,255,255,.78); text-align: center; font-size: 14px; line-height: 1.8; } .dycf-fallback.is-visible { display: flex; } .dycf-fallback a { color: #7aa7ff; text-decoration: none; } .dycf-resize { position: absolute; right: 0; bottom: 0; width: 18px; height: 18px; cursor: nwse-resize; z-index: 2; } .dycf-resize::after { content: ""; position: absolute; right: 5px; bottom: 5px; width: 8px; height: 8px; border-right: 2px solid rgba(255,255,255,.45); border-bottom: 2px solid rgba(255,255,255,.45); } `; (document.head || document.documentElement).appendChild(style); } function ensureDefaultGeometry() { const fallbackWidth = Math.round(window.innerWidth * 0.52); const fallbackHeight = Math.round(window.innerHeight * 0.78); state.width = clamp(state.width || fallbackWidth, 360, window.innerWidth - 16); state.height = clamp(state.height || fallbackHeight, 280, window.innerHeight - 16); if (!Number.isFinite(state.x) || state.x <= 0) { state.x = window.innerWidth - state.width - 24; } if (!Number.isFinite(state.y)) state.y = 56; clampToViewport(); } function clamp(value, min, max) { return Math.min(Math.max(value, min), Math.max(min, max)); } function clampToViewport() { state.width = clamp(state.width, 360, window.innerWidth - 16); state.height = clamp(state.height, 280, window.innerHeight - 16); state.x = clamp(state.x, 8, window.innerWidth - state.width - 8); state.y = clamp(state.y, 8, window.innerHeight - (state.minimized ? 42 : state.height) - 8); } function applyGeometry() { if (!floatWin) return; clampToViewport(); floatWin.style.transform = `translate(${state.x}px, ${state.y}px)`; floatWin.style.width = `${state.width}px`; floatWin.style.height = state.minimized ? '42px' : `${state.height}px`; floatWin.classList.toggle('dycf-minimized', state.minimized); saveState(); } function createFloatingWindow() { injectStyle(); ensureDefaultGeometry(); const root = document.createElement('div'); root.className = 'dycf-window'; root.innerHTML = `