你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式
你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式
你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式
你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式
你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式
你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式
(我已經安裝了使用者樣式管理器,讓我安裝!)
// ==UserScript==
// @name 豆包无水印图片下载
// @namespace http://tampermonkey.net/
// @version 1.3.0
// @description 为豆包添加无水印图片下载功能
// @author Qalxry
// @license GPL-3.0
// @supportURL https://github.com/Qalxry/doubao-no-watermark
// @icon https://lf-flow-web-cdn.doubao.com/obj/flow-doubao/doubao/web/logo-icon.png
// @match https://*.doubao.com/*
// @grant none
// ==/UserScript==
(function () {
"use strict";
let selectedImage = null;
let selectedImageContainer = null;
let selectedImageUrl = null;
const toastDiv = document.createElement("div");
toastDiv.style.cssText = `
position: fixed;
top: 40%;
left: 50%;
transform: translateX(-50%);
background: #fff8f8ff;
color: #ff6060;
padding: 10px 20px;
border: 1px solid #ff6060;
border-radius: 10px;
z-index: 99999;
font-family: sans-serif;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
transition: opacity 0.3s ease;
display: none;
text-align: center;
opacity: 0.8;
`;
toastDiv.innerHTML = "消息内容";
document.body.appendChild(toastDiv);
function showToast(message, duration = 3000) {
toastDiv.innerHTML = message;
if (toastDiv.style.display === "block") {
// 如果已经显示,先清除之前的隐藏定时器
clearTimeout(toastDiv.hideTimeout);
}
toastDiv.style.display = "block";
toastDiv.style.opacity = "1";
if (duration <= 0) return; // 不自动关闭
// 设置一个定时器来隐藏
toastDiv.hideTimeout = setTimeout(() => {
toastDiv.style.opacity = "0";
toastDiv.hideTimeout = setTimeout(() => {
toastDiv.style.display = "none";
}, 300); // 等待过渡效果结束后再隐藏
}, duration);
}
document.addEventListener(
"contextmenu",
(e) => {
if (e.target.matches?.("img")) {
selectedImage = e.target;
console.log("选中的图片:", selectedImage);
// 该逻辑已废弃,因为豆包图片结构改版
// // 向上找到第一个 class 为 "image-box-grid-item-{xxxxx} " 的 div
// let element = e.target;
// while (element && element !== document) {
// if (
// element.classList &&
// element.classList.length &&
// element.classList[0].startsWith("image-box-grid-item-")
// ) {
// selectedImageContainer = element;
// break;
// }
// element = element.parentElement;
// }
// if (!selectedImageContainer) {
// console.warn("未找到包含图片的容器,可能是页面结构发生了变化");
// return;
// }
// const pngImage = selectedImageContainer.querySelector('img[data-testid="in_painting_picture"]');
// if (pngImage) {
// selectedImageUrl = pngImage.src;
// console.log("选中的图片 URL:", selectedImageUrl);
// } else {
// console.warn("未找到完整图片,可能是页面结构发生了变化");
// return;
// }
}
},
true
);
/**
* 合并两张图片,用第2张图片的右下部分覆盖第1张图片的右下部分
* 如果第2张图片大小不等于第1张图片,则将第2张图片缩放至第1张图片大小
* 并向body插入一个警告,说“该图片缓存已失效,请注意必须在图片刚生成的时候才能成功”
* @param {Blob} image1_blob
* @param {Blob} image2_blob
*/
async function mergeImage(image1_blob, image2_blob) {
return new Promise((resolve, reject) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img1 = new Image();
const img2 = new Image();
let loadedCount = 0;
let hasError = false;
let needScale = false; // 标记是否需要缩放
function checkBothLoaded() {
loadedCount++;
if (loadedCount === 2 && !hasError) {
// 检查图片尺寸是否相同
if (img1.width !== img2.width || img1.height !== img2.height) {
needScale = true;
// 插入警告
console.warn("图片尺寸不一致,正在尝试缩放第二张图片");
showToast("该图片缓存已失效,请注意必须在图片刚生成的时候才能成功");
// 创建临时canvas缩放img2
const scaleCanvas = document.createElement("canvas");
scaleCanvas.width = img1.width;
scaleCanvas.height = img1.height;
const scaleCtx = scaleCanvas.getContext("2d");
// 缩放绘制
scaleCtx.drawImage(img2, 0, 0, img1.width, img1.height);
// 将缩放后的图片转换为新的Image对象
scaleCanvas.toBlob((blob) => {
if (!blob) {
reject(new Error("缩放图片失败"));
return;
}
const scaledImg = new Image();
scaledImg.onload = () => {
// 使用缩放后的图片进行合并
performMerge(img1, scaledImg);
};
scaledImg.onerror = () => {
reject(new Error("加载缩放后的图片失败"));
};
scaledImg.src = URL.createObjectURL(blob);
}, "image/png");
return;
}
// 尺寸相同直接合并
performMerge(img1, img2);
}
}
// 执行实际合并操作的函数
function performMerge(baseImg, overlayImg) {
canvas.width = baseImg.width;
canvas.height = baseImg.height;
// 绘制底图
ctx.drawImage(baseImg, 0, 0);
// 计算覆盖区域(右下1/4)
const copyWidth = overlayImg.width / 2;
const copyHeight = overlayImg.height / 2;
const sourceX = overlayImg.width / 2;
const sourceY = overlayImg.height / 2;
const destX = baseImg.width / 2;
const destY = baseImg.height / 2;
// 覆盖右下角
ctx.drawImage(overlayImg, sourceX, sourceY, copyWidth, copyHeight, destX, destY, copyWidth, copyHeight);
// 转换为Blob
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error("无法生成图片"));
}
}, "image/png");
}
img1.onload = checkBothLoaded;
img2.onload = checkBothLoaded;
img1.onerror = () => {
hasError = true;
reject(new Error("加载第一张图片失败"));
};
img2.onerror = () => {
hasError = true;
reject(new Error("加载第二张图片失败"));
};
img1.src = URL.createObjectURL(image1_blob);
img2.src = URL.createObjectURL(image2_blob);
});
}
/* 轮询直到剪贴板出现图片 */
async function waitForImageInClipboard(interval = 200, timeout = 10_000) {
const start = Date.now();
while (Date.now() - start < timeout) {
try {
const items = await navigator.clipboard.read();
const imgItem = items.find((it) => it.types.some((t) => t.startsWith("image/")));
if (imgItem) {
return imgItem.getType(imgItem.types.find((t) => t.startsWith("image/")));
}
} catch {
/* 忽略权限或空剪贴板错误 */
}
await new Promise((r) => setTimeout(r, interval));
}
throw new Error("未检测到剪贴板图片");
}
async function copyAndMerge(selectedImageUrl) {
const copyDiv = document.querySelector('div[data-testid="right_click_copy_image"]');
if (!copyDiv) return;
/* 1. 同步清空剪贴板(必须在同一用户手势里) */
// 构造一个空的 ClipboardItem,同步写进去
const emptyItem = new ClipboardItem({ "text/plain": new Blob([""], { type: "text/plain" }) });
await navigator.clipboard.write([emptyItem]);
/* 2. 立即触发网页自己的复制按钮 */
copyDiv.click();
/* 3. 等待真正出现图片(此时一定是“新”图片) */
const clipboardBlob = await waitForImageInClipboard(); // 轮询直到出现图片
const netBlob = await fetch(selectedImageUrl).then((r) => r.blob());
const mergedBlob = await mergeImage(clipboardBlob, netBlob);
/* 4. 下载 */
const url = URL.createObjectURL(mergedBlob);
const a = document.createElement("a");
a.href = url;
a.download = `豆包无水印_${new Date().toISOString().replace(/[:.]/g, "-")}.png`;
a.style.display = "none";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
const mo = new MutationObserver(() => {
const menu = document.querySelector('div.semi-dropdown-content div[data-testid="image_context_menu"]');
if (menu && !menu.querySelector(".tm-inject-selection")) {
// 找同级第一个有 class 的 div
const cls = [...menu.children].find((el) => el.classList.length)?.className || "";
const downloadDiv = document.createElement("div");
downloadDiv.className = `${cls} tm-inject-selection`;
downloadDiv.style.color = "#ff6060";
downloadDiv.innerHTML = `
<span role="img" class="semi-icon semi-icon-default">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" d="M19.207 12.707a1 1 0 0 0-1.414-1.414L13 16.086V2a1 1 0 1 0-2 0v14.086l-4.793-4.793a1 1 0 0 0-1.414 1.414l6.5 6.5c.195.195.45.293.706.293H5a1 1 0 1 0 0 2h14a1 1 0 1 0 0-2h-6.999a1 1 0 0 0 .706-.293z"/>
</svg>
</span>
下载无水印图片(仅适用于刚刚生成的图片)
`;
downloadDiv.addEventListener("click", async () => {
if (!selectedImage) {
console.warn("未选中图片或图片 URL 无效");
return;
}
selectedImage.click(); // 确保 aside 出现
if (!document.body.querySelector('img[data-testid="in_painting_picture"]')) {
showToast("打开侧边栏中,请稍候...");
await new Promise((r) => setTimeout(r, 2000)); // 等待一下
} else {
await new Promise((r) => setTimeout(r, 1000)); // 等待一下
}
selectedImageUrl = document.body.querySelector('img[data-testid="in_painting_picture"]')?.src;
if (!selectedImageUrl) {
console.warn("未找到完整图片,可能是页面结构发生了变化");
showToast("未找到完整图片,可能是页面结构发生了变化");
return;
}
showToast("开始无水印下载,请勿进行任何操作。<br>如果出现【复制成功】的消息表明工作正常。<br>如果出现【复制失败】的消息,请在本次操作结束后再试一次。", 0);
try {
await copyAndMerge(selectedImageUrl);
console.log("下载成功");
showToast("操作完成!");
} catch (error) {
console.error("下载失败:", error);
showToast("下载失败,请查看控制台日志!");
}
});
menu.appendChild(downloadDiv);
}
});
mo.observe(document.body, { childList: true });
})();