// ==UserScript== // @name 豆包图片/视频一键无水印下载 // @namespace http://tampermonkey.net/ // @version 2.2 // @description 豆包图片/视频一键无水印下载(稳定修复版) // @author hydrachs // @match https://www.doubao.com/* // @grant GM_download // @run-at document-start // ==/UserScript== (function() { 'use strict'; // 配置 const MAX_DEDUP_SIZE = 100; const videoCache = new Map(); const imageDataMap = new Map(); let domObserverActive = false; let floatPanel = null; let isDragging = false; let dragStartX = 0, dragStartY = 0; let panelStartX = 0, panelStartY = 0; // ==================== 工具函数 ==================== function generateUUID() { if (typeof crypto !== 'undefined' && crypto.randomUUID) { return crypto.randomUUID(); } return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { const r = Math.random() * 16 | 0; const v = c === 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); } function extractFileKey(url) { if (!url) return null; const match = url.match(/rc_gen_image\/([^?~]+)/); return match ? match[1] : null; } function replaceWatermarkParam(url) { if (!url) return url; return url.replace(/lr=video_gen_watermark(?:_dyn)?/g, 'lr=video_gen_no_watermark'); } function showToast(message, type = "error") { const toast = document.createElement("div"); const colors = { success: "#10b981", error: "#ef4444", info: "#3b82f6" }; toast.style.cssText = ` position: fixed; bottom: 20px; right: 20px; background: ${colors[type] || colors.error}; color: white; padding: 10px 16px; border-radius: 8px; font-size: 13px; z-index: 100001; animation: fadeInOut 2.5s ease forwards; backdrop-filter: blur(8px); pointer-events: none; `; toast.textContent = type === "success" ? "✓ " + message : "⚠️ " + message; document.body.appendChild(toast); if (!document.getElementById("doubao-toast-style")) { const style = document.createElement("style"); style.id = "doubao-toast-style"; style.textContent = ` @keyframes fadeInOut { 0% { opacity: 0; transform: translateY(10px); } 15% { opacity: 1; transform: translateY(0); } 85% { opacity: 1; } 100% { opacity: 0; visibility: hidden; } } `; document.head.appendChild(style); } setTimeout(() => toast.remove(), 2500); } // ==================== 下载函数 ==================== async function downloadFile(url, filename) { try { const response = await fetch(url); const blob = await response.blob(); const blobUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = blobUrl; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(blobUrl); } catch (err) { const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); } } // ==================== 视频API ==================== async function callGetPlayInfo(videoKey) { const url = `https://www.doubao.com/samantha/media/get_play_info?aid=497858&device_platform=web&samantha_web=1&web_tab_id=${generateUUID()}`; const response = await fetch(url, { method: 'POST', headers: { 'accept': 'application/json', 'content-type': 'application/json', 'agw-js-conv': 'str', 'origin': location.origin, 'referer': location.href }, credentials: 'include', body: JSON.stringify({ key: videoKey, type: 'video' }) }); const json = await response.json(); if (json.code !== 0) throw new Error(`API error: ${json.code}`); const originalMedia = json.data?.original_media_info; if (originalMedia?.main_url) { return { mainUrl: replaceWatermarkParam(originalMedia.main_url), backupUrl: originalMedia.backup_url ? replaceWatermarkParam(originalMedia.backup_url) : null }; } const playInfo = json.data?.play_infos?.[0] || json.data?.play_info; if (playInfo?.main) { return { mainUrl: replaceWatermarkParam(playInfo.main) }; } throw new Error('No video URL'); } async function callDoubaoShareSave(messageId) { return new Promise((resolve) => { function handler(ev) { if (ev.data?.type === "doubaoShareSaveResult") { window.removeEventListener("message", handler); resolve(ev.data.data); } } window.postMessage({ type: "doubaoShareSave", messageId }, "*"); window.addEventListener("message", handler); setTimeout(() => { window.removeEventListener("message", handler); resolve(null); }, 15000); }); } async function callGetVideoShareInfo(shareId, vid) { const url = `https://www.doubao.com/creativity/share/get_video_share_info?web_tab_id=${generateUUID()}`; try { const resp = await fetch(url, { method: "POST", headers: { "accept": "application/json", "content-type": "application/json", "agw-js-conv": "str" }, credentials: 'include', body: JSON.stringify({ share_id: shareId, vid, creation_id: "" }) }); const json = await resp.json(); return json.code === 0 ? json.data : null; } catch (e) { return null; } } async function startVideoDownloadByMessageId(messageId) { const vid = videoCache.get(messageId); if (!vid) return { success: false, error: 'no vid', messageId }; try { const result = await callGetPlayInfo(vid); if (result?.mainUrl) { return { success: true, videoUrl: result.mainUrl, messageId }; } } catch (err) {} const share = await callDoubaoShareSave(messageId); if (share?.share_id) { const videoData = await callGetVideoShareInfo(share.share_id, vid); if (videoData?.play_infos?.[0]?.main) { return { success: true, videoUrl: replaceWatermarkParam(videoData.play_infos[0].main), messageId }; } } return { success: false, messageId }; } // ==================== 数据提取 ==================== function findVidInObject(obj, depth = 0) { if (depth > 10 || !obj) return null; if (Array.isArray(obj)) { for (const item of obj) { const found = findVidInObject(item, depth + 1); if (found) return found; } } else if (typeof obj === "object") { if (obj.vid && typeof obj.vid === "string" && obj.vid.startsWith("v0")) return obj.vid; if (obj.video_id && typeof obj.video_id === "string" && obj.video_id.startsWith("v0")) return obj.video_id; for (const val of Object.values(obj)) { const found = findVidInObject(val, depth + 1); if (found) return found; } } return null; } // 递归提取图片 function extractImagesFromObject(obj, depth = 0) { const images = []; if (depth > 8 || !obj) return images; if (Array.isArray(obj)) { for (const item of obj) { images.push(...extractImagesFromObject(item, depth + 1)); } return images; } if (typeof obj !== 'object') return images; // 检查 creations const creations = obj.creations || obj.creation_block?.creations; if (creations && Array.isArray(creations)) { for (const cr of creations) { const img = cr?.image; const raw = img?.image_ori_raw; if (raw?.url) { images.push({ watermark_url: img.image_thumb?.url, no_watermark_url: raw.url, width: raw.width, height: raw.height, key: extractFileKey(raw.url) }); } } } // 检查 content_block const contentBlocks = obj.content_block || obj.content_blocks; if (contentBlocks && Array.isArray(contentBlocks)) { for (const block of contentBlocks) { images.push(...extractImagesFromObject(block, depth + 1)); } } // 检查直接的 image const image = obj.image; if (image?.image_ori_raw?.url) { images.push({ no_watermark_url: image.image_ori_raw.url, width: image.image_ori_raw.width, height: image.image_ori_raw.height, key: extractFileKey(image.image_ori_raw.url) }); } // 递归其他属性 for (const val of Object.values(obj)) { if (val && typeof val === 'object') { images.push(...extractImagesFromObject(val, depth + 1)); } } return images; } function extractFromMessages(messages) { const images = []; const videos = []; for (const msg of messages) { const msgId = String(msg.message_id || "").trim(); if (msgId && msgId !== "0") { const vid = findVidInObject(msg); if (vid) { videos.push({ vid, messageId: msgId }); } } images.push(...extractImagesFromObject(msg)); } return { images, videos }; } function extractFromPatchOps(patchOps) { let images = []; for (const op of patchOps || []) { images.push(...extractImagesFromObject(op?.patch_value)); } return images; } // ==================== 网络拦截 ==================== const processedUrls = new Set(); function setupNetworkInterceptors() { // XHR拦截 const originalXHROpen = XMLHttpRequest.prototype.open; const originalXHRSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function(method, url, ...rest) { this._url = url; return originalXHROpen.call(this, method, url, ...rest); }; XMLHttpRequest.prototype.send = function(...args) { this.addEventListener("load", () => { if (this._url?.includes("chain/single") && !processedUrls.has(this._url)) { try { const data = JSON.parse(this.responseText); const messages = data?.downlink_body?.pull_singe_chain_downlink_body?.messages; if (messages) { const { images, videos } = extractFromMessages(messages); if (images.length) { images.forEach(img => { if (img.key) imageDataMap.set(img.key, img); }); window.postMessage({ type: "imageDataExtracted", data: images }, "*"); } if (videos.length) { videos.forEach(v => videoCache.set(v.messageId, v.vid)); window.postMessage({ type: "videoDataExtracted", data: videos }, "*"); } } processedUrls.add(this._url); if (processedUrls.size > MAX_DEDUP_SIZE) { const first = processedUrls.values().next().value; processedUrls.delete(first); } } catch (e) {} } }); return originalXHRSend.apply(this, args); }; // Fetch拦截 + SSE const originalFetch = window.fetch; window.fetch = async function(...args) { const url = typeof args[0] === "string" ? args[0] : args[0]?.url; if (url?.includes("chat/completion") && !processedUrls.has(url)) { processedUrls.add(url); const response = await originalFetch.apply(this, args); const ct = response.headers.get("content-type") || ""; if (ct.includes("text/event-stream")) { const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; (async () => { try { while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const parts = buffer.split("\n\n"); buffer = parts.pop() || ""; for (const part of parts) { const match = part.match(/^data: (.+)$/m); if (match) { try { const data = JSON.parse(match[1]); const images = extractFromPatchOps(data?.patch_op); if (images.length) { images.forEach(img => { if (img.key) imageDataMap.set(img.key, img); }); window.postMessage({ type: "imageDataExtracted", data: images }, "*"); } if (data.message_id && data.patch_op) { for (const op of data.patch_op) { const vid = findVidInObject(op?.patch_value); if (vid && data.message_id) { videoCache.set(data.message_id, vid); window.postMessage({ type: "videoDataExtracted", data: [{ vid, messageId: data.message_id }] }, "*"); } } } } catch (e) {} } } } } catch (e) {} })(); return response; } return response; } return originalFetch.apply(this, args); }; } // ==================== UI注入 ==================== const DOWNLOAD_ICON = ``; const IMAGE_ICON = ``; let injectedElements = new WeakSet(); let pendingButtons = new Map(); function injectStyles() { if (document.getElementById("doubao-dl-styles")) return; const style = document.createElement("style"); style.id = "doubao-dl-styles"; style.textContent = ` .doubao-dl-btn { position: absolute; bottom: 10px; right: 10px; z-index: 9999; display: inline-flex; align-items: center; gap: 5px; padding: 6px 12px; background: rgba(0,0,0,0.62); color: #fff; border: none; border-radius: 8px; font-size: 12px; font-weight: 500; cursor: pointer; backdrop-filter: blur(6px); transition: all 0.2s ease; } .doubao-dl-btn:hover:not(:disabled) { background: rgba(0,0,0,0.82); transform: scale(1.02); } .doubao-dl-btn:active:not(:disabled) { transform: scale(0.98); } .doubao-dl-btn:disabled { opacity: 0.6; cursor: not-allowed; } .doubao-dl-btn.success { background: rgba(16,185,129,0.85); } .doubao-dl-btn.error { background: rgba(239,68,68,0.82); } .doubao-img-container, .doubao-video-container { position: relative; } `; document.head.appendChild(style); } function findMessageId(element) { let el = element; for (let i = 0; i < 20 && el && el !== document.body; i++) { if (el.dataset?.messageId) return el.dataset.messageId; if (el.dataset?.message_id) return el.dataset.message_id; el = el.parentElement; } return null; } function findParentContainer(img) { let parent = img.parentElement; for (let i = 0; i < 6 && parent && parent !== document.body; i++) { const rect = parent.getBoundingClientRect(); if (rect.width >= 100 && rect.height >= 80) break; parent = parent.parentElement; } return parent || img.parentElement; } function injectImageButton(img, imageData) { if (injectedElements.has(img)) return; injectedElements.add(img); const container = findParentContainer(img); if (getComputedStyle(container).position === "static") { container.style.position = "relative"; } container.classList.add("doubao-img-container"); const btn = document.createElement("button"); btn.className = "doubao-dl-btn"; btn.innerHTML = IMAGE_ICON + " 下载原图"; btn.onclick = async (e) => { e.preventDefault(); e.stopPropagation(); btn.disabled = true; btn.innerHTML = "下载中..."; try { const filename = `doubao_${Date.now()}.png`; await downloadFile(imageData.no_watermark_url, filename); btn.innerHTML = "✓ 已下载"; btn.classList.add("success"); } catch (err) { btn.innerHTML = "重试"; btn.classList.add("error"); } setTimeout(() => { if (btn.isConnected) { btn.innerHTML = IMAGE_ICON + " 下载原图"; btn.classList.remove("success", "error"); btn.disabled = false; } }, 2000); }; container.appendChild(btn); } function injectVideoButton(container, messageId) { if (injectedElements.has(container) || !messageId) return; injectedElements.add(container); if (getComputedStyle(container).position === "static") { container.style.position = "relative"; } container.classList.add("doubao-video-container"); const btn = document.createElement("button"); btn.className = "doubao-dl-btn"; btn.innerHTML = DOWNLOAD_ICON + " 下载视频"; btn.onclick = (e) => { e.preventDefault(); e.stopPropagation(); btn.disabled = true; btn.innerHTML = "获取链接..."; pendingButtons.set(messageId, btn); window.postMessage({ type: "startVideoDownloadByMessageId", messageId }, "*"); }; container.appendChild(btn); } function scanAndInject() { // 图片注入 - 只处理有水印且有无水印版本已知的图片 document.querySelectorAll("img[src*='rc_gen_image']").forEach(img => { if (injectedElements.has(img)) return; const key = extractFileKey(img.src); if (key && imageDataMap.has(key)) { const imageData = imageDataMap.get(key); if (imageData) injectImageButton(img, imageData); } }); // 视频注入 document.querySelectorAll('[class*="block-video"], [class*="video-block"]').forEach(el => { if (injectedElements.has(el)) return; const messageId = findMessageId(el); if (messageId && videoCache.has(messageId)) { injectVideoButton(el, messageId); } }); } function startDOMObserver() { if (domObserverActive) return; domObserverActive = true; injectStyles(); scanAndInject(); const observer = new MutationObserver(() => { scanAndInject(); }); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["src", "class"] }); } // ==================== 悬浮面板(可拖动版)==================== function createFloatPanel() { if (floatPanel) return; floatPanel = document.createElement("div"); floatPanel.id = "doubao-float-panel"; floatPanel.style.cssText = ` position: fixed; bottom: 20px; left: 20px; width: 130px; background: rgba(30, 30, 35, 0.95); backdrop-filter: blur(12px); border: 1px solid rgba(255,255,255,0.15); border-radius: 16px; box-shadow: 0 10px 30px rgba(0,0,0,0.3); z-index: 999999; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; overflow: hidden; color: #e0e0e0; cursor: default; `; // 标题栏(可拖动) const header = document.createElement("div"); header.style.cssText = ` padding: 10px 12px; background: rgba(255,255,255,0.08); display: flex; justify-content: space-between; align-items: center; cursor: move; font-size: 12px; font-weight: 500; user-select: none; `; header.innerHTML = '📥 发现资源'; // 内容区 const body = document.createElement("div"); body.id = "panel-body"; body.style.padding = "12px"; body.innerHTML = `
0
无水印图片
0
可下载视频
`; floatPanel.appendChild(header); floatPanel.appendChild(body); document.body.appendChild(floatPanel); // 拖动功能 let dragActive = false; let dragOffsetX = 0, dragOffsetY = 0; header.addEventListener("mousedown", (e) => { if (e.target.id === "panel-min" || e.target.id === "panel-close") return; dragActive = true; const rect = floatPanel.getBoundingClientRect(); dragOffsetX = e.clientX - rect.left; dragOffsetY = e.clientY - rect.top; floatPanel.style.cursor = "grabbing"; e.preventDefault(); }); document.addEventListener("mousemove", (e) => { if (!dragActive) return; let newLeft = e.clientX - dragOffsetX; let newTop = e.clientY - dragOffsetY; // 边界限制 newLeft = Math.max(0, Math.min(window.innerWidth - floatPanel.offsetWidth, newLeft)); newTop = Math.max(0, Math.min(window.innerHeight - floatPanel.offsetHeight, newTop)); floatPanel.style.left = newLeft + "px"; floatPanel.style.top = newTop + "px"; floatPanel.style.right = "auto"; floatPanel.style.bottom = "auto"; }); document.addEventListener("mouseup", () => { dragActive = false; floatPanel.style.cursor = ""; }); // 按钮事件 floatPanel.querySelector("#panel-min").onclick = () => { const b = floatPanel.querySelector("#panel-body"); b.style.display = b.style.display === "none" ? "block" : "none"; }; floatPanel.querySelector("#panel-close").onclick = () => { floatPanel.style.display = "none"; }; floatPanel.querySelector("#refresh-btn").onclick = () => { updateFloatPanel(); showToast("已刷新资源列表", "info"); scanAndInject(); }; updateFloatPanel(); setInterval(updateFloatPanel, 3000); } function updateFloatPanel() { if (!floatPanel) return; const imgCount = imageDataMap.size; const vidCount = videoCache.size; const imgEl = floatPanel.querySelector("#img-count"); const vidEl = floatPanel.querySelector("#vid-count"); if (imgEl) imgEl.innerText = imgCount; if (vidEl) vidEl.innerText = vidCount; } // ==================== 初始扫描 ==================== function scanInitialData() { // 从 _ROUTER_DATA 扫描 const routerData = window._ROUTER_DATA; if (routerData) { const cells = routerData?.loaderData?.chat_layout?.trimmedChainRecentConvCells || []; for (const cell of cells) { const messages = cell?.conversation?.messages || []; const { images, videos } = extractFromMessages(messages); images.forEach(img => { if (img.key) imageDataMap.set(img.key, img); }); videos.forEach(v => videoCache.set(v.messageId, v.vid)); } } // 扫描页面上已有的图片 document.querySelectorAll("img[src*='rc_gen_image']").forEach(img => { const key = extractFileKey(img.src); if (key && !imageDataMap.has(key)) { // 尝试从父元素查找无水印版本 const parent = img.closest('[class*="message"], [class*="creation"]'); if (parent) { const allImgs = parent.querySelectorAll("img"); for (const otherImg of allImgs) { const otherKey = extractFileKey(otherImg.src); if (otherKey && otherKey !== key && !otherImg.src.includes("watermark")) { imageDataMap.set(otherKey, { no_watermark_url: otherImg.src, key: otherKey }); break; } } } } }); updateFloatPanel(); } // ==================== 消息监听 ==================== window.addEventListener("message", async (ev) => { const data = ev.data; if (data?.type === "startVideoDownloadByMessageId") { const result = await startVideoDownloadByMessageId(data.messageId); window.postMessage({ type: "videoDownloadResult", data: result }, "*"); } else if (data?.type === "videoDownloadResult") { const result = data.data; const btn = pendingButtons.get(result.messageId); if (btn) { btn.disabled = false; if (result.success && result.videoUrl) { const filename = `doubao_video_${Date.now()}.mp4`; await downloadFile(result.videoUrl, filename); btn.innerHTML = "✓ 已下载"; btn.classList.add("success"); } else { btn.innerHTML = "失败重试"; btn.classList.add("error"); } setTimeout(() => { if (btn.isConnected) { btn.innerHTML = DOWNLOAD_ICON + " 下载视频"; btn.classList.remove("success", "error"); } pendingButtons.delete(result.messageId); }, 2000); } } else if (data?.type === "imageDataExtracted") { scanAndInject(); updateFloatPanel(); } else if (data?.type === "videoDataExtracted") { scanAndInject(); updateFloatPanel(); } }); // ==================== 启动 ==================== function init() { setupNetworkInterceptors(); createFloatPanel(); startDOMObserver(); // 延迟扫描初始数据 setTimeout(() => { scanInitialData(); scanAndInject(); }, 1000); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); } })();