资讯动态

恢复图片搜索“查看图片”按钮:浏览器扩展实战

发布时间:2026/10/29 12:13:28 来源:尧图企业网站定制
用搜索引擎找图这件事我几乎每天都要干几回写稿找配图、验证一张截图的出处、给素材找高清版本。以前在图片搜索结果页上鼠标悬停一张图右下角会挂着几个按钮其中那个“查看图片”最顺手——点一下直接跳到原图不用进中间页也不用反复试右键。后来这个按钮没了页面只剩一个铺满屏的瀑布流点哪张都把我甩到一个夹着广告和推荐位的中间页面里再点一次才看到原图。这篇东西讲的就是怎么把那个按钮找回来一个几十行代码的浏览器扩展三分钟装好装上之后按钮重新长在缩略图上点一下直出原图。它解决的不是什么高深问题纯粹是“一次点击 vs 三次点击”的效率损耗但只要你是重度找图用户——设计师、运营、做内容搬运的、查证图片真伪的——这个损耗一天攒下来就是几百次多余的跳转。下文我把思路、代码、踩过的坑全摊开懂一点 HTML 的可以直接抄完全不懂前端的照着步骤装也能跑起来。1. 按钮消失了消失的到底是什么1.1 先还原用户视角的四个真实痛点很多人以为按钮消失是“功能被砍了”其实更准确的说法是“入口被挪走或藏起来了”。这两件事在体验上的差别很大功能被砍意味着能力彻底不存在入口被挪走意味着能力还在只是你现在得多绕几步才能碰到它。我把自己和身边几个同事的抱怨归了一下类痛点其实就四个多一跳。以前悬停即点直达原图现在必须先点缩略图进中间页再看一遍广告和“相关搜索”才找到真正的图片链接。右键失效。早期的搜索结果页右键“在新标签页中打开图片”能拿到大图改版之后部分图片是懒加载的占位图或者低分辨率缩略图右键存下来只有几十 KB放大就糊。位置漂移。有些图在中间页里藏的原始链接还带着一串跳转参数手动复制出来还得删参数删错了直接 404。移动端更惨。窄屏下根本没有悬停长按弹出的菜单里也没有“查看图片”这一项只能一步步点。这四条合在一起就是一个很典型的“产品为了引导流量做的改版”与“重度用户的操作习惯”之间的冲突。改版方有它的道理——中间页能塞更多相关内容、能带更多曝光但对于只想拿图的人来说这条路纯属负担。1.2 页面变了但数据还在我一开始也担心“是不是接口没了”。花了一个晚上在开发者工具里翻结论让人松口气图片的真实地址依然完整地躺在前端 DOM 里只是承接“查看图片”这个动作的那个按钮节点从缩略图容器里被移除了或者被塞进了一个需要额外交互才展开的菜单。这件事的关键在于图片搜索页是一个典型的前端异步渲染页面。你看到的瀑布流不是一次性从服务器吐出来的 HTML而是页面加载完之后由 JavaScript 分批请求数据、拼接 DOM、再插进容器的。滚动一屏就插一批新节点。这个过程有个很要命的副作用——如果你在页面load事件里跑一次查询你只能扫到第一批图片往下滚动新出来的全是“漏网之鱼”。所以任何“找回按钮”的方案技术核心都不是“调接口”而是两件事找到图片节点用一组尽量稳的选择器把页面上所有缩略图img捞出来。跟上页面的节奏页面每插入一批新节点就重新扫一遍把按钮补上去。第一件事靠 CSS 选择器第二件事靠MutationObserver——浏览器原生的 DOM 变更监听器。搞明白这两点剩下的全是体力活。提示判断一个“页面功能消失”的问题是前端问题还是后端问题最快的办法是打开开发者工具在 Elements 面板里搜一下你关心的数据还在不在。在就是前端的事不在才需要往后端想。2. 方案选型四条路摆在面前为什么选扩展2.1 四条候选路线的横向对比在动手之前我把能想到的路子都过了一遍列成表更清楚方案实现成本生效范围更新维护主要短板浏览器扩展Content Script中需要写 manifest 和注入脚本只在匹配的域名和路径下生效页面改版后改选择器即可需要开启开发者模式手动加载用户脚本管理器 脚本低一个.user.js文件同样按 URL 匹配同上依赖第三方脚本管理器多一层信任成本书签小工具Bookmarklet极低一段javascript:代码手动点击才执行改书签即可每次都要点一次且容易被页面的 CSP 拦浏览器自带功能 / 系统级工具无全局不可控拿不到页面内的原始图片地址我最后选扩展理由有三个说穿了都很朴素第一它是“一次装好长期自动生效”的。书签小工具每次都要手动点我这人记性差点两次就懒得点了。扩展可以在页面加载完成后自动跑用户完全无感。第二它能把按钮“长”在页面上而不是弹一个浮层。书签小工具最常见的做法是弹一个面板列出所有图片链接逻辑上能用但体验割裂——你还得在面板里再找一遍“我要的是哪张”。扩展可以直接在每张缩略图上挂一个按钮位置对应点谁是谁。第三调试体验最好。扩展的 Content Script 在开发者工具里有独立的执行上下文console.log打在页面的 Console 里断点能打选择器能实时改。用户脚本管理器虽然也能调但多一层注入出问题的时候排查链路更长。代价也很明确扩展的权限模型比用户脚本严格。Manifest V3 之后content_scripts的matches必须写成明确的 URL 模式不能用*://*/*这种大撒网虽然技术上能写但装的时候会弹一个非常吓人的“读取所有网站数据”提示。我的做法是把matches收窄到搜索页的路径特征上这样安装时只提示“读取此站点数据”心理负担小很多也更符合最小权限原则。2.2 两个必须先想清楚的约束选完路线之后还有两个约束条件必须在写代码前定下来不然后面全是返工。约束一不能破坏页面原有的点击行为。缩略图本身是可点的点了会跳中间页。我加的按钮如果只是浮在上面点按钮的时候事件会冒泡到父级a上结果就是“既开了新标签页原页面又跳走了”。所以按钮的点击处理里必须preventDefault()加stopPropagation()两个都要少一个都不行。约束二不能重复注入。页面是异步渲染的我用MutationObserver监听变更而“给容器加按钮”这个动作本身也会触发 DOM 变更——如果不做去重就会陷入“加按钮 → 触发变更 → 再加按钮 → 再触发变更”的死循环几秒钟之后页面上每张图叠了十几个按钮浏览器直接卡死。解决办法是给已经处理过的容器打一个标记属性扫描时先查标记。这两个约束听着像是常识但我在第一版里两个都踩了后面第 6 节会详细说现场。3. 核心实现拆解三个绕不开的技术点3.1 Manifest V3 下的最小骨架现代浏览器扩展的清单文件已经不是当年那个宽松的 JSON 了Manifest V3 对权限做了大幅收紧。对我们这个场景真正需要的字段其实很少manifest_version必须是3这是硬性的。content_scripts是核心它声明“在哪些页面、注入哪些文件、什么时候注入”。matches决定生效范围写成搜索页的 URL 特征。run_at决定注入时机这里我选document_idle。关于run_at有句话值得展开。它有三个常见取值document_startDOM 还没建好就注入、document_endDOM 建好但资源可能没加载完、document_idle浏览器觉得空闲了再注入通常在document_end到load之间。选document_idle的原因是我们的脚本本身要依赖MutationObserver去追后续渲染所以完全不需要抢跑抢跑反而会因为document.body还不存在而报一堆null引用错误。还有all_frames默认是false意思是只在顶层文档注入不进 iframe。搜索结果页的图片基本都在主文档里保持false就行。如果哪天你把范围扩到了别的站点发现某些图在 iframe 里再把它改成true——但要注意改成true之后同一个页面里的每个 iframe 都会跑一份你的脚本去重逻辑得更小心。3.2 用 MutationObserver 追上异步渲染的页面这是整个方案里唯一需要动点脑子地方。先说反面教材我第一版用的是window.addEventListener(load, ...)加上一个setInterval每 500ms 扫描一次。能用但很难看——页面静置的时候它也在空转笔记本风扇转得明显而且 500ms 的间隔在快速滚动时会漏掉一些节点。MutationObserver的用法本身不复杂观察document.documentElement配置childList: true和subtree: true这样整棵树的任何层级新增节点都会通知到我。但直接回调里干活会出事因为瀑布流滚动的时候DOM 变更是一秒几十次的高频事件每次变更都全量扫一遍页面主线程会被拖死。所以中间必须加一层节流收到通知不立刻扫而是起一个 200ms 的定时器定时器触发时才执行真正的扫描。200ms 这个数值是我试出来的——低于 100ms快速滚动时扫描次数暴涨帧率掉得能看出来高于 400ms新图片出来之后按钮要愣一下才出现手感很怪。200ms 基本是“视觉上即时、性能上无感”的平衡点。还有一个小细节滚动到底部再往上滚已经处理过的容器会重新进入视口MutationObserver可能因为懒加载替换src再次触发。这时候前面提到的标记属性就派上用场了容器有标记就直接跳过不做任何查询。3.3 按钮注入与去重别让页面长出三胞胎注入这步看着简单——createElement(button)、设文本、挂事件、appendChild——但真正决定成败的是挂在哪个节点上。我试过三种挂法挂在img上不行。img是替换元素直接给它加子节点会被浏览器忽略而且img的position属性设置之后定位基准也很怪。挂在外层a上基本可行但前提是搜索页的结果确实是a包裹的。如果哪天改版换成div加 JS 跳转选择器就全废了。挂在最近的定位祖先上最稳。用img.closest(a, div)找到最近的可定位父容器给它加position: relative按钮用绝对定位贴右下角。我最终用的是第二种加第三种的混合优先找a找不到就退到最近的div。同时给容器加一个统一的类名方便 CSS 里统一处理定位。去重标记我用的是自定义属性>{ manifest_version: 3, name: Image View Restorer, version: 1.0.0, description: 在图片搜索结果页恢复“查看图片”快捷入口, content_scripts: [ { matches: [ https://*.example-search.tld/search*, https://*.example-search.tld/imghp* ], js: [content.js], css: [inject.css], run_at: document_idle, all_frames: false } ] }matches里的example-search.tld是占位符你需要替换成自己实际访问的搜索页域名。写这个模式有几个规则要记牢*在主机部分只能匹配一级子域https://*.example.com/*能匹配a.example.com但匹配不了a.b.example.com。路径部分的*可以匹配任意字符包括/所以/search*能覆盖/search?q...和/search/images?...。协议必须写全https://和http://是两个不同的模式要分别写通常只写https://就够了。路径不写*就代表精确匹配很容易漏。改完matches之后必须回到扩展管理页重新加载一次扩展否则新规则不生效。这是新手最容易卡住的地方——改了半天代码怎么刷新页面都没反应以为代码写错了其实是扩展还跑在旧配置上。注意这里的域名不要图省事写成https://*/*。虽然技术上能跑但安装时浏览器会提示“读取和更改您在所有网站上的所有数据”一是心理上有负担二是权限过大意味着哪天代码出了 bug 可能影响到别的站点。4.3 content.js 完整代码与逐段讲解下面这份是能直接跑的版本我按功能分了段每段后面说清楚它在干嘛。// ---------- 1. 常量与状态 ---------- const MARK_ATTR data-ivr-injected; const BTN_CLASS ivr-view-btn; const HOST_CLASS ivr-host; const SCAN_DELAY 200; const MIN_SIZE 80; let scanTimer null; // ---------- 2. 地址推导 ---------- const SIZE_RULES [ // 把 ?w200h200 之类的尺寸参数抬到大图 { re: /([?](?:w|width|h|height|size|sz))\d{1,4}/gi, rep: $11600 }, // 把 xxx-320x480.jpg 这类后缀去掉 { re: /-\d{2,4}x\d{2,4}(\.(?:jpe?g|png|webp|gif))/i, rep: $1 } ]; function upgradeUrl(url) { if (!url) return ; let out url; for (const r of SIZE_RULES) { out out.replace(r.re, r.rep); } return out; } function resolveTargetUrl(img) { const raw img.currentSrc || img.src || ; const upgraded upgradeUrl(raw); if (upgraded upgraded ! raw) return upgraded; const anchor img.closest(a[href]); if (anchor anchor.href) return anchor.href; return raw; }resolveTargetUrl是这个扩展里唯一带点“猜测”成分的函数逻辑分三层先看缩略图地址能不能通过替换尺寸参数变大图能就直接用不能就退到外层链接再不行就用原始src。这三层是递进关系不是并列——因为大多数情况下缩略图地址确实带尺寸参数改造一下就能拿到大图这是最优解因为它省掉了一次页面跳转。// ---------- 3. 按钮构建 ---------- function buildButton(img) { const btn document.createElement(button); btn.type button; btn.className BTN_CLASS; btn.textContent 查看图片; btn.setAttribute(MARK_ATTR, 1); btn.addEventListener(click, (e) { e.preventDefault(); e.stopPropagation(); const url resolveTargetUrl(img); if (url) { window.open(url, _blank, noopener,noreferrer); } }, false); // 阻止 mousedown 冒泡避免某些页面用它做跳转 btn.addEventListener(mousedown, (e) e.stopPropagation(), false); return btn; }这里加了mousedown的阻止冒泡是踩坑之后补的。有些页面不用click做跳转而是监听父容器的mousedown或者pointerdown只拦click根本拦不住。把这三个事件都拦掉才算是彻底干净。window.open的第三个参数noopener,noreferrer也别省。它是让新开的页面拿不到window.opener一是安全二是某些站点检测到opener存在时会改行为比如强制在当前页跳转。// ---------- 4. 扫描与注入 ---------- const IMG_SELECTORS [ div[data-attrid] img, a[rolelink] img, div[jsname] img, div a img ].join(,); function markHost(container) { if (!container) return null; if (container.hasAttribute(MARK_ATTR) || container.querySelector([${MARK_ATTR}])) { return null; } container.classList.add(HOST_CLASS); return container; } function scan(root) { let imgs; try { imgs root.querySelectorAll(IMG_SELECTORS); } catch (err) { return; } for (const img of imgs) { if (img.naturalWidth img.naturalWidth MIN_SIZE) continue; if (img.naturalHeight img.naturalHeight MIN_SIZE) continue; let host img.closest(a) || img.closest(div); host markHost(host); if (!host) continue; host.setAttribute(MARK_ATTR, 1); host.appendChild(buildButton(img)); } }IMG_SELECTORS里我并列了四条选择器用逗号拼起来交给querySelectorAll语义上是“满足任意一条即可”。为什么要冗余这么多条因为搜索页的结果容器经常改class尤其是带哈希的那种像classabc123每次构建都变但结构性属性相对稳定>// ---------- 5. 监听与节流 ---------- function scheduleScan() { if (scanTimer) return; scanTimer setTimeout(() { scanTimer null; scan(document); }, SCAN_DELAY); } function boot() { scan(document); const mo new MutationObserver(scheduleScan); mo.observe(document.documentElement, { childList: true, subtree: true }); // 兜底单页应用的路由切换不一定触发 DOM 变更 window.addEventListener(popstate, scheduleScan, false); document.addEventListener(visibilitychange, () { if (!document.hidden) scheduleScan(); }, false); } if (document.readyState loading) { document.addEventListener(DOMContentLoaded, boot, { once: true }); } else { boot(); }最后那个readyState判断是保险。虽然run_at: document_idle保证了注入时 DOM 基本就绪但如果哪天你把run_at改成document_start做实验没这个判断就会拿到null。加上它脚本在两种注入时机下都能正常工作。4.4 inject.css让按钮不碍事.ivr-host { position: relative !important; } .ivr-view-btn { position: absolute; right: 8px; bottom: 8px; z-index: 20; padding: 4px 10px; border: none; border-radius: 14px; background: rgba(16, 16, 16, 0.78); color: #fff; font-size: 12px; line-height: 1.5; font-family: inherit; cursor: pointer; opacity: 0; transform: translateY(4px); transition: opacity 0.15s ease, transform 0.15s ease; pointer-events: none; } .ivr-host:hover .ivr-view-btn, .ivr-view-btn:focus-visible { opacity: 1; transform: translateY(0); pointer-events: auto; }设计上的几个取舍说明一下。opacity: 0加pointer-events: none的组合是为了让按钮默认隐形——图片墙本来就很花每张图都挂一个深色药丸会非常吵。只在悬停时显形视觉上干净得多。pointer-events: none是配合opacity: 0用的不然按钮虽然看不见但仍然可以点会莫名其妙触发。focus-visible那条是为了键盘用户Tab 到按钮上时它要显形否则键盘操作完全没法用。这个细节很少有人写但加上之后整个东西才算完整。4.5 加载与验证装的过程分三步熟练的话确实三分钟打开扩展管理页右上角打开“开发者模式”开关。点“加载已解压的扩展程序”选中那个包含manifest.json的文件夹。回到搜索页刷新一次。验证方法很简单鼠标悬停一张缩略图右下角应该浮出一个深色的小按钮。点它新标签页打开的就是大图。如果没出现按这个顺序排查先看扩展有没有加载成功。管理页里如果显示红色错误说明manifest.json有语法问题最常见的是多了一个逗号或者少了一个引号。再看matches写对没有。在搜索页按 F12Console 里敲location.href把实际地址和你的模式对一遍。最后看 Console 有没有报错。Content Script 的错误会打在页面的 Console 里红字很显眼。5. 从“能用”到“顺手”的三个增强5.1 地址推导的边界在哪里upgradeUrl那两条正则只能覆盖最常见的情况。真实世界里图片地址的尺寸参数五花八门有的用?w200有的用?width200有的用路径里的-320x480还有的把尺寸编在哈希里这种基本没救只能老老实实跳中间页拿原图。我的建议是不要试图写完所有规则而是先抓高频的跑一段时间看哪些图还是拿不到大图再把对应规则补进去。这是典型的“二八开”场景前两条规则大概能覆盖八成图片剩下两成补规则的成本急剧上升收益却很小。有个例外情况值得单独说如果原图本身就比缩略图小你的放大操作会拿到 404。因为服务器上根本没有那个尺寸的文件。所以upgradeUrl返回的地址最好在打开前做一次可用性检查——但window.open之前做异步检查会丢失“用户手势”上下文导致弹窗被拦。折中方案是不检查直接开如果 404 了用户退回原页面再点一次按钮手动点“复制图片地址”。我在按钮上加了一个contextmenu监听右键按钮直接复制地址到剪贴板算是给这种情况留了个后门。5.2 别让样式污染页面.ivr-host那条规则用了!important这在我的代码里是唯一一处。原因是搜索结果页的样式表非常“霸道”大量使用高优先级的定位规则不加!important经常被覆盖导致按钮跑到页面左上角去。但!important是把双刃剑——它会让这条规则在层叠里几乎无法被覆盖。所以我把它限制在最小的范围只影响position这一个属性而且只在带ivr-host类的节点上生效。ivr-前缀的作用是避免和页面原有类名撞车这个前缀策略是所有注入式扩展都应该遵守的基本规矩。还有一个更隐蔽的污染我最初给按钮设的是all: unset想清掉浏览器默认样式。结果按钮的字体、行高全丢了和页面风格完全不搭。后来改成显式声明font-family: inherit和line-height: 1.5让它继承页面的排版风格观感自然得多。5.3 一个顺手的键盘增强按钮加好之后我给自己又补了一个功能按Shift 点击直接复制大图地址不打开新标签页。btn.addEventListener(click, async (e) { e.preventDefault(); e.stopPropagation(); const url resolveTargetUrl(img); if (!url) return; if (e.shiftKey) { try { await navigator.clipboard.writeText(url); btn.textContent 已复制; setTimeout(() { btn.textContent 查看图片; }, 1200); } catch (err) { btn.textContent 复制失败; } return; } window.open(url, _blank, noopener,noreferrer); });navigator.clipboard.writeText在扩展的 Content Script 里是能用的但有两个前提页面必须是安全上下文HTTPS且调用时要有用户手势。Shift 点击恰好满足这两个条件所以很稳。复制成功后把按钮文字改成“已复制”1.2 秒后改回来这个反馈很轻但很有用——不然你点了之后不知道到底成没成。6. 排坑实录那些让我熬夜的问题6.1 问题速查表这张表是我自己还有几个同事遇到的问题汇总按“症状 → 可能原因 → 处理方式”组织遇到问题可以直接对着查症状最可能的原因处理方式按钮完全不出现matches没匹配上当前地址在 Console 敲location.href比对模式按钮出现但页面滚动后新图没有没监听 DOM 变更或监听了但没节流检查MutationObserver是否挂上同一张图叠了多个按钮去重判断写成了querySelector找自身改用hasAttribute点按钮后原页面也跳走了只拦了click没拦mousedown两个都要stopPropagation打开的图很小几十 KB拿到的是缩略图地址补SIZE_RULES规则页面滚动明显掉帧扫描太频繁或全量扫document调大节流间隔缩小扫描范围刷新后扩展没生效改了manifest.json没重载扩展回管理页点重新加载按钮位置跑到页面角落宿主容器没有position: relative检查.ivr-host是否生效6.2 三条让我印象最深的心得第一条先确认数据在不在再决定要不要写代码。我最开始花了大半天时间研究“怎么找替代入口”甚至想过写个工具去模拟点击中间页再解析结果。后来在开发者工具里随手document.querySelectorAll(img).length发现页面上躺着好几十个imgsrc全是真实的大图地址。那一刻我意识到自己差点走了一条完全错误的路线。遇到“功能消失”类问题第一件事永远是打开开发者工具看一眼原始数据还在不在这个动作只要十秒钟却能省下大半天。第二条不要用class做选择器的主力。第一版我写的是.isv-r img和.Wf5APd img这类选择器当天能用。三天后我打开搜索页按钮全没了——因为它把带哈希的类名重新生成了。后来改成用>

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑