经常做技术调研的人应该都有这种经验:一个问题往往要在四五个不同的地方分别搜索,官方文档看一遍,社区帖子翻一遍,代码仓库的 issue 再查一遍,最后还要去博客平台碰碰运气。时间花了不少,结果往往是同一篇文章被重复看到好几遍。我就是受不了这个来回切标签的流程,才动手写了一个聚合检索的小工具,代号叫OneSearch。它做的事情很简单:只留一个搜索框,输入关键词以后,同时去多个目标源检索,把结果合并、去重、排序后统一展示。
这篇内容我打算把 OneSearch 从需求拆解到技术实现完整复盘一遍。如果你也有"多平台重复检索、结果分散、入口混乱"的痛点,或者想做一个类似的个人工具,这篇应该能帮你少走不少弯路。里面会涉及产品形态的取舍、架构设计、适配器模式、去重排序策略、前端交互细节,以及我实际运行几个月后踩过的坑。
1. 项目定位与需求拆解
1.1 最初的需求模型:为什么需要"一个搜索框"
做技术类内容时,我每天的检索路径基本是固定的:遇到一个报错,先去文档站确认 API 用法,再去技术社区看有没有人遇到类似问题,接着去代码仓库搜相关 issue,最后可能还要到博客站找更深入的分析文章。
这个流程有三个很明显的问题。
第一,入口太多。每个站点的搜索框风格、快捷键、翻页逻辑都不一样,切来切去非常容易断思路。第二,重复劳动。同一个问题在不同站点搜出来,结果大量交叉,经常是同一篇内容换个 URL 又出现一次,人工去重很费神。第三,排序标准不统一。有的站点按时间排,有的按热度排,有的按相关度排,落在同一个任务里根本没法横向对比。
所以 OneSearch 的核心需求模型其实只有三条:统一入口、聚合结果、去重排序。它不打算替代任何一个站点的完整搜索能力,只解决"我第一轮想看什么"的问题。这个定位很重要,因为一旦想多了,工具就会变成另一个搜索引擎,项目范围会迅速失控。
1.2 产品形态的取舍:Web 服务比浏览器插件更合适
需求清楚以后,摆在面前的是产品形态选择。我认真比较过四种方案。
| 形态 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 浏览器插件 | 调用方便,可读取当前页面上下文 | 需适配多个浏览器,权限和自动更新麻烦 | 重度单机用户 |
| 独立 Web 服务 | 设备无关,手机电脑都能用,前后端分离清晰 | 需要自己部署和维护 | 个人工具、团队小工具 |
| 命令行工具 | 轻量,适合脚本化调用 | 交互体验差,不适合日常浏览式检索 | 自动化流程 |
| 桌面客户端 | 启动快,系统集成好 | 打包分发成本高,跨平台工作量大 | 需要离线能力的场景 |
我最后选了独立 Web 服务,而且是跑在 localhost 或局域网里的那种轻量服务。原因很实际:我个人需要在手机和电脑之间切换使用,写代码的时候在电脑前,随手拿起手机也想查一下刚才搜过的东西。Web 服务天然跨设备,不用处理浏览器插件的多端兼容问题,后端逻辑也能独立演进。
这个选择还带来一个额外好处:因为前端只是一个单页,后端完全通过接口提供检索能力,后面想做命令行调用、自动化脚本接进来,都非常容易。
2. 技术选型与整体架构设计
2.1 前端尽量轻量化,不折腾框架
OneSearch 的前端我没有上重型框架,就用原生 HTML、CSS 加少量 JavaScript。原因有两个。
第一,这个页面本身不复杂。核心就是一个输入框、一个结果列表、一个设置面板。第二,我希望搜索结果几乎瞬时呈现,少一层框架就少一分加载和解析开销。实测下来,首屏从打开到可以输入,基本没有等待感。
前端的主要逻辑集中在几个点:输入防抖、请求合并、结果渲染和键盘导航。防抖用的是很简单的 300ms 延时,避免每次敲键都向后端发请求。请求合并则是把"用户连续输入但还没来得及执行搜索"的状态统一取消,只保留最后一次关键字请求。渲染层没有用虚拟滚动,因为我把每个源的结果上限做了限制,一次最多展示几十条,原生 DOM 操作完全够用。
交互上有一条很重要的原则:不要让用户等待所有请求完成才看到结果。后端是分批返回的,前端收到一个源的数据就先渲染一个区,用户会感觉到"搜一下,内容一条条出来",而不是转圈半天。
2.2 后端检索核心与调度机制
后端我选了 Python。选它不是因为性能最好,而是因为做网页解析、文本处理这套生态非常成熟,写适配器的效率很高。OneSearch 本质上是一个"检索调度器",它对性能的要求不在于单次请求多快,而在于并发调度和容错处理是否稳定。
每个检索源对应一个适配器,适配器向后端返回统一结构的结果。它的核心字段大概是这样的:
{ "source": "docs", "title": "页面标题", "url": "原始链接", "snippet": "摘要内容", "score": 0.96, "published": "2024-01-15" }后端收到前端的搜索请求后,会遍历所有已注册的适配器,并发发起检索。并发这里不是简单开几个线程就完事,我给每个源都设置了独立的超时时间,默认是 5 秒。这个时间非常关键:如果某个源的接口突然变慢,不能让它拖垮整个页面的响应。超时的源直接返回空结果,前端会有对应的提示。
同时,后端有一个全局的信号量,控制同时跑多少个源的请求,避免一次性把本机网络连接打满。信号量的默认值我设为 4,对于个人使用来说,已经能保证大多数场景的响应速度,同时不会让某个目标站点觉得你在猛烈抓取。
2.3 数据去重与排序策略
聚合检索最大的麻烦不是把结果凑到一起,而是把结果整理得干净、可读。去重我做了三层。
第一层是 URL 规范化。很多站点会给链接加跟踪参数、排序参数,有的还会把协议从 http 跳到 https。我会把这些无意义的参数去掉,再统一协议和域名大小写,最后对比 URL 是否重复。
第二层是标题归一化。有些内容被不同站点转载,URL 完全不同,但标题几乎一样。我把标题里的空格、标点、特殊符号统一删掉,再算相似度。标题完全相同的直接视为重复,编辑距离低于一个阈值的也标记为高度疑似重复。
第三层是来源优先级。如果两条结果疑似重复,但它们的来源权威度不一样,我会保留权重更高的那个源。比如同一个内容,官方文档的结果通常比博客转载更值得保留。
排序策略我用了一个非常朴素的加权公式:
score = 0.45 * authority_score + 0.35 * keyword_density + 0.20 * recency_score这里的authority_score是每个源预设的权重,比如官方文档是 1.0,社区帖子 0.8,博客 0.7。keyword_density是关键词在标题和摘要中的出现密度,recency_score则是一个随时间衰减的分数,越新的内容得分越高。三个系数根据实际使用体验调过几次,目前的配比比较均衡:相关性优先,但对过时内容有一定惩罚。
3. 核心模块实现与实操细节
3.1 检索源适配器的设计模式
所有检索源都通过同一个抽象接口对外提供服务。这样做的好处是:新增一个搜索源,不需要改动调度核心,只需要写一个适配器类并注册进去。
适配器的接口非常简单:
class SearchAdapter: name = "base" timeout = 5 def search(self, keyword: str, limit: int = 10) -> list[dict]: raise NotImplementedError返回的 list 里的每个 dict,就是我前面说的统一结构。具体实现里,不同源的接入方式千差万别。有的源有开放 API,直接发请求、解析 JSON 就行;有的源提供 RSS 订阅,把查询词拼进 RSS 地址,然后解析 XML;还有的源什么都没有,只能请求普通搜索页面,再用正则或解析库提取结果。
这里有一个重要的设计原则:适配器必须完全隔离异常。我在调度层统一捕获每个适配器的异常,某个源抛错了,只记录错误并返回空,绝不会让整个 OneSearch 挂掉。这个设计后来救了我很多次,因为有些站点的页面结构说改就改,解析代码很容易直接抛异常。
另外,每个适配器都要遵守基本的网络礼仪:自定义合理的 User-Agent,控制请求频率,遵守目标站点的 robots 约定。做聚合工具不是为了突破谁的限制,而是在公开合法允许的范围内提升自己的检索效率。
3.2 关键词高亮与结果摘要生成
搜索结果如果没有关键词高亮,扫起来会非常累。高亮处理里最容易被忽略的是安全问题。搜索结果里的文本来自外部站点,直接拼进 HTML 会有代码注入风险。我的处理方式是:先对整个字段做 HTML 转义,再在转义后的文本里定位关键词位置,包上<mark>标签。这样既能高亮,又不会引入可执行的脚本。
摘要生成比想象中麻烦。很多源给的结果里没有摘要字段,只有标题和链接,这时候我会尝试抓取目标页面的正文,取第一段文字作为摘要。如果正文太长,就按关键词出现的上下文窗口截取:找到关键词第一次出现的位置,往前截几十个字符,再往后截几十个字符,拼成一段有上下文意义的文字。
这里有个实操细节:截取摘要时要注意不要把一个完整的词截成两半,更不要在句子中间硬生生掐断。我目前的实现是截到最近的一个句号或者逗号附近,不够完整就把范围放宽一点,宁可摘要长一些,也不要读起来像断头文。
3.3 前端交互设计:结果页布局与快捷键
OneSearch 的前端交互,我刻意模仿了几种主流检索产品的优点,但去掉了它们各自槽点。布局上左边是主导航侧栏,显示已经接入的搜索源;中间是主结果区,每个结果卡展示标题、来源标签、摘要、时间和链接。来源标签用不同颜色区分,一眼就能看出这条结果来自哪里。
键盘操作这块值得单独说一下。输入框聚焦以后,按回车触发一次聚合搜索。结果列表加载出来后,可以直接用上下方向键在结果间移动光标,按回车在当前高亮的结果上打开新标签页。我还加了几个常用快捷键:Ctrl+K聚焦搜索框、Ctrl+J切换到下一个源分类、Esc清空当前搜索并回到初始态。
这些快捷键刚开始不觉得有多重要,但真正用了几天之后就会发现,鼠标移动的每一次停顿都在打断思路。键盘全流程操作,可以让"搜索-扫视-打开-再搜索"这个循环流畅很多。
3.4 本地部署配置与运行方式
OneSearch 的部署很简单,本地直接跑 Python 服务就行。我用的是标准库自带的 HTTP 服务和少量第三方库,所以环境准备成本不高。
项目结构大致是这样:
onesearch/ ├── adapters/ │ ├── __init__.py │ ├── docs_adapter.py │ ├── community_adapter.py │ └── blog_adapter.py ├── core/ │ ├── dispatcher.py │ ├── dedup.py │ └── scorer.py ├── static/ │ ├── index.html │ ├── app.js │ └── style.css ├── config.yaml └── app.py启动命令就一条:
pip install -r requirements.txt python app.py默认监听127.0.0.1:8080。如果要在手机等设备上用,需要把监听地址改成0.0.0.0,同时注意局域网防火墙设置。配置文件负责维护源列表、各源权重、超时时间、缓存时长等参数,改配置不用动代码。
4. 常见问题与排查技巧实录
4.1 部分源抓取不到内容,怎么排查
OneSearch 运行这么久,遇到最多的就是"某个源突然搜不出东西了"。现象很统一:其他源正常,就那一个源返回空。
排查顺序我总结成一套流程:
- 先用浏览器直接打开目标源的搜索页面,看是不是真的能搜出内容。
- 打开 OneSearch 的后端日志,看这个适配器有没有报错。
- 如果没有报错,翻代码里解析结果的部分,对比页面结构是不是变了。
- 如果页面结构变了,更新解析逻辑;如果没变,怀疑是请求频率或者来源 IP 被临时限制。
针对第 4 种情况,我给适配器加了连续失败熔断机制。某个源连续失败 3 次,自动熔断 30 分钟,期间不再发请求,避免被误判为恶意访问。熔断结束后会恢复,同时记录一次告警。这个机制上线以后,源状态变得非常稳定,至少不会因为一个源的问题影响整天的使用。
4.2 结果重复、广告和低质量内容如何过滤
聚合搜索必然会把不同源的重复内容带出来。除了前面说的 URL 和标题去重,我还维护了一条"低质量结果过滤规则"。
这条规则的核心是:对标题特别短的、摘要里有明显推广词和跳转参数的、发布时间明显异常的,直接降低权重或者干脆不展示。比如有些站点会输出"下一页""相关推荐"这种导航类结果,它们也会被当成搜索结果抓回来,必须过滤掉。我用了两个关键词黑名单和长长短短的规则模板,一开始黑名单写得很长,后来发现不如做一个简单分类器来得干净。
分类器的思路很朴素:把标题、URL、摘要拼成一条文本,提取特征,跑一个非常小的规则评分。评分低于某个阈值就丢弃。这个分类器不需要机器学习,几条 if 规则就可以覆盖九成以上的噪声。真正有用的经验是:过滤规则要写成"可解释"的,出了问题能一眼看出来是哪条规则干的,不要搞一堆不可调试的正则。
4.3 并发请求导致部分源响应超时
刚开始没有全局信号量的时候,前端一次搜索会同时向 8 个源发请求,本机网络连接立刻飙升。有些源对并发特别敏感,直接返回超时或者 HTTP 429。
我的解决办法是全局限制并发数。信号量设为 4,同时最多只有 4 个适配器在发起请求。其他适配器排队等待。这个量级对于个人使用已经完全够用,而且能明显降低源站的压力。
另外,缓存也帮了大忙。同样的关键词在短时间内被重复搜索,直接命中缓存,不用重新请求外部源。缓存时间是 10 分钟,超过就过期。如果某个源特别慢,我还会单独把它的时间调短,避免占用整个搜索窗口太久。
4.4 前端首屏体验优化
最初版本的前端是等所有源都返回完毕才渲染结果,结果就是等待时间等于最慢的那个源。后来改成流式渲染,哪个源先返回就先显示哪个。
实现上不算复杂:后端发起搜索后,把每个源的结果通过流式响应逐批推给前端。前端每收到一批数据,就追加到对应区域。这个改动让首屏时间从原来的 5 秒左右降到了 1 秒以内,实际体验提升非常大。
另一个细节是输入框的防抖。我见过很多聚合工具,输入一个关键词,浏览器立刻发起请求,一边输入一边搜,既浪费流量又让前端界面不断闪烁。OneSearch 里我把防抖时间设为 300ms,并且每次输入都取消掉上一次未完成的请求,只有停顿 300ms 之后的关键词才会真正触发搜索。
5. 使用体验沉淀与后续扩展
5.1 用了一段时间后的真实感受
OneSearch 稳定跑了几个月后,我对它的评价是"值得做,也值得维护"。
效率提升是很明显的。以前做一个技术方案调研,可能要花一两个小时在几个站之间反复切换;现在一个搜索框,几秒钟就能看到所有源的关键结果。去重排序之后,扫一眼基本就能判断哪些值得点进去细看。那种"翻开七八个标签页发现都是同一篇文章"的挫败感基本上没有了。
但也要说句公道话:这类工具的效率红利,很大一部分来自维护成本。源站点的接口和页面结构不会永远不变,适配器需要定期检查更新。这不是一劳永逸的东西,更像是在养一盆需要浇水的植物。对我这种喜欢折腾工具的人来说,这个维护过程本身也是乐趣;但如果只想要开箱即用的工具,市面上成熟的聚合搜索服务可能更合适。
5.2 后续值得继续做的三个方向
第一个方向是本地索引和搜索历史。现在每次搜索都要实时请求外部源,如果把历史搜索结果缓存到本地,构建一个轻量索引,后续相同或相似的关键词可以直接从本地快速出结果,外部源只在缓存过期时才重新请求。这样速度可以更快,源站压力也更小。
第二个方向是订阅式检索推送。有些关键词我会隔几天搜一次,比如某个软件的更新说明、某类技术方案的实践文章。与其每次都手动搜,不如让 OneSearch 定时执行搜索,把新增结果汇总成一个简单的"动态列表"。这本质上是一个定时任务加结果对比,逻辑不复杂,但日常价值很高。
第三个方向是个性化排序。目前权重是全局配置,后续可以记录用户点击习惯,看用户更倾向于打开哪个源的结果,然后自动调整个人权重系数。这样用久了之后,每个源的排序会更加贴近个人口味。
5.3 给想动手做类似工具的朋友一个建议
如果只让我说一条经验,那就是:第一版不要追求功能全,先把"一个搜索框、多个源、去重、排序"这条主线跑通。我当时的第一版一共只用了不到两天,很多细节都是后面慢慢补的。先解决最痛的那一步,剩下的功能都会在真实使用中自己浮现出来,比一开始就设计一堆界面和配置要靠谱得多。
我个人最大的体会也在这里:工具开发最怕的不是技术难点,而是需求边界不清楚。OneSearch 从诞生到现在,功能增加得很少,但每加一个功能都确实提升了日常使用效率。先把每天重复最多的流程自动化,你会发现这比任何花哨的功能都更实在。