// ==UserScript== // @name 豆包视频&图片下载助手 // @namespace https://www.doubao.com/ // @version 5.1.5 // @description 自动提取豆包原始图片与无水印视频下载地址,支持视频/图片预览、一键复制、批量下载,带可拖拽悬浮按钮和红点提醒 // @author Doubao Downloader // @match https://www.doubao.com/* // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_notification // @grant GM_xmlhttpRequest // @grant unsafeWindow // @connect doubao.com // @connect doubaocdn.com // @connect byteimg.com // @connect snssdk.com // @run-at document-start // @noframes // @supportURL https://github.com // @homepageURL https://github.com // @license MIT // // 免责声明:本脚本仅供学习和参考,切勿用于违法或商用,否则后果自负。 // // ==/UserScript== (function () { 'use strict'; const videoLinks = new Map(); const imageLinks = new Map(); const processedVideoSrcs = new Set(); const processedImageSrcs = new Set(); const videoAliasToKey = new Map(); const imageAliasToKey = new Map(); const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; let activeTab = 'video'; let dataHooksInstalled = false; let creationNodeCache = null; let refreshTimer = null; const API_QUERY = 'aid=497858&device_platform=web&samantha_web=1&use-olympus-account=1&version_code=20800&pkg_type=release_version'; const API_HOME = `/samantha/aispace/homepage?${API_QUERY}`; const API_NODE = `/samantha/aispace/node_info?${API_QUERY}`; const API_DOWNLOAD = `/samantha/aispace/get_download_info?${API_QUERY}`; GM_addStyle(` #db-dl-btn, #db-dl-panel { --bg: #ffffff; --bg-sub: #f8fafc; --bg-elevated: #f1f5f9; --bg-hover: #f1f5f9; --border: #e2e8f0; --border-strong: #cbd5e1; --text: #0f172a; --text-sub: #475569; --text-muted: #94a3b8; --primary: #2563eb; --primary-hover: #1d4ed8; --primary-soft: #eff6ff; --danger: #ef4444; --danger-soft: #fef2f2; --danger-hover: #dc2626; --success: #10b981; --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04); --shadow: 0 2px 8px rgba(15, 23, 42, 0.06); --shadow-lg: -8px 0 32px rgba(15, 23, 42, 0.12); } #db-dl-panel, #db-dl-btn, #db-dl-toast { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } #db-dl-btn { position: fixed; bottom: 24px; right: 24px; z-index: 2147483646; width: 48px; height: 48px; border-radius: 12px; background: #ffffff; box-shadow: 0 4px 14px rgba(15, 23, 42, 0.18); cursor: move; display: flex; align-items: center; justify-content: center; transition: transform 0.2s ease, box-shadow 0.2s ease; border: 1px solid var(--border); outline: none; user-select: none; -webkit-tap-highlight-color: transparent; } #db-dl-btn:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(15, 23, 42, 0.25); } #db-dl-btn:active { transform: translateY(0); cursor: grabbing; } #db-dl-btn img { width: 32px; height: 32px; pointer-events: none; border-radius: 6px; } #db-dl-btn .badge { position: absolute; top: -4px; right: -4px; width: 14px; height: 14px; border-radius: 50%; background: #ef4444; box-shadow: 0 0 0 2px #ffffff, 0 2px 8px rgba(239, 68, 68, 0.5); pointer-events: none; transition: transform 0.2s ease; z-index: 10; } #db-dl-btn .badge.hidden { display: none; } #db-dl-btn .badge.pulse { animation: db-badge-pulse 1.6s ease-in-out infinite; } @keyframes db-badge-pulse { 0%,100% { box-shadow: 0 0 0 2px #ffffff, 0 2px 6px rgba(239, 68, 68, 0.4); transform: scale(1); } 50% { box-shadow: 0 0 0 2px #ffffff, 0 0 0 8px rgba(239, 68, 68, 0.15), 0 2px 8px rgba(239, 68, 68, 0.5); transform: scale(1.05); } } #db-dl-overlay { position: fixed; inset: 0; z-index: 2147483647; background: rgba(15, 23, 42, 0.4); opacity: 0; pointer-events: none; transition: opacity 0.25s ease; } #db-dl-overlay.show { opacity: 1; pointer-events: auto; } #db-dl-panel { position: fixed; top: 0; right: 0; bottom: 0; width: 480px; max-width: 92vw; z-index: 2147483648; background: var(--bg); color: var(--text); box-shadow: var(--shadow-lg); transform: translateX(105%); transition: transform 0.3s cubic-bezier(0.22, 0.61, 0.36, 1); display: flex; flex-direction: column; font-size: 14px; } #db-dl-panel.open { transform: translateX(0); } .db-panel-header { display: flex; align-items: center; justify-content: space-between; padding: 18px 16px 0 20px; flex-shrink: 0; background: var(--bg); } .db-panel-header .title { font-size: 16px; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 8px; letter-spacing: -0.01em; flex: 1; min-width: 0; } .db-panel-header .title svg { width: 22px; height: 22px; flex-shrink: 0; border-radius: 5px; } .db-panel-header .title-text { flex-shrink: 0; } .db-panel-header .count-tag { background: var(--primary); color: #ffffff; font-size: 11px; font-weight: 700; padding: 3px 9px; border-radius: 10px; min-width: 24px; text-align: center; flex-shrink: 0; } .db-panel-header .close-btn { width: 30px; height: 30px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); color: var(--text-sub); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s ease; line-height: 1; font-size: 18px; flex-shrink: 0; } .db-panel-header .close-btn:hover { background: var(--danger-soft); color: var(--danger); border-color: var(--danger); } .db-panel-tabs { display: flex; gap: 4px; padding: 12px 20px 0; flex-shrink: 0; background: var(--bg); } .db-panel-tabs button { flex: 1; padding: 8px 12px; border: none; background: transparent; color: var(--text-sub); font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.15s ease; border-radius: 8px 8px 0 0; font-family: inherit; display: flex; align-items: center; justify-content: center; gap: 6px; border-bottom: 2px solid transparent; } .db-panel-tabs button svg { width: 14px; height: 14px; fill: currentColor; } .db-panel-tabs button:hover { color: var(--text); background: var(--bg-hover); } .db-panel-tabs button.active { color: var(--primary); border-bottom-color: var(--primary); } .db-panel-tabs .tab-count { background: var(--bg-elevated); color: var(--text-sub); font-size: 10px; font-weight: 700; padding: 1px 6px; border-radius: 8px; min-width: 18px; text-align: center; } .db-panel-tabs button.active .tab-count { background: var(--primary-soft); color: var(--primary); } .db-panel-toolbar { display: flex; gap: 6px; padding: 10px 16px; flex-shrink: 0; background: var(--bg); border-bottom: 1px solid var(--border); } .db-panel-toolbar button { flex: 1; padding: 7px 0; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--text-sub); font-size: 12px; cursor: pointer; transition: all 0.15s ease; font-weight: 500; font-family: inherit; display: flex; align-items: center; justify-content: center; gap: 4px; } .db-panel-toolbar button svg { width: 12px; height: 12px; fill: currentColor; } .db-panel-toolbar button:hover { background: var(--primary-soft); color: var(--primary); border-color: var(--primary); } .db-panel-toolbar button.primary { background: var(--primary); color: #fff; border-color: var(--primary); box-shadow: 0 1px 2px rgba(37, 99, 235, 0.25); } .db-panel-toolbar button.primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); color: #fff; } .db-panel-list { flex: 1; overflow-y: auto; padding: 12px; overscroll-behavior: contain; background: var(--bg-sub); } .db-panel-list::-webkit-scrollbar { width: 6px; } .db-panel-list::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; } .db-panel-list::-webkit-scrollbar-track { background: transparent; } .db-disclaimer { flex-shrink: 0; padding: 10px 16px; border-top: 1px solid var(--border); font-size: 11px; color: var(--text-muted); text-align: center; line-height: 1.5; background: var(--bg); } .db-empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--text-muted); text-align: center; padding: 40px 20px; gap: 6px; } .db-empty-state .icon-wrap { width: 64px; height: 64px; border-radius: 16px; background: var(--bg); border: 1px solid var(--border); display: flex; align-items: center; justify-content: center; margin-bottom: 14px; } .db-empty-state .icon-wrap svg { width: 28px; height: 28px; fill: var(--primary); opacity: 0.5; } .db-empty-state .hint { font-size: 12px; color: var(--text-muted); margin-top: 2px; } .db-video-card, .db-image-card { background: var(--bg); border: 1px solid var(--border); border-radius: 10px; padding: 12px; margin-bottom: 10px; transition: border-color 0.15s ease, box-shadow 0.15s ease; animation: db-card-in 0.3s ease; position: relative; } @keyframes db-card-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } @keyframes db-card-out { to { opacity: 0; transform: translateX(40px); height: 0; padding: 0; margin: 0; border-width: 0; } } .db-video-card:hover, .db-image-card:hover { border-color: var(--primary); box-shadow: 0 2px 12px rgba(37, 99, 235, 0.08); } .db-card-row { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; } .db-thumb { width: 80px; height: 60px; border-radius: 6px; background: var(--bg-elevated); flex-shrink: 0; display: flex; align-items: center; justify-content: center; overflow: hidden; border: 1px solid var(--border); } .db-thumb.video { height: 45px; } .db-thumb img { width: 100%; height: 100%; object-fit: cover; pointer-events: none; } .db-thumb svg { width: 22px; height: 22px; fill: var(--primary); opacity: 0.5; } .db-card-info { flex: 1; min-width: 0; } .db-card-info .meta { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text-muted); margin-bottom: 4px; } .db-card-info .meta .dot { width: 3px; height: 3px; border-radius: 50%; background: var(--border-strong); } .db-card-info .meta .badge-mini { background: var(--primary-soft); color: var(--primary); font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 4px; } .db-card-info .filename { font-size: 12px; color: var(--text); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace; line-height: 1.4; } .db-delete-btn { position: absolute; top: 8px; right: 8px; width: 22px; height: 22px; border-radius: 5px; border: none; background: transparent; color: var(--text-muted); cursor: pointer; display: flex; align-items: center; justify-content: center; opacity: 0; transition: all 0.15s ease; } .db-video-card:hover .db-delete-btn, .db-image-card:hover .db-delete-btn { opacity: 1; } .db-delete-btn:hover { background: var(--danger-soft); color: var(--danger); } .db-delete-btn svg { width: 12px; height: 12px; fill: currentColor; } .db-card-actions { display: flex; gap: 6px; } .db-card-actions button { flex: 1; padding: 7px 0; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--text-sub); font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.15s ease; display: flex; align-items: center; justify-content: center; gap: 4px; font-family: inherit; } .db-card-actions button svg { width: 13px; height: 13px; fill: currentColor; } .db-card-actions button.copy-btn:hover { background: var(--primary-soft); color: var(--primary); border-color: var(--primary); } .db-card-actions button.dl-btn { background: var(--primary); color: #fff; border-color: var(--primary); } .db-card-actions button.dl-btn:hover { background: var(--primary-hover); border-color: var(--primary-hover); } .db-card-actions button.copied { background: var(--success) !important; border-color: var(--success) !important; color: #fff !important; } #db-dl-toast { position: fixed; bottom: 88px; left: 50%; transform: translateX(-50%) translateY(8px); z-index: 2147483649; background: var(--text); color: var(--bg); padding: 9px 18px; border-radius: 8px; font-size: 12px; font-weight: 500; opacity: 0; pointer-events: none; transition: all 0.2s ease; white-space: nowrap; box-shadow: 0 4px 16px rgba(15, 23, 42, 0.2); } #db-dl-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } #db-preview-modal { position: fixed; inset: 0; z-index: 2147483650; background: rgba(0, 0, 0, 0.85); display: none; align-items: center; justify-content: center; padding: 32px; backdrop-filter: blur(8px); } #db-preview-modal.show { display: flex; animation: db-modal-in 0.25s ease; } @keyframes db-modal-in { from { opacity: 0; } to { opacity: 1; } } #db-preview-modal .preview-content { position: relative; max-width: 92vw; max-height: 92vh; display: flex; align-items: center; justify-content: center; } #db-preview-modal img, #db-preview-modal video { max-width: 92vw; max-height: 88vh; border-radius: 8px; box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5); background: #000; display: block; } #db-preview-modal .preview-close { position: fixed; top: 20px; right: 24px; width: 40px; height: 40px; border-radius: 50%; border: none; background: rgba(255, 255, 255, 0.15); color: #fff; font-size: 22px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background 0.15s; z-index: 10; line-height: 1; } #db-preview-modal .preview-close:hover { background: rgba(255, 255, 255, 0.3); } #db-preview-modal .preview-info { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: rgba(0, 0, 0, 0.7); color: #fff; padding: 10px 18px; border-radius: 8px; font-size: 12px; max-width: 80vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace; } #db-preview-modal video { outline: none; } .db-thumb { cursor: pointer; position: relative; } .db-thumb::after { content: '预览'; position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; color: rgba(255, 255, 255, 0); letter-spacing: 0.5px; transition: all 0.15s ease; pointer-events: none; border-radius: 6px; } .db-video-card:hover .db-thumb::after, .db-image-card:hover .db-thumb::after { background: rgba(15, 23, 42, 0.55); color: #fff; } `); function showToast(msg, duration) { let toast = document.getElementById('db-dl-toast'); if (!toast) { toast = document.createElement('div'); toast.id = 'db-dl-toast'; document.body.appendChild(toast); } toast.textContent = msg; toast.classList.add('show'); clearTimeout(toast._timer); toast._timer = setTimeout(() => toast.classList.remove('show'), duration || 1800); } function normalizeKey(url) { try { const u = new URL(url, location.href); if (/flow-imagex|byteimg/i.test(u.hostname)) { return 'image:' + u.pathname.split('~tplv-')[0]; } return u.origin + u.pathname; } catch (e) { return url.split('?')[0].split('#')[0]; } } function decodeHtmlUrl(url) { return String(url || '') .replace(/&/g, '&') .replace(/\\u002F/gi, '/') .replace(/\\\//g, '/'); } function hasWatermark(url) { const value = decodeHtmlUrl(url).toLowerCase(); return /(^|[\/_:=&-])(?:watermark|video_gen_watermark[^\/?]*)|(?:^|[\/_:=&-])lwm\d+/.test(value); } function isStaticUiImage(url) { const value = String(url || '').toLowerCase(); return value.includes('user-avatar') || value.includes('/rc/icon/') || value.includes('/favicon/') || value.includes('doubao_avatar') || value.includes('/logo'); } function isVideoUrl(url) { if (!url) return false; if (url.includes('.mp4') || url.includes('.m3u8') || url.includes('.flv') || url.includes('.webm')) return true; if (url.includes('mime_type=video')) return true; if (url.includes('/video/') && url.includes('douyin')) return true; return false; } function isImageUrl(url) { if (!url) return false; if (/\.(png|jpg|jpeg|webp|gif|bmp|svg)(\?|$|#)/i.test(url)) return true; if (url.includes('mime_type=image')) return true; if (url.includes('imagex') || url.includes('rc_gen_image')) return true; if (url.includes('p1-sign') || url.includes('p3-flow-imagex') || url.includes('byteimg.com')) return true; return false; } function extractVideoSrc(element) { if (!element) return null; if (element.tagName === 'VIDEO') { if (element.src && element.src.startsWith('http')) return element.src; if (element.currentSrc && element.currentSrc.startsWith('http')) return element.currentSrc; const source = element.querySelector('source[src]'); if (source && source.src.startsWith('http')) return source.src; } if (element.querySelectorAll) { const innerVid = element.querySelector('video[src]'); if (innerVid && innerVid.src && innerVid.src.startsWith('http')) return innerVid.src; } const dataUrl = element.getAttribute && element.getAttribute('data-url'); if (dataUrl && dataUrl.startsWith('http')) return dataUrl; return null; } function extractImageSrc(element) { if (!element) return null; if (element.tagName === 'IMG') { const src = element.src || element.currentSrc; if (src && src.startsWith('http')) return src; const dataSrc = element.getAttribute('data-src') || element.getAttribute('data-original'); if (dataSrc && dataSrc.startsWith('http')) return dataSrc; } if (element.tagName === 'SOURCE') { if (element.src && element.src.startsWith('http')) return element.src; const srcset = element.srcset; if (srcset) { const firstUrl = srcset.split(',')[0].trim().split(' ')[0]; if (firstUrl.startsWith('http')) return firstUrl; } } if (element.querySelectorAll) { const innerImg = element.querySelector('img[src]'); if (innerImg) { const src = innerImg.src || innerImg.currentSrc; if (src && src.startsWith('http')) return src; } } const bgImg = element.style && element.style.backgroundImage; if (bgImg && bgImg.includes('url(')) { const match = bgImg.match(/url\(["']?(https?:\/\/[^"')]+)["']?\)/); if (match) return match[1]; } return null; } function getObjectUrl(value) { if (!value) return ''; if (typeof value === 'string') return decodeHtmlUrl(value); if (typeof value === 'object' && typeof value.url === 'string') return decodeHtmlUrl(value.url); return ''; } function collectUrls(value, result, depth) { if (value == null || depth > 5) return result; if (typeof value === 'string') { const url = decodeHtmlUrl(value); if (/^https?:\/\//i.test(url)) result.add(url); return result; } if (typeof value !== 'object') return result; if (Array.isArray(value)) { value.forEach(item => collectUrls(item, result, depth + 1)); return result; } for (const [key, item] of Object.entries(value)) { if (typeof item === 'string' && /^https?:\/\//i.test(decodeHtmlUrl(item))) { result.add(decodeHtmlUrl(item)); } else if (item && typeof item === 'object' && (key === 'url' || key === 'url_formats' || key === 'format_urls' || depth < 2)) { collectUrls(item, result, depth + 1); } } return result; } function chooseImageDownloadUrl(image) { const rawCandidates = [ getObjectUrl(image && image.image_ori_raw), getObjectUrl(image && image.raw_image), getObjectUrl(image && image.image_original), getObjectUrl(image && image.original_image), getObjectUrl(image && image.image_ori), getObjectUrl(image && image.image_preview), getObjectUrl(image && image.image_thumb), getObjectUrl(image && image.url) ].filter(Boolean); const raw = rawCandidates.find(url => isImageUrl(url) && !hasWatermark(url)); return raw || ''; } function chooseImagePreviewUrl(image) { return [ getObjectUrl(image && image.image_thumb), getObjectUrl(image && image.image_preview), getObjectUrl(image && image.image_ori), getObjectUrl(image && image.image_ori_raw), getObjectUrl(image && image.raw_image), chooseImageDownloadUrl(image) ].find(url => url && isImageUrl(url)) || ''; } function findImageEntryForAliases(aliasKeys) { for (const alias of aliasKeys) { const key = imageAliasToKey.get(alias); if (key && imageLinks.has(key)) return key; } for (const [key, item] of imageLinks) { const candidates = [item.downloadSrc, item.previewSrc, item.src]; if (candidates.some(url => url && aliasKeys.includes(normalizeKey(url)))) return key; } return null; } function registerImageData(image) { if (!image || typeof image !== 'object') return; const downloadSrc = chooseImageDownloadUrl(image); if (!downloadSrc) return; const previewSrc = chooseImagePreviewUrl(image) || downloadSrc; const allUrls = collectUrls(image, new Set(), 0); allUrls.add(downloadSrc); if (previewSrc) allUrls.add(previewSrc); const aliasKeys = [...allUrls].map(normalizeKey); const key = normalizeKey(downloadSrc); const existingKey = (imageLinks.has(key) && key) || findImageEntryForAliases(aliasKeys); const timestamp = new Date().toLocaleTimeString('zh-CN', { hour12: false }); const item = existingKey ? imageLinks.get(existingKey) : { src: downloadSrc, previewSrc, downloadSrc, type: 'image', title: extractFilename(downloadSrc, '.png'), timestamp }; const finalKey = existingKey || key; item.src = downloadSrc; item.downloadSrc = downloadSrc; item.previewSrc = previewSrc; item.sizeKb = item.sizeKb || Math.round((Number(image.image_ori_raw?.size) || Number(image.image_ori?.size) || 0) / 1024) || 0; imageLinks.set(finalKey, item); processedImageSrcs.add(finalKey); aliasKeys.forEach(alias => { imageAliasToKey.set(alias, finalKey); processedImageSrcs.add(alias); }); for (const [otherKey, otherItem] of imageLinks) { if (otherKey === finalKey) continue; const otherAliases = [otherItem.downloadSrc, otherItem.previewSrc, otherItem.src] .filter(Boolean) .map(normalizeKey); if (otherAliases.some(alias => aliasKeys.includes(alias))) { imageLinks.delete(otherKey); processedImageSrcs.delete(otherKey); } } scheduleMediaRefresh(); } function isMediaImageObject(image) { if (!image || typeof image !== 'object') return false; if (image.image_ori_raw || image.image_ori || image.raw_image || image.image_720) return true; return typeof image.key === 'string' && /(image|rc_gen_image)/i.test(image.key); } function registerVideoData(video) { if (!video || typeof video !== 'object' || !video.vid) return; const vid = String(video.vid); const allUrls = collectUrls(video, new Set(), 0); const messageUrls = [...allUrls].filter(isVideoUrl); const previewSrc = messageUrls.find(url => url) || ''; const cleanSrc = messageUrls.find(url => !hasWatermark(url)) || ''; const aliasKeys = [ `vid:${vid}`, ...allUrls.map(normalizeKey) ]; const key = `video:${vid}`; const existingKey = videoAliasToKey.get(`vid:${vid}`) || aliasKeys.map(alias => videoAliasToKey.get(alias)).find(Boolean) || (videoLinks.has(key) ? key : null); const item = existingKey ? videoLinks.get(existingKey) : { src: previewSrc, previewSrc, downloadSrc: cleanSrc, type: 'video', title: extractFilename(cleanSrc || previewSrc, '.mp4'), timestamp: new Date().toLocaleTimeString('zh-CN', { hour12: false }) }; const finalKey = existingKey || key; item.vid = vid; item.previewSrc = item.previewSrc || previewSrc; item.src = item.previewSrc || previewSrc; if (cleanSrc && !hasWatermark(cleanSrc)) item.downloadSrc = cleanSrc; if (!item.resolution && video.width && video.height) item.resolution = `${video.width}×${video.height}`; if (!item.duration && video.duration) item.duration = Number(video.duration); videoLinks.set(finalKey, item); processedVideoSrcs.add(finalKey); aliasKeys.forEach(alias => { videoAliasToKey.set(alias, finalKey); processedVideoSrcs.add(alias); }); for (const [otherKey, otherItem] of videoLinks) { if (otherKey === finalKey || otherItem.vid) continue; const otherAliases = [otherItem.previewSrc, otherItem.src] .filter(Boolean) .map(normalizeKey); if (otherAliases.some(alias => aliasKeys.includes(alias))) { videoLinks.delete(otherKey); processedVideoSrcs.delete(otherKey); } } scheduleMediaRefresh(); } function captureMediaData(root) { if (!root || typeof root !== 'object') return; const visited = new WeakSet(); let inspected = 0; function walk(value, depth, mediaContext) { if (!value || typeof value !== 'object' || depth > 24 || inspected > 12000 || visited.has(value)) return; visited.add(value); inspected++; const looksLikeCreation = Boolean(value.gen_detail || value.creation_meta || value.can_publish !== undefined); if ((mediaContext || looksLikeCreation) && value.video && typeof value.video === 'object' && value.video.vid) { registerVideoData(value.video); } if ((mediaContext || looksLikeCreation) && value.image && isMediaImageObject(value.image)) { registerImageData(value.image); } if (Array.isArray(value)) { for (const child of value) walk(child, depth + 1, mediaContext); } else { for (const [key, child] of Object.entries(value)) { const childContext = mediaContext || key === 'creations' || key === 'attachments' || key === 'creation_list'; walk(child, depth + 1, childContext); } } } try { walk(root, 0, false); } catch (e) { console.debug('[豆包下载助手] 媒体数据解析失败', e); } } function installDataHooks() { if (dataHooksInstalled) return; dataHooksInstalled = true; try { const pageJSON = pageWindow.JSON; if (pageJSON && typeof pageJSON.parse === 'function' && !pageJSON.parse.__dbWrapped) { const nativeParse = pageJSON.parse; const wrappedParse = function () { const value = Reflect.apply(nativeParse, pageJSON, arguments); captureMediaData(value); return value; }; wrappedParse.__dbWrapped = true; pageJSON.parse = wrappedParse; } } catch (e) { console.debug('[豆包下载助手] 无法挂接 JSON 解析器', e); } try { const nativeFetch = pageWindow.fetch; if (typeof nativeFetch === 'function' && !nativeFetch.__dbWrapped) { const wrappedFetch = function () { return Reflect.apply(nativeFetch, this, arguments).then(response => { if (response && typeof response.json === 'function') { const nativeJson = response.json; response.json = function () { return Reflect.apply(nativeJson, response, arguments).then(data => { captureMediaData(data); return data; }); }; } return response; }); }; wrappedFetch.__dbWrapped = true; pageWindow.fetch = wrappedFetch; } } catch (e) { console.debug('[豆包下载助手] 无法挂接网络响应', e); } } function scanEmbeddedData() { const globals = [ '_ROUTER_DATA', '__NEXT_DATA__', '__INITIAL_STATE__', '__FLOW_STATE__', '__DOUBAO_STATE__' ]; for (const name of globals) { try { if (pageWindow[name]) captureMediaData(pageWindow[name]); } catch (e) {} } document.querySelectorAll('script[type="application/json"]').forEach(script => { try { captureMediaData(pageWindow.JSON.parse(script.textContent || 'null')); } catch (e) {} }); } function getActiveMap() { return activeTab === 'video' ? videoLinks : imageLinks; } function getActiveProcessed() { return activeTab === 'video' ? processedVideoSrcs : processedImageSrcs; } function getActiveExtractor() { return activeTab === 'video' ? extractVideoSrc : extractImageSrc; } function processVideo(el) { const src = extractVideoSrc(el); if (!src) return; const cleanSrc = decodeHtmlUrl(src); const key = normalizeKey(cleanSrc); const capturedKey = videoAliasToKey.get(key); if (capturedKey && videoLinks.has(capturedKey)) { const item = videoLinks.get(capturedKey); if (!item.previewSrc) item.previewSrc = cleanSrc; return; } if (processedVideoSrcs.has(key)) return; const timestamp = new Date().toLocaleTimeString('zh-CN', { hour12: false }); const filename = extractFilename(cleanSrc, '.mp4'); videoLinks.set(key, { src: cleanSrc, previewSrc: cleanSrc, downloadSrc: hasWatermark(cleanSrc) ? '' : cleanSrc, type: 'video', title: filename, timestamp, resolution: extractResolution(cleanSrc) }); processedVideoSrcs.add(key); scheduleMediaRefresh(); } function processImage(src, sizeKb) { if (!src || !src.startsWith('http')) return; if (!isImageUrl(src)) return; if (isStaticUiImage(src)) return; const cleanSrc = decodeHtmlUrl(src); const key = normalizeKey(cleanSrc); const capturedKey = imageAliasToKey.get(key); if (capturedKey && imageLinks.has(capturedKey)) return; if (processedImageSrcs.has(key)) return; const timestamp = new Date().toLocaleTimeString('zh-CN', { hour12: false }); const filename = extractFilename(cleanSrc, '.png'); imageLinks.set(key, { src: cleanSrc, previewSrc: cleanSrc, downloadSrc: hasWatermark(cleanSrc) ? '' : cleanSrc, type: 'image', title: filename, timestamp, sizeKb }); processedImageSrcs.add(key); scheduleMediaRefresh(); } function processImageElement(el) { if (!el || el.closest?.('#db-dl-panel, #db-preview-modal, .flow_chat_sidebar, [class*="sidebar"], nav')) return; if (el.closest?.('[data-remote-icon], header')) return; const rect = el.getBoundingClientRect?.(); if (rect && rect.width < 72) return; const url = extractImageSrc(el); if (url && isImageUrl(url) && !isStaticUiImage(url)) processImage(url); } function processElement(el) { const element = el && el.nodeType === Node.TEXT_NODE ? el.parentElement : el; if (!element) return; const url = extractImageSrc(element); if (url && isImageUrl(url) && !isStaticUiImage(url)) { processImageElement(element); return; } const vidUrl = extractVideoSrc(element); if (vidUrl) processVideo(element); } function extractFilename(url, defaultExt) { try { const u = new URL(url); const pathParts = u.pathname.split('/').filter(Boolean); if (pathParts.length > 0) { const last = decodeURIComponent(pathParts[pathParts.length - 1]).split('~tplv-')[0]; if (/\.(png|jpe?g|webp|gif|bmp|svg|mp4|webm|mov|flv)$/i.test(last)) return last; return last + defaultExt; } } catch (e) {} return 'file_' + Date.now() + defaultExt; } function extractResolution(url) { const match = url.match(/br=(\d+)/); if (match) { const br = parseInt(match[1]); return br > 1000 ? (br / 1000).toFixed(1) + ' Mbps' : br + ' kbps'; } return ''; } function formatSize(kb) { if (!kb) return ''; if (kb > 1024) return (kb / 1024).toFixed(1) + ' MB'; return kb + ' KB'; } function scheduleMediaRefresh() { clearTimeout(refreshTimer); refreshTimer = setTimeout(() => { updateCounts(); updateBadge(); const panel = document.getElementById('db-dl-panel'); if (panel && panel.classList.contains('open')) renderActiveTab(); }, 120); } async function apiPost(url, body) { const response = await pageWindow.fetch(url, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) }); if (!response.ok) throw new Error(`请求失败:${response.status} ${response.statusText}`); const data = await response.json(); if (data && data.code !== undefined && data.code !== 0) { throw new Error(data.message || '豆包接口请求失败'); } return data; } async function getCreationNodes(forceRefresh) { if (creationNodeCache && !forceRefresh) return creationNodeCache; creationNodeCache = (async () => { const home = await apiPost(API_HOME, {}); const creationId = home.data?.children?.find(item => item.name === '我的创作')?.id; if (!creationId) throw new Error('未找到“我的创作”节点'); const result = await apiPost(API_NODE, { node_id: creationId, need_full_path: true, size: 500, sort_param: { need_sort_config: true, sort_order: 1, sort_type: 0 } }); const index = new Map(); for (const node of result.data?.children || []) { if (node && node.key && node.id) index.set(String(node.key), String(node.id)); } return index; })().catch(error => { creationNodeCache = null; throw error; }); return creationNodeCache; } async function resolveVideoDownload(item) { if (item.downloadSrc && isVideoUrl(item.downloadSrc) && !hasWatermark(item.downloadSrc)) { return item.downloadSrc; } if (!item.vid) { throw new Error('未捕获视频标识,请刷新页面并播放一次视频'); } let nodeIndex = await getCreationNodes(false); let nodeId = nodeIndex.get(String(item.vid)); if (!nodeId) { nodeIndex = await getCreationNodes(true); nodeId = nodeIndex.get(String(item.vid)); } if (!nodeId) throw new Error('豆包创作列表中暂未找到该视频'); const data = await apiPost(API_DOWNLOAD, { requests: [{ node_id: nodeId }] }); const info = data.data?.download_infos?.[0]; const cleanUrl = [info?.main_url, info?.backup_url] .find(url => url && isVideoUrl(url) && !hasWatermark(url)); if (!cleanUrl) throw new Error('豆包未返回可用的无水印视频地址'); item.downloadSrc = cleanUrl; scheduleMediaRefresh(); return cleanUrl; } async function resolveDownloadUrl(item) { if (!item) throw new Error('资源不存在'); if (item.type === 'video') return resolveVideoDownload(item); if (item.downloadSrc && !hasWatermark(item.downloadSrc)) return item.downloadSrc; scanEmbeddedData(); if (item.downloadSrc && !hasWatermark(item.downloadSrc)) return item.downloadSrc; throw new Error('未捕获该图片的原始地址,请刷新页面后重试'); } function scanPage() { document.querySelectorAll('div[class*="video-canvas-panel-player"]').forEach(processVideo); document.querySelectorAll('.xgplayer').forEach(processVideo); document.querySelectorAll('video').forEach(processVideo); document.querySelectorAll('img[src]').forEach(el => { processImageElement(el); }); document.querySelectorAll('img[data-src]').forEach(el => { processImageElement(el); }); document.querySelectorAll('source[srcset]').forEach(el => { const srcset = el.srcset; if (srcset) { const firstUrl = srcset.split(',')[0].trim().split(' ')[0]; if (firstUrl.startsWith('http') && !isStaticUiImage(firstUrl)) processImage(firstUrl); } }); } function setupPerformanceObserver() { if (!window.PerformanceObserver) return; try { const obs = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { const url = entry.name; if (!url || !url.startsWith('http')) continue; if (!isImageUrl(url)) continue; if (!url.includes('/rc_gen_image/')) continue; const size = entry.transferSize ? Math.round(entry.transferSize / 1024) : 0; processImage(url, size); } }); obs.observe({ type: 'resource', buffered: true }); } catch (e) {} } function setupObserver() { const observer = new MutationObserver((mutations) => { let shouldScan = false; for (const mutation of mutations) { if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { for (const node of mutation.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE) { if (node.querySelectorAll) { const matches = node.querySelectorAll('video[src], img[src], img[data-src], .xgplayer, div[class*="video-canvas-panel-player"]'); if (matches.length > 0) { shouldScan = true; break; } } if (node.tagName === 'VIDEO' || node.tagName === 'IMG' || node.tagName === 'SOURCE') { shouldScan = true; break; } } } } if (mutation.type === 'attributes') { if (mutation.target.tagName === 'VIDEO' && mutation.attributeName === 'src') { if (mutation.target.src && mutation.target.src.startsWith('http')) processVideo(mutation.target); } if (mutation.target.tagName === 'IMG' && (mutation.attributeName === 'src' || mutation.attributeName === 'data-src')) { processImageElement(mutation.target); } } } if (shouldScan) { clearTimeout(observer._debounce); observer._debounce = setTimeout(scanPage, 500); } }); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['src', 'data-src'] }); return observer; } function buildUI() { const btn = document.createElement('button'); btn.id = 'db-dl-btn'; btn.title = '豆包视频&图片下载助手'; btn.innerHTML = ` `; btn.addEventListener('click', togglePanel); setupDrag(btn); restorePosition(btn); document.body.appendChild(btn); const overlay = document.createElement('div'); overlay.id = 'db-dl-overlay'; overlay.addEventListener('click', closePanel); document.body.appendChild(overlay); const panel = document.createElement('div'); panel.id = 'db-dl-panel'; panel.innerHTML = `
豆包视频&图片下载助手
免责声明:本脚本仅供学习和参考,切勿用于违法或商用,否则后果自负。
`; document.body.appendChild(panel); document.getElementById('db-close-btn').addEventListener('click', closePanel); document.getElementById('db-scan-btn').addEventListener('click', () => { scanPage(); showToast('扫描完成'); }); document.getElementById('db-copyall-btn').addEventListener('click', copyAllLinks); document.getElementById('db-dlall-btn').addEventListener('click', downloadAll); document.getElementById('db-tab-video').addEventListener('click', () => switchTab('video')); document.getElementById('db-tab-image').addEventListener('click', () => switchTab('image')); renderActiveTab(); } function switchTab(tab) { activeTab = tab; document.getElementById('db-tab-video').classList.toggle('active', tab === 'video'); document.getElementById('db-tab-image').classList.toggle('active', tab === 'image'); renderActiveTab(); } function renderActiveTab() { const container = document.getElementById('db-list-container'); if (!container) return; container.innerHTML = ''; const items = Array.from(getActiveMap().values()); if (items.length === 0) { container.innerHTML = `
暂无检测到${activeTab === 'video' ? '视频' : '图片'} ${activeTab === 'video' ? '播放页面中的视频后将自动捕获' : '浏览页面或等待图片加载完成'}
`; return; } items.forEach(item => addCard(null, item.type, item)); } function addCard(key, type, preloadedItem) { const container = document.getElementById('db-list-container'); if (!container) return; const emptyEl = container.querySelector('.db-empty-state'); if (emptyEl) emptyEl.remove(); const map = type === 'video' ? videoLinks : imageLinks; const item = preloadedItem || map.get(key); if (!item) return; const itemKey = key || [...map.entries()].find(([, value]) => value === item)?.[0] || normalizeKey(item.downloadSrc || item.previewSrc || item.src); const card = document.createElement('div'); card.className = type === 'video' ? 'db-video-card' : 'db-image-card'; const previewSrc = item.previewSrc || item.src; card.dataset.src = previewSrc; card.dataset.download = item.downloadSrc || ''; card.dataset.key = itemKey; const metaParts = [`${type === 'video' ? 'MP4' : 'IMG'}`, `${item.timestamp}`]; if (type === 'video' && item.resolution) metaParts.push(`${item.resolution}`); if (type === 'video' && item.preparedBlob) metaParts.push(`无水印已准备`); if (type === 'image' && item.sizeKb) metaParts.push(`${formatSize(item.sizeKb)}`); card.innerHTML = `
${metaParts.join('')}
${item.title}
`; card.querySelector('.copy-btn').addEventListener('click', function () { copyItemLink(item, this); }); card.querySelector('.dl-btn').addEventListener('click', function () { downloadFile(item, item.title); }); card.querySelector('.db-delete-btn').addEventListener('click', function (e) { e.stopPropagation(); deleteCard(itemKey, type, card); }); card.addEventListener('click', function (e) { if (e.target.closest('.db-card-actions') || e.target.closest('.db-delete-btn')) return; openPreview(previewSrc, type, previewSrc); }); loadThumb(card.querySelector('.db-thumb'), previewSrc, type); container.insertBefore(card, container.firstChild); updateCounts(); } function deleteCard(key, type, cardEl) { if (!cardEl) return; cardEl.style.animation = 'db-card-out 0.3s ease forwards'; setTimeout(() => { cardEl.remove(); if (type === 'video') { videoLinks.delete(key); processedVideoSrcs.delete(key); } else { imageLinks.delete(key); processedImageSrcs.delete(key); } updateCounts(); updateBadge(); const container = document.getElementById('db-list-container'); if (container && container.children.length === 0) renderActiveTab(); showToast('已删除'); }, 280); } function loadThumb(thumbEl, src, type) { if (type === 'image') { const img = document.createElement('img'); img.crossOrigin = 'anonymous'; img.referrerPolicy = 'no-referrer'; img.onload = () => { thumbEl.innerHTML = ''; thumbEl.appendChild(img); }; img.onerror = () => {}; img.src = src; } else { // 视频:先用 canvas 截取第一帧 const video = document.createElement('video'); video.src = src; video.crossOrigin = 'anonymous'; video.preload = 'metadata'; video.muted = true; video.style.cssText = 'width:100%;height:100%;object-fit:cover;'; let thumbLoaded = false; video.addEventListener('loadeddata', () => { try { video.currentTime = Math.min(0.1, video.duration * 0.05); } catch (e) {} }); video.addEventListener('seeked', () => { if (thumbLoaded) return; thumbLoaded = true; try { const canvas = document.createElement('canvas'); canvas.width = 160; canvas.height = 90; const ctx = canvas.getContext('2d'); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); const img = document.createElement('img'); img.src = canvas.toDataURL('image/jpeg', 0.7); img.style.cssText = 'width:100%;height:100%;object-fit:cover;'; thumbEl.innerHTML = ''; thumbEl.appendChild(img); } catch (e) {} cleanup(); }); video.addEventListener('error', () => { if (thumbLoaded) return; thumbLoaded = true; cleanup(); }); function cleanup() { try { video.pause(); video.removeAttribute('src'); video.load(); } catch (e) {} } // 兜底:2秒后还没加载就用 img 标签加载视频 URL(部分浏览器支持) setTimeout(() => { if (thumbLoaded) return; thumbLoaded = true; const img = document.createElement('img'); img.crossOrigin = 'anonymous'; img.referrerPolicy = 'no-referrer'; img.style.cssText = 'width:100%;height:100%;object-fit:cover;'; img.onload = () => { if (img.naturalWidth > 0) { thumbEl.innerHTML = ''; thumbEl.appendChild(img); } }; img.src = src; }, 2500); } } function openPreview(src, type, title) { let modal = document.getElementById('db-preview-modal'); if (!modal) { modal = document.createElement('div'); modal.id = 'db-preview-modal'; modal.innerHTML = `
`; document.body.appendChild(modal); modal.addEventListener('click', (e) => { if (e.target === modal) closePreview(); }); modal.querySelector('.preview-close').addEventListener('click', closePreview); } const content = modal.querySelector('.preview-content'); const info = modal.querySelector('.preview-info'); content.innerHTML = ''; info.textContent = title || ''; if (type === 'video') { const video = document.createElement('video'); video.src = src; video.controls = true; video.autoplay = true; video.style.cssText = 'max-width:92vw;max-height:88vh;'; content.appendChild(video); } else { const img = document.createElement('img'); img.src = src; img.alt = title || ''; content.appendChild(img); } modal.classList.add('show'); } function closePreview() { const modal = document.getElementById('db-preview-modal'); if (!modal) return; modal.classList.remove('show'); const content = modal.querySelector('.preview-content'); if (content) { content.querySelectorAll('video').forEach(v => { try { v.pause(); v.removeAttribute('src'); v.load(); } catch (e) {} }); content.innerHTML = ''; } } function updateCounts() { const videoCount = document.getElementById('db-tab-video-count'); const imageCount = document.getElementById('db-tab-image-count'); if (!videoCount || !imageCount) return; const vc = videoLinks.size; const ic = imageLinks.size; videoCount.textContent = vc; imageCount.textContent = ic; } function updateBadge() { const badge = document.querySelector('#db-dl-btn .badge'); if (!badge) return; const total = videoLinks.size + imageLinks.size; if (total === 0) { badge.classList.add('hidden'); badge.classList.remove('pulse'); } else { badge.classList.remove('hidden'); badge.style.display = 'block'; badge.classList.remove('pulse'); void badge.offsetWidth; badge.classList.add('pulse'); } } function togglePanel() { const panel = document.getElementById('db-dl-panel'); if (panel.classList.contains('open')) closePanel(); else openPanel(); } function openPanel() { document.getElementById('db-dl-panel').classList.add('open'); document.getElementById('db-dl-overlay').classList.add('show'); document.body.style.overflow = 'hidden'; renderActiveTab(); updateCounts(); } function closePanel() { document.getElementById('db-dl-panel').classList.remove('open'); document.getElementById('db-dl-overlay').classList.remove('show'); document.body.style.overflow = ''; } async function copyToClipboard(text, btnEl) { const originalHTML = btnEl.innerHTML; try { await navigator.clipboard.writeText(text); if (btnEl) { btnEl.classList.add('copied'); btnEl.textContent = '✓ 已复制'; setTimeout(() => { btnEl.classList.remove('copied'); btnEl.innerHTML = originalHTML; }, 1800); } showToast('链接已复制'); } catch (e) { const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;left:-9999px;'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); showToast('链接已复制'); } } async function copyItemLink(item, btnEl) { try { const url = await resolveDownloadUrl(item); await copyToClipboard(url, btnEl); } catch (error) { showToast(error.message || '获取无水印链接失败', 3200); } } async function copyAllLinks() { const map = getActiveMap(); if (map.size === 0) { showToast('没有可复制的链接'); return; } const urls = []; let failed = 0; for (const item of map.values()) { try { urls.push(await resolveDownloadUrl(item)); } catch (error) { failed++; } } if (urls.length === 0) { showToast('没有可复制的无水印链接', 3200); return; } await copyToClipboard(urls.join('\n'), null); showToast(failed ? `已复制 ${urls.length} 个链接,${failed} 个获取失败` : `已复制 ${urls.length} 个无水印链接`); } function safeFilename(filename, type) { const fallback = type === 'video' ? 'doubao_video.mp4' : 'doubao_image.png'; let name = String(filename || fallback) .replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_') .replace(/^\.+|\.+$/g, '') .slice(0, 180) || fallback; if (type === 'video' && !/\.(mp4|webm|mov)$/i.test(name)) name += '.mp4'; if (type === 'image' && !/\.(png|jpe?g|webp|gif|bmp)$/i.test(name)) name += '.png'; return name; } function triggerAnchorDownload(url, filename) { const a = document.createElement('a'); a.href = url; a.download = filename; a.rel = 'noopener noreferrer'; a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(() => a.remove(), 200); } function saveBlob(blob, filename) { if (!blob || !blob.size) throw new Error('下载文件为空'); const blobUrl = URL.createObjectURL(blob); triggerAnchorDownload(blobUrl, filename); setTimeout(() => URL.revokeObjectURL(blobUrl), 60000); } async function downloadViaPageFetch(url, filename) { const response = await pageWindow.fetch(url, { credentials: 'omit', cache: 'no-store' }); if (!response.ok) throw new Error(`原图请求失败:HTTP ${response.status}`); const blob = await response.blob(); saveBlob(blob, filename); } function downloadBlobViaGM(url, timeoutMs, onProgress) { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== 'function') { reject(new Error('GM_xmlhttpRequest 不可用')); return; } GM_xmlhttpRequest({ method: 'GET', url, responseType: 'blob', timeout: timeoutMs || 180000, onprogress: progress => { if (typeof onProgress === 'function') onProgress(progress); }, onload: response => { try { if (response.status < 200 || response.status >= 300) { throw new Error(`文件下载失败:HTTP ${response.status}`); } let blob = response.response; if (blob instanceof ArrayBuffer) { blob = new Blob([blob], { type: 'application/octet-stream' }); } if (!blob || !blob.size) throw new Error('下载文件为空'); resolve(blob); } catch (error) { reject(error); } }, onerror: error => reject(error || new Error('文件下载失败')), ontimeout: () => reject(new Error('下载超时')) }); }); } async function prepareVideoBlob(item, url, filename) { let lastProgressAt = 0; try { item.preparedBlob = await downloadBlobViaGM(url, 180000, progress => { const now = Date.now(); if (now - lastProgressAt < 700 || !progress.total) return; lastProgressAt = now; const percent = Math.min(100, Math.round(progress.loaded / progress.total * 100)); showToast(`正在准备无水印视频 ${percent}%`, 1600); }); } catch (gmRequestError) { throw gmRequestError; } item.preparedName = filename; scheduleMediaRefresh(); } async function downloadFile(item, filename) { const name = safeFilename(filename || item.title, item.type); if (item.type === 'video' && item.preparedBlob) { const blob = item.preparedBlob; item.preparedBlob = null; item.preparedName = ''; saveBlob(blob, name); scheduleMediaRefresh(); showToast(`已开始保存:${name}`, 2600); return true; } showToast(item.type === 'video' ? '正在获取无水印视频地址…' : '正在准备原图…', 2400); try { const url = await resolveDownloadUrl(item); if (item.type === 'video') { await prepareVideoBlob(item, url, name); showToast('无水印视频已准备好,请再点一次“保存”', 6000); return true; } try { await downloadViaPageFetch(url, name); } catch (pageFetchError) { console.debug('[豆包下载助手] 页面 Blob 下载失败,尝试扩展下载', pageFetchError); const blob = await downloadBlobViaGM(url, 60000); saveBlob(blob, name); } showToast(`开始下载:${name}`, 2400); return true; } catch (error) { showToast(error?.message || error?.error || '下载失败', 3500); return false; } } async function downloadAll() { const map = getActiveMap(); if (map.size === 0) { showToast('没有可下载的资源'); return; } const items = Array.from(map.values()); let failed = 0; let preparedVideos = 0; for (let index = 0; index < items.length; index++) { showToast(`正在处理 ${index + 1}/${items.length}(仅下载无水印原文件)`, 2400); const ok = await downloadFile(items[index], items[index].title); if (!ok) failed++; if (ok && items[index].preparedBlob) preparedVideos++; if (index < items.length - 1) await new Promise(resolve => setTimeout(resolve, 700)); } if (preparedVideos > 0) { showToast(`已准备 ${preparedVideos} 个无水印视频,请逐个点击“保存”`, 6000); } else { showToast(failed ? `批量下载完成,${failed} 个资源失败` : `已开始下载 ${items.length} 个无水印资源`, 3200); } } function setupDrag(btn) { let isDragging = false; let startX, startY, startRight, startBottom; let moved = false; const onStart = (e) => { if (e.button !== 0) return; isDragging = true; moved = false; startX = e.clientX; startY = e.clientY; const rect = btn.getBoundingClientRect(); startRight = window.innerWidth - rect.right; startBottom = window.innerHeight - rect.bottom; btn.style.transition = 'none'; document.body.style.userSelect = 'none'; }; const onMove = (e) => { if (!isDragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true; const newRight = Math.max(0, Math.min(window.innerWidth - 48, startRight - dx)); const newBottom = Math.max(0, Math.min(window.innerHeight - 48, startBottom - dy)); btn.style.right = newRight + 'px'; btn.style.bottom = newBottom + 'px'; }; const onEnd = () => { if (!isDragging) return; isDragging = false; btn.style.transition = ''; document.body.style.userSelect = ''; if (moved) { savePosition(btn); btn._suppressClick = true; setTimeout(() => { btn._suppressClick = false; }, 50); } }; btn.addEventListener('mousedown', onStart); document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onEnd); btn.addEventListener('click', (e) => { if (btn._suppressClick) { e.stopImmediatePropagation(); e.preventDefault(); } }, true); btn.addEventListener('touchstart', (e) => { const t = e.touches[0]; onStart({ button: 0, clientX: t.clientX, clientY: t.clientY }); }, { passive: true }); btn.addEventListener('touchmove', (e) => { const t = e.touches[0]; onMove({ clientX: t.clientX, clientY: t.clientY }); }, { passive: true }); btn.addEventListener('touchend', onEnd); } function savePosition(btn) { try { const rect = btn.getBoundingClientRect(); localStorage.setItem('db-dl-btn-pos', JSON.stringify({ right: window.innerWidth - rect.right, bottom: window.innerHeight - rect.bottom })); } catch (e) {} } function restorePosition(btn) { try { const saved = localStorage.getItem('db-dl-btn-pos'); if (saved) { const pos = JSON.parse(saved); if (typeof pos.right === 'number') btn.style.right = pos.right + 'px'; if (typeof pos.bottom === 'number') btn.style.bottom = pos.bottom + 'px'; } } catch (e) {} } function setupKeyboard() { document.addEventListener('keydown', (e) => { if (e.ctrlKey && e.shiftKey && e.key === 'D') { e.preventDefault(); togglePanel(); } if (e.key === 'Escape') { const modal = document.getElementById('db-preview-modal'); if (modal && modal.classList.contains('show')) { closePreview(); return; } const panel = document.getElementById('db-dl-panel'); if (panel && panel.classList.contains('open')) closePanel(); } }); } function init() { buildUI(); setupKeyboard(); scanEmbeddedData(); setTimeout(scanPage, 300); setupPerformanceObserver(); setupObserver(); setInterval(scanPage, 2000); console.log('[豆包下载助手] v5.1.5 已启动,图片与视频均使用无水印原文件,快捷键 Ctrl+Shift+D'); console.debug('[豆包下载助手] GM_xmlhttpRequest:', typeof GM_xmlhttpRequest); } installDataHooks(); if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => setTimeout(init, 300), { once: true }); } else { setTimeout(init, 300); } })();