// ==UserScript== // @name 小红书视频下载助手:4K超清·多画质切换·精准定位无水印版 // @namespace https://greasyfork.org/zh-CN/scripts/596284 // @version 1.2.2 // @description 小红书视频一键下载(仅供个人学习研究):直接在首页弹窗保存视频,不用再点开帖子链接;自动识别正在播放的那条,支持 4K / 2K / 1080P / 720P 多画质,左下角悬浮按钮。 // @author LBCN // @license MIT // @match https://www.xiaohongshu.com/* // @grant GM_download // @grant GM_setClipboard // @grant unsafeWindow // @connect * // @run-at document-start // ==/UserScript== (function () { 'use strict'; const LOG_PREFIX = '[XHS-DL]'; function log() { try { console.log.apply(console, [LOG_PREFIX].concat(Array.prototype.slice.call(arguments))); } catch (e) { } } function warn() { try { console.warn.apply(console, [LOG_PREFIX].concat(Array.prototype.slice.call(arguments))); } catch (e) { } } function pick(obj) { if (!obj || typeof obj !== 'object') return undefined; for (let i = 1; i < arguments.length; i++) { const key = arguments[i]; const value = obj[key]; if (value !== undefined && value !== null && value !== '') return value; } return undefined; } function toArray(value) { if (value === undefined || value === null || value === '') return []; return Array.isArray(value) ? value : [value]; } function sanitizeFileName(name, maxLength) { const cleaned = String(name === undefined || name === null ? '' : name) .replace(/[\r\n\t]+/g, ' ') .replace(/[\\/:*?"<>|]/g, '_') .replace(/[\u0000-\u001f\u007f]/g, '') .replace(/\s{2,}/g, ' ') .trim(); const limited = cleaned.slice(0, maxLength || 60); return limited || '小红书视频'; } function normalizeUrl(url) { if (!url || typeof url !== 'string') return ''; let value = url.trim(); if (!value) return ''; value = value.replace(/\\u002F/gi, '/').replace(/\\\//g, '/'); if (value.startsWith('//')) value = 'https:' + value; if (value.startsWith('http://')) value = 'https://' + value.slice(7); return value; } function urlKey(url) { return String(url || '').split('?')[0].split('#')[0]; } function resourceFingerprint(url) { const value = normalizeUrl(url); if (!value) return ''; const pathPart = urlKey(value); const last = pathPart.substring(pathPart.lastIndexOf('/') + 1); return last.split('!')[0].replace(/\.(jpe?g|png|webp|avif|mp4|mov)$/i, ''); } function normalizeTitle(title) { return String(title || '') .replace(/[\s\u3000]+/g, '') .replace(/[,。!?、;:“”‘’()【】《》,.!?;:'"()\[\]<>~`@#$%^&*_+=|\\/-]/g, '') .toLowerCase() .slice(0, 40); } function throttle(fn, wait) { let last = 0; let timer = null; let lastArgs = null; let lastThis = null; return function () { lastArgs = arguments; lastThis = this; const now = Date.now(); const remaining = wait - (now - last); if (remaining <= 0) { last = now; fn.apply(lastThis, lastArgs); } else if (!timer) { timer = setTimeout(function () { timer = null; last = Date.now(); fn.apply(lastThis, lastArgs); }, remaining); } }; } function whenDomReady(callback) { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', callback, { once: true }); } else { callback(); } } const NOTE_POOL_LIMIT = 200; function scoreNote(note) { if (!note) return -1; let score = 0; const streams = note.video && note.video.media ? (note.video.media.stream || note.video.media.streams) : null; if (streams) { for (const codec in streams) { if (Object.prototype.hasOwnProperty.call(streams, codec) && Array.isArray(streams[codec])) { score += streams[codec].length * 10; } } } if (note.title) score += 5; if (note.cover) score += 1; return score; } const noteStore = { map: new Map(), order: [], orphan: [], latestId: null, add(note) { if (!note || !note.video) return false; const id = note.id ? String(note.id) : ''; if (!id) { this.orphan.unshift(note); if (this.orphan.length > 5) this.orphan.length = 5; return false; } const existed = this.map.get(id); if (existed) { if (scoreNote(note) >= scoreNote(existed)) { existed.video = note.video; existed.cover = note.cover || existed.cover; } if (!existed.title && note.title) existed.title = note.title; existed.ts = Date.now(); } else { this.map.set(id, { id: id, title: note.title || '', video: note.video, cover: note.cover || '', ts: Date.now() }); } const index = this.order.indexOf(id); if (index !== -1) this.order.splice(index, 1); this.order.push(id); this.latestId = id; while (this.order.length > NOTE_POOL_LIMIT) { const dropped = this.order.shift(); if (dropped !== id) this.map.delete(dropped); else this.order.push(dropped); } return true; }, get(id) { if (!id) return null; return this.map.get(String(id)) || null; }, has(id) { return !!this.get(id); }, size() { return this.map.size; }, latest() { return this.get(this.latestId); }, findByTitle(title) { const target = normalizeTitle(title); if (target.length < 4) return null; let matched = null; for (const note of this.map.values()) { const current = normalizeTitle(note.title); if (!current || current.length < 4) continue; if (current === target || current.indexOf(target) !== -1 || target.indexOf(current) !== -1) { matched = note; } } return matched; }, findByCoverFingerprint(fingerprint) { if (!fingerprint || fingerprint.length < 8) return null; for (const note of this.map.values()) { if (note.cover && resourceFingerprint(note.cover) === fingerprint) return note; } return null; }, snapshot() { return { size: this.map.size, latestId: this.latestId, ids: this.order.slice(-10) }; } }; const PROBE_FLAG = '__XHS_DL_PROBE__'; const PROBE_URL_FLAG = '__xhsDlUrl'; const API_URL_RE = /\/api\//; const STREAM_HINT_RE = /master_url|masterUrl/; function shouldInspectUrl(url) { if (!url || typeof url !== 'string') return false; if (!API_URL_RE.test(url)) return false; return true; } function extractRequestUrl(args) { try { const input = args[0]; if (typeof input === 'string') return input; if (input && typeof input.url === 'string') return input.url; } catch (e) { } return ''; } function extractCoverUrl(video) { const media = video && (video.media || video.mediaInfo); if (!media) return ''; const cover = media.video_cover || media.videoCover || media.cover || video.cover; if (!cover) return ''; if (typeof cover === 'string') return cover; const direct = pick(cover, 'url', 'url_default', 'urlDefault'); if (direct) return direct; const list = toArray(pick(cover, 'url_list', 'urlList', 'urls')); for (const item of list) { if (typeof item === 'string') return item; const nested = pick(item, 'url', 'url_default', 'urlDefault'); if (nested) return nested; } return ''; } function collectNotes(node, depth, ctx, seen) { if (!node || typeof node !== 'object') return; if (depth > 12) return; if (seen.has(node)) return; seen.add(node); if (Array.isArray(node)) { for (let i = 0; i < node.length; i++) { collectNotes(node[i], depth + 1, ctx, seen); } return; } const ownId = pick(node, 'note_id', 'noteId', 'noteIdStr'); const isNoteContainer = !!(node.video || node.note_card || node.noteCard || node.note); const id = ownId || (isNoteContainer ? pick(node, 'id') : undefined) || ctx.id; const title = pick(node, 'title', 'display_title', 'displayTitle', 'desc', 'note_title', 'noteTitle') || ctx.title; const video = node.video; if (video && (video.media || video.mediaInfo)) { noteStore.add({ id: id, title: title, video: video, cover: extractCoverUrl(video) }); } const childCtx = { id: id, title: title }; for (const key in node) { if (!Object.prototype.hasOwnProperty.call(node, key)) continue; const value = node[key]; if (value && typeof value === 'object') { collectNotes(value, depth + 1, childCtx, seen); } } } function inspectResponseText(text, url) { if (!text || text.length < 32) return; if (!STREAM_HINT_RE.test(text)) return; let data; try { data = JSON.parse(text); } catch (e) { return; } inspectResponseData(data, url); } function inspectResponseData(data, url) { if (!data || typeof data !== 'object') return; const before = noteStore.size(); collectNotes(data, 0, { id: '', title: '' }, new WeakSet()); const added = noteStore.size() - before; if (added > 0) { log('捕获 ' + added + ' 条笔记(池中共 ' + noteStore.size() + ' 条)', url || ''); } } function hookFetch(win) { if (typeof win.fetch !== 'function' || win.fetch[PROBE_FLAG]) return; const rawFetch = win.fetch; const wrapped = function () { const args = arguments; const promise = rawFetch.apply(this, args); try { const url = extractRequestUrl(args); if (url && shouldInspectUrl(url) && promise && typeof promise.then === 'function') { promise.then(function (response) { try { if (!response || typeof response.clone !== 'function') return; const contentType = response.headers && response.headers.get ? (response.headers.get('content-type') || '') : ''; if (contentType && contentType.indexOf('json') === -1) return; response.clone().text().then(function (text) { inspectResponseText(text, url); }).catch(function () { }); } catch (e) { } }).catch(function () { }); } } catch (e) { } return promise; }; wrapped[PROBE_FLAG] = true; wrapped.toString = function () { return rawFetch.toString(); }; try { win.fetch = wrapped; } catch (e) { warn('fetch 劫持失败', e); } } function hookXhr(win) { const XHR = win.XMLHttpRequest; if (!XHR || !XHR.prototype || XHR[PROBE_FLAG]) return; const rawOpen = XHR.prototype.open; const rawSend = XHR.prototype.send; XHR.prototype.open = function (method, url) { try { this[PROBE_URL_FLAG] = typeof url === 'string' ? url : (url && url.toString && url.toString()); } catch (e) { } return rawOpen.apply(this, arguments); }; XHR.prototype.send = function () { try { const url = this[PROBE_URL_FLAG]; if (url && shouldInspectUrl(url)) { this.addEventListener('load', function () { try { const type = this.responseType; if (!type || type === 'text') { inspectResponseText(this.responseText, url); } else if (type === 'json') { inspectResponseData(this.response, url); } } catch (e) { } }); } } catch (e) { } return rawSend.apply(this, arguments); }; XHR[PROBE_FLAG] = true; } function installProbe(win) { if (!win) return; if (win[PROBE_FLAG]) return; win[PROBE_FLAG] = true; hookFetch(win); hookXhr(win); log('网络探针已安装'); } let lastHarvestSignature = ''; let lastHarvestTime = 0; function harvestInitialState(win, force) { const now = Date.now(); if (!force && now - lastHarvestTime < 1500) return; lastHarvestTime = now; let state; try { state = win.__INITIAL_STATE__; } catch (e) { return; } if (!state || typeof state !== 'object') return; let signature = ''; try { const note = state.note || {}; const detailMap = note.noteDetailMap || note.note_detail_map || {}; const feed = state.feed || {}; signature = [ pick(note, 'currentNoteId', 'current_note_id') || '', Object.keys(detailMap).length, (feed.feeds || feed.feedsList || []).length ].join('|'); } catch (e) { } if (!force && signature === lastHarvestSignature) return; lastHarvestSignature = signature; try { collectNotes(state, 0, { id: '', title: '' }, new WeakSet()); } catch (e) { warn('扫描 __INITIAL_STATE__ 出错', e); } } const CODEC_WEIGHT = { av1: 3, h265: 2, hevc: 2, h264: 1, avc: 1 }; function classifyQuality(width, height) { const shortSide = Math.min(width, height); const longSide = Math.max(width, height); if (!shortSide && !longSide) return { label: '原画', rank: 0 }; if (shortSide >= 2000) return { label: '4K 超清', rank: 4000 }; if (shortSide >= 1400) return { label: '2K 超清', rank: 2500 }; if (shortSide >= 1000) return { label: '1080P 全高清', rank: 1920 }; if (shortSide >= 700) return { label: '720P 高清', rank: 1280 }; if (shortSide >= 500) return { label: '540P 标清', rank: 960 }; if (shortSide >= 320) return { label: '360P 低清', rank: 640 }; return { label: '低清', rank: longSide || 100 }; } function buildQualityTag(width, height, stream, codec) { const parts = []; if (width && height) parts.push(width + 'x' + height); const codecName = pick(stream, 'video_codec', 'videoCodec', 'codec') || codec; parts.push(String(codecName).toUpperCase().replace('H265', 'H.265').replace('H264', 'H.264')); const bitrate = Number(pick(stream, 'avg_bitrate', 'avgBitrate', 'bitrate') || 0); if (bitrate > 0) parts.push((bitrate / 1000000).toFixed(1) + 'Mbps'); return parts.join(' · '); } function streamBitrate(stream) { return Number(pick(stream, 'avg_bitrate', 'avgBitrate', 'bitrate') || 0); } function parseQualityList(video) { const result = []; const media = video && (video.media || video.mediaInfo); const streamMap = media && (media.stream || media.streams); if (!streamMap || typeof streamMap !== 'object') return result; for (const codec in streamMap) { if (!Object.prototype.hasOwnProperty.call(streamMap, codec)) continue; const list = streamMap[codec]; if (!Array.isArray(list)) continue; for (const stream of list) { if (!stream || typeof stream !== 'object') continue; const candidates = [] .concat(toArray(pick(stream, 'master_url', 'masterUrl'))) .concat(toArray(pick(stream, 'backup_urls', 'backupUrl', 'backup_url'))) .concat(toArray(pick(stream, 'url'))); let url = ''; for (const candidate of candidates) { const normalized = normalizeUrl(candidate); if (normalized && !normalized.startsWith('blob:')) { url = normalized; break; } } if (!url) continue; const width = Number(pick(stream, 'width', 'video_width', 'videoWidth') || 0); const height = Number(pick(stream, 'height', 'video_height', 'videoHeight') || 0); const quality = classifyQuality(width, height); result.push({ label: quality.label, tag: buildQualityTag(width, height, stream, codec), url: url, width: width, height: height, rank: quality.rank * 10000 + (CODEC_WEIGHT[String(codec).toLowerCase()] || 0) * 100 + Math.min(streamBitrate(stream) / 10000, 99) }); } } result.sort(function (a, b) { return b.rank - a.rank; }); const seen = new Set(); return result.filter(function (item) { const key = urlKey(item.url); if (seen.has(key)) return false; seen.add(key); return true; }); } const STRICT_NOTE_ID_RE = /^[0-9a-f]{24}$/i; const LOOSE_NOTE_ID_RE = /^[0-9a-zA-Z_-]{16,36}$/; const NOTE_URL_RES = [ /\/(?:explore|discovery\/item)\/([0-9a-zA-Z]+)/, /[?&]note_?id=([0-9a-zA-Z]+)/i, /[?&]source_note_id=([0-9a-zA-Z]+)/i ]; const TITLE_SELECTORS = [ '#detail-title', '.note-detail-mask .title', '[class*="note-detail"] [class*="title"]', '[class*="noteDetail"] [class*="title"]', '.note-container .title', '.note-scroller .title', '#noteContainer [class*="title"]' ]; function looksLikeNoteId(value) { if (typeof value !== 'string' || !value) return false; if (STRICT_NOTE_ID_RE.test(value)) return true; if (!LOOSE_NOTE_ID_RE.test(value)) return false; if (/-/.test(value)) return false; if (/^\d+$/.test(value)) return false; return true; } function getUrlNoteId() { const href = location.href; for (const re of NOTE_URL_RES) { const matched = href.match(re); if (matched && matched[1]) return matched[1]; } return ''; } function getStateCurrentNoteId(win) { try { const state = win.__INITIAL_STATE__; if (!state || !state.note) return ''; const id = pick(state.note, 'currentNoteId', 'current_note_id', 'currentNoteID'); return id ? String(id) : ''; } catch (e) { return ''; } } function extractNoteFromDetailMap(win, noteId) { if (!noteId) return null; try { const state = win.__INITIAL_STATE__; const map = state && state.note && (state.note.noteDetailMap || state.note.note_detail_map); if (!map) return null; const entry = map[noteId]; if (!entry || typeof entry !== 'object') return null; const note = entry.note || entry.noteCard || entry.note_card || entry; if (!note || typeof note !== 'object' || !note.video) return null; return { id: String(noteId), title: pick(note, 'title', 'display_title', 'displayTitle', 'desc') || '', video: note.video, cover: extractCoverUrl(note.video) }; } catch (e) { return null; } } const OVERLAY_SELECTORS = [ '.note-detail-mask', '[class*="note-detail"]', '[class*="noteDetail"]', '#noteContainer', '[class*="modal"]' ]; function getOverlayRoot() { for (const selector of OVERLAY_SELECTORS) { let nodes; try { nodes = document.querySelectorAll(selector); } catch (e) { continue; } for (let i = 0; i < nodes.length; i++) { const element = nodes[i]; let rect; try { rect = element.getBoundingClientRect(); } catch (e) { continue; } if (rect.width < 240 || rect.height < 240) continue; if (element.querySelector && element.querySelector('video')) return element; } } return null; } function pickLargestVisibleVideo(root) { let best = null; let bestArea = 0; const viewportHeight = window.innerHeight || 0; const list = (root || document).querySelectorAll('video'); for (let i = 0; i < list.length; i++) { const video = list[i]; let rect; try { rect = video.getBoundingClientRect(); } catch (e) { continue; } if (rect.width < 80 || rect.height < 80) continue; if (rect.bottom <= 0 || rect.top >= viewportHeight + 200) continue; const area = rect.width * rect.height; if (area > bestArea) { bestArea = area; best = video; } } return best; } function getActiveVideoElement() { const overlay = getOverlayRoot(); if (overlay) { const inner = pickLargestVisibleVideo(overlay); if (inner) return inner; } return pickLargestVisibleVideo(null); } function findNoteIdFromDom(element) { let node = element; let depth = 0; while (node && node !== document.documentElement && depth < 15) { if (node.dataset) { for (const key in node.dataset) { if (!Object.prototype.hasOwnProperty.call(node.dataset, key)) continue; if (/note/i.test(key) && looksLikeNoteId(node.dataset[key])) { return node.dataset[key]; } } } if (node.getAttribute) { const attr = node.getAttribute('data-note-id') || node.getAttribute('note-id'); if (looksLikeNoteId(attr)) return attr; } if (node.querySelector) { const link = node.querySelector('a[href*="/explore/"], a[href*="/discovery/item/"]'); if (link) { const href = link.getAttribute('href') || ''; const matched = href.match(/\/(?:explore|discovery\/item)\/([0-9a-zA-Z]+)/); if (matched && matched[1]) return matched[1]; } } node = node.parentElement; depth++; } return ''; } function matchNoteByPoster(videoElement) { if (!videoElement) return null; let poster = ''; try { poster = videoElement.getAttribute('poster') || videoElement.poster || ''; } catch (e) { } const fingerprint = resourceFingerprint(poster); if (!fingerprint) return null; return noteStore.findByCoverFingerprint(fingerprint); } function getDomNoteTitle() { for (const selector of TITLE_SELECTORS) { let nodes; try { nodes = document.querySelectorAll(selector); } catch (e) { continue; } for (let i = 0; i < nodes.length; i++) { const text = (nodes[i].textContent || '').trim(); if (text.length >= 4 && text.length <= 200) return text; } } return ''; } function resolveCurrentNote(win) { const activeVideo = getActiveVideoElement(); const stateId = getStateCurrentNoteId(win); if (stateId) { const note = noteStore.get(stateId) || extractNoteFromDetailMap(win, stateId); if (note) return { note: note, source: 'state' }; } const urlId = getUrlNoteId(); if (urlId) { const note = noteStore.get(urlId) || extractNoteFromDetailMap(win, urlId); if (note) return { note: note, source: 'url' }; } if (activeVideo) { const domId = findNoteIdFromDom(activeVideo); const noteById = noteStore.get(domId); if (noteById) return { note: noteById, source: 'dom' }; const noteByPoster = matchNoteByPoster(activeVideo); if (noteByPoster) return { note: noteByPoster, source: 'poster' }; } const byTitle = noteStore.findByTitle(getDomNoteTitle()); if (byTitle) return { note: byTitle, source: 'title' }; if (noteStore.size() === 1) { return { note: noteStore.latest(), source: 'single' }; } if (noteStore.size() > 0 && (activeVideo || document.querySelector('video'))) { return { note: noteStore.latest(), source: 'latest' }; } return { note: null, source: 'none' }; } function buildFileName(title, qualityLabel) { const base = sanitizeFileName(title || '小红书视频', 60); const suffix = qualityLabel ? '_' + sanitizeFileName(qualityLabel, 20) : ''; return base + suffix + '.mp4'; } function hasGmDownload() { try { return typeof GM_download === 'function'; } catch (e) { return false; } } function hasGmSetClipboard() { try { return typeof GM_setClipboard === 'function'; } catch (e) { return false; } } function copyToClipboard(text) { if (!text) return Promise.resolve(false); if (hasGmSetClipboard()) { try { GM_setClipboard(text, 'text'); return Promise.resolve(true); } catch (e) { } } try { if (navigator.clipboard && navigator.clipboard.writeText) { return navigator.clipboard.writeText(text).then(function () { return true; }, function () { return false; }); } } catch (e) { } try { const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.select(); const ok = document.execCommand('copy'); textarea.remove(); return Promise.resolve(ok); } catch (e) { return Promise.resolve(false); } } function nativeDownload(url, fileName) { try { const anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName; anchor.style.display = 'none'; document.body.appendChild(anchor); anchor.click(); anchor.remove(); return true; } catch (e) { return false; } } function downloadVideo(options) { const url = normalizeUrl(options.url); const fileName = options.fileName || buildFileName('', ''); const onProgress = options.onProgress || function () {}; const onDone = options.onDone || function () {}; const onError = options.onError || function () {}; if (!url) { onError('视频直链为空'); return; } if (!hasGmDownload()) { nativeDownload(url, fileName); copyToClipboard(url).then(function () { onError('当前脚本管理器未提供 GM_download,已尝试浏览器直接下载,并复制直链'); }); return; } try { GM_download({ url: url, name: fileName, saveAs: true, onprogress: function (progress) { try { if (progress && progress.total) { onProgress(Math.round((progress.loaded / progress.total) * 100)); } else if (progress && typeof progress.loaded === 'number') { onProgress(-1); } } catch (e) { } }, onload: function () { onDone(); }, onerror: function (err) { warn('GM_download 失败', err); nativeDownload(url, fileName); copyToClipboard(url).then(function () { onError('官方下载通道失败,已尝试浏览器直接下载,并复制直链到剪贴板'); }); }, ontimeout: function () { onError('下载超时,已为您复制视频直链'); copyToClipboard(url); } }); } catch (e) { warn('GM_download 调用异常', e); nativeDownload(url, fileName); copyToClipboard(url).then(function () { onError('下载调用异常,已复制视频直链到剪贴板'); }); } } const UI_STYLES = ` #xhs-dl-wrapper { position: fixed; bottom: 25px; left: 20px; z-index: 2147483647; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; } #xhs-dl-btn { background-color: #ff2442; color: #ffffff; width: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; box-shadow: 0 4px 16px rgba(255, 36, 66, 0.4); transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); border: 2px solid #ffffff; user-select: none; } #xhs-dl-btn:hover { transform: scale(1.08); box-shadow: 0 6px 20px rgba(255, 36, 66, 0.6); } #xhs-dl-btn.xhs-disabled { background-color: #9a9a9a !important; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); cursor: not-allowed; opacity: 0.85; transform: none !important; } #xhs-dl-btn.xhs-downloading { background-color: #ff9800 !important; cursor: progress; animation: xhs-dl-pulse 1.4s infinite; } #xhs-dl-btn svg { width: 22px; height: 22px; fill: currentColor; pointer-events: none; } @keyframes xhs-dl-pulse { 0% { transform: scale(1); } 50% { transform: scale(1.08); } 100% { transform: scale(1); } } #xhs-dl-wrapper.xhs-open #xhs-dl-btn { opacity: 0; transform: scale(0.5); pointer-events: none; } #xhs-dl-menu { position: absolute; bottom: 0; left: 0; transform-origin: 0 100%; background: rgba(255, 255, 255, 0.98); backdrop-filter: blur(12px); border-radius: 12px; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25); border: 1px solid rgba(0, 0, 0, 0.08); padding: 10px; width: 230px; box-sizing: border-box; opacity: 0; visibility: hidden; transform: scale(0.4) translateY(6px); transition: opacity 0.22s ease, transform 0.28s cubic-bezier(0.34, 1.32, 0.64, 1), visibility 0.28s; pointer-events: none; } #xhs-dl-menu.xhs-active { opacity: 1; visibility: visible; transform: scale(1) translateY(0); pointer-events: auto; } .xhs-menu-header { font-size: 11px; font-weight: 700; color: #888; padding: 2px 6px 8px 6px; border-bottom: 1px solid #f0f0f0; margin-bottom: 6px; display: flex; align-items: center; gap: 6px; } #xhs-dl-note { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #444; } #xhs-stream-count { flex: none; color: #ff2442; font-weight: 600; } #xhs-quality-container { max-height: 300px; overflow-y: auto; } #xhs-quality-container::-webkit-scrollbar { width: 4px; } #xhs-quality-container::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; } .xhs-quality-item { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px; border-radius: 8px; cursor: pointer; transition: background 0.2s; font-size: 12px; color: #333; margin-bottom: 4px; } .xhs-quality-item:hover { background-color: #fff0f2; color: #ff2442; font-weight: bold; } .xhs-quality-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .xhs-quality-tag { flex: none; font-size: 10px; padding: 2px 6px; border-radius: 4px; background: #f5f5f5; color: #666; font-weight: 400; max-width: 118px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .xhs-quality-item:hover .xhs-quality-tag { background: #ffdce1; color: #ff2442; } .xhs-empty-tip { font-size: 11px; color: #999; padding: 12px 8px; text-align: center; line-height: 1.6; } .xhs-menu-footer { display: flex; gap: 6px; margin-top: 8px; padding-top: 8px; border-top: 1px solid #f0f0f0; } .xhs-menu-footer button { flex: 1; font-size: 11px; padding: 5px 0; border-radius: 6px; border: 1px solid #eee; background: #fafafa; color: #666; cursor: pointer; font-family: inherit; transition: all 0.2s; } .xhs-menu-footer button:hover { border-color: #ff2442; color: #ff2442; background: #fff5f6; } #xhs-dl-toast { position: fixed; bottom: 78px; left: 20px; z-index: 2147483647; max-width: 280px; background: rgba(30, 30, 30, 0.92); color: #fff; font-size: 12px; line-height: 1.6; padding: 8px 12px; border-radius: 8px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); opacity: 0; transform: translateY(8px); transition: opacity 0.25s, transform 0.25s; pointer-events: none; word-break: break-all; } #xhs-dl-toast.xhs-show { opacity: 1; transform: translateY(0); } `; const DOWNLOAD_ICON = ` `; let currentStreams = []; let currentNoteRef = null; let lastRenderKey = ''; let toastTimer = null; const MENU_HIDE_DELAY = 1000; let menuHideTimer = null; function uiElement(id) { return document.getElementById(id); } function isMenuOpen() { const menu = uiElement('xhs-dl-menu'); return !!menu && menu.classList.contains('xhs-active'); } function openMenu() { clearTimeout(menuHideTimer); menuHideTimer = null; const wrapper = uiElement('xhs-dl-wrapper'); const menu = uiElement('xhs-dl-menu'); if (!wrapper || !menu) return; wrapper.classList.add('xhs-open'); menu.classList.add('xhs-active'); } function closeMenu() { clearTimeout(menuHideTimer); menuHideTimer = null; const wrapper = uiElement('xhs-dl-wrapper'); const menu = uiElement('xhs-dl-menu'); if (wrapper) wrapper.classList.remove('xhs-open'); if (menu) menu.classList.remove('xhs-active'); } function scheduleCloseMenu(delay) { clearTimeout(menuHideTimer); menuHideTimer = setTimeout(closeMenu, typeof delay === 'number' ? delay : MENU_HIDE_DELAY); } function cancelCloseMenu() { clearTimeout(menuHideTimer); menuHideTimer = null; } function injectStyle() { if (uiElement('xhs-dl-style')) return; const styleSheet = document.createElement('style'); styleSheet.id = 'xhs-dl-style'; styleSheet.textContent = UI_STYLES; (document.head || document.documentElement).appendChild(styleSheet); } function createUI() { if (uiElement('xhs-dl-wrapper') || !document.body) return; const wrapper = document.createElement('div'); wrapper.id = 'xhs-dl-wrapper'; wrapper.innerHTML = [ '
', '