即梦AI生图去水印实用指南:免费方法,轻松拿下纯净原图

即梦AI生图去水印实用指南:免费方法,轻松拿下纯净原图

说明:本文以浏览器插件的技术实现为例,仅用于下载本人创作、已获授权或平台明确允许保存的内容。请遵守网站服务条款、版权规则与当地法律,不要绕过 DRM、付费墙或访问控制。

很多内容平台的作品页面上,图片和视频都被组件、按钮、遮罩层包得严严实实。右键保存不一定有效,复制到的也可能只是缩略图。

但浏览器要把内容显示出来,就必须先拿到某种媒体资源:可能是<img>的真实地址,也可能是<video><source>,或者播放器使用的分片流。

于是我做了一个很轻量的 Chrome Manifest V3 插件:鼠标移到图片或视频附近时,页面上出现一个下载按钮;点击后,由插件后台调用浏览器下载能力保存文件。

它不依赖框架,不需要扫描整页,也不修改原网页布局。真正值得讲的,不是“加一个下载按钮”,而是背后的三个工程问题:

  1. 怎样找到浏览器实际加载的媒体地址?

  2. 怎样在不破坏页面的前提下放入交互入口?

  3. 怎样把网页里的点击动作安全地交给扩展后台执行?

下面拆开来说。


一、先纠正一个误区:无水印资源并不总是存在

有些页面的水印只是 CSS、DOM 或 Canvas 叠加层,底层<img>加载的文件本身没有水印。此时直接取得媒体 URL,确实可能保存到底层文件。

但这不是通用规律。平台也可能:

  • 在服务端把水印直接写入图片或视频;

  • 只向页面下发压缩图或带水印版本;

  • 使用临时签名 URL,并校验 Cookie、Referer 或有效期;

  • 通过 MSE、HLS、DASH 等方式播放分片视频;

  • 对内容施加 DRM 或其他访问控制。

所以更准确的表述是:插件下载的是浏览器当前可访问的媒体资源,不保证一定是原图、最高画质或无水印版本。

对普通<img>和直链<video>,这个思路通常很有效;遇到分片、鉴权和 DRM,则需要止步于合规边界。

二、为什么要拆成 content script 和 background

Manifest V3 扩展里,这两个角色分工明确:

网页 DOM │ │ content.js:识别图片/视频、显示按钮、提取 URL │ └── chrome.runtime.sendMessage() │ ▼ background.js(Service Worker) 调用 chrome.downloads.download()

content.js能读取页面 DOM,却不能直接使用所有扩展专属 API;background.js拿得到chrome.downloads,但不能操作网页 DOM。

因此,最自然的设计就是:

  • 内容脚本负责“看见什么”;

  • 后台脚本负责“下载什么”;

  • 两者用消息传递连接。

manifest.json的核心配置如下:

{ "manifest_version": 3, "permissions": ["downloads"], "host_permissions": ["<all_urls>"], "content_scripts": [ { "matches": ["<all_urls>"], "js": ["content.js"], "css": ["content.css"], "run_at": "document_idle" } ], "background": { "service_worker": "background.js" } }

如果插件只服务于少数网站,建议把<all_urls>收窄到明确域名。权限越少,用户越容易理解和信任。

三、图片 URL:不要只盯着src

现代网页中的图片经常使用响应式加载和懒加载。一个<img>上可能同时存在:

  • currentSrc:浏览器最终选中的资源;

  • src:默认地址;

  • srcset:多个尺寸候选;

  • data-src/data-srcset:懒加载库保存的真实地址。

因此,提取顺序应该优先尊重浏览器已经做出的选择:

function getImageUrl(img) { let url = img.currentSrc || img.src || img.dataset.src || ''; if (!url) { const srcset = img.getAttribute('srcset') || img.getAttribute('data-srcset'); url = pickBestFromSrcset(srcset); } if (!url || url.startsWith('data:') || url.startsWith('blob:')) { return null; } return new URL(url, location.href).href; }

这里有两个细节很重要。

第一,currentSrc通常比src更可靠,因为它代表浏览器根据屏幕密度、视口宽度和srcset规则真正选中的资源。

第二,如果只能解析srcset,不要机械地取第一个候选。srcset往往从小图排到大图,更稳妥的做法是解析每个候选的wx描述符,再选择规格最高的一个。

四、别把扩展名当成唯一真相

CDN 地址经常没有标准文件后缀,例如:

https://cdn.example.com/asset/abc123?format=webp https://cdn.example.com/image.webp/resize/1080

插件可以按以下顺序做“类型猜测”:

  1. 检查 URL 路径末尾的扩展名;

  2. 检查路径中的格式片段;

  3. 检查formattypeext等查询参数;

  4. 必要时在下载后参考响应的Content-Type

但要注意:URL 规则只能帮助识别,不能证明文件内容一定与后缀一致。生产级实现最好把“识别媒体”和“生成文件名”分开,避免因为猜错扩展名而保存出无法打开的文件。

五、视频为什么比图片难得多

普通直链视频很简单:

function getVideoUrl(video) { let url = video.currentSrc || video.src || ''; if (!url || url.startsWith('blob:')) { const source = video.querySelector('source[src]'); url = source?.src || ''; } if (!url || url.startsWith('blob:') || url.startsWith('data:')) { return null; } return new URL(url, location.href).href; }

真正棘手的是blob:

当播放器使用 MSE(Media Source Extensions)时,页面会把一段段媒体数据送进SourceBuffer。此时:

blob:https://example.com/xxxxxxxx

只是浏览器内部对象的临时地址,并不是一个可直接下载的远程文件。

如果<source>中仍有普通 URL,可以继续使用;如果只有 HLS、DASH 或其他分片清单,就进入了完全不同的处理范畴。插件不应该把“下载 blob 地址”伪装成成功,更不应尝试绕过 DRM。

六、一个按钮,如何做到不破坏原页面

我选择的方案是:全页只创建一个按钮,把它追加到document.body,再用position: fixed跟随当前目标移动。

let button; function ensureButton() { if (button) return button; button = document.createElement('button'); button.id = 'media-download-overlay'; button.textContent = '下载'; document.body.appendChild(button); return button; }
#media-download-overlay { position: fixed !important; z-index: 2147483647 !important; display: none; margin: 0 !important; }

这个方案的好处有三个:

  • 按钮脱离文档流,不会挤压页面内容;

  • 全局复用一个节点,避免给每张图片重复绑定 UI;

  • 新内容通过无限滚动加载后,也无需重新批量注入按钮。

为了减少样式冲突,可以使用足够独特的 ID、明确重置关键属性。更彻底的隔离方案则是使用 Shadow DOM,不过实现成本也会略高。

七、媒体检测:事件委托比全量扫描更轻

最简单的入口是把事件监听挂在document上:

document.addEventListener('mouseover', (event) => { const media = event.target.closest?.('img, video'); if (media) showButtonFor(media); });

事件委托天然支持后续动态插入的节点,因此通常不需要MutationObserver扫描整个页面。

但遮罩层会带来一个限制:如果鼠标命中的始终是覆盖在媒体上方的独立元素,event.targetdocument.elementFromPoint()得到的都可能只是遮罩,而不是下面的图片。

更稳妥的补充方式包括:

  • 从遮罩节点向父级查找,再在容器内查询img, video

  • 对已知站点适配其卡片结构;

  • 使用elementsFromPoint()获取坐标处的元素栈,再筛选媒体元素;

  • 对高频mousemove做节流,避免影响页面性能。

const stack = document.elementsFromPoint(x, y); const media = stack.find(el => el.matches?.('img, video'));

这比声称elementFromPoint()能“穿透遮罩”更准确:单数版本只返回最上层元素,复数版本才能检查整条命中栈。

八、过滤、定位与状态反馈

如果所有小图标都弹出下载按钮,插件很快会变成视觉噪音。因此需要设置尺寸门槛,例如图片宽高至少 60px、视频至少 120px。

按钮定位时,还要根据视口边界进行修正:

function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } function positionButton(media, button) { const rect = media.getBoundingClientRect(); const left = clamp(rect.left + 8, 4, innerWidth - button.offsetWidth - 4); const top = clamp(rect.top + 8, 4, innerHeight - button.offsetHeight - 4); button.style.left = `${left}px`; button.style.top = `${top}px`; }

点击之后,不要让用户猜下载是否成功。可以用短暂的颜色和文案反馈:

  • 下载已创建:绿色 +“已开始”;

  • 调用失败:红色 +“失败”;

  • 1.5 秒后恢复默认状态。

这里最好写“已开始”,而不是“已下载”。chrome.downloads.download()返回下载任务 ID,只代表任务已创建,文件是否最终完成还需要监听下载状态。

九、后台下载与文件名清理

内容脚本只发送必要信息:

chrome.runtime.sendMessage({ action: 'download', url, filename });

后台负责校验消息并创建下载:

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.action !== 'download') return; const safeFilename = sanitizeFilename(message.filename); chrome.downloads.download({ url: message.url, filename: safeFilename, saveAs: false, conflictAction: 'uniquify' }).then(id => { sendResponse({ success: true, downloadId: id }); }).catch(error => { sendResponse({ success: false, error: error.message }); }); return true; });

文件名至少需要处理 Windows 不允许的字符,并限制长度:

function sanitizeFilename(filename = '') { return filename .replace(/[\\/:*?"<>|]/g, '_') .replace(/[. ]+$/g, '') .replace(/\s+/g, ' ') .trim() .slice(0, 180) || `media_${Date.now()}`; }

此外还应在后台验证 URL 协议,只接受预期的http:/https:,不要盲目信任来自页面的消息参数。

十、这个方案支持什么,又不支持什么

场景支持情况说明
<img src>/currentSrc最稳定
srcset响应式图片应解析并选择合适候选
data-src懒加载图片需兼容站点属性命名
<video src>/<source>仅限普通可访问 URL
CDN 查询参数标记格式⚠️可以猜测,最好结合响应类型
blob:MSE 视频不是可直接下载的远程文件
Canvas 内存绘制⚠️需要单独导出逻辑,且可能受跨域污染限制
临时签名或鉴权资源⚠️受 Cookie、Referer、有效期和权限影响
DRM 内容不应绕过

十一、回头看,真正可复用的是这套设计

这个插件没有复杂依赖,核心代码也不长,但它体现了几个很实用的浏览器扩展设计原则:

  • 权限最小化:只申请真正需要的权限和站点范围;

  • 职责分离:DOM 识别放在 content script,下载放在 background;

  • 事件委托:动态页面无需反复扫描;

  • 单实例 UI:一个悬浮按钮复用到底;

  • 明确失败边界:直链能下,blob:、鉴权和 DRM 不假装能下;

  • 反馈说人话:任务创建叫“已开始”,完成状态另行监听。

一句话总结:

浏览器扩展的关键,不是“把页面上的东西扒下来”,而是识别浏览器已经获得的合法资源,并用最少权限、最小侵入完成用户明确授权的保存操作。

如果你正在学习 Manifest V3,这个项目很适合作为练手案例:它同时涉及 DOM、事件委托、跨上下文消息、Service Worker、下载 API 和安全边界,却又不需要引入复杂框架。

开源地址:https://github.com/LumeVault/jimeng-image