// ==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 = `