// ==UserScript== // @name 豆包水印清除 // @name:en Dola Watermark Remover // @namespace https://greasyfork.org/zh-CN/users/178351-yesilin // @version 0.0.10 // @description 清除豆包图片水印,支持无水印预览与下载。 // @description:en Removes watermarks from Dola images, supports mark‑free preview and download. // @author YeSilin // @license GPL-3.0-or-later // @icon https://lf-flow-web-cdn.doubao.com/obj/flow-doubao/doubao/chat/favicon.png // @match https://www.doubao.com/chat/* // @match https://www.dola.com/chat/* // @run-at document-start // @grant none // ==/UserScript== (function () { "use strict"; if (window.__DOLA_WATERMARK_REMOVER__) return; window.__DOLA_WATERMARK_REMOVER__ = {}; /* ------------------------ 常量定义 ------------------------ */ const CREATIONS = "creations"; const IMAGE_ORI_RAW = "image_ori_raw"; const IMAGE_ORI = "image_ori"; const IMAGE_PREVIEW = "image_preview"; const IMAGE_THUMB = "image_thumb"; const IMAGE_PREVIEW_RESIZE = "image_preview_resize"; const IMAGE_KEYS = [IMAGE_ORI, IMAGE_PREVIEW, IMAGE_THUMB, IMAGE_PREVIEW_RESIZE]; // 缓存容量上限 const MAX_CACHE_SIZE = 200; /* ------------------------ 原生方法引用 ------------------------ */ const nativeJSONParse = window.JSON.parse; const nativeFetch = window.fetch; const nativeXHROpen = XMLHttpRequest.prototype.open; const nativeAnchorClick = HTMLAnchorElement.prototype.click; /* ------------------------ 缓存与映射 ------------------------ */ // 路径(去除 `~tplv-` 参数) -> 原始无水印 URL const rawByPath = new Map(); // 规范化字符串缓存 const normCache = new Map(); // 路径键缓存 const pathKeyCache = new Map(); // 辅助函数:限制Map大小(LRU策略,删除最旧条目) function limitMapSize(map, maxSize) { while (map.size > maxSize) { const oldestKey = map.keys().next().value; map.delete(oldestKey); } } /* ------------------------ 工具函数 ------------------------ */ /** * 规范化字符串: * - 将转义的斜杠还原 * - 将 HTML 实体 & 还原为 & */ function normalizeString(s) { if (typeof s !== "string") return s; if (normCache.has(s)) return normCache.get(s); const normalized = s .replace(/\\u002F/g, "/") .replace(/\\\//g, "/") .replace(/&/g, "&"); normCache.set(s, normalized); limitMapSize(normCache, MAX_CACHE_SIZE); return normalized; } /** * 提取 URL 的“路径键”: * 去除查询参数,并截断到 `~tplv-` 之前的部分。 * 使用纯字符串操作替代 new URL,提升性能。 */ function getPathKey(url) { if (typeof url !== "string") return ""; if (pathKeyCache.has(url)) return pathKeyCache.get(url); // 快速判断是否含有 ~tplv-,没有则直接返回空 const tplvIndex = url.indexOf("~tplv-"); if (tplvIndex === -1) { pathKeyCache.set(url, ""); limitMapSize(pathKeyCache, MAX_CACHE_SIZE); return ""; } // 提取路径部分(去掉查询参数) const queryIndex = url.indexOf("?"); const pathEnd = queryIndex === -1 ? url.length : queryIndex; const pathname = url.substring(0, pathEnd); // 截断到 ~tplv- 之前 const key = pathname.substring(0, tplvIndex); pathKeyCache.set(url, key); limitMapSize(pathKeyCache, MAX_CACHE_SIZE); return key; } /** * 判断一个 URL 是否为合法的原始无水印 URL。 */ function isValidRawUrl(url) { if (typeof url !== "string" || !url.includes("~tplv-")) return false; return /(image_raw_b|image_raw|ori_raw)/i.test(url); } /** * 将带水印的 URL 重写为原始无水印 URL(如果映射表中存在)。 */ function rewriteForDownload(url) { if (typeof url !== "string" || !url.includes("~tplv-")) return url; const key = getPathKey(url); return key ? rawByPath.get(key) || url : url; } /* ------------------------ 核心:处理 JSON 数据 ------------------------ */ /** * 处理解析后的 JSON 数据: * - 快速检查是否可能包含图片字段,减少不必要的遍历 * - 迭代遍历数据结构,替换水印 URL 并收集映射 */ function processParsedData(text, data) { if (typeof text !== "string" || (text.indexOf(CREATIONS) === -1 && text.indexOf(IMAGE_ORI_RAW) === -1)) { return data; } traverseDataIterative(data); return data; } /** * 迭代方式遍历对象/数组,查找包含 creations 属性的对象。 * 使用显式栈,避免递归栈溢出。 */ function traverseDataIterative(root) { const stack = [{ value: root, key: null, parent: null }]; while (stack.length > 0) { const { value, key, parent } = stack.pop(); if (!value || typeof value !== "object") continue; const isArray = Array.isArray(value); const isPlainObject = !isArray; // 如果当前对象自身有 creations 属性,优先处理 if (isPlainObject && Object.prototype.hasOwnProperty.call(value, CREATIONS)) { processCreationList(value[CREATIONS]); } // 将子对象压入栈 const keys = Object.keys(value); for (let i = 0; i < keys.length; i++) { const childKey = keys[i]; // 跳过已处理的 creations 属性,避免重复处理 if (isPlainObject && childKey === CREATIONS) continue; const child = value[childKey]; if (child && typeof child === "object") { stack.push({ value: child, key: childKey, parent: value }); } } } } /** * 处理单个 creations 数组: * - 提取原始无水印 URL(image_ori_raw) * - 将 image_ori、image_preview 等字段的 URL 替换为原始 URL * - 同时收集旧水印 URL 的路径键与原始 URL 的映射 */ function processCreationList(creationList) { if (!Array.isArray(creationList)) return; for (const item of creationList) { const rawUrl = item?.image?.[IMAGE_ORI_RAW]?.url; if (!rawUrl) continue; const normalizedRaw = normalizeString(rawUrl); const rawValid = isValidRawUrl(normalizedRaw); const rawKey = rawValid ? getPathKey(normalizedRaw) : ""; // 替换所有受影响的图片字段,并建立旧 URL -> 原始 URL 的映射 for (const imageKey of IMAGE_KEYS) { const imageField = item.image?.[imageKey]; if (!imageField) continue; const oldUrl = imageField.url; imageField.url = rawUrl; // 直接替换为无水印 URL if (oldUrl && rawValid) { const oldKey = getPathKey(oldUrl); if (oldKey) { rawByPath.set(oldKey, normalizedRaw); limitMapSize(rawByPath, MAX_CACHE_SIZE); } } } // 确保原始 URL 自身也有映射 if (rawKey) { rawByPath.set(rawKey, normalizedRaw); limitMapSize(rawByPath, MAX_CACHE_SIZE); } } } /* ------------------------ 拦截 JSON.parse ------------------------ */ window.JSON.parse = function (text) { const data = nativeJSONParse(text); return processParsedData(text, data); }; /* ------------------------ 拦截网络请求 ------------------------ */ // 拦截 fetch:重写资源 URL(如果是已知的水印 URL) if (typeof nativeFetch === "function") { window.fetch = function patchedFetch(resource, init) { let nextResource = resource; try { if (typeof resource === "string") { nextResource = rewriteForDownload(resource); } else if (resource instanceof Request) { const rewritten = rewriteForDownload(resource.url); if (rewritten !== resource.url) { nextResource = new Request(rewritten, resource); } } } catch { // 忽略解析错误 } return nativeFetch.call(this, nextResource, init); }; } // 拦截 XMLHttpRequest.open:重写请求 URL XMLHttpRequest.prototype.open = function patchedOpen(method, url, ...rest) { let nextUrl = String(url || ""); try { nextUrl = rewriteForDownload(nextUrl); this.__db_req_url = nextUrl; } catch { // 忽略错误 } return nativeXHROpen.call(this, method, nextUrl, ...rest); }; /* ------------------------ 拦截 Image.src(下载场景) ------------------------ */ const imgSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, "src"); if (imgSrcDescriptor?.set) { Object.defineProperty(HTMLImageElement.prototype, "src", { configurable: true, enumerable: imgSrcDescriptor.enumerable, get: imgSrcDescriptor.get, set(value) { const raw = String(value || ""); // 对于已连接 DOM 的图片直接设置原始值,避免不必要的重写 if (this.isConnected) { return imgSrcDescriptor.set.call(this, raw); } const next = rewriteForDownload(raw); return imgSrcDescriptor.set.call(this, next); }, }); } /* ------------------------ 下载链接重写 ------------------------ */ /** * 尝试将 a 标签的 href 重写为无水印 URL。 */ function tryPatchAnchor(anchor) { if (!(anchor instanceof HTMLAnchorElement)) return; const href = anchor.getAttribute("href") || anchor.href || ""; if (!href.includes("~tplv-")) return; const next = rewriteForDownload(href); if (next && next !== href) { anchor.setAttribute("href", next); } } // 使用事件委托捕获阶段监听点击,仅在目标为a或包含a时处理 document.addEventListener( "click", (event) => { const target = event.target; if (!(target instanceof Element)) return; const anchor = target.closest("a[href]"); if (!anchor) return; // 仅当href包含水印参数时才尝试重写 const href = anchor.getAttribute("href") || ""; if (href.includes("~tplv-")) { tryPatchAnchor(anchor); } }, true, ); // 拦截程序化的 a.click() HTMLAnchorElement.prototype.click = function patchedAnchorClick(...args) { try { tryPatchAnchor(this); } catch { // 忽略错误 } return nativeAnchorClick.apply(this, args); }; /* ------------------------ 调试接口 ------------------------ */ window.__DOLA_WATERMARK_REMOVER__ = { rawByPath, rewriteForDownload, }; })();