别再乱装图片插件了!我手写了一个,能扒光整个网页(含背景/iframe/Shadow DOM)

开场白

我真的受够了,每次想从网页批量保存图片,要么右键被禁用,要么装了五六个插件还漏掉一半的 CSS 背景图,要么好不容易抓到图了,却发现插件在后台偷偷上报我的浏览记录。

于是我自己写了一个 ------ Image Harvest 。它能把网页里所有图片(包括 <img>、CSS 背景、iframe 内嵌、甚至 Shadow DOM 里的)全部扒出来,一键打包 ZIP,而且本地处理,零追踪。

点击立即体验

这篇文章不讲产品吹水,只说技术实现:MV3 踩坑、深度图片提取、客户端感知哈希去重、Side Panel + Popup 双形态共存。干货 + 代码 + 真实踩坑记录,希望对写 Chrome 插件的朋友有帮助。


一、为什么我要自己写一个图片下载插件?

现有的同类插件,我试过 10+ 个,普遍三个问题:

  1. 抓不全 :只能抓 <img>,CSS background-imageiframe、Shadow DOM 里的图基本放弃。
  2. 有隐私风险 :manifest 里申请 <all_urls> + webRequest,还往未知服务器发数据。
  3. 体验拉胯:批量下载要么一张张点,要么 ZIP 包里一半是占位图。

所以我决定自己造轮子。核心目标:

  • 不漏图(递归提取所有图片源)
  • 不监守自盗(纯本地,零数据收集)
  • 好用(侧边栏/弹窗双模式、暗色主题、3 档密度)

二、Manifest V3 的几个坑(附解法)

2.1 Service Worker 冷启动

V3 用 service worker 替代 V2 的常驻 background page。它会在几秒无活动后休眠,导致下次调用时变量全丢。

解决方案 :用 chrome.storage.session 缓存关键状态。

javascript 复制代码
// 抓取完成后存入 session
await chrome.storage.session.set({ 
  lastExtract: { images, timestamp: Date.now() } 
});

// 下次打开面板时恢复
const cached = await chrome.storage.session.get('lastExtract');
if (cached && Date.now() - cached.timestamp < 60000) {
  return cached.images;
}

2.2 远程代码被禁止

V3 完全禁止执行从外部下载的脚本。对我没影响:Image Harvest 所有代码本地打包,不依赖任何远程配置。

2.3 webRequestdeclarativeNetRequest 替代

如果你需要修改网络请求(如给图片请求加 header),现在只能用声明式规则,灵活性降低。不过图片下载器不需要这玩意儿。


三、深度图片提取:从 <img> 到 Shadow DOM

3.1 基础提取:<img><picture>

javascript 复制代码
function extractSimpleImages() {
  const urls = [];
  document.querySelectorAll('img').forEach(img => {
    if (img.src) urls.push(img.src);
  });
  document.querySelectorAll('picture source').forEach(source => {
    if (source.srcset) {
      const highest = source.srcset.split(',').pop().trim().split(' ')[0];
      urls.push(highest);
    }
  });
  return urls;
}

3.2 提取 CSS background-image

很多网站的 Banner、图标都用背景图实现,必须挖出来。

javascript 复制代码
function extractBgImages(root = document) {
  const elements = root.querySelectorAll('*');
  const bgUrls = [];
  for (let i = 0; i < Math.min(elements.length, 2000); i++) {
    const bg = getComputedStyle(elements[i]).backgroundImage;
    if (bg && bg !== 'none') {
      const match = bg.match(/url\(["']?([^"')]+)["']?\)/);
      if (match) bgUrls.push(match[1]);
    }
  }
  return bgUrls;
}

3.3 递归 Shadow DOM

现代前端框架(React/Vue)常把图片封装在 Shadow DOM 里,必须递归遍历。

javascript 复制代码
function extractFromShadowDOM(root = document) {
  let results = [];
  // 普通图片
  results.push(...extractSimpleImages(root));
  results.push(...extractBgImages(root));
  // 递归 Shadow DOM
  const hosts = root.querySelectorAll('*');
  hosts.forEach(host => {
    if (host.shadowRoot) {
      results.push(...extractFromShadowDOM(host.shadowRoot));
    }
  });
  return results;
}

3.4 iframe 处理

同源 iframe 可以用 chrome.scripting.executeScript 注入提取函数。需要 webNavigation 权限获取所有 frame。

javascript 复制代码
const frames = await chrome.webNavigation.getAllFrames({ tabId });
for (const frame of frames) {
  if (frame.parentFrameId !== -1) continue; // 只处理顶层 iframe
  const injection = await chrome.scripting.executeScript({
    target: { tabId, frameIds: [frame.frameId] },
    func: extractFromShadowDOM,
  });
  // 合并结果...
}

四、客户端感知哈希(pHash)实现相似图去重

很多用户反馈:"下载 100 张图,里面有 30 张是重复的缩略图"。所以我在 Pro 版中加了相似图检测。

4.1 算法选择:dHash

  • 速度快(纯前端)
  • 对缩放、轻微裁剪不敏感
  • 汉明距离 ≤ 5 判定为相似

4.2 核心代码

javascript 复制代码
async function computeDHash(blob) {
  const img = await createImageBitmap(blob);
  const canvas = new OffscreenCanvas(9, 8);
  const ctx = canvas.getContext('2d');
  ctx.drawImage(img, 0, 0, 9, 8);
  const data = ctx.getImageData(0, 0, 9, 8).data;
  
  // 转灰度
  const gray = [];
  for (let i = 0; i < data.length; i += 4) {
    gray.push(0.299 * data[i] + 0.587 * data[i+1] + 0.114 * data[i+2]);
  }
  
  // 差分哈希
  let hash = 0n;
  for (let row = 0; row < 8; row++) {
    for (let col = 0; col < 7; col++) {
      const left = gray[row * 9 + col];
      const right = gray[row * 9 + col + 1];
      if (right > left) hash |= (1n << BigInt(row * 7 + col));
    }
  }
  return hash;
}

4.3 Worker 中运行,不阻塞 UI

javascript 复制代码
const worker = new Worker('phash-worker.js');
worker.postMessage({ blob });
worker.onmessage = (e) => {
  console.log(`哈希: ${e.data.hash}`);
};

Chrome 115+ 支持 Side Panel,但老用户习惯 Popup。我两个都要。

实现要点

  • manifest 中配置 side_panel.default_path = "sidepanel.html"
  • action.default_popup 留空,动态控制
javascript 复制代码
chrome.action.onClicked.addListener(async (tab) => {
  const settings = await getAppSettings();
  if (settings.useSidePanel) {
    await chrome.sidePanel.open({ tabId: tab.id });
  } else {
    await chrome.action.setPopup({ tabId: tab.id, popup: 'popup.html' });
    chrome.action.openPopup();
  }
});

同一套 UI 代码,通过 window.location.pathname 判断当前模式,微调布局(弹窗固定 620×600,侧边栏自适应)。


六、上架 Chrome Web Store 的 4 个雷区

  1. 图标尺寸不全:必须 16/32/48/128 px,缺一个直接驳回。
  2. 描述太短:简短描述 ≤132 字符,要包含核心关键词。
  3. 隐私政策缺失:即使不收集数据,也要写一份说明"不收集什么"。
  4. 权限过度 :不需要 <all_urls> 就别写,否则审核会问。

我第一次提交被拒就是因为隐私政策链接 404。补上后 2 天过审。

相关推荐
rrr21 小时前
【前端开发】|GUI 基本概念和框架基础
前端·qt
方安乐1 小时前
前端“硬核”性能优化
前端
前端AI充电站1 小时前
第 9 篇:让 AI 助手记住会话:示例问题点击发送与 localStorage 持久化
前端·人工智能·前端框架
Densen20141 小时前
企业H5站点升级PWA (三)
前端·nginx·c#
朝阳391 小时前
react【实战】搜索框(含联动动画,清空按钮)
前端·javascript·react.js
小王C语言2 小时前
【linux进程信号】————产生信号:signal自定义信号处理动作(自定义捕捉)、前后台进程、产生信号的方式(函数、软条件、硬件异常)....等等
运维·服务器·前端
芝士就是力量啊 ೄ೨2 小时前
Windows11使用Edge切屏后,会卡屏的解决方案
前端·edge
尘世壹俗人2 小时前
前端如何自适应宽高
前端
JianZhen✓2 小时前
前端竞争力提升
前端