☰
本地优先浏览器插件:打造中文AI情报聚合与推送系统
2026/10/20 18:00:30 网站建设 项目流程

1. 信息过载时代的个人情报站搭建思路

每天打开浏览器,光是各种技术社区、行业媒体、社交平台推送的AI相关资讯就够刷上大半个小时。更麻烦的是,大量优质内容还是英文的,中文圈子里真正有信息密度的讨论又散落在各个角落。我粗略统计过自己过去一个月的浏览记录,光是主动打开又关掉的资讯页面就有四百多个,真正值得细读的不到十分之一。这种状态下,我决定不再被动地被算法投喂,而是自己动手做一个本地优先的浏览器插件,让中文AI情报主动来找我。

这个项目的核心目标很明确:把分散在各处的AI相关中文内容聚合起来,在浏览器层面做一层轻量的信息过滤和推送,所有数据和处理逻辑都跑在本地,不依赖任何云端服务。它解决的不是“信息获取”的问题,而是“信息筛选”和“主动触达”的问题。适合那些每天需要跟踪AI领域动态、但又不想被信息洪流裹挟的开发者、产品经理和技术爱好者。哪怕你只会写一点JavaScript,也能跟着我的思路把这个东西搭出来。

为什么强调“本地优先”?因为资讯聚合类工具最让人担心的就是隐私和数据流向。你浏览了什么、对什么内容感兴趣、在哪些页面停留了多久,这些数据一旦上传到某个服务器,就完全脱离了你的控制。本地优先意味着所有抓取、解析、匹配、存储都在你的浏览器里完成,插件不需要任何后端接口,也不需要你注册账号。你甚至可以断网使用已经缓存的内容。这个选择在架构上会带来一些限制,比如无法做跨设备的同步,但换来的是完全的数据自主权,我认为这笔账是划算的。

在技术选型上,我对比了几种方案。用油猴脚本是最轻的,但它的生命周期管理比较弱,多个脚本之间容易冲突,而且UI定制能力有限。用Electron做一个独立应用倒是可以,但那样就脱离了浏览器的使用场景,每次还要单独打开一个应用,反而增加了操作成本。最终我选择了浏览器插件的形式,具体来说是Manifest V3规范下的扩展程序。MV3对后台脚本的限制比较严格,Service Worker不能常驻,但这反而逼着我用更事件驱动的方式去设计,减少了不必要的资源消耗。对于需要定时抓取的任务,可以用chrome.alarms API来替代setInterval,既符合规范又省电。

整个插件的功能模块可以拆成四层:内容采集层、解析过滤层、本地存储层和展示推送层。内容采集层负责从用户配置的源站拉取数据,这里我用的是RSS和公开API两种方式,RSS覆盖面广、格式统一,公开API则用于那些提供结构化数据的平台。解析过滤层是核心,它要把原始内容拆成标题、摘要、来源、时间戳、关键词等字段,然后根据用户设定的兴趣标签做匹配打分。本地存储层用IndexedDB来存,因为资讯数据量会随着时间增长,localStorage的5MB上限很快就不够用了。展示推送层则包括插件弹窗、新标签页替换和桌面通知三种形式,用户可以根据自己的习惯选择。

注意:Manifest V3的Service Worker在闲置约30秒后会被终止,所有依赖全局变量的状态管理都会失效。必须把状态持久化到chrome.storage或IndexedDB中,每次唤醒时重新读取。

这个架构最大的优势是解耦。每一层都可以独立替换,比如你不想用RSS了,换成爬虫解析某个网页,只需要改采集层,后面的逻辑完全不用动。过滤层的打分算法也可以随时调整权重,不影响其他模块。我在实际开发中就是先把采集和存储跑通,确认数据能稳定落库之后,再慢慢打磨过滤和展示的部分。这种渐进式的做法比一上来就追求大而全要靠谱得多,尤其是个人项目,保持每个阶段都能跑起来比什么都重要。

2. 核心细节解析与实操要点

2.1 内容源的选取与采集策略

内容源的质量直接决定了整个插件的信息价值。我一开始贪多,一口气配了二十多个源,结果发现每天抓回来的内容里有大量重复和低质信息,过滤层根本忙不过来。后来我做了减法,把源分成三个梯队:第一梯队是更新频率稳定、内容质量有保障的少数几个,每天必抓;第二梯队是更新不那么规律但偶尔有深度内容的,每两天抓一次;第三梯队是备选源,只在手动触发时才去拉取。这样既保证了日常的信息覆盖,又不会让本地数据库膨胀得太快。

具体到采集方式,RSS依然是最可靠的选择。它的结构固定,解析起来不费劲,而且大部分技术社区和独立博客都提供RSS输出。我用的是浏览器原生的DOMParser来解析XML,不需要引入额外的库。对于不提供RSS的源,我会先看它有没有公开的JSON接口,如果有就直接fetch。如果两者都没有,才会考虑用content script去解析页面DOM,但这种方式维护成本高,页面结构一变就得跟着改,所以我尽量不用。

采集频率的设置也有讲究。我试过每15分钟抓一次,结果发现很多源一天才更新一两篇,大部分请求都是白跑。后来改成根据源的历史更新规律来动态调整间隔:更新频繁的源间隔短一些,更新少的源间隔长一些。具体实现是在IndexedDB里给每个源存一个lastUpdated和averageInterval字段,每次抓取后更新这两个值,下次的抓取时间就根据averageInterval来计算。这个逻辑不复杂,但效果很明显,请求量降了大概六成。

// 动态计算下次抓取时间的核心逻辑 function calculateNextFetch(source) { const baseInterval = source.averageInterval || 3600000; // 默认1小时 const jitter = Math.random() * 0.2 * baseInterval; // 加20%以内的随机抖动 return Date.now() + baseInterval + jitter; }

加随机抖动是为了避免多个源在同一时刻集中请求,造成瞬时资源占用过高。这个细节在源数量少的时候感觉不出来,但当你配了十几个源之后,错峰的效果就很明显了。

2.2 中文内容的识别与关键词匹配

既然定位是“中文AI情报”,那就需要把中文内容从混合语种的信息流里挑出来。我用的判断逻辑比较简单:统计标题和摘要中中文字符的占比,超过30%就认为是中文内容。这个阈值是我试出来的,太低会把一些中英混合但以英文为主的内容误判进来,太高又会漏掉一些标题是英文但正文是中文的条目。实际跑下来,30%这个值在准确率和召回率之间平衡得比较好。

关键词匹配这块,我没有用复杂的NLP模型,而是采用了“关键词表+权重”的方式。关键词表分成核心词和扩展词两类。核心词是“大模型”“机器学习”“深度学习”“神经网络”这种指向性很强的词,命中一个就加较高的分。扩展词是“推理”“训练”“微调”“部署”这种在AI语境下常见但其他领域也可能出现的词,命中后加的分少一些。最终每条内容会得到一个匹配分数,分数超过阈值的才会进入展示队列。

关键词表不是写死的,我留了一个配置文件让用户可以自己增删。这个设计很重要,因为每个人的关注点不一样。有人关注AI绘画,有人关注语音识别,有人关注推荐系统,用一个固定的词表去覆盖所有人是不现实的。我自己的词表里就加了“扩散模型”“文生图”“语音合成”这些偏应用层的词,因为我的工作跟这些方向关系比较大。

实操心得:关键词匹配时要注意词边界问题。比如“Transformer”这个词,如果不做边界处理,“Transformers”也会被匹配到,虽然两者相关,但有时候用户可能只想看前者。我的做法是在匹配时检查前后字符是否为字母或数字,如果是则视为不匹配。这个细节能减少不少误报。

另外,我还加了一个简单的去重逻辑。不同源之间经常转载同一篇文章,如果不去重,展示列表里会出现大量重复条目。去重的方式是对标题做归一化处理(去掉标点、空格、大小写差异),然后计算SimHash值,相似度超过阈值的视为重复。SimHash的实现不复杂,网上有很多参考,我这里就不展开代码了。实测下来,去重能砍掉大约四分之一的内容量,展示列表干净了很多。

2.3 本地存储方案的选择与优化

存储方案我前面提到用IndexedDB,这里展开说一下为什么不用其他方案。localStorage的容量限制是5MB左右,对于纯文本的资讯数据来说,如果只存最近几天的内容,勉强够用。但我想保留至少一个月的的历史数据,方便回溯和搜索,5MB就捉襟见肘了。chrome.storage.local的容量大一些,但它的API是异步的,而且在大批量读写时性能不如IndexedDB。IndexedDB虽然API用起来麻烦一点,但它的容量上限高得多,而且支持索引和游标,做范围查询和分页很方便。

我在IndexedDB里建了两个对象仓库:一个存源配置,一个存资讯条目。资讯条目的主键用自增ID,同时给timestamp和sourceId建了索引,这样按时间范围查询和按来源筛选都能走索引,速度很快。每条记录大概占1-2KB,存一万条也就十几MB,完全在可控范围内。

// IndexedDB初始化与对象仓库创建 const DB_NAME = 'ai-intel-db'; const DB_VERSION = 1; function openDB() { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = (event) => { const db = event.target.result; if (!db.objectStoreNames.contains('sources')) { db.createObjectStore('sources', { keyPath: 'id', autoIncrement: true }); } if (!db.objectStoreNames.contains('articles')) { const store = db.createObjectStore('articles', { keyPath: 'id', autoIncrement: true }); store.createIndex('timestamp', 'timestamp', { unique: false }); store.createIndex('sourceId', 'sourceId', { unique: false }); store.createIndex('score', 'score', { unique: false }); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); }

清理策略也是必须的。我设置了一个上限,比如最多保留5000条记录,超过之后按时间从旧到新删除。这个清理动作放在每次抓取完成之后执行,用游标遍历索引,删够数量就停。不要一次性删太多,否则会阻塞主线程,影响插件的响应速度。我一般每次清理不超过200条,分多次完成。

2.4 展示层的交互设计与推送时机

展示层我做了三个入口:插件图标点击后的弹窗、新标签页的替换页面、以及桌面通知。弹窗适合快速浏览最新几条,新标签页适合坐下来仔细筛选,桌面通知则用于那些匹配分数特别高的内容,做到真正的“主动来找你”。

弹窗的设计尽量简洁,只显示最近24小时内匹配分数最高的10条,每条显示标题、来源和时间,点击直接在新标签页打开原文。底部有一个“查看更多”的链接,点击后跳转到完整的新标签页界面。这个设计参考了邮件客户端的未读列表,用最少的信息让用户快速判断哪条值得点开。

新标签页的界面就丰富一些,支持按来源筛选、按时间范围筛选、按关键词搜索,还有一个“已读/未读”的标记功能。已读状态存在IndexedDB里,跟条目记录关联。这个功能看起来简单,但实际用起来很提升体验,因为你可以清楚地知道哪些是已经看过的,哪些是新的。

桌面通知的触发条件我调了好几次。一开始设的是分数超过80就通知,结果一天弹了十几次,很快就烦了。后来改成两个条件同时满足才通知:分数超过85,且该来源在过去24小时内没有发过通知。这样既保证了高价值内容的及时触达,又避免了同一来源的连续轰炸。通知的点击行为也做了处理,点击后直接打开原文,同时把该条标记为已读,避免重复提醒。

注意:桌面通知需要用户明确授权,而且不能滥用。我的做法是在插件安装后的第一次运行时,用一个引导页面说明通知的用途,让用户自己决定是否开启。如果用户拒绝,后续就不再请求,只在弹窗里做红点提示。

3. 实操过程与核心环节实现

3.1 项目初始化与Manifest配置

动手的第一步是创建项目目录和manifest.json文件。Manifest V3的配置跟V2有不少差异,我踩过的坑主要集中在这几个地方:background脚本从page变成了service_worker,不能再引用DOM API;host_permissions和permissions的分离更加明确;web_accessible_resources的写法也变了。下面是我实际使用的manifest配置,去掉了跟这个项目无关的字段。

{ "manifest_version": 3, "name": "AI情报聚合器", "version": "1.0.0", "description": "本地优先的中文AI资讯聚合与推送工具", "permissions": [ "storage", "alarms", "notifications" ], "host_permissions": [ "https://*/*" ], "background": { "service_worker": "background.js" }, "action": { "default_popup": "popup.html", "default_icon": "icon.png" }, "chrome_url_overrides": { "newtab": "newtab.html" }, "icons": { "48": "icon.png", "128": "icon.png" } }

host_permissions我用了通配符,因为用户可能会添加任意来源的RSS地址,没法提前枚举。如果你对安全性要求更高,可以在用户添加源的时候动态申请权限,但那样交互会麻烦一些。我权衡之后选择了通配符方案,毕竟所有请求都是用户主动配置的,风险可控。

Service Worker的注册和事件监听是后台逻辑的入口。这里要注意,Service Worker随时可能被终止,所以所有的事件监听必须在顶层注册,不能放在异步回调里。我一开始把alarms的监听写在了IndexedDB初始化完成之后,结果经常出现闹钟触发了但监听没注册上的情况。后来改成在顶层直接注册,问题就消失了。

// background.js 顶层事件注册 chrome.alarms.onAlarm.addListener(handleAlarm); chrome.runtime.onInstalled.addListener(handleInstall); chrome.notifications.onClicked.addListener(handleNotificationClick); // 初始化闹钟 chrome.alarms.create('fetch-articles', { periodInMinutes: 30 });

3.2 抓取与解析的完整流程

抓取流程从闹钟触发开始,到数据写入IndexedDB结束。中间要经过源列表读取、逐个请求、内容解析、去重、打分、存储这几个步骤。我把每个步骤都拆成了独立的函数,方便单独测试和替换。

请求部分我用的是fetch API,配合AbortController做超时控制。超时时间设的是15秒,超过就放弃该次请求,记录错误但不影响其他源。错误处理很重要,因为有些源会偶尔抽风,如果不做隔离,一个源的失败可能会阻塞整个流程。

async function fetchSource(source) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 15000); try { const response = await fetch(source.url, { signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) throw new Error(`HTTP ${response.status}`); const text = await response.text(); return parseFeed(text, source); } catch (err) { clearTimeout(timeoutId); console.error(`抓取失败: ${source.url}`, err); return []; } }

解析RSS我用DOMParser,把XML字符串转成DOM树,然后遍历item节点提取字段。这里要注意命名空间的问题,有些RSS使用了content:encoded这样的带前缀标签,querySelector的时候需要转义冒号。我一开始没注意,导致部分源的内容摘要提取不出来,排查了好一会儿才发现是选择器写错了。

function parseFeed(xmlText, source) { const parser = new DOMParser(); const doc = parser.parseFromString(xmlText, 'text/xml'); const items = doc.querySelectorAll('item, entry'); const articles = []; items.forEach(item => { const title = item.querySelector('title')?.textContent?.trim() || ''; const link = item.querySelector('link')?.getAttribute('href') || item.querySelector('link')?.textContent?.trim() || ''; const pubDate = item.querySelector('pubDate, updated, published')?.textContent || ''; const description = item.querySelector('description, summary, content')?.textContent || ''; if (title && link) { articles.push({ title, link, sourceId: source.id, sourceName: source.name, timestamp: pubDate ? new Date(pubDate).getTime() : Date.now(), summary: stripHtml(description).slice(0, 300), score: 0, read: false }); } }); return articles; }

打分环节在解析之后、存储之前执行。每条内容根据标题和摘要中的关键词命中情况计算分数,同时结合来源的权重做一个加权。来源权重是我手动设置的,质量高的源权重高一些,这样同样内容从不同源进来,分数会有差异,展示时优质源的内容会排在前面。

3.3 去重与打分算法的实现细节

去重我用的是标题归一化加SimHash的方案。归一化就是去掉标点、空格,统一转小写。SimHash的计算过程是把文本分词后,对每个词计算哈希值,然后按位加权求和,最后降维得到指纹。两个指纹的汉明距离小于3就认为是重复内容。这个阈值也是试出来的,太小会漏掉一些改写过的重复内容,太大会把不相关的相似标题误判为重复。

function simhash(text) { const tokens = text.toLowerCase().replace(/[^\w\u4e00-\u9fa5]/g, ' ').split(/\s+/).filter(Boolean); const vector = new Array(64).fill(0); tokens.forEach(token => { const hash = stringHash(token); for (let i = 0; i < 64; i++) { vector[i] += (hash >> i) & 1 ? 1 : -1; } }); let fingerprint = 0n; for (let i = 0; i < 64; i++) { if (vector[i] > 0) fingerprint |= (1n << BigInt(i)); } return fingerprint; } function hammingDistance(a, b) { let xor = a ^ b; let count = 0; while (xor) { count += Number(xor & 1n); xor >>= 1n; } return count; }

打分算法的权重配置我放在了一个单独的config对象里,方便调整。核心词权重是10,扩展词权重是3,来源权重是0到5之间的一个系数。最终分数是关键词得分乘以来源权重系数。这个公式很简单,但实际效果不错,因为关键词的区分度已经足够高了。

const SCORE_CONFIG = { coreKeywords: ['大模型', '机器学习', '深度学习', '神经网络', 'Transformer', 'LLM'], extendedKeywords: ['推理', '训练', '微调', '部署', '数据集', '算法', '模型'], coreWeight: 10, extendedWeight: 3 }; function calculateScore(article, sourceWeight) { const text = article.title + ' ' + article.summary; let score = 0; SCORE_CONFIG.coreKeywords.forEach(kw => { if (text.includes(kw)) score += SCORE_CONFIG.coreWeight; }); SCORE_CONFIG.extendedKeywords.forEach(kw => { if (text.includes(kw)) score += SCORE_CONFIG.extendedWeight; }); return score * (sourceWeight || 1); }

实操心得:关键词匹配时,中文不需要考虑词边界,但英文关键词需要。我的做法是对英文关键词用正则加\b边界,中文关键词直接用includes。这个细节在混合语种的内容里很重要,能避免不少误匹配。

3.4 通知触发与用户反馈闭环

通知的触发逻辑我放在每次抓取完成之后。遍历新入库的条目,找出分数超过阈值且来源在冷却期外的,逐条发送通知。冷却期的实现是在内存里维护一个sourceId到lastNotifyTime的映射,但这个映射在Service Worker重启后会丢失。所以我把lastNotifyTime也存到了IndexedDB的源记录里,每次发送通知后更新,读取时从存储里取。

async function maybeNotify(articles) { const now = Date.now(); const COOLDOWN = 24 * 60 * 60 * 1000; for (const article of articles) { if (article.score < 85) continue; const source = await getSource(article.sourceId); if (source.lastNotifyTime && now - source.lastNotifyTime < COOLDOWN) continue; chrome.notifications.create({ type: 'basic', iconUrl: 'icon.png', title: article.title, message: article.summary.slice(0, 100), contextMessage: article.sourceName }); await updateSourceNotifyTime(article.sourceId, now); } }

用户反馈这块我加了一个简单的“有用/无用”按钮,放在弹窗的每条内容旁边。点击后把反馈存到IndexedDB里,后续可以用来调整关键词权重。比如某条内容被标记为“无用”,但它的分数很高,说明关键词匹配有误报,可以把这个案例记下来,手动调整词表。这个反馈闭环目前还比较粗糙,但已经能帮我发现一些明显的匹配问题了。

4. 常见问题与排查技巧实录

4.1 抓取失败与解析异常的排查

抓取失败是最常见的问题,表现是某个源连续多次没有新内容入库。排查的第一步是看控制台有没有报错,Service Worker的日志在扩展程序管理页面的“检查视图”里可以看到。常见的错误类型有几种:CORS跨域被拦截、源站返回了非XML格式的内容、超时、以及证书问题。

CORS问题在Manifest V3里比较微妙。虽然host_permissions声明了权限,但有些源站的响应头里没有正确的Access-Control-Allow-Origin,导致fetch被浏览器拦截。这种情况我试过用declarativeNetRequest来修改响应头,但配置起来比较麻烦。后来我的做法是优先选择那些CORS友好的源,实在需要的源就通过一个本地代理来转发,但代理方案会增加部署复杂度,我最终没有采用。

解析异常通常表现为条目数量为零或者字段缺失。我遇到过一次是因为源站返回的XML里包含了BOM头,DOMParser解析时把BOM当成了内容的一部分,导致根节点识别错误。解决办法是在解析前先去掉BOM字符。还有一次是源站用了CDATA包裹内容,querySelector的textContent取不到值,需要用CDATA节点的nodeValue来获取。

function stripBOM(text) { if (text.charCodeAt(0) === 0xFEFF) { return text.slice(1); } return text; }

4.2 存储膨胀与性能下降的应对

IndexedDB用久了之后,如果清理策略没做好,数据量会持续增长,导致查询变慢、内存占用升高。我遇到过的最严重的一次是数据库涨到了快200MB,新标签页打开要好几秒才能渲染出来。排查后发现是清理逻辑有个bug,删除操作没有正确提交事务,导致旧数据一直堆积。

修复之后我加了一个监控机制,每次抓取完成后检查数据库的总记录数,超过阈值就触发清理。同时在新标签页的渲染上做了分页,默认只加载最近100条,滚动到底部再加载更多。这个改动让首屏渲染时间从两三秒降到了两百毫秒以内。

问题现象可能原因排查方法解决方案
新标签页加载缓慢数据量过大,一次性查询全部记录在DevTools的Application面板查看IndexedDB大小加分页,限制单次查询数量
抓取后无新内容源站改版或RSS地址失效手动访问源地址确认是否正常更新源地址或移除失效源
通知不触发Service Worker被终止,闹钟未注册检查扩展程序页面的Service Worker状态确保alarms在顶层注册
关键词匹配误报多词表过于宽泛或缺少边界处理查看高分但实际不相关的内容调整词表,英文词加边界
去重效果差SimHash阈值设置不当统计重复内容的比例调整汉明距离阈值

4.3 跨浏览器兼容性的取舍

我最初想同时支持Chrome和Firefox,但实际做下来发现兼容成本比预期高。Firefox对Manifest V3的支持还在完善中,有些API的行为跟Chrome不一致,比如alarms的最小间隔、notifications的图标要求等。我试过用webextension-polyfill来做适配,但Service Worker在Firefox里的表现跟Chrome差异较大,调试起来很费时间。

最终我决定先只支持Chrome系浏览器,把功能做稳定。如果你用的是Edge或Brave,因为都是Chromium内核,直接加载解压的扩展程序就能用。Firefox用户如果确实需要,可以等后续版本再考虑适配。这个取舍让我省下了大量兼容性调试的时间,把精力集中在了核心功能的打磨上。

注意:在Chrome里加载未打包的扩展程序时,每次修改代码都需要在扩展管理页面手动点击刷新,而且Service Worker的缓存有时候会导致旧代码继续运行。我的习惯是修改后先点刷新,然后在Service Worker的检查视图里执行一次“跳过等待”或者直接注销再重新注册,确保新代码生效。

4.4 实际使用中的体验优化记录

用了一段时间之后,我根据实际体验做了几处优化。第一处是弹窗的加载速度,原来每次打开弹窗都要重新查询IndexedDB,后来加了一层内存缓存,把最近查询的结果暂存起来,弹窗打开时先显示缓存,后台再异步更新。这个改动让弹窗的响应快了很多,几乎感觉不到延迟。

第二处是通知的点击行为。原来点击通知只是打开原文,但不会标记已读,导致同一条内容可能被重复通知。后来在通知的创建参数里加了一个notificationId,跟文章ID关联,点击时根据ID找到对应记录并标记已读,同时更新来源的lastNotifyTime,避免重复。

第三处是关键词的自动学习。我加了一个简单的机制:如果用户对某条内容点击了“有用”,就把该内容标题中出现频率高但不在词表里的词提取出来,提示用户是否加入扩展词表。这个功能还在试验阶段,但已经帮我发现了好几个之前没注意到的关键词。

// 从已标记为有用的内容中提取候选关键词 function extractCandidateKeywords(articles) { const freq = {}; articles.forEach(article => { const words = article.title.split(/[\s,,。、]+/).filter(w => w.length >= 2); words.forEach(w => { freq[w] = (freq[w] || 0) + 1; }); }); return Object.entries(freq) .filter(([word, count]) => count >= 3 && !isInWordList(word)) .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([word]) => word); }

这个提取逻辑很粗糙,但对于个人使用来说够用了。它不需要理解语义,只是统计词频,然后把高频的新词推荐给用户。用户确认后加入词表,下次匹配时就能覆盖到这些新词。整个过程是本地完成的,不涉及任何外部服务。

最后再分享一个小技巧:如果你也想做类似的本地优先工具,建议把配置和数据的导入导出功能做好。我用JSON格式做了一键导出,换电脑或者重装浏览器时,导入配置文件就能恢复所有源和词表,省去了重新配置的麻烦。这个功能实现起来很简单,但实际用起来非常省心。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询