// ==UserScript== // @name 豆包·净印 // @namespace https://github.com/shiyi312/doubao-jing-yin // @version 1.0.3 // @description 清除豆包图片水印自动提取豆包无水印图片/视频 · 智能替换页面预览 · 一键批量下载 // @author 辻弌20 // @homepage https://scriptcat.org/zh-CN/users/202800 // @match https://www.doubao.com/* // @match https://www.qianwen.com/* // @run-at document-start // @grant GM_download // @grant unsafeWindow // @connect * // @license MIT // @icon https://raw.githubusercontent.com/shiyi312/doubao-jing-yin/main/icon.png // ==/UserScript== (function () { 'use strict'; /* ======================== 基础配置 ======================== */ const PAGE_WIN = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; const LOCATION = PAGE_WIN.location; // 调试开关 const DEBUG = false; /* ======================== 映射存储 ======================== */ // 路径键(水印URL去参数)-> 原始无水印URL const rawByPath = new Map(); // 路径键 -> 缓存文件名(避免重复生成) const filenameCache = new Map(); // 图片/视频列表(用于UI展示) let imageList = []; let videoList = []; let imageUrlSet = new Set(); let videoIdSet = new Set(); // 辅助变量 let lastBlobTime = 0; let lastImageReqUrl = ''; /* ======================== 工具函数 ======================== */ const normCache = new Map(); function normalize(str) { if (typeof str !== 'string') return str; if (normCache.has(str)) return normCache.get(str); const res = str.replace(/\\u002F/g, '/').replace(/\\\//g, '/').replace(/&/g, '&'); normCache.set(str, res); return res; } const pathKeyCache = new Map(); function getPathKey(url) { if (typeof url !== 'string') return ''; if (pathKeyCache.has(url)) return pathKeyCache.get(url); try { const u = new URL(normalize(url), LOCATION.href); const idx = u.pathname.indexOf('~tplv-'); const key = idx >= 0 ? u.pathname.slice(0, idx) : u.pathname; pathKeyCache.set(url, key); return key; } catch { pathKeyCache.set(url, ''); return ''; } } function isValidRaw(url) { return typeof url === 'string' && url.includes('~tplv-') && /(image_raw_b|image_raw|ori_raw)/i.test(url); } function rewriteForDownload(url) { if (typeof url !== 'string' || !url.includes('~tplv-')) return url; const key = getPathKey(url); return key && rawByPath.has(key) ? rawByPath.get(key) : url; } // 生成iOS风格文件名 function generateFileName(url, extHint) { const d = new Date(); const pad = n => String(n).padStart(2, '0'); const date = `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}`; const time = `${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; let ext = extHint || 'PNG'; try { const path = new URL(url || '', LOCATION.href).pathname.toLowerCase(); if (path.includes('.jpeg') || path.includes('.jpg')) ext = 'JPG'; else if (path.includes('.webp')) ext = 'WEBP'; else if (path.includes('.heic')) ext = 'HEIC'; else if (path.includes('.mp4')) ext = 'MP4'; } catch {} return `IMG_${date}_${time}.${ext}`; } function getStableFileName(url) { const key = getPathKey(url); if (!key) return generateFileName(url); if (!filenameCache.has(key)) { filenameCache.set(key, generateFileName(url)); } return filenameCache.get(key); } /* ======================== 记录映射 ======================== */ function rememberRaw(rawUrl, ...altUrls) { rawUrl = normalize(rawUrl); if (!isValidRaw(rawUrl)) return; const rawKey = getPathKey(rawUrl); if (rawKey) rawByPath.set(rawKey, rawUrl); for (const alt of altUrls) { const k = getPathKey(alt); if (k) rawByPath.set(k, rawUrl); } } /* ======================== 解析响应数据 ======================== */ // 从文本中提取 image_ori_raw 并建立映射 function parseTextForRaw(text) { if (typeof text !== 'string') return; // 快速跳过 if (!text.includes('image_ori_raw') && !text.includes('creations')) return; const t = normalize(text); // 提取 image_ori_raw 及其常用配对 const re = /"image_ori_raw"\s*:\s*\{\s*"url"\s*:\s*"([^"]+)"/g; let m; while ((m = re.exec(t)) !== null) { const raw = m[1]; // 尝试寻找同级的其他字段(如 image_preview)作为备用键 const previewRe = new RegExp(`"image_preview"\\s*:\\s*\\{\\s*"url"\\s*:\\s*"([^"]+)"`, 'g'); let p; while ((p = previewRe.exec(t)) !== null) { rememberRaw(raw, p[1]); } rememberRaw(raw); // 只记录自身 } } // 遍历对象,直接替换字段值 function traverseAndReplace(obj) { if (!obj || typeof obj !== 'object') return; const keys = Object.keys(obj); for (const key of keys) { const val = obj[key]; if (key === 'creations' && Array.isArray(val)) { for (const item of val) { if (item?.image) { const raw = item.image.image_ori_raw?.url; if (raw && isValidRaw(raw)) { const normalizedRaw = normalize(raw); rememberRaw(normalizedRaw); // 替换其他字段 const fields = ['image_ori', 'image_preview', 'image_thumb', 'image_preview_resize']; for (const f of fields) { if (item.image[f]?.url) { const old = item.image[f].url; item.image[f].url = normalizedRaw; rememberRaw(normalizedRaw, old); } } // 收集图片信息 const url = normalizedRaw; if (!imageUrlSet.has(url)) { imageUrlSet.add(url); const w = item.image.image_ori?.width || item.image.image_ori_raw?.width || 0; const h = item.image.image_ori?.height || item.image.image_ori_raw?.height || 0; imageList.push({ url, width: w, height: h }); } } // 视频处理 if (item.video?.vid) { const vid = item.video.vid; if (!videoIdSet.has(vid)) { videoIdSet.add(vid); // 异步获取无水印视频 fetchDoubaoVideo(vid).then(info => { if (info && !videoList.find(v => v.vid === vid)) { videoList.push(info); updateUI(); } }); } } } } } else { traverseAndReplace(val); } } } // 劫持 JSON.parse const nativeJSONParse = PAGE_WIN.JSON.parse; PAGE_WIN.JSON.parse = function (text) { const data = nativeJSONParse(text); try { if (typeof text === 'string' && (text.includes('creations') || text.includes('image_ori_raw'))) { traverseAndReplace(data); } } catch {} return data; }; /* ======================== 网络请求拦截 ======================== */ // Fetch const nativeFetch = PAGE_WIN.fetch; PAGE_WIN.fetch = async function (resource, init) { let reqUrl = ''; let nextResource = resource; try { if (typeof resource === 'string') { reqUrl = resource; const rewritten = rewriteForDownload(resource); if (rewritten !== resource) nextResource = rewritten; lastImageReqUrl = rewritten || resource; } else if (resource instanceof Request) { reqUrl = resource.url; const rewritten = rewriteForDownload(resource.url); if (rewritten !== resource.url) { nextResource = new Request(rewritten, resource); } lastImageReqUrl = rewritten || resource.url; } } catch {} const resp = await nativeFetch.call(this, nextResource, init); // 克隆响应并解析 try { const ct = (resp.headers.get('content-type') || '').toLowerCase(); if (ct.includes('json') || ct.includes('text') || ct.includes('event-stream')) { const clone = resp.clone(); const text = await clone.text(); parseTextForRaw(text); // 如果是流式,也可以手动解析 if (ct.includes('event-stream')) { parseEventStream(text); } } else if (ct.includes('image') && reqUrl) { // 图片请求,可能用于下载 const rewritten = rewriteForDownload(reqUrl); if (rewritten !== reqUrl) lastImageReqUrl = rewritten; } } catch {} return resp; }; // XHR const xhrOpen = XMLHttpRequest.prototype.open; const xhrSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function (method, url, ...rest) { let next = String(url || ''); try { next = rewriteForDownload(next); this.__db_req_url = next; } catch {} return xhrOpen.call(this, method, next, ...rest); }; XMLHttpRequest.prototype.send = function (...args) { this.addEventListener('readystatechange', function () { if (this.readyState === 4) { try { const ct = (this.getResponseHeader('content-type') || '').toLowerCase(); if (ct.includes('json') || ct.includes('text')) { parseTextForRaw(this.responseText); } if (ct.includes('image') && this.__db_req_url) { lastImageReqUrl = rewriteForDownload(this.__db_req_url); } } catch {} } }); return xhrSend.apply(this, args); }; // 解析 EventStream(流式) function parseEventStream(text) { const lines = text.split('\n'); for (const line of lines) { if (line.startsWith('data: ')) { try { const json = JSON.parse(line.slice(6)); traverseAndReplace(json); } catch {} } } } /* ======================== 视频获取 ======================== */ async function fetchDoubaoVideo(vid) { if (!vid) return null; const post = async (path, body) => { const res = await PAGE_WIN.fetch(`https://www.doubao.com/samantha/aispace/${path}`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', Origin: 'https://www.doubao.com', Referer: 'https://www.doubao.com/', }, body: JSON.stringify(body), }); return res.json(); }; try { const home = await post('homepage', {}); const creation = (home?.data?.children || []).find(c => c?.name === '我的创作'); if (!creation?.id) return null; const nodeInfo = await post('node_info?aid=582478', { node_id: creation.id }); const node = (nodeInfo?.data?.children || []).find(c => c?.key === vid); if (!node?.id) return null; const dl = await post('get_download_info?aid=582478', { requests: [{ node_id: node.id }] }); const info = (dl?.data?.download_infos || []).find(i => i?.main_url); if (!info) return null; return { vid, url: info.main_url, width: info.width || 0, height: info.height || 0, duration: info.duration || 0, poster: info.poster_url || '', }; } catch (e) { DEBUG && console.warn('[水印助手] 视频获取失败:', e); return null; } } /* ======================== UI 界面 ======================== */ let floatingBtn = null; let modal = null; let modalVisible = false; let currentTab = 'image'; function createUI() { // 防止重复创建 if (document.getElementById('db-watermark-helper')) return; const host = document.createElement('div'); host.id = 'db-watermark-helper'; host.innerHTML = `
`; document.body.appendChild(host); floatingBtn = document.getElementById('db-watermark-float-btn'); modal = document.getElementById('db-watermark-modal'); updateBadge(); // 事件绑定 floatingBtn.addEventListener('click', () => { modal.classList.add('show'); modalVisible = true; renderGrid(currentTab); }); modal.querySelector('.modal-close').addEventListener('click', () => { modal.classList.remove('show'); modalVisible = false; }); modal.addEventListener('click', (e) => { if (e.target === modal) { modal.classList.remove('show'); modalVisible = false; } }); modal.querySelectorAll('.modal-tabs button').forEach(btn => { btn.addEventListener('click', () => { modal.querySelectorAll('.modal-tabs button').forEach(b => b.classList.remove('active')); btn.classList.add('active'); currentTab = btn.dataset.tab; renderGrid(currentTab); }); }); document.getElementById('db-select-all').addEventListener('click', () => { document.querySelectorAll('#db-media-grid .media-check').forEach(cb => cb.checked = true); }); document.getElementById('db-clear-all').addEventListener('click', () => { document.querySelectorAll('#db-media-grid .media-check').forEach(cb => cb.checked = false); }); document.getElementById('db-batch-download').addEventListener('click', batchDownload); } function updateBadge() { const total = imageList.length + videoList.length; const badge = document.getElementById('db-badge'); if (badge) badge.textContent = total; const imgCount = document.getElementById('img-count'); if (imgCount) imgCount.textContent = imageList.length; const vidCount = document.getElementById('vid-count'); if (vidCount) vidCount.textContent = videoList.length; } function renderGrid(tab) { const grid = document.getElementById('db-media-grid'); if (!grid) return; const items = tab === 'image' ? imageList : videoList; if (items.length === 0) { grid.innerHTML = `
暂无${tab === 'image' ? '图片' : '视频'}
`; return; } grid.innerHTML = items.map((item, idx) => { const isVideo = tab === 'video'; const url = item.url; const name = getStableFileName(url); const preview = isVideo ? `` : ``; const info = isVideo ? `${item.width||0}×${item.height||0} · ${Math.round((item.duration||0)/60)}s` : `${item.width||0}×${item.height||0}`; return `
${preview}
${info}
`; }).join(''); // 单张下载 grid.querySelectorAll('.db-download-single').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const type = btn.dataset.type; const idx = parseInt(btn.dataset.index); const item = (type === 'image' ? imageList : videoList)[idx]; if (!item) return; const url = item.url; const name = getStableFileName(url); await downloadFile(url, name); }); }); } async function downloadFile(url, filename) { try { if (typeof GM_download === 'function') { GM_download({ url, name: filename, saveAs: false }); } else { const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); } } catch (e) { console.error('[水印助手] 下载失败:', e); alert('下载失败,请重试'); } } async function batchDownload() { const checks = document.querySelectorAll('#db-media-grid .media-check:checked'); if (!checks.length) return alert('请至少选择一项'); const items = []; for (const cb of checks) { const type = cb.dataset.type; const idx = parseInt(cb.dataset.index); const item = (type === 'image' ? imageList : videoList)[idx]; if (item) items.push(item); } for (const item of items) { await downloadFile(item.url, getStableFileName(item.url)); } } function updateUI() { updateBadge(); if (modalVisible) renderGrid(currentTab); } /* ======================== 下载链接自动重写 ======================== */ // 拦截 Image.src(仅对未连接的图片) const imgDesc = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src'); if (imgDesc?.set) { Object.defineProperty(HTMLImageElement.prototype, 'src', { configurable: true, enumerable: imgDesc.enumerable, get: imgDesc.get, set(value) { const raw = String(value || ''); const next = this.isConnected ? raw : rewriteForDownload(raw); if (!this.isConnected) lastImageReqUrl = next; return imgDesc.set.call(this, next); } }); } // 拦截 a 标签点击(捕获阶段) document.addEventListener('click', (e) => { const a = e.target.closest('a[href]'); if (!a) return; const href = a.getAttribute('href') || a.href || ''; if (href.includes('~tplv-') || href.startsWith('blob:')) { const rewritten = rewriteForDownload(href); if (rewritten !== href) a.setAttribute('href', rewritten); // 设置文件名 const name = getStableFileName(rewritten || href); if (name) a.setAttribute('download', name); } }, true); // 拦截 a.click() const nativeAClick = HTMLAnchorElement.prototype.click; HTMLAnchorElement.prototype.click = function () { const href = this.getAttribute('href') || this.href || ''; if (href.includes('~tplv-') || href.startsWith('blob:')) { const rewritten = rewriteForDownload(href); if (rewritten !== href) this.setAttribute('href', rewritten); const name = getStableFileName(rewritten || href); if (name) this.setAttribute('download', name); } return nativeAClick.call(this); }; // 拦截 URL.createObjectURL(用于blob下载) const nativeCreateObjectURL = URL.createObjectURL; URL.createObjectURL = function (obj) { const url = nativeCreateObjectURL.call(this, obj); if (url.startsWith('blob:')) lastBlobTime = Date.now(); return url; }; /* ======================== 初始化 ======================== */ function init() { if (document.getElementById('db-watermark-helper')) return; createUI(); // 若页面已存在数据,触发解析(例如分享页的script标签) setTimeout(() => { const scripts = document.querySelectorAll('script[data-script-src]'); for (const s of scripts) { const args = s.getAttribute('data-fn-args'); if (args) { try { const data = JSON.parse(args.replace(/"/g, '"')); traverseAndReplace(data); } catch {} } } updateUI(); }, 500); } // 页面加载完成后初始化 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } // 监听路由变化(SPA),重新扫描 let lastUrl = LOCATION.href; setInterval(() => { if (LOCATION.href !== lastUrl) { lastUrl = LOCATION.href; // 清空列表?保留累积,但可以根据需要重置 // 但更好的做法是保持累积,因为新页面可能加载新素材 updateUI(); } }, 2000); // 暴露调试接口 window.__DB_WATERMARK_HELPER = { rawByPath, imageList, videoList, rewriteForDownload }; console.log('[水印助手] 已启动,无水印提取中...'); })();