// ==UserScript== // @name 短视频去水印下载 Pro + 全站嗅探下载 // @name:zh-CN 短视频去水印下载 Pro + 全站嗅探下载 // @version 4.3.2 // @license MIT // @description 🎯 抖音 / 小红书网页版无水印高清下载。修复错位下载、卡死、嗅探失效;新增精确到秒的发布时间、每日下载目录强制选择。 // @author LightDownload // @match *://*.douyin.com/* // @match *://*.xiaohongshu.com/* // @match *://*.bilibili.com/* // @match *://*/* // @grant GM_xmlhttpRequest // @grant GM_download // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant GM_notification // @grant GM_registerMenuCommand // @grant unsafeWindow // @connect * // @run-at document-start // @namespace https://greasyfork.org/ // ==/UserScript== (function () { 'use strict'; /* ══════════════════════════════════════════════════════════ 0. 全局防护:脚本任何异常都不允许冒泡到页面 ══════════════════════════════════════════════════════════ */ function safe(fn, label) { return function () { try { return fn.apply(this, arguments); } catch (e) { console.warn('[SVD] ' + (label || 'err') + ':', e); return null; } }; } /* ══════════════════════════════════════════════════════════ 1. SETTINGS ══════════════════════════════════════════════════════════ */ var SETTINGS = { themeMode: GM_getValue('cfg_themeMode', 'auto'), rememberTriggerPos: GM_getValue('cfg_rememberTriggerPos', true), rememberLastTab: GM_getValue('cfg_rememberLastTab', true), windowOpacity: GM_getValue('svd_win_opacity', 1.0), autoSaveCover: GM_getValue('cfg_autoSaveCover', false), filenameTemplate: GM_getValue('cfg_filenameTemplate', '{author}-{desc}'), notifyOnComplete: GM_getValue('cfg_notifyOnComplete', true), duplicateStrategy: GM_getValue('cfg_duplicateStrategy', 'rename'), minResolution: GM_getValue('cfg_minResolution', '0'), linkTypeFilter: GM_getValue('cfg_linkTypeFilter', 'all'), autoCopyFirst: GM_getValue('cfg_autoCopyFirst', false), showSniffPreview: GM_getValue('cfg_showSniffPreview', true), requireDailyDir: GM_getValue('cfg_requireDailyDir', true), // 新增:每日强制选目录 lastTab: GM_getValue('cfg_lastTab', 'main'), triggerPos: GM_getValue('cfg_triggerPos', null) }; function saveSetting(key, value) { SETTINGS[key] = value; var storeKey = key === 'windowOpacity' ? 'svd_win_opacity' : ('cfg_' + key); try { GM_setValue(storeKey, value); } catch (e) {} } /* ══════════════════════════════════════════════════════════ 2. 中国时间 / 每日下载目录闸门 【新增功能 2】 - 每天(北京时间)当前网页首次下载,必须弹窗选目录,否则不允许下载 - 过了 00:00 自动重置 ══════════════════════════════════════════════════════════ */ /** 取北京时间(UTC+8)的日期键 YYYY-MM-DD,与本机时区无关 */ function cnDayKey(d) { var t = (d || new Date()).getTime(); var bj = new Date(t + 8 * 3600 * 1000); // 移到 UTC+8 return bj.getUTCFullYear() + '-' + ('0' + (bj.getUTCMonth() + 1)).slice(-2) + '-' + ('0' + bj.getUTCDate()).slice(-2); } /** 距离下一个北京时间 00:00 的毫秒数 */ function msUntilCnMidnight() { var now = Date.now(); var bj = new Date(now + 8 * 3600 * 1000); var next = Date.UTC(bj.getUTCFullYear(), bj.getUTCMonth(), bj.getUTCDate() + 1, 0, 0, 0, 0); return (next - 8 * 3600 * 1000) - now; } // 「当前网页」= 当前标签页会话。用 sessionStorage 保证刷新页面后仍算同一网页会话, // 但新开标签页需重新确认;跨天则一定重新确认。 var DIR_SESSION_KEY = 'svd_dir_day'; function getDirGrantDay() { try { return sessionStorage.getItem(DIR_SESSION_KEY); } catch (e) { return _memGrantDay; } } var _memGrantDay = null; function setDirGrantDay(day) { _memGrantDay = day; try { sessionStorage.setItem(DIR_SESSION_KEY, day); } catch (e) {} } function clearDirGrantDay() { _memGrantDay = null; try { sessionStorage.removeItem(DIR_SESSION_KEY); } catch (e) {} } /** 今天是否已在本网页会话内选过目录 */ function isDirGrantedToday() { return !!dirHandle && getDirGrantDay() === cnDayKey(); } // 到北京时间 0 点自动重置 function scheduleMidnightReset() { setTimeout(function () { clearDirGrantDay(); dirHandle = null; _applyDirHandleUI('', false); setStatus('🕛 已过零点(北京时间),请重新选择今日下载目录', 'info'); logInfo('已过零点,下载目录已重置', '下次下载会重新让你选一次保存文件夹'); scheduleMidnightReset(); }, msUntilCnMidnight() + 1000); } /** * 下载闸门:任何下载动作前必须调用。 * 返回 Promise,true 才允许继续。 * 必须由用户手势(点击)链上调用,否则 showDirectoryPicker 会被浏览器拒绝。 */ async function ensureDailyDir() { if (!SETTINGS.requireDailyDir) return true; if (isDirGrantedToday()) return true; if (!window.showDirectoryPicker) { // 浏览器不支持 File System Access:降级为「本会话确认一次」 if (getDirGrantDay() === cnDayKey()) return true; var ok = window.confirm( '当前浏览器不支持选择下载目录,将使用浏览器默认下载路径。\n' + '(今日本页面仅确认一次)\n\n确定继续下载吗?' ); if (ok) { setDirGrantDay(cnDayKey()); return true; } setStatus('❌ 已取消:今日尚未确认下载目录', 'err'); return false; } setStatus('📂 今日首次下载,请选择保存目录…', 'info'); try { var dh = await window.showDirectoryPicker({ mode: 'readwrite', id: 'svd-daily' }); var perm = await dh.requestPermission({ mode: 'readwrite' }); if (perm !== 'granted') { setStatus('❌ 未授权写入该目录,无法下载', 'err'); return false; } dirHandle = dh; setDirGrantDay(cnDayKey()); await _idbSaveHandle(dh); try { GM_setValue('svd_last_dir_name', dh.name); } catch (e) {} _applyDirHandleUI(dh.name, true); setStatus('✅ 今日下载目录:' + dh.name, 'ok'); logOk('已选择今日保存目录:' + dh.name, '今天在这个页面就不会再弹窗了,明天零点后会重新问一次'); return true; } catch (e) { if (e && e.name === 'AbortError') { logWarn('你取消了目录选择,本次没有下载', '每天第一次下载需要先选一个保存文件夹'); setStatus('❌ 已取消选择目录,本次下载中止', 'err'); } else { logErr('选择目录时出错', (e && e.message) ? e.message : String(e)); setStatus('❌ 目录选择失败:' + (e && e.message ? e.message : e), 'err'); } return false; } } /* ══════════════════════════════════════════════════════════ 3. STATE ══════════════════════════════════════════════════════════ */ var dirHandle = null; var videoInfo = null; var isDownloading = false; var lastNavUrl = ''; var imageDownloading = false; var xhsNoteData = null; var detectedUrls = new Set(); var sniffMemoryVault = []; var isSniffSectionVisible = GM_getValue('sniff_section_visible', false); var sniffFirstCopied = false; var refreshSeq = 0; // 竞态令牌:丢弃过期的解析结果 /* ── 抖音:按 aweme_id 建立数据仓库(修复「下载错视频/总是同一个」)── */ var awemeStore = new Map(); // awemeId -> 归一化后的作品数据 var AWEME_MAX = 300; var currentAweme = null; // 当前屏上作品的 aweme_id function putAweme(id, data) { if (!id || !data) return; var old = awemeStore.get(id) || {}; // 合并:新数据优先,但不要用空值覆盖已有的好值 Object.keys(data).forEach(function (k) { var v = data[k]; if (v === null || v === undefined || v === '' ) return; if (Array.isArray(v) && !v.length) return; old[k] = v; }); old.awemeId = id; awemeStore.set(id, old); if (awemeStore.size > AWEME_MAX) { var firstKey = awemeStore.keys().next().value; awemeStore.delete(firstKey); } } /* ══════════════════════════════════════════════════════════ 4. PLATFORM ══════════════════════════════════════════════════════════ */ function isDouyin() { return location.hostname.indexOf('douyin.com') > -1; } function isXiaohongshu() { return location.hostname.indexOf('xiaohongshu.com') > -1; } function isBili() { return location.hostname.indexOf('bilibili.com') > -1; } function isDyOrXhs() { return isDouyin() || isXiaohongshu(); } /* ══════════════════════════════════════════════════════════ 5. UTILS ══════════════════════════════════════════════════════════ */ function q(id) { return document.getElementById(id); } function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>'); } function avPh() { var d = document.createElement('div'); d.className = 'svd-avph'; d.textContent = '👤'; return d; } function pad2(n) { return ('0' + n).slice(-2); } /** 秒级时间戳/毫秒时间戳 -> 北京时间 "YYYY-MM-DD HH:mm:ss" 【新增功能 1】*/ function fmtCnTime(ts) { if (!ts && ts !== 0) return ''; var n = Number(ts); if (!isFinite(n) || n <= 0) return ''; if (n < 1e11) n *= 1000; // 10位=秒 -> 毫秒 var bj = new Date(n + 8 * 3600 * 1000); return bj.getUTCFullYear() + '-' + pad2(bj.getUTCMonth() + 1) + '-' + pad2(bj.getUTCDate()) + ' ' + pad2(bj.getUTCHours()) + ':' + pad2(bj.getUTCMinutes()) + ':' + pad2(bj.getUTCSeconds()); } function setStatus(m, c) { var e = q('svd-stattxt'); if (!e) return; e.textContent = m; e.className = c || ''; } function setProgress(r) { var b = q('svd-prog'), f = q('svd-progfill'); if (!b || !f) return; if (r <= 0 || r >= 1) { b.style.display = 'none'; f.style.width = '0%'; } else { b.style.display = 'block'; f.style.width = (r * 100).toFixed(1) + '%'; } } function setBtnsEnabled(on) { var vb = q('svd-btn-v'), cb = q('svd-btn-c'); if (!on) { if (vb) vb.disabled = true; if (cb) cb.disabled = true; return; } if (vb) vb.disabled = !!(videoInfo && videoInfo.isImagePost); if (cb) cb.disabled = !(videoInfo && videoInfo.coverUrl); } function sanitize(s) { return String(s == null ? '' : s).replace(/[\\/:*?"<>|\r\n\t]/g, '_').trim().substring(0, 80); } function getUrlFilename(url) { try { var p = new URL(url, location.href).pathname.split('/').filter(Boolean); var last = p[p.length - 1] || ''; if (last) return decodeURIComponent(last); } catch (e) {} var m = String(url).match(/\/([^/?#]+)(?:\?|#|$)/); return m ? decodeURIComponent(m[1]) : 'video.mp4'; } function copyToClipboard(text) { if (navigator.clipboard && navigator.clipboard.writeText) { return navigator.clipboard.writeText(text).catch(function () { return _copyFallback(text); }); } return _copyFallback(text); } function _copyFallback(text) { return new Promise(function (resolve, reject) { var ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;top:-9999px;opacity:0'; document.body.appendChild(ta); ta.select(); var ok = false; try { ok = document.execCommand('copy'); } catch (e) {} document.body.removeChild(ta); ok ? resolve() : reject(new Error('copy failed')); }); } function notify(title, text) { if (!SETTINGS.notifyOnComplete) return; try { if (typeof GM_notification === 'function') { GM_notification({ title: title, text: text, timeout: 4000, silent: false }); return; } } catch (e) {} try { if ('Notification' in window && Notification.permission === 'granted') new Notification(title, { body: text }); } catch (e) {} } /* ══════════════════════════════════════════════════════════ LOGGER — 使用日志(说人话版) 目标:出问题时,普通人一眼能看懂发生了什么、该怎么办。 所以每条日志尽量写成「结果 + 原因 + 建议」,不写英文报错。 ══════════════════════════════════════════════════════════ */ var LOG_MAX = 300; var logBuf = []; var logSeq = 0; function _logTimeNow() { var bj = new Date(Date.now() + 8 * 3600 * 1000); return pad2(bj.getUTCHours()) + ':' + pad2(bj.getUTCMinutes()) + ':' + pad2(bj.getUTCSeconds()); } /** * @param level ok / err / warn / info * @param what 发生了什么(一句人话) * @param why 为什么 / 怎么办(可选) */ function logAdd(level, what, why) { var e = { id: ++logSeq, t: _logTimeNow(), lv: level || 'info', what: String(what || ''), why: why ? String(why) : '' }; logBuf.push(e); if (logBuf.length > LOG_MAX) logBuf.shift(); try { GM_setValue('svd_logs', JSON.stringify(logBuf.slice(-120))); } catch (err) {} renderLogIfOpen(e); return e; } function logOk(w, y) { return logAdd('ok', w, y); } function logErr(w, y) { return logAdd('err', w, y); } function logWarn(w, y) { return logAdd('warn', w, y); } function logInfo(w, y) { return logAdd('info', w, y); } function loadLogs() { try { var raw = GM_getValue('svd_logs', ''); if (!raw) return; var arr = JSON.parse(raw); if (Array.isArray(arr)) { logBuf = arr.slice(-LOG_MAX); logSeq = logBuf.length ? logBuf[logBuf.length - 1].id : 0; } } catch (e) {} } function clearLogs() { logBuf = []; logSeq = 0; try { GM_setValue('svd_logs', ''); } catch (e) {} var list = q('svd-log-list'); if (list) list.innerHTML = ''; updateLogEmpty(); logInfo('日志已清空'); } var LV_META = { ok: { ico: '✅', cls: 'svd-log-ok' }, err: { ico: '❌', cls: 'svd-log-err' }, warn: { ico: '⚠️', cls: 'svd-log-warn' }, info: { ico: 'ℹ️', cls: 'svd-log-info' } }; function makeLogRow(e) { var meta = LV_META[e.lv] || LV_META.info; var li = document.createElement('div'); li.className = 'svd-log-row ' + meta.cls; li.dataset.lv = e.lv; li.innerHTML = '' + meta.ico + '' + '
' + '
' + esc(e.what) + '
' + (e.why ? '
' + esc(e.why) + '
' : '') + '
' + '' + esc(e.t) + ''; return li; } function currentLogFilter() { var seg = q('svd-log-filter'); if (!seg) return 'all'; var on = seg.querySelector('button.active'); return on ? on.dataset.val : 'all'; } function rowMatchesFilter(lv, f) { if (f === 'all') return true; if (f === 'err') return lv === 'err' || lv === 'warn'; return lv === f; } function renderLogIfOpen(e) { var list = q('svd-log-list'); if (!list) return; var row = makeLogRow(e); if (!rowMatchesFilter(e.lv, currentLogFilter())) row.style.display = 'none'; list.prepend(row); while (list.children.length > LOG_MAX) list.removeChild(list.lastChild); updateLogEmpty(); } function renderAllLogs() { var list = q('svd-log-list'); if (!list) return; list.innerHTML = ''; var f = currentLogFilter(); logBuf.slice().reverse().forEach(function (e) { var row = makeLogRow(e); if (!rowMatchesFilter(e.lv, f)) row.style.display = 'none'; list.appendChild(row); }); updateLogEmpty(); } function updateLogEmpty() { var list = q('svd-log-list'), empty = q('svd-log-empty'); if (!list || !empty) return; var visible = Array.prototype.filter.call(list.children, function (c) { return c.style.display !== 'none'; }).length; empty.style.display = visible === 0 ? 'block' : 'none'; } /** 导出为纯文本,方便直接发给作者 */ function buildLogText() { var head = [ '短视频下载 Pro 使用日志', '导出时间:' + fmtCnTime(Date.now()) + '(北京时间)', '脚本版本:v4.3.0', '当前网址:' + location.href, '浏览器:' + navigator.userAgent, '——————————————————————' ]; var body = logBuf.map(function (e) { var tag = e.lv === 'ok' ? '[成功]' : e.lv === 'err' ? '[失败]' : e.lv === 'warn' ? '[注意]' : '[信息]'; return e.t + ' ' + tag + ' ' + e.what + (e.why ? '(' + e.why + ')' : ''); }); return head.concat(body.length ? body : ['(暂无日志)']).join('\n'); } /* ══════════════════════════════════════════════════════════ INDEXEDDB — 目录句柄持久化 ══════════════════════════════════════════════════════════ */ function _idbOp(fn) { return new Promise(function (resolve) { var req; try { req = indexedDB.open('svd_fs_db', 1); } catch (e) { resolve(null); return; } req.onupgradeneeded = function (e) { try { e.target.result.createObjectStore('handles'); } catch (err) {} }; req.onsuccess = function (e) { try { fn(e.target.result, resolve); } catch (err) { resolve(null); } }; req.onerror = function () { resolve(null); }; }); } function _idbSaveHandle(handle) { return _idbOp(function (db, resolve) { var tx = db.transaction('handles', 'readwrite'); tx.objectStore('handles').put(handle, 'dir'); tx.oncomplete = function () { db.close(); resolve(true); }; tx.onerror = function () { db.close(); resolve(false); }; }); } function _idbLoadHandle() { return _idbOp(function (db, resolve) { var tx = db.transaction('handles', 'readonly'); var gr = tx.objectStore('handles').get('dir'); gr.onsuccess = function () { db.close(); resolve(gr.result || null); }; gr.onerror = function () { db.close(); resolve(null); }; }); } /** * 恢复目录句柄。注意:即使 queryPermission 是 granted,也【不】直接算作 * 「今日已选目录」——每日首次下载必须走 ensureDailyDir 的弹窗。 */ async function _restoreDirHandle() { try { var handle = await _idbLoadHandle(); if (!handle) return; var perm = 'prompt'; try { perm = await handle.queryPermission({ mode: 'readwrite' }); } catch (e) {} _applyDirHandleUI(handle.name, perm === 'granted'); if (perm === 'granted') dirHandle = handle; // 仅供 UI 展示与二次授权,闸门仍独立生效 } catch (e) {} } function _applyDirHandleUI(name, authorized) { var d = q('svd-path-txt'); if (!d) return; if (!name) { d.textContent = '未选择目录 · 今日首次下载将弹窗选择'; d.classList.remove('set'); d.title = ''; return; } var todayOk = isDirGrantedToday(); d.textContent = '📁 ' + name + (todayOk ? ' (今日已授权)' : (authorized ? '(今日需重新确认)' : '(请点击「选择」重新授权)')); d.title = '目录:' + name; d.classList.toggle('set', todayOk); } /* ══════════════════════════════════════════════════════════ 7. SNIFFER ══════════════════════════════════════════════════════════ */ function getResTag(u) { u = String(u).toLowerCase(); if (u.indexOf('8k') > -1 || u.indexOf('4320') > -1) return '[👑 8K]'; if (u.indexOf('4k') > -1 || u.indexOf('2160') > -1) return '[💎 4K]'; if (u.indexOf('1080') > -1 || u.indexOf('1920') > -1) return '[🔥 1080P]'; if (u.indexOf('720') > -1) return '[🎬 720P]'; if (u.indexOf('480') > -1) return '[📺 480P]'; return ''; } function getUrlResolution(u) { u = String(u).toLowerCase(); if (u.indexOf('8k') > -1 || u.indexOf('4320') > -1) return 4320; if (u.indexOf('4k') > -1 || u.indexOf('2160') > -1) return 2160; if (u.indexOf('1080') > -1 || u.indexOf('1920') > -1) return 1080; if (u.indexOf('720') > -1) return 720; if (u.indexOf('480') > -1) return 480; if (u.indexOf('360') > -1) return 360; return 0; } function getLinkType(u) { if (/\.m3u8(\?|$)/i.test(u)) return 'm3u8'; if (/\.mp4(\?|$)/i.test(u)) return 'mp4'; return 'other'; } function tryRemoveWatermark(rawUrl) { try { var u = new URL(rawUrl, location.href); var changed = false; ['watermark', 'wm', 'x-watermark', 'x-wm', 'logo'].forEach(function (p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); var np = u.pathname.replace(/\/watermark\//i, '/'); if (np !== u.pathname) { u.pathname = np; changed = true; } return changed ? u.href : null; } catch (e) { return null; } } function addUrl(url, customTitle, isBiliBatch) { if (typeof url !== 'string' || !url) return; var fingerprint = url.split('?')[0]; if (detectedUrls.has(fingerprint)) return; if (!isBiliBatch && !/\.(m3u8|mp4|ts|m4s|mpd|m4v|mkv|webm|mov|avi|flv)(\?|$)/i.test(url) && !/\/video\/|\/vod\/|\/stream\/|\/playlist\/|m3u8/i.test(url)) return; if (url.indexOf('fragment') > -1 && (url.indexOf('.ts') > -1 || url.indexOf('seg-') > -1)) return; if (!isBiliBatch) { var minRes = parseInt(SETTINGS.minResolution, 10) || 0; if (minRes > 0) { var res = getUrlResolution(url); if (res > 0 && res < minRes) return; } if (SETTINGS.linkTypeFilter !== 'all' && getLinkType(url) !== SETTINGS.linkTypeFilter) return; } if (window.self !== window.top) { try { window.top.postMessage({ type: 'VIDEO_SNIFF_ADD', url: url, customTitle: customTitle, isBiliBatch: isBiliBatch }, '*'); } catch (e) {} return; } detectedUrls.add(fingerprint); var item = { url: url, customTitle: customTitle, isBiliBatch: isBiliBatch }; sniffMemoryVault.push(item); if (sniffMemoryVault.length > 200) sniffMemoryVault.shift(); renderSingleSniffItem(item); updateSniffStatus(); if (SETTINGS.autoCopyFirst && !sniffFirstCopied && !isBiliBatch) { sniffFirstCopied = true; copyToClipboard(url).then(function () { setStatus('🔗 已自动复制首条嗅探链接', 'ok'); }).catch(function () {}); } if (customTitle && !isBiliBatch) { var clean = tryRemoveWatermark(url); if (clean && !detectedUrls.has(clean.split('?')[0])) addUrl(clean, customTitle + '(去水印尝试)', false); } } function renderSingleSniffItem(item) { var list = q('svd-m3u8-list'); if (!list) return; var li = document.createElement('li'); li.className = 'svd-m3u8-item'; var tagText = item.isBiliBatch ? '🎬 选集' : getResTag(item.url); var tagClass = item.isBiliBatch ? 'svd-tag-bili' : 'svd-tag-res'; var title = item.customTitle || item.url.split('?')[0].split('/').filter(Boolean).pop() || item.url.substring(0, 50); var header = document.createElement('div'); header.className = 'svd-sniff-header'; header.innerHTML = (tagText ? '' + esc(tagText) + '' : '') + '' + esc(title) + '' + ''; header.addEventListener('click', function () { li.classList.toggle('collapsed'); }); var body = document.createElement('div'); body.className = 'svd-sniff-body'; /* ★v4.2 修复「一直转圈」★ 原因:嗅探到的多是跨域 CDN 直链,浏览器直接