// ==UserScript== // @name 抖音轻助手(定制精简版) // @namespace douyin-lite-helper // @version 1.6.0 // @description ① 播放器右下角一键下载视频(自动最高画质)② 图集/实况照片一键逐张下载 ③ 直播间切后台不卡死 ④ 自动过滤广告 + 一键拉黑作者自动跳过 ⑤ 侧边栏只留"推荐" // @author 定制 // @license MIT // @match *://*.douyin.com/* // @exclude *://creator.douyin.com/* // @connect * // @grant GM_download // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @run-at document-start // @noframes // ==/UserScript== (function () { "use strict"; /* ================= 小工具:提示浮层 ================= */ const toast = (() => { let el = null; let timer = null; function ensure() { if (!el) { el = document.createElement("div"); el.style.cssText = [ "position:fixed", "bottom:90px", "left:50%", "transform:translateX(-50%)", "background:rgba(0,0,0,0.78)", "color:#fff", "padding:8px 16px", "border-radius:8px", "font-size:14px", "line-height:1.5", "max-width:60vw", "z-index:2147483647", "opacity:0", "transition:opacity .25s", "pointer-events:none", ].join(";"); (document.body || document.documentElement).appendChild(el); } return el; } function show(text, autoHide, duration) { const node = ensure(); node.textContent = text; node.style.opacity = "1"; if (timer) { clearTimeout(timer); timer = null; } if (autoHide) timer = setTimeout(() => { node.style.opacity = "0"; }, duration || 2500); } return { show: (t) => show(t, false), success: (t) => show("✔ " + t, true), error: (t) => show("✖ " + t, true, 4000), progress: (loaded, total) => { if (total > 0) show(`下载中 ${((loaded / total) * 100).toFixed(1)}%`, false); }, }; })(); function isBigVideo(v) { try { const r = v.getBoundingClientRect(); return r.width > 150 && r.height > 150; } catch (e) { return false; } } /* 取当前画面里最大的那个 video(直播间/菜单兜底下载用) */ function pickMainVideo() { let best = null; let bestArea = 0; document.querySelectorAll("video").forEach((v) => { if (!isBigVideo(v)) return; const r = v.getBoundingClientRect(); const area = r.width * r.height; if (area > bestArea) { best = v; bestArea = area; } }); return best; } /* ================= 1. 直播防卡死 ================= */ const isLiveRoute = () => location.hostname === "live.douyin.com" || location.pathname.startsWith("/live/"); (function installLiveKeepAlive() { /* 1.1 伪装页面可见性:在直播间时,让页面认为自己"可见"。 注意:Chrome 已把 document.hidden 做成实例上不可重写的属性, 所以这里先试实例、再试原型,失败了也不影响后面两层兜底 (Firefox 等浏览器实例重定义仍然有效) */ const ownHidden = Object.getOwnPropertyDescriptor(document, "hidden"); const protoHidden = Object.getOwnPropertyDescriptor(Document.prototype, "hidden"); const realHidden = () => { const d = ownHidden || protoHidden; return d ? d.get.call(document) : false; }; const spoof = (obj, prop, fakeValue) => { try { const desc = Object.getOwnPropertyDescriptor(obj, prop); if (!desc || !desc.configurable) return false; Object.defineProperty(obj, prop, { configurable: true, get() { return isLiveRoute() ? fakeValue : desc.get.call(this); }, }); return true; } catch (e) { return false; } }; if (!ownHidden) { spoof(document, "hidden", false); spoof(document, "visibilityState", "visible"); spoof(document, "webkitVisibilityState", "visible"); } else { spoof(Document.prototype, "hidden", false); spoof(Document.prototype, "visibilityState", "visible"); spoof(Document.prototype, "webkitVisibilityState", "visible"); } /* 1.2 直播间里拦截"切到后台"事件,不让页面收到 hidden 通知 */ const blocker = (e) => { if (isLiveRoute() && realHidden()) e.stopImmediatePropagation(); }; window.addEventListener("visibilitychange", blocker, true); document.addEventListener("visibilitychange", blocker, true); /* 1.3 兜底:切回标签页时自动恢复播放,并追到直播最新画面 */ let wasPlaying = false; const resumeWhenBack = () => { setTimeout(() => { if (!isLiveRoute()) return; const v = pickMainVideo(); if (!v) return; if (v.paused && wasPlaying) v.play().catch(() => {}); try { const b = v.buffered; if (b.length) { const edge = b.end(b.length - 1); if (edge - v.currentTime > 2.5) v.currentTime = Math.max(0, edge - 0.3); } } catch (e) { /* ignore */ } }, 300); }; window.addEventListener( "visibilitychange", () => { if (!realHidden()) resumeWhenBack(); }, false ); /* 1.4 后台保护罩:把页面"切后台就停播"能用的手段全部拦掉。 Chrome 不许改写 document.hidden 的读取(伪装这条路已实测被堵死), 所以改为直接拦截页面在后台期间做出的破坏性动作 */ const bgGuard = (fn) => { try { fn(); } catch (e) { /* 某个 hook 失败不影响其它 */ } }; /* 1.4.1 拒绝后台暂停:页面停直播靠 video.pause() */ bgGuard(() => { const origPause = HTMLMediaElement.prototype.pause; HTMLMediaElement.prototype.pause = function () { if (isLiveRoute() && realHidden() && !this.paused) return; return origPause.apply(this, arguments); }; }); /* 1.4.2 document.hasFocus() 恒真:有代码靠它判断窗口焦点 */ bgGuard(() => { const origHasFocus = Document.prototype.hasFocus; Document.prototype.hasFocus = function () { if (this === document && isLiveRoute()) return true; return origHasFocus.apply(this, arguments); }; }); /* 1.4.3 拦 window blur:切走时页面收不到失焦通知 (切标签页时 blur 先于 hidden 触发,所以不能等 realHidden) */ window.addEventListener( "blur", (e) => { if (isLiveRoute()) e.stopImmediatePropagation(); }, true ); /* 1.4.4 后台不许关闭拉流/心跳用的 WebSocket */ bgGuard(() => { const origWSClose = WebSocket.prototype.close; WebSocket.prototype.close = function () { if (isLiveRoute() && realHidden()) return; return origWSClose.apply(this, arguments); }; }); /* 1.4.5 后台不许拆除 MSE 流(SourceBuffer.abort / endOfStream) */ bgGuard(() => { if (typeof SourceBuffer !== "undefined") { const origAbort = SourceBuffer.prototype.abort; SourceBuffer.prototype.abort = function () { if (isLiveRoute() && realHidden()) return; return origAbort.apply(this, arguments); }; } if (typeof MediaSource !== "undefined") { const origEOS = MediaSource.prototype.endOfStream; MediaSource.prototype.endOfStream = function () { if (isLiveRoute() && realHidden()) return; return origEOS.apply(this, arguments); }; } }); /* 1.4.6 后台不许清空直播视频的 src(防 teardown) */ bgGuard(() => { const srcDesc = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, "src"); if (srcDesc && srcDesc.configurable && srcDesc.set) { Object.defineProperty(HTMLMediaElement.prototype, "src", { get: srcDesc.get, set(v) { if (isLiveRoute() && realHidden() && !this.paused && (v == null || v === "" || v === "about:blank")) return; return srcDesc.set.call(this, v); }, configurable: true, }); } }); /* 1.5 心跳:后台时如果视频被悄悄暂停了就续上。 Worker 里的定时器不受浏览器后台节流限制,主线程 interval 作备用 */ const tick = () => { if (!isLiveRoute()) return; const v = pickMainVideo(); if (!v) return; if (!realHidden()) { wasPlaying = !v.paused; return; } if (v.paused && wasPlaying) v.play().catch(() => {}); }; setInterval(tick, 1500); try { const worker = new Worker( URL.createObjectURL(new Blob(["setInterval(function(){postMessage(1)},1500)"], { type: "text/javascript" })) ); worker.onmessage = tick; } catch (e) { /* Worker 被限制时仅靠主线程 interval */ } })(); /* ================= 2. 从页面里解析视频/图集数据 ================= */ function getFiber(el) { if (!el) return null; for (const key in el) { if (key.startsWith("__reactFiber$") || key.startsWith("__reactInternalInstance$")) return el[key]; } return null; } /* 沿 React Fiber 往上找 awemeInfo(与原"抖音优化"脚本同思路) */ function findAwemeInfo(startEl) { let fiber = getFiber(startEl); for (let i = 0; fiber && i < 40; i++, fiber = fiber.return) { const props = fiber.memoizedProps; if (!props || typeof props !== "object") continue; const candidates = [ props.awemeInfo, props.originData && props.originData.awemeInfo, props.item && props.item.awemeInfo, props.data && props.data.awemeInfo, ]; for (const c of candidates) { if (c && typeof c === "object" && (c.video || c.images)) return c; } const itemList = props.videoInfoRes && props.videoInfoRes.item_list; if (Array.isArray(itemList) && itemList[0] && itemList[0].video) return itemList[0]; } return null; } /* 兜底:老版页面会把数据放在 #RENDER_DATA 里 */ function deepFindAweme(node, depth, seen) { if (!node || typeof node !== "object" || depth > 8 || seen.size > 8000) return null; if (seen.has(node)) return null; seen.add(node); if (typeof node.aweme_id === "string" && (node.video || node.images)) return node; for (const key in node) { if (key === "__proto__" || key === "parent") continue; const child = node[key]; if (!child || typeof child !== "object") continue; const r = deepFindAweme(child, depth + 1, seen); if (r) return r; } return null; } function getAwemeInfoFromRenderData() { try { const el = document.getElementById("RENDER_DATA"); if (!el || !el.textContent) return null; return deepFindAweme(JSON.parse(decodeURIComponent(el.textContent)), 0, new Set()); } catch (e) { return null; } } /* 统一解析地址对象:抖音 DOM 数据是驼峰(urlList/dataSize), API/RENDER_DATA 数据是下划线(url_list/data_size),两种都要兼容。 playAddr 本体还可能是数组(每项是字符串或带 urlList 的对象) */ function collectUrls(addr, out) { if (!addr) return; if (Array.isArray(addr)) { addr.forEach((a) => collectUrls(a, out)); return; } if (typeof addr === "string") { if (/^https?:/.test(addr)) out.push(addr); return; } if (typeof addr === "object") { const raw = addr.urlList || addr.url_list || []; if (Array.isArray(raw)) raw.forEach((u) => collectUrls(u, out)); } } function normalizeAddr(addr) { const urls = []; collectUrls(addr, urls); if (!urls.length) return null; const src = Array.isArray(addr) ? addr.find((a) => a && typeof a === "object") : addr; return { urls, width: (src && src.width) || 0, height: (src && src.height) || 0, size: (src && (src.dataSize || src.data_size)) || 0, }; } /* 在画质列表里挑最高画质:优先非 H265、分辨率最高、体积更大 */ function pickBestBitrate(list) { if (!Array.isArray(list) || !list.length) return null; let best = null; const seenUrls = new Set(); for (const it of list) { if (!it || typeof it !== "object") continue; const urls = []; collectUrls(it.playAddr || it.play_addr, urls); /* DOM 驼峰格式:每档自带签名好的 playApi 直链;排除 DASH 分段流 */ const playApi = it.playApi || it.play_api; if (typeof playApi === "string" && /^https?:/.test(playApi) && !playApi.includes("/play/dash/")) { urls.push(playApi); } const unique = urls.filter((u) => !seenUrls.has(u)); if (!unique.length) continue; unique.forEach((u) => seenUrls.add(u)); const addr = normalizeAddr(it.playAddr || it.play_addr); const width = it.width || (addr && addr.width) || 0; const height = it.height || (addr && addr.height) || 0; const size = it.dataSize || it.data_size || (addr && addr.size) || 0; const isH265 = it.isH265 != null ? !!it.isH265 : !!it.is_h265; const entry = { urls: unique, width, height, size, isH265 }; if ( !best || width * height > best.width * best.height || (width * height === best.width * best.height && ((entry.isH265 !== best.isH265 && !entry.isH265) || (entry.isH265 === best.isH265 && entry.size > best.size))) ) { best = entry; } } return best; } function getDownloadUrls(info) { const v = (info && info.video) || {}; const urls = []; const best = pickBestBitrate(v.bitRateList || v.bit_rate); if (best) urls.push(...best.urls); const playAddr = normalizeAddr(v.playAddr || v.play_addr); if (playAddr) urls.push(...playAddr.urls); if (typeof v.playApi === "string" && /^https?:/.test(v.playApi) && !v.playApi.includes("/play/dash/")) { urls.push(v.playApi); } const downloadAddr = normalizeAddr(v.downloadAddr || v.download_addr); if (downloadAddr) urls.push(...downloadAddr.urls); return [...new Set(urls)].map((u) => (u.startsWith("http:") ? "https:" + u.slice(5) : u)); } function getImages(info) { return (info && Array.isArray(info.images) && info.images) || []; } function getImageUrlList(img) { if (!img || typeof img !== "object") return []; const urls = []; /* urlList 的元素可能是字符串,也可能是对象,统一交给 collectUrls 挖 */ collectUrls(img.urlList, urls); collectUrls(img.url_list, urls); collectUrls(img.downloadUrlList, urls); collectUrls(img.download_url_list, urls); return [...new Set(urls)].map((u) => (u.startsWith("http:") ? "https:" + u.slice(5) : u)); } function buildFileName(info) { const author = (info && ((info.authorInfo && info.authorInfo.nickname) || (info.author && info.author.nickname))) || ""; const desc = ((info && info.desc) || "").replace(/\s+/g, " ").trim(); let name = [author, desc].filter(Boolean).join("-"); name = name .replace(/[\\/:*?"<>|\u0000-\u001f\u007f]/g, "_") .slice(0, 60) .replace(/^[.\s]+|[.\s]+$/g, ""); return name || (info && (info.awemeId || info.aweme_id)) || String(Date.now()); } function guessExt(url, fallback) { try { const m = new URL(url, location.origin).pathname.match(/\.([a-z0-9]{2,5})$/i); if (m) return m[1].toLowerCase(); } catch (e) { /* ignore */ } return fallback; } /* ================= 3. 下载 ================= */ const REFERER = "https://www.douyin.com/"; /* 单个文件直接落盘下载:带硬超时,超时会主动 abort */ function gmDownloadOne(url, name, timeoutMs = 60000) { return new Promise((resolve, reject) => { let settled = false; let handle = null; const finish = (ok, err) => { if (settled) return; settled = true; clearTimeout(timer); if (!ok && handle && typeof handle.abort === "function") { try { handle.abort(); } catch (e) { /* ignore */ } } ok ? resolve() : reject(err); }; const timer = setTimeout(() => finish(false, new Error("超时")), timeoutMs); try { handle = GM_download({ url, name, headers: { Referer: REFERER }, onload: () => finish(true), onerror: () => finish(false, new Error("下载失败")), ontimeout: () => finish(false, new Error("超时")), }); } catch (e) { finish(false, e); } }); } function downloadVideo(info) { const urls = getDownloadUrls(info); if (!urls.length) { toast.error("没有解析到可下载的视频地址"); return; } if (typeof GM_download !== "function") { toast.show("当前环境不支持 GM_download,已在新标签页打开视频地址"); window.open(urls[0], "_blank"); return; } const fileName = buildFileName(info) + ".mp4"; const tryNext = (index) => { toast.show("开始下载:" + fileName); GM_download({ url: urls[index], name: fileName, headers: { Referer: REFERER }, onprogress: (d) => { if (d && d.total) toast.progress(d.loaded, d.total); }, onload: () => toast.success("下载完成:" + fileName), onerror: () => { if (index + 1 < urls.length) tryNext(index + 1); else toast.error("下载失败,地址可能已过期,请刷新页面后重试"); }, ontimeout: () => { if (index + 1 < urls.length) tryNext(index + 1); else toast.error("下载超时"); }, }); }; tryNext(0); } /* 图集逐张下载(不打包),4 路并行 + 硬超时,绝不卡死 */ async function downloadImages(info) { const images = getImages(info); if (!images.length) { toast.error("没有找到图片数据"); return; } if (typeof GM_download !== "function") { toast.error("当前环境不支持 GM_download,无法下载"); return; } const baseName = buildFileName(info); const total = images.length; const t0 = Date.now(); let doneCount = 0; let okCount = 0; const progress = () => { const secs = Math.round((Date.now() - t0) / 1000); toast.show(`图集下载 ${doneCount}/${total}(${secs}秒)`); }; progress(); const fetchOne = async (img, i) => { const indexStr = String(i + 1).padStart(String(total).length, "0"); const urlList = getImageUrlList(img).slice(0, 2); if (!urlList.length) return false; for (const u of urlList) { try { await gmDownloadOne(u, `${baseName}_${indexStr}.${guessExt(u, "jpg")}`); /* 实况照片的动态视频也一起下(失败不影响图片) */ const liveAddr = pickBestBitrate(img.video && (img.video.bitRateList || img.video.bit_rate)); if (liveAddr && liveAddr.urls[0]) { try { await gmDownloadOne(liveAddr.urls[0], `${baseName}_${indexStr}_实况.mp4`, 90000); } catch (e) { /* ignore */ } } return true; } catch (e) { /* 换下一个 CDN 地址重试 */ } } return false; }; /* 4 路并发消费任务队列 */ const results = new Array(total); let cursor = 0; const worker = async () => { while (cursor < total) { const i = cursor++; results[i] = await fetchOne(images[i], i); doneCount++; if (results[i]) okCount++; progress(); } }; await Promise.all([worker(), worker(), worker(), worker()]); const failN = total - okCount; if (!okCount) toast.error("一张图片都没下载成功,抖音可能改版了,截图提示发我"); else toast.success(`图集下载完成:成功 ${okCount}/${total}` + (failN ? `(失败 ${failN} 张)` : "")); } function handleInfo(info) { if (!info) { toast.error("未获取到视频数据,刷新页面后重试"); return; } if (getImages(info).length) downloadImages(info); else downloadVideo(info); } /* ================= 4. 注入下载按钮 ================= */ function onDownloadClick(e) { e.preventDefault(); e.stopPropagation(); const btn = e.currentTarget; const container = btn.closest(".basePlayerContainer"); const video = container && container.querySelector("video"); const info = findAwemeInfo(container) || findAwemeInfo(video) || findAwemeInfo(btn) || getAwemeInfoFromRenderData(); handleInfo(info); } function createDownloadButton() { const btn = document.createElement("xg-icon"); btn.className = "dy-lite-dl-btn"; btn.innerHTML = '
" + '