自制浏览器插件:实现网页内容高亮、自动整理收藏夹功能
以 Chrome 扩展 Manifest V3 为例,构建一个实用型插件:在网页上高亮选中的内容,并自动整理浏览器收藏夹。文章包含架构、权限、核心脚本与选项页示例。
目标与特性
- 网页内容高亮:选择文本后一键高亮并持久保存,页面再次打开自动恢复。
- 收藏夹整理:按域名分组、去重、排序,支持定时或手动触发。
- 配置可选:选项页开关行为,数据存储于
chrome.storage.local。 - 技术栈:
Manifest V3、content script、service worker、contextMenus、bookmarks与historyAPI。
架构与文件
manifest.json:声明权限、背景脚本、选项页。background.js:服务工作线程,处理菜单、消息、注入脚本与收藏夹整理。content.js:页面脚本,执行文本高亮与应用历史高亮。options.html/options.js:配置界面,管理开关与策略。
权限与 Manifest 配置
json
{
"manifest_version": 3,
"name": "Web Highlighter & Bookmark Organizer",
"version": "0.1.0",
"permissions": ["bookmarks", "storage", "scripting", "activeTab", "contextMenus", "history", "alarms"],
"host_permissions": ["<all_urls>"],
"background": { "service_worker": "background.js" },
"action": { "default_title": "Highlight & Organize" },
"options_page": "options.html"
}
页面脚本:高亮与恢复(content.js)
js
function getXPath(el){
let path=''
let node=el
while(node&&node.nodeType===1){
const siblings=node.parentNode?Array.from(node.parentNode.children).filter(n=>n.tagName===node.tagName):[]
const index=siblings.indexOf(node)+1
path='/' + node.tagName + '[' + index + ']' + path
node=node.parentElement
}
return path.toLowerCase()
}
function ensureStyle(){
if(document.getElementById('ext-highlight-style'))return
const style=document.createElement('style')
style.id='ext-highlight-style'
style.textContent='.highlight-ext{background:#ffeb3b;padding:0 2px;border-radius:2px}'
document.documentElement.appendChild(style)
}
function wrapSelection(){
const sel=window.getSelection()
if(!sel||sel.isCollapsed)return
const range=sel.getRangeAt(0)
const span=document.createElement('span')
span.className='highlight-ext'
range.surroundContents(span)
const payload={url:location.href,text:sel.toString(),xpath:getXPath(span),time:Date.now()}
chrome.runtime.sendMessage({type:'save-highlight',data:payload})
sel.removeAllRanges()
}
function wrapTextOccurrences(keyword){
if(!keyword||keyword.length<2)return
const walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT,null)
let node
let count=0
const limit=50
const lower=keyword.toLowerCase()
while((node=walker.nextNode())){
const text=node.nodeValue||''
const idx=text.toLowerCase().indexOf(lower)
if(idx!==-1){
const range=document.createRange()
range.setStart(node,idx)
range.setEnd(node,idx+keyword.length)
const span=document.createElement('span')
span.className='highlight-ext'
range.surroundContents(span)
count++
if(count>=limit)break
}
}
}
function applyHighlights(list){
ensureStyle()
list.forEach(h=>wrapTextOccurrences(h.text))
}
chrome.runtime.onMessage.addListener((msg)=>{
if(msg&&msg.type==='do-highlight'){ensureStyle();wrapSelection()}
if(msg&&msg.type==='apply-highlights'){applyHighlights(msg.data||[])}
})
背景脚本:菜单、注入与收藏夹整理(background.js)
js
chrome.runtime.onInstalled.addListener(()=>{
chrome.contextMenus.create({id:'highlight',title:'高亮选中文本',contexts:['selection']})
chrome.contextMenus.create({id:'organize',title:'整理收藏夹',contexts:['action']})
})
chrome.contextMenus.onClicked.addListener(async(info,tab)=>{
if(info.menuItemId==='highlight'&&tab&&tab.id){
await chrome.scripting.executeScript({target:{tabId:tab.id},files:['content.js']})
chrome.tabs.sendMessage(tab.id,{type:'do-highlight'})
}
if(info.menuItemId==='organize'){
await organizeBookmarks()
}
})
chrome.runtime.onMessage.addListener(async(msg,sender)=>{
if(msg&&msg.type==='save-highlight'){
const key='highlights:' + msg.data.url
const prev=await chrome.storage.local.get(key)
const list=prev[key]||[]
const exists=list.some(x=>x.text===msg.data.text)
const next=exists?list:list.concat([msg.data])
await chrome.storage.local.set({[key]:next})
}
})
chrome.tabs.onUpdated.addListener(async(tabId,changeInfo,tab)=>{
if(changeInfo.status==='complete'&&tab&&tab.url){
const key='highlights:' + tab.url
const prev=await chrome.storage.local.get(key)
const list=prev[key]||[]
await chrome.scripting.executeScript({target:{tabId},files:['content.js']})
chrome.tabs.sendMessage(tabId,{type:'apply-highlights',data:list})
}
})
async function organizeBookmarks(){
const opts=await chrome.storage.local.get(['groupByHostname','dedupeBookmarks','sortByLastVisit'])
const groupByHostname=opts.groupByHostname!==false
const dedupe=opts.dedupeBookmarks!==false
const sortByLastVisit=opts.sortByLastVisit===true
const tree=await chrome.bookmarks.getTree()
const list=[]
function walk(nodes){
nodes.forEach(n=>{if(n.url)list.push(n);if(n.children)walk(n.children)})
}
walk(tree)
const map=new Map()
list.forEach(b=>{
const u=new URL(b.url)
const host=groupByHostname?u.hostname:'Ungrouped'
if(!map.has(host))map.set(host,[])
map.get(host).push(b)
})
if(dedupe){
for(const [host,items] of map.entries()){
const seen=new Set()
map.set(host,items.filter(i=>{const k=i.url;if(seen.has(k))return false;seen.add(k);return true}))
}
}
let visits=new Map()
if(sortByLastVisit){
const urls=list.map(b=>b.url)
const hist=await chrome.history.search({text:'',maxResults:10000,startTime:0})
hist.forEach(h=>visits.set(h.url,h.lastVisitTime||0))
}
for(const [host,items] of map.entries()){
const root=await ensureFolder('By Domain')
const folder=await ensureSubFolder(root.id,host)
const sorted=sortByLastVisit?items.sort((a,b)=>((visits.get(b.url)||0)-(visits.get(a.url)||0))):items
for(const bm of sorted){
try{await chrome.bookmarks.move(bm.id,{parentId:folder.id})}catch(e){}
}
}
}
async function ensureFolder(name){
const others=await chrome.bookmarks.getTree()
const root=others[0]
const target=root.children.find(f=>!f.url&&f.title===name)
if(target)return target
const created=await chrome.bookmarks.create({title:name})
return created
}
async function ensureSubFolder(parentId,name){
const children=await chrome.bookmarks.getChildren(parentId)
const target=children.find(f=>!f.url&&f.title===name)
if(target)return target
const created=await chrome.bookmarks.create({parentId,title:name})
return created
}
chrome.alarms.onAlarm.addListener(async a=>{
if(a.name==='organize-bookmarks')await organizeBookmarks()
})
选项页示例(options.html / options.js)
html
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Extension Options</title>
<style>
body{font-family:sans-serif;padding:16px}
label{display:flex;align-items:center;gap:8px;margin:8px 0}
button{margin-top:12px}
</style>
</head>
<body>
<label><input id="auto" type="checkbox" /> 页面加载自动应用高亮</label>
<label><input id="group" type="checkbox" checked /> 收藏夹按域名分组</label>
<label><input id="dedupe" type="checkbox" checked /> 收藏夹去重</label>
<label><input id="sort" type="checkbox" /> 按最近访问排序</label>
<button id="save">保存</button>
<script src="options.js"></script>
</body>
</html>
js
const auto=document.getElementById('auto')
const group=document.getElementById('group')
const dedupe=document.getElementById('dedupe')
const sort=document.getElementById('sort')
const save=document.getElementById('save')
chrome.storage.local.get(['autoApplyHighlights','groupByHostname','dedupeBookmarks','sortByLastVisit']).then(v=>{
auto.checked=v.autoApplyHighlights===true
group.checked=v.groupByHostname!==false
dedupe.checked=v.dedupeBookmarks!==false
sort.checked=v.sortByLastVisit===true
})
save.addEventListener('click',async()=>{
await chrome.storage.local.set({
autoApplyHighlights:auto.checked,
groupByHostname:group.checked,
dedupeBookmarks:dedupe.checked,
sortByLastVisit:sort.checked
})
})
交互与使用
- 选中文本后右键选择"高亮选中文本",内容将被包裹并保存;再次打开页面自动恢复。
- 点击扩展图标菜单"整理收藏夹",按照域名分组并去重,选项页可开启排序与自动执行(可用
chrome.alarms)。 - 选项页可修改策略;数据均存储在
chrome.storage.local。
性能与局限
- 高亮恢复使用文本检索,若页面结构变化较大可能偏移;可结合
xpath与文本片段增强。 - 大量书签操作建议分批执行并设置简单的速率限制。
- 历史访问时间依赖
chrome.history,隐私模式与部分场景可能不可用。
常见坑与规约
- MV3 背景脚本为 Service Worker,需通过
scripting动态注入页面脚本。 - 跨域注入需在
host_permissions中声明,建议使用<all_urls>并谨慎控制。 - 书签根目录结构因浏览器与用户习惯不同,创建文件夹时需兜底处理。
- 文本高亮避免破坏交互元素节点,建议过滤
SCRIPT、STYLE、AUDIO、VIDEO等标签。
扩展方向
- 多样式高亮与侧边栏列表管理,支持删除与跳转定位。
- 书签分类增强:根据路径与关键词打标签,生成统计图表。
- 同步与备份:导出导入配置与高亮记录,结合云端存储。
总结
- 高亮与收藏夹整理是两个高频痛点,结合 MV3 API 可快速实现。
- 保持脚本职责清晰、数据结构简单,在选项页暴露必要开关,能兼顾易用与稳定。
- 后续可渐进增强精确恢复与智能分类,让插件在个人知识管理中持续发挥价值。