即梦AI生图去水印实用指南:免费方法,轻松拿下纯净原图
说明:本文以浏览器插件的技术实现为例,仅用于下载本人创作、已获授权或平台明确允许保存的内容。请遵守网站服务条款、版权规则与当地法律,不要绕过 DRM、付费墙或访问控制。
很多内容平台的作品页面上,图片和视频都被组件、按钮、遮罩层包得严严实实。右键保存不一定有效,复制到的也可能只是缩略图。
但浏览器要把内容显示出来,就必须先拿到某种媒体资源:可能是<img>的真实地址,也可能是<video>、<source>,或者播放器使用的分片流。
于是我做了一个很轻量的 Chrome Manifest V3 插件:鼠标移到图片或视频附近时,页面上出现一个下载按钮;点击后,由插件后台调用浏览器下载能力保存文件。
它不依赖框架,不需要扫描整页,也不修改原网页布局。真正值得讲的,不是“加一个下载按钮”,而是背后的三个工程问题:
怎样找到浏览器实际加载的媒体地址?
怎样在不破坏页面的前提下放入交互入口?
怎样把网页里的点击动作安全地交给扩展后台执行?
下面拆开来说。
一、先纠正一个误区:无水印资源并不总是存在
有些页面的水印只是 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往往从小图排到大图,更稳妥的做法是解析每个候选的w或x描述符,再选择规格最高的一个。
四、别把扩展名当成唯一真相
CDN 地址经常没有标准文件后缀,例如:
https://cdn.example.com/asset/abc123?format=webp https://cdn.example.com/image.webp/resize/1080插件可以按以下顺序做“类型猜测”:
检查 URL 路径末尾的扩展名;
检查路径中的格式片段;
检查
format、type、ext等查询参数;必要时在下载后参考响应的
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.target和document.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