// ==UserScript== // @name 豆包水印去除 // @name:en Dola Watermark Remover // @namespace https://greasyfork.org/zh-CN/users/178351-yesilin // @version 0.0.17 // @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"; // 需要替换为无水印 URL 的图片字段(固定顺序,避免运行时创建数组) const IMAGE_KEYS = [IMAGE_ORI, IMAGE_PREVIEW, IMAGE_THUMB, IMAGE_PREVIEW_RESIZE]; // 缓存容量上限 const MAX_CACHE_SIZE = 200; /* ------------------------ 原生方法引用 ------------------------ */ const nativeJSONParse = window.JSON.parse; const nativeResponseText = Response.prototype.text; 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-` 之前的部分, * 用于匹配同一张图片的不同水印变体。 */ function getPathKey(url) { if (typeof url !== "string") return ""; if (pathKeyCache.has(url)) return pathKeyCache.get(url); try { const parsed = new URL(normalizeString(url), location.href); const tplvIndex = parsed.pathname.indexOf("~tplv-"); const key = tplvIndex >= 0 ? parsed.pathname.slice(0, tplvIndex) : parsed.pathname; pathKeyCache.set(url, key); limitMapSize(pathKeyCache, MAX_CACHE_SIZE); return key; } catch { pathKeyCache.set(url, ""); limitMapSize(pathKeyCache, MAX_CACHE_SIZE); return ""; } } /** * 判断一个 URL 是否为合法的原始无水印 URL。 * 合法条件: * - 包含 `~tplv-` 参数 * - 路径中包含 `image_raw` 或 `ori_raw` 标识 */ function isValidRawUrl(url) { if (typeof url !== "string" || !url.includes("~tplv-")) return false; return /(image_raw|ori_raw)/i.test(url); } /** * 将带水印的 URL 重写为原始无水印 URL(如果映射表中存在)。 * 用于下载场景(fetch、XHR、未连接的 Image、a 标签点击)。 */ 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 数据: * - 遍历数据结构,找到 creations 列表 * - 替换其中的水印 URL 并收集映射关系 */ function processParsedData(text, data) { // 快速跳过不包含 creations 字段的响应 if (typeof text !== "string" || text.indexOf(CREATIONS) === -1) { return data; } traverseData(data); return data; } /** * 递归遍历对象/数组,查找包含 creations 属性的对象。 * 使用 Object.keys 遍历,跳过已处理的 creations 属性以避免重复。 */ function traverseData(value) { if (!value || typeof value !== "object") return; const isPlainObject = !Array.isArray(value); // 如果当前对象自身有 creations 属性,优先处理 if (isPlainObject && Object.prototype.hasOwnProperty.call(value, CREATIONS)) { processCreationList(value[CREATIONS]); } // 遍历其余属性 for (const key of Object.keys(value)) { if (isPlainObject && key === CREATIONS) continue; // 已处理过 const child = value[key]; if (child && typeof child === "object") { traverseData(child); } } } /** * 处理单个 creations 数组。 * * 主要职责: * 1. 从每个 creation 条目中提取原始无水印 URL(`image_ori_raw.url`)。 * 2. 将条目中 `IMAGE_KEYS` 列表内所有图片字段的 URL 替换为原始无水印 URL。 * 3. 收集原始无水印 URL 的路径键映射,供后续网络请求和下载链接重写时使用。 * * @param {Array} creationList - 从 JSON 数据中提取的 creations 数组。 */ function processCreationList(creationList) { // 校验参数是否为数组,否则直接返回 if (!Array.isArray(creationList)) return; for (let i = 0; i < creationList.length; i++) { const item = creationList[i]; // 提取 image 对象,不存在则跳过当前条目 const image = item && item.image; if (!image) continue; // 获取原始无水印 URL(image_ori_raw.url) const rawUrl = image[IMAGE_ORI_RAW] && image[IMAGE_ORI_RAW].url; if (!rawUrl) continue; // 规范化 URL 并验证合法性 const normalizedRaw = normalizeString(rawUrl); const rawValid = isValidRawUrl(normalizedRaw); const rawKey = rawValid ? getPathKey(normalizedRaw) : ""; // 遍历所有需要替换为无水印 URL 的图片字段 for (let j = 0; j < IMAGE_KEYS.length; j++) { const imageKey = IMAGE_KEYS[j]; const imageField = image[imageKey]; if (!imageField || !imageField.url) continue; imageField.url = rawUrl; // 替换为原始无水印 URL } // 如果原始 URL 合法,将其路径键加入映射表 // 注意:所有水印变体的路径键(截断 ~tplv- 后)均相同,因此只需记录一次 if (rawValid && rawKey) { rawByPath.set(rawKey, normalizedRaw); // 控制缓存大小,保持 LRU 策略 limitMapSize(rawByPath, MAX_CACHE_SIZE); } } } /* ------------------------ 拦截 JSON.parse ------------------------ */ window.JSON.parse = function (text) { const data = nativeJSONParse(text); return processParsedData(text, data); }; Response.prototype.json = function () { return nativeResponseText.call(this).then((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; // 保留重写后的 URL,便于调试 } 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 的图片不重写(避免影响页面展示); // 未连接的图片通常是用于下载的,此时重写为无水印 URL const next = this.isConnected ? raw : rewriteForDownload(raw); return imgSrcDescriptor.set.call(this, next); }, }); } /* ------------------------ 下载链接重写 ------------------------ */ /** * 尝试将 a 标签的 href 重写为无水印 URL。 * 如果 href 包含水印参数且有对应映射,则更新属性。 */ 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); } } // 捕获阶段监听点击,确保在默认行为前重写链接 document.addEventListener( "click", (event) => { const target = event.target; if (!(target instanceof Element)) return; const anchor = target.closest("a[href]"); if (!anchor) return; tryPatchAnchor(anchor); }, true, // 捕获阶段 ); // 拦截程序化的 a.click(),先重写 href 再触发点击 HTMLAnchorElement.prototype.click = function patchedAnchorClick(...args) { try { tryPatchAnchor(this); } catch { // 忽略错误 } return nativeAnchorClick.apply(this, args); }; /* ------------------------ 调试接口 ------------------------ */ window.__DOLA_WATERMARK_REMOVER__ = { rawByPath, rewriteForDownload, }; })();