资讯动态

图片搜索原图按钮消失:浏览器扩展恢复入口实践

发布时间:2026/10/29 16:00:26 来源:尧图企业网站定制
图片搜索结果页里那个查看图片的入口说没就没了。前一天还能点开侧栏直接跳到原图第二天打开发现预览里只剩分享和访问来源想看原尺寸得绕三四道弯。我一开始以为是缓存问题清了 Cookie、换了浏览器配置、甚至开了个全新的用户资料目录结果一样这不是我这边的问题是页面结构被改了。折腾了两天之后我干脆自己写了个小扩展把按钮补回去用下来发现网上已经有现成的开源版本装起来三分钟的事。这篇就把这件事从头拆一遍按钮为什么消失、扩展到底动了哪些手脚、装的时候哪几步最容易卡住、以及如果你想自己改一版该从哪儿下手。1. 按钮为什么会消失一次前端改版引发的连锁反应1.1 从点开就能看大图到只能右键另存最早期的图片搜索结果页逻辑非常朴素缩略图是一个链接链接指向一个内部跳转地址跳转地址里带着原图的 URL 参数点查看图片就是把这个参数解出来重定向过去。这个设计对用户最友好看到喜欢的图一次点击就能拿到全尺寸原图下载、参考、比对都方便。改版之后这个入口被收进了侧栏预览里再后来连侧栏里的按钮也没了只剩下访问按钮跳转到图片所在网页。对普通浏览用户来说差别不大但对需要频繁取原图的人——做设计的要找参考图、做内容要核对图片出处、做数据集要筛样本——这个改动直接让单张图的处理时间从两秒变成了二十秒一天下来几百张图效率损失非常直观。我不认为这是刻意限制更像是产品在权衡把流量留在站内和让用户直达原图这两件事。站内预览能带来更多停留和更多广告位直达原图则把用户送走了。产品选前者很正常。但用户也有用户的办法浏览器扩展就是最轻的那一种。1.2 消失的按钮背后DOM 结构与接口的三处变化想自己把按钮加回来得先搞清楚页面到底变了什么。我对比了新旧两版页面主要差异集中在三个地方第一处是结果卡片的 DOM 层级。老版本的缩略图外面套着一层链接链接上的查询参数里直接带着原图地址结构稳定、提取简单。新版本把图片信息塞进了更深的自定义属性里有些字段还是经过编码的 JSON 字符串需要先解析再取值。第二处是侧栏预览的渲染方式。老版本是整块 HTML 一次性插入新版本改成了按需挂载点开一张图才创建对应节点关掉就销毁。这意味着靠页面加载完成后扫一遍的脚本会完全抓不到侧栏里的元素。第三处是图片地址本身。缩略图用的是一套图片代理服务的地址域名和原图完全不同路径里带缩放参数。你不能直接把缩略图的src当成原图用那样拿到的永远是低分辨率版本。这三个变化叠加起来直接导致大量依赖固定选择器的脚本集体失效。不是某个扩展写得差是页面结构变了之后按老路径去找元素的逻辑全都走不通了。1.3 谁最需要把它找回来不是所有人都需要这个按钮。如果你只是偶尔搜张图看看多一次跳转完全能接受。但有几类场景这个按钮的价值很明显做视觉参考的设计岗一天要存几十上百张图每张多两步操作就是实打实的时间成本做内容校对的人需要确认图片原始尺寸和清晰度缩略图看不出问题做素材整理的人希望直接拿到原始文件而不是经过压缩的代理图做前端或者爬虫学习的人想看看图片搜索结果的真实数据结构长什么样。如果你属于其中任何一类花三分钟装个扩展是划算的。如果你只是想找回那个按钮的使用手感也值得因为改版后的交互路径确实变长了。2. 开源扩展的恢复思路不破解接口只补一层界面2.1 内容脚本注入的最小闭环这类扩展的原理其实非常朴素一句话说完在图片搜索页面加载时注入一段脚本脚本在每张结果图上补一个链接元素。它不修改页面原有的任何逻辑不拦截请求不替换响应只做加一个按钮这一件事。之所以强调这一点是因为很多人一听扩展就以为要逆向接口、要伪造请求头、要绕过什么验证。完全不需要。页面上的图片信息本来就摆在那儿浏览器渲染的时候已经把原图地址带过来了你要做的只是把它读出来、拼成一个可以点击的链接。一个能跑起来的最小闭环包含四样东西组成部分作用缺失后果manifest 清单告诉浏览器扩展叫什么、在哪些页面生效扩展根本加载不进去内容脚本扫页面、提取地址、插入按钮页面毫无变化样式表让按钮浮在缩略图上且不挡住原有点击区按钮错位或不可点匹配规则限定只在图片搜索页生效所有网页都被注入既慢又危险这个结构在主流浏览器的扩展规范里是通用的用到的是内容脚本content script能力。内容脚本运行在页面的 DOM 环境里能读写页面元素但和页面自己的 JavaScript 变量是隔离的这个隔离既是安全边界也意味着你不能直接调用页面内部的函数——不过我们也不需要。2.2 从缩略图反推原图地址的三种路径这是整个扩展里最核心的一步也是最容易写错的一步。原图地址通常藏在三个地方按可靠性从高到低排路径一链接元素的查询参数。缩略图外层通常包着一个链接链接指向站内跳转地址查询串里带一个类似imgurl的参数值就是经过编码的原图地址。这是最干净的来源取出来做一次解码就能用。缺点是参数名会随版本变化写死一个名字早晚会失效所以我一般会准备一个候选名单逐个尝试。路径二图片元素的自定义数据属性。有些卡片会把完整信息塞进>function extractOriginUrl(card) { const anchor card.querySelector(a[href]); if (anchor) { const candidates [imgurl, url, src, mediaurl]; let parsed; try { parsed new URL(anchor.href, location.origin); } catch (e) { parsed null; } if (parsed) { for (const key of candidates) { const raw parsed.searchParams.get(key); if (raw /^https?:\/\//i.test(raw)) { return decodeURIComponent(raw); } } } } const img card.querySelector(img); if (img) { const dataRaw img.getAttribute(data-info) || img.dataset.info; if (dataRaw) { try { const info JSON.parse(decodeURIComponent(dataRaw)); const found deepFindUrl(info); if (found) return found; } catch (e) { // 解析失败就继续走后面的路径 } } const src img.currentSrc || img.src; if (src) { try { const u new URL(src); for (const key of [url, src, original]) { const v u.searchParams.get(key); if (v /^https?:\/\//i.test(v)) return decodeURIComponent(v); } } catch (e) { // 忽略非法 URL } } } return null; } function deepFindUrl(node, depth 0) { if (depth 6 || node null || node undefined) return null; if (typeof node string) { return /^https?:\/\/.\.(jpg|jpeg|png|webp|gif)(\?|$)/i.test(node) ? node : null; } if (Array.isArray(node)) { for (const item of node) { const hit deepFindUrl(item, depth 1); if (hit) return hit; } return null; } if (typeof node object) { for (const key of Object.keys(node)) { const hit deepFindUrl(node[key], depth 1); if (hit) return hit; } } return null; }上面这段代码是示意版本选择器和属性名都需要按当前页面的实际情况调整。但它体现的思路是通用的多来源、逐级降级、失败即放弃。2.3 为什么用 MutationObserver 而不是 setInterval新手写这类脚本最常见的做法是setInterval(scan, 1000)每秒扫一次页面。能跑但问题不少页面静止时白白消耗 CPU滚动加载时扫描有延迟用户会看到按钮跳出来页面元素多的时候每次全量扫描都很重。正确做法是用MutationObserver监听 DOM 变化只在有新增节点时才触发扫描并且用requestAnimationFrame合并同一帧内的多次触发。这样既及时又不浪费。let pending false; function scheduleScan() { if (pending) return; pending true; requestAnimationFrame(() { pending false; scanCards(); }); } const observer new MutationObserver(scheduleScan); observer.observe(document.body, { childList: true, subtree: true });这里有个细节值得说subtree: true是必须的因为新卡片往往挂在某个深层容器里不观察子树就收不到通知。但观察整个body的子树在图片搜索结果页上触发频率相当高所以合并触发这一步不能省。还有一种情况要处理页面首次加载时脚本可能在结果还没渲染出来就执行了。这时候观察器本身能兜住后续变化但最好在初始化时主动扫一次避免第一次搜索没有按钮滚动一下才出现的观感问题。3. 三分钟装好从下载到点亮的完整操作链路3.1 拿到扩展源码后先看目录结构开源扩展的源码包通常是一个压缩包解压后应该能看到类似这样的结构image-viewer-restorer/ ├── manifest.json ├── content.js ├── style.css ├── icons/ │ ├── 16.png │ └── 48.png └── README.md判断能不能用先看manifest.json。打开它确认三件事manifest_version是 2 还是 3现在主流浏览器都要求 3v2 的包可能加载后被禁用、matches里的地址规则是否还匹配当前的图片搜索页、content_scripts里引用的文件名是否和目录里实际存在的文件一致。我见过不少人卡在第一步原因是压缩包解压后多套了一层目录真正含manifest.json的文件夹在下一层。加载的时候选错了层级浏览器会报清单文件缺失。这个错误提示很不直观第一次遇到容易懵。一个快速判断方法在文件管理器里打开解压后的目录如果看到的是一个同名文件夹而不是一堆散文件就再往里进一层。3.2 加载解压扩展的详细步骤以主流 Chromium 内核浏览器为例完整流程是这样打开浏览器在地址栏输入扩展管理页地址并回车。如果你不确定具体地址可以从菜单里找扩展程序或管理扩展入口。打开页面右上角的开发者模式开关。这个开关不开下面的按钮不会出现。点击加载已解压的扩展程序在弹出的目录选择框里选中包含manifest.json的那一层目录。加载成功后列表里会出现这个扩展确认它的开关是打开的。回到图片搜索页刷新一次。如果没有出现按钮再刷新一次或者关掉标签页重新打开。整个过程熟练的话不到一分钟第一次操作加上找入口、找文件三分钟足够。装完之后建议做一次验证随便搜一个关键词把鼠标移到任意一张结果图上看右下角有没有出现一个小按钮。有就说明生效了没有就进入下一节的排查。3.3 装完不生效的四种常见原因原因一匹配规则没覆盖当前页面。图片搜索可能有多个入口域名比如主站、地区站、移动版。扩展的matches只写了其中一个你从另一个入口进来就不生效。解决办法是打开扩展的清单文件把当前地址补进去然后在扩展管理页点一下重新加载。原因二扩展处于停用状态。有些浏览器在加载解压扩展后默认是关闭的或者在某些模式下会自动禁用。看一眼列表里的开关状态就知道。原因三页面结构变了选择器全部失效。这是最麻烦的一种。表现是扩展显示已启用但页面上就是没有按钮。这时候需要打开开发者工具看看实际的结果卡片用什么属性标记然后对照扩展里的选择器改。改完记得在扩展管理页点重新加载光刷新页面不够内容脚本是跟着扩展一起加载的。原因四和其他同类扩展冲突。如果你之前装过别的图片增强类扩展两个都往同一个位置插按钮可能出现样式打架或者互相覆盖的情况。先把旧的停掉再试。排查顺序我一般是从最简单的开始先确认开关再确认地址匹配最后才动选择器。反过来先改代码很可能白改一通。4. 自己动手写一个核心代码逐段拆解4.1 manifest 配置里最容易写错的三个字段如果你不满足于用现成的想自己改一版从清单文件开始。下面是一个可用的骨架{ manifest_version: 3, name: Image Origin Button, version: 1.0.0, description: 在图片搜索结果上补回查看原图的入口, content_scripts: [ { matches: [https://images.example-search.test/*], js: [content.js], css: [style.css], run_at: document_idle, all_frames: false } ], host_permissions: [https://images.example-search.test/*] }三个容易写错的字段run_at决定脚本注入时机。默认是document_idle意思是等页面基本加载完再注入。如果设成document_start脚本会在 DOM 还没建好时执行这时候document.body可能是null观察器直接报错。除非你明确知道自己在做什么否则保持document_idle。all_frames默认是false也就是只在顶层页面注入。图片搜索有时候会把内容放在 iframe 里如果你的按钮不出现可以试着把它改成true。但这会带来性能开销因为每个子框架都会跑一遍脚本所以只在确认需要时才开。host_permissions和matches是两个东西。前者是权限声明后者是注入范围。只写matches不写权限读取页面内容可能受限只写权限不写matches脚本根本不会注入。两个都要写而且域名要一致。4.2 定位结果卡片的选择器策略选择器写得好不好直接决定扩展能活多久。我的原则是优先找语义化的、和业务含义绑定的属性避开自动生成的哈希类名。自动生成的类名长这样.aBcDeF、.x1y2z3。这类名字每次发版都可能变写死必死。相对稳定的标记有三类>const PRIMARY [data-item-kindimage]; const FALLBACK div:has( a img); function collectCards(root) { let nodes root.querySelectorAll(PRIMARY); if (nodes.length 0) { nodes root.querySelectorAll(FALLBACK); } return Array.from(nodes); }FALLBACK里用了:has()这个选择器在较新的浏览器里都支持写起来比逐层遍历清爽得多。如果你的目标用户可能用老版本浏览器那就改成手动遍历判断。还有一个容易忽略的点结果卡片可能是嵌套的。你选的容器如果太外层一个卡片里可能包含好几张图太内层又会漏掉部分结构。判断标准很简单——用这个选择器在控制台里查一次看看查出来的数量和页面上肉眼可见的图片数量是否对得上。4.3 注入按钮与事件处理的代码骨架核心逻辑不长关键是几个防重复和防出错的判断const BTN_CLASS origin-view-btn; const PROCESSED data-origin-btn-done; function scanCards() { const cards collectCards(document); let added 0; for (const card of cards) { if (card.hasAttribute(PROCESSED)) continue; const url extractOriginUrl(card); if (!url) continue; card.setAttribute(PROCESSED, 1); card.style.position card.style.position || relative; card.appendChild(buildButton(url)); added; } return added; } function buildButton(url) { const a document.createElement(a); a.className BTN_CLASS; a.href url; a.target _blank; a.rel noopener noreferrer; a.textContent 查看图片; a.addEventListener(click, (e) { e.stopPropagation(); }); return a; }几个要点解释一下。PROCESSED属性是防重复插入的关键。没有它观察器每触发一次扫描同一张图上就会多一个按钮刷几次页面就叠了一排。加了这个标记处理过的卡片直接跳过。e.stopPropagation()是为了阻止点击事件冒泡到卡片本身。卡片上通常绑着打开预览的逻辑不阻止的话你点查看图片会同时触发预览弹层体验很怪。relnoopener noreferrer是安全习惯。新标签页打开外链时不加这个属性目标页面可以通过window.opener拿到原页面的引用做点不该做的事。虽然这里的原图地址大多可信但习惯要养成。target_blank也有个权衡新标签打开会把标签越开越多同标签跳转又会丢掉搜索结果页。我个人的偏好是新标签因为搜图是个来回比对的过程保留结果页更有用。你可以根据自己的习惯改。4.4 样式隔离与悬浮层定位按钮插进去了还得让它看得见、点得着、不挡事。样式上要解决三个问题.origin-view-btn { position: absolute; right: 8px; bottom: 8px; z-index: 20; padding: 3px 8px; font-size: 12px; line-height: 18px; color: #fff; background: rgba(0, 0, 0, 0.62); border-radius: 4px; text-decoration: none; opacity: 0; transition: opacity 0.15s ease; pointer-events: auto; } *:hover .origin-view-btn, .origin-view-btn:focus-visible { opacity: 1; }第一定位基准。position: absolute要求父元素是position: relative而页面原始卡片的定位方式不确定。所以上面代码里在插入前主动把卡片的position补成relative如果它原本不是定位元素的话。这一步不做按钮会跑到离它最近的定位祖先那里去位置完全失控。第二层级。z-index要足够高否则会被卡片上的遮罩层盖住看得见但点不着。20 是个经验值大部分情况下够用实在不行就往上加。第三默认隐藏、悬停显示。图片搜索结果页上的缩略图本来就密每个角上都挂个按钮会很乱。用opacity控制显示鼠标移上去才出现视觉上干净很多。这里用opacity而不是display: none因为后者会导致元素不参与布局鼠标移上去的判定区域会变化容易出现移上去就闪走的抖动。还有一点如果你的按钮是用::after伪元素做的就没法绑定点击事件。必须用真实元素。这条踩过坑的人应该都有印象。5. 实测踩坑记录动态加载、路由切换与选择器失效5.1 滚动加载时按钮只出现一半第一版脚本跑起来之后遇到一个很典型的现象首屏的图都有按钮往下滚动加载出来的新图有一半没有。打开控制台看观察器确实在触发但扫描的时候新卡片还没渲染完。原因是滚动加载的时序容器先插入一个占位骨架图片信息是异步填进去的。扫描在骨架阶段就跑了extractOriginUrl拿不到地址直接跳过然后这张卡片被标记为处理过了吗没有因为我在拿不到地址时就continue了没打标记。按理说下次扫描会重新处理但问题在于——骨架填充完成之后如果不产生新的 DOM 节点观察器不会触发。解决办法有两个。一是在提取失败时不打标记同时加一个延迟重试队列把失败的卡片收集起来几百毫秒后再扫一次。二是在观察器回调之外监听滚动事件做节流扫描。我选了前者因为对页面性能影响更小。const retryQueue new Set(); function scanCards() { const cards collectCards(document); for (const card of cards) { if (card.hasAttribute(PROCESSED)) continue; const url extractOriginUrl(card); if (url) { card.setAttribute(PROCESSED, 1); card.style.position card.style.position || relative; card.appendChild(buildButton(url)); retryQueue.delete(card); } else { retryQueue.add(card); } } } setInterval(() { if (retryQueue.size 0) return; for (const card of Array.from(retryQueue)) { if (!card.isConnected) { retryQueue.delete(card); continue; } const url extractOriginUrl(card); if (url) { card.setAttribute(PROCESSED, 1); card.style.position card.style.position || relative; card.appendChild(buildButton(url)); retryQueue.delete(card); } } }, 800);注意card.isConnected这个判断。被移出文档的卡片如果一直留在队列里会越积越多形成内存泄漏。每次重试前检查一下它还在不在页面上不在就清掉。5.2 站内切 Tab 后扩展失忆图片搜索页上通常有几个分类标签切换标签时页面不会重新加载而是把结果区整个换掉。这时候老节点被销毁新节点插入观察器能收到通知理论上没问题。但实测发现切换之后按钮一个都没有。排查了半天发现是两个原因叠加。一是新结果区用的是另一套 DOM 结构类名和属性都不一样主选择器匹配不上。二是切换标签时的节点替换是先清空再插入清空和插入之间有个极短的窗口扫描刚好落在这个窗口里什么也没抓到。第一个问题靠扩充选择器解决我把PRIMARY改成了一个属性选择器列表只要能匹配上任意一种就算。第二个问题靠延迟解决观察器触发后不立即扫描而是等一个requestAnimationFrame让浏览器先把这一批 DOM 操作处理完。let rafId null; const observer new MutationObserver(() { if (rafId ! null) return; rafId requestAnimationFrame(() { rafId null; scanCards(); }); });5.3 选择器失效后的排查链路这是这类扩展最常见的故障也是最值得掌握的一项技能。完整的排查链路我总结成下面几步按顺序走基本能在十分钟内定位问题。第一步确认扩展是否真的在运行。打开开发者工具切到控制台输入扩展里定义的一个全局变量名比如typeof scanCards。如果是undefined说明脚本没注入问题在清单配置或者扩展开关不在选择器。第二步确认选择器能否匹配到元素。在控制台执行document.querySelectorAll(你的选择器).length。返回 0 就是选择器失效了返回正常值就继续往下查。第三步确认原图地址能否提取。手动选中一张卡片的 DOM 节点在控制台里把它赋给一个变量然后跑一遍extractOriginUrl。返回null说明提取逻辑要改返回地址就继续往下。第四步确认按钮是否被插入但看不见。在控制台执行document.querySelectorAll(.origin-view-btn).length。如果数量正常但肉眼看不到是样式问题检查z-index、opacity、定位基准。第五步确认按钮是否被插入但被覆盖。用开发者工具的元素检查器选中按钮看它的盒模型位置。如果位置在可视区之外说明定位基准错了如果位置正确但点不到说明有透明层盖在上面。这套流程的价值在于它把不生效这个模糊的现象拆成了五个可以独立验证的环节每一步都有明确的判断依据。比漫无目的地改代码高效得多。5.4 权限与安全别让扩展要到它不需要的东西最后说一个容易被忽视但很重要的问题。开源扩展装之前建议看一眼清单文件里申请了哪些权限。一个只做补按钮这件事的扩展需要的权限非常有限目标页面的访问权限加上内容脚本注入。如果它的清单里申请了读取所有网站数据、标签页管理、下载管理、历史记录那就要打个问号了——这些权限和它的功能不匹配。同理你自己写扩展的时候也别图省事直接写通配符。把matches收窄到具体的路径前缀既减少误注入带来的性能损耗也降低潜在风险。内容脚本运行在别人的页面上能读到页面里的所有内容这个能力不应该被滥用。另外加载解压扩展意味着代码不会自动更新。开源项目修了 bug 你也不知道得手动拉新版本重新加载。如果你打算长期用记得隔一段时间去看看仓库有没有更新。6. 扩展做完之后还能怎么改几个实用方向6.1 支持多搜索引擎的抽象层现在这个版本只针对一个图片搜索站点。如果你同时用好几个搜索引擎可以把提取逻辑抽成配置const ADAPTERS [ { name: site-a, match: (h) h.includes(images.example-a.test), card: [data-item-kindimage], extract: extractFromQueryParam }, { name: site-b, match: (h) h.includes(image.example-b.test), card: .result-item, extract: extractFromDataAttr } ]; function pickAdapter() { return ADAPTERS.find((a) a.match(location.hostname)) || null; }这样加一个新站点只需要往数组里加一条配置不用改核心逻辑。写的时候注意把每个站点的提取函数独立出来因为不同站点的数据结构差异可能很大硬塞进一个函数会变成一团乱麻。6.2 快捷键与大图预览补回按钮只是第一步用久了你会发现还有优化空间。比如加一个快捷键鼠标悬停在某张图上时按某个键直接在新标签打开原图连移动鼠标去点按钮都省了。再比如做一个轻量的浮层预览点按钮时不直接跳转而是在当前页面上盖一层把原图加载进来看看尺寸和清晰度确认要用了再下载。这个功能对筛图的场景特别有用能省掉大量来回切换标签页的操作。实现上要注意的是浮层预览加载大图可能很慢最好加个加载状态提示并在关闭时把img.src清空避免大图一直占着内存。6.3 打包分发与版本维护如果你改得比较顺手想分享给别人用有两条路。一是打包成扩展安装包别人下载后拖进扩展管理页就能装二是直接发源码目录让别人用加载已解压的方式装。前者体验好后者更新方便看你面向的是什么人。版本维护上有几个习惯建议养成清单文件里的version每次改动都递增方便排查用户装的是哪个版本改动选择器之后在多个账号、多种窗口宽度下各测一遍因为响应式布局可能导致某些宽度下 DOM 结构不同在 README 里写清楚适用范围和已知问题能省掉大量重复答疑。我自己维护的版本里还加了一个小机制把提取失败的卡片数量打到控制台。平时不看一旦发现失败率突然升高就知道页面结构又变了该去更新选择器了。这个信号比等用户反馈要快得多。写这类小工具最大的体会是它的价值不在于技术有多复杂而在于把一件每天要重复几十次的操作压缩成一次点击。我前后花在这上面的时间大概两个晚上但用起来的收益早就覆盖了。如果你也遇到类似的界面改动让你别扭不妨先搜搜有没有现成的开源方案没有的话自己动手写一个很多时候真的就是几十行代码的事。

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

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

免费获取报价 →
↑