浏览器书签栏塞了四百多条链接,找一份三个月前存的技术文档,得靠翻历史记录碰运气;聊天窗口里同事甩过来的一串 URL,当时想着"待会儿再看",回头就淹没在消息流里。这个场景我忍了太久,Furl 就是我为了解决它写的一个小工具。名字取自 friendly URL 的缩写,定位朴素到有点无聊:把散落在浏览器、聊天记录、备忘录里的链接收进一个地方,能打标签、能搜、能去重、能导出,仅此而已。它不是一个云端收藏平台,也不做社交化分享,就是一个跑在本地、双击即用的 URL 管理工具。
我写它的初衷很具体——不是要做一个"更好的书签管理器",而是要把"记录链接"这个动作的成本压到最低。成本一高,人就懒得记;懒得记,收藏夹就会退化成垃圾场。所以 Furl 在设计上做了大量减法:不加账号体系,不做推荐算法,不搞花哨的卡片视图,命令行的添加动作必须在一秒内完成。这篇文章会把这套东西从设计取舍、数据结构、核心算法,一路讲到实际敲代码时踩的坑,适合想自己动手做一个个人工具的后端开发者、喜欢折腾效率工具的读者,以及任何被书签栏折磨过的人。文中涉及的代码片段和参数选择,都是我在自己机器上跑过、用了大半年的版本,可以直接抄。
1. 项目整体设计与思路拆解
1.1 为什么现成的书签管理满足不了我
先说清楚一件事:市面上不缺收藏工具。浏览器自带书签、稍后读类应用、笔记软件的网页剪藏,功能都比 Furl 强。但我用下来的共同问题是——它们把"管理"做成了"负担"。打开一个收藏应用,先要面对一堆分类目录、智能推荐、热门标签,添加一条链接要经历弹窗、选目录、填标题、点保存四步,一步慢了用户就放弃。我更想要的是在终端里敲一行furl add <链接>,回车,完事,剩下的整理工作交给程序去做。
还有一个更硬的需求:数据必须在我自己手里。很多收藏工具的核心资产是"你的收藏列表",格式不透明,迁移困难。我需要的是纯文本或标准数据库文件,随时能grep,随时能打包带走。Furl 从第一天起就把存储格式定成 SQLite 单文件加一份可选的 JSONL 导出,任何一个懂点 SQL 的人都能直接操作它,工具本身坏了也不影响数据。
再往下拆,其实我真正需要的是三件事:快速录入、可靠去重、精确检索。录入要快,是因为人的收藏冲动只持续几秒;去重要准,是因为同一个页面往往有十几个带跟踪参数的变体;检索要灵,是因为我记不住标题,只记得"那个讲缓存穿透的"。Furl 的所有设计都围绕这三点展开,其他功能一律视为锦上添花,能砍就砍。
1.2 功能边界:明确不做哪些事
做个人工具最容易犯的错是功能膨胀。我给自己定了一条规矩:任何新功能,如果不能在三十秒内向别人解释清楚它解决了什么痛点,就不做。按这条规矩,Furl 明确不做以下几件事。
不做网页全文抓取。抓正文意味着要处理反爬、编码、动态渲染,维护成本极高,而且大部分时候我只需要标题和摘要,正文交给浏览器。不做多端实时同步,个人使用场景下,一个放在同步目录里的数据库文件已经够用,自己搭一套同步协议纯属自找麻烦。不做短链生成服务,那是另一个方向的需求,Furl 只负责记录,不负责美化。不做复杂的权限和分享,本地单人使用,没有协作者,权限模型就是文件系统权限。
这条边界线划下来之后,整个项目的体积控制得非常好:核心代码两千行出头,依赖只有三个——一个命令行参数解析库、一个 SQLite 驱动、一个 HTTP 客户端。依赖少的好处是升级不会崩,我见过太多小工具因为某个间接依赖大版本跳变而直接跑不起来。
1.3 技术选型背后的权衡
语言选了 Python,理由不是"生态好"这种空话,而是迭代速度。个人工具的生命周期里,改需求的时间远多于写代码的时间,动态语言的即时反馈在这个场景下价值极高。性能方面,管理几万条 URL 的记录,Python 的解释开销完全不是瓶颈,真正的耗时在磁盘 IO 和网络请求上。
存储用 SQLite 而不是纯 JSON 文件,这个决定我犹豫过。JSON 的优点是肉眼可读、直接用文本编辑器改;缺点是每次写入都要全量序列化,几万条记录时写一次要几百毫秒,而且并发写有覆盖风险。SQLite 单文件、零配置、支持事务和索引,还能用 FTS5 做全文检索,几乎是这个场景的最优解。代价是可读性下降,我用一个furl export命令补上了这个缺口——需要看的时候导出一份 JSONL,不需要的时候让数据安静地躺在二进制文件里。
搜索方案上,我先试过自己写倒排索引,几十行代码就能跑,但中文分词、前缀匹配、排序打分的细节越写越多,最后一算,还不如直接用 SQLite 自带的 FTS5。它内置了 unicode61 分词器,配合 trigram 模式可以对中文做子串匹配,性能足够。这个取舍的教训是:能用成熟组件的地方不要自己造,把省下来的时间花在真正独特的业务逻辑上。
2. 核心细节解析:从 URL 解析到标签体系
2.1 URL 规范化:去重的第一道关口
去重这件事,看起来是"字符串相等就合并",实际做起来坑非常多。同一个页面,常见的变体至少有这些:带utm_source等跟踪参数、http和https混用、域名大小写不一致、末尾多一个斜杠、锚点不同、参数顺序被打乱。如果不做规范化,收藏夹里会出现五六条指向同一个页面的记录,去重形同虚设。
我的规范化流程分五步,顺序不能乱:先补全协议,再统一小写,然后剥离跟踪参数,接着对剩余参数排序,最后处理末尾斜杠。补全协议必须放在最前面,否则urlsplit解析出来的 hostname 会是空的,后面全错。参数排序这一步容易被忽略,?a=1&b=2和?b=2&a=1语义相同,但字符串不同,排序之后才能保证哈希一致。
TRACKING_EXACT = {"fbclid", "gclid", "yclid", "msclkid", "igshid", "spm", "scene"} TRACKING_PREFIX = ("utm_", "share_", "ref_") def normalize(raw: str) -> str: raw = raw.strip() if not raw: raise ValueError("empty url") if "://" not in raw: raw = "https://" + raw parts = urlsplit(raw) scheme = parts.scheme.lower() host = (parts.hostname or "").lower() port = parts.port # 默认端口不写进规范化结果 netloc = host if port in (None, 80, 443) else f"{host}:{port}" path = parts.path or "/" if len(path) > 1 and path.endswith("/"): path = path.rstrip("/") kept = [] for k, v in parse_qsl(parts.query, keep_blank_values=True): kl = k.lower() if kl in TRACKING_EXACT or kl.startswith(TRACKING_PREFIX): continue kept.append((k, v)) kept.sort() query = urlencode(kept) return urlunsplit((scheme, netloc, path, query, ""))这段代码里有几个刻意的选择需要说明。锚点(fragment)直接丢弃,因为同一个页面内的锚点定位对"我收藏了这个页面"这件事没有意义,反而会导致大量重复。默认端口 80 和 443 从 netloc 里去掉,避免example.com:443和example.com被当成两条。末尾斜杠只在路径长度大于一时才剥离,因为根路径/必须保留,否则拼出来的 URL 不合法。
注意:跟踪参数名单千万不要写死得太激进。我一开始把
from、source、page都当成跟踪参数剥掉,结果把不少内容站的分页链接和合法参数搞坏了,比如?page=3被删掉之后,所有分页都指向第一页。后来改成白名单前缀匹配,只处理明确是统计用途的那些。
2.2 去重键设计:哈希还是唯一索引
规范化之后,需要一个稳定的去重键。最直接的做法是把规范化后的完整字符串建成唯一索引,SQLite 完全支持,索引长度也不是问题。但我最后还是选了哈希,用blake2b取 16 字节摘要,理由是查询和比较都更快,而且后续做变更检测时,短哈希更省空间。
哈希长度选 16 字节(对应 32 个十六进制字符),这个数字不是随便定的。按生日界估算,n 条记录发生碰撞的概率约为 n²/2^129,即使记录数达到一亿条,碰撞概率也在 10^-20 量级,实际使用中可以认为不会发生。如果只取 8 字节,一亿条记录时碰撞概率会升到 10^-4 量级,对于"绝对不能误合并"的场景就不够安全了。多花 16 个字节换一个安心,很划算。
但这里有个容易忽略的问题:规范化规则一旦修改,历史数据的哈希就全部失效。我踩过这个坑,第二版加了参数排序规则之后,旧记录的哈希和新算出来的对不上,去重直接失灵,出现了大量重复。解决办法是在数据库里额外存一列normalizer_version,规则升级时写一个迁移脚本,把所有旧记录重新规范化并重算哈希。迁移脚本要能重入,中途失败再跑一遍不能出错。
2.3 标签、集合与检索的配合
标签体系的设计上,我坚决不做树形分类。树形结构在录入时要求用户做判断——"这条链接应该放哪个目录",这个判断成本极高,而且一旦放错,后续根本找不到。扁平的多标签没有这个问题,录入时想到什么打什么,检索时任意组合求交集即可。
标签本身也做了规范化:统一小写、去首尾空格、内部空白转连字符、限制长度不超过 32 字符。这一套规则是为了避免"Python"和"python"、"机器学习"和"机器学习 "被当成两个标签。同时限制单个 URL 的标签数量不超过 12 个,超过的多半是滥用,标签一多就失去了区分度。
除了标签,我还加了一个更轻的概念叫"集合"(collection),本质上是一个命名的查询条件,比如"本周新增且未读"、"域名为某技术站且打了性能标签"。集合不存储成员,每次打开时实时求值,好处是永远不会过期。这个设计参考了智能文件夹的思路,用起来比手工维护分组省心得多。
全文检索这部分,SQLite 的 FTS5 用外部内容表模式挂到主表上,通过触发器同步。索引覆盖标题、描述、域名、规范化 URL 四个字段,标签则通过关联表在查询时 JOIN 进来。中文场景下有个必须注意的点:默认的 unicode61 分词器对中文是按字符切的,搜"缓存"能命中,但搜"存穿"这种跨词片段就失效。我的处理是额外建一个 trigram 模式的索引专门用于子串匹配,查询时根据关键词长度自动选择走哪个索引——两个字符以内走 trigram,超过就走主索引,实测响应都在 20 毫秒以内。
3. 实操过程:从零搭一个可用的 Furl
3.1 环境准备与项目骨架
动手之前先把目录结构定好。我见过不少个人项目,所有代码堆在一个文件里,写到八百行就开始痛苦。Furl 的结构按职责分层,每个人都能一眼看懂该往哪加代码。
furl/ ├── pyproject.toml ├── furl/ │ ├── __init__.py │ ├── cli.py # 命令行入口,只做参数解析和输出 │ ├── core/ │ │ ├── normalize.py # URL 规范化 │ │ ├── store.py # 数据库读写 │ │ ├── search.py # 检索逻辑 │ │ └── fetch.py # 元信息抓取 │ ├── io/ │ │ ├── importer.py # 浏览器书签、CSV 导入 │ │ └── exporter.py # JSONL、Markdown 导出 │ └── schema.sql └── tests/依赖清单控制在三个:click负责命令行,httpx负责抓取,标准库里的sqlite3已经够用,不需要额外的 ORM。ORM 在这个规模下是负担,手写 SQL 更直观,性能也更好控制。
数据表设计如下,字段不多但每个都有明确用途:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INTEGER | 自增主键 |
| hash | TEXT | 规范化 URL 的 blake2b 摘要,唯一索引 |
| url | TEXT | 规范化后的完整 URL |
| raw_url | TEXT | 用户原始输入,便于回溯 |
| title | TEXT | 页面标题,可手动覆盖 |
| description | TEXT | 摘要,抓取或手填 |
| domain | TEXT | 从 URL 提取,单独建索引用于按站筛选 |
| created_at | INTEGER | Unix 时间戳,秒级 |
| last_seen_at | INTEGER | 最后一次被重复添加的时间 |
| visit_count | INTEGER | 重复添加次数,衡量关注度 |
| status | TEXT | active / dead / archived |
| normalizer_version | INTEGER | 规范化规则版本号 |
visit_count这个字段是后加的,起因是我发现有些链接反复被收藏,说明它真的很重要,但我自己完全没意识到。把它暴露在搜索结果里,按次数排序,相当于给收藏夹做了一次隐性的价值排序。last_seen_at则解决了"这条链接是不是已经过时了"的判断问题,配合定期巡检可以标出长期没人碰的僵尸记录。
3.2 添加流程的完整实现
furl add是整个工具最高频的操作,路径必须短。完整流程是:参数校验 → 规范化 → 计算哈希 → 查重 → 抓取元信息 → 落库 → 输出结果。查重放在抓取之前,这一步顺序很关键,因为抓取要走网络,慢且可能失败,先查重能省掉大量无谓请求。
def add_url(conn, raw_url, tags=None, title=None, fetch_meta=True): url = normalize(raw_url) digest = blake2b(url.encode("utf-8"), digest_size=16).hexdigest() row = conn.execute( "SELECT id, visit_count FROM urls WHERE hash = ?", (digest,) ).fetchone() now = int(time.time()) if row: conn.execute( "UPDATE urls SET visit_count = visit_count + 1, last_seen_at = ? WHERE id = ?", (now, row["id"]), ) conn.commit() return row["id"], False # 已存在 meta = fetch_metadata(url) if fetch_meta else {} cur = conn.execute( """INSERT INTO urls (hash, url, raw_url, title, description, domain, created_at, last_seen_at, visit_count, status, normalizer_version) VALUES (?,?,?,?,?,?,?,?,1,'active',?)""", (digest, url, raw_url, title or meta.get("title", ""), meta.get("description", ""), urlsplit(url).hostname, now, now, NORMALIZER_VERSION), ) conn.commit() return cur.lastrowid, True重复添加的处理逻辑值得多说两句。很多工具遇到重复就报错或者静默忽略,我觉得都不好。Furl 的做法是把它当成一次"投票"——更新visit_count和last_seen_at,但不覆盖已有的标题和标签。原因很实际:第一次添加时手动写的标题和标签,价值远高于后来重复添加时的默认值,不能被覆盖掉。
抓取元信息的部分要做超时控制。我设的是连接超时 3 秒、读取超时 5 秒,超过就放弃,留空标题让用户后续补。这个参数是实测调出来的:3 秒足够覆盖绝大多数站点的首字节返回,设到 10 秒会让批量导入变得难以忍受。抓取失败不影响入库,元信息永远是锦上添花,不能成为阻塞主流程的理由。
def fetch_metadata(url, timeout=(3.0, 5.0)): try: with httpx.Client(timeout=timeout, follow_redirects=True, headers={"User-Agent": "Furl/1.0"}) as client: resp = client.get(url) if resp.status_code >= 400: return {} html = resp.text[:200_000] # 只解析前 200KB except Exception: return {} return { "title": extract_tag(html, "title"), "description": extract_meta(html, "description"), }只解析前 200KB 是个务实的限制。页面头部基本都在这范围内,完整下载一个大页面反而浪费时间,还容易在批量导入时把内存吃满。
3.3 命令行交互与高频命令设计
命令行的设计哲学是"常用命令短、危险命令长"。日常用的add、search、ls都是几个字母,破坏性的delete、purge必须带确认参数。
| 命令 | 用途 | 典型用法 |
|---|---|---|
furl add | 添加链接 | furl add https://example.org/doc -t 性能,缓存 |
furl search | 组合检索 | furl search 缓存 --tag 性能 --limit 20 |
furl ls | 最近列表 | furl ls --since 7d |
furl open | 打开第一条结果 | furl open 缓存穿透 |
furl check | 巡检失效链接 | furl check --older-than 90d --concurrency 8 |
furl export | 导出数据 | furl export --format jsonl -o out.jsonl |
furl import | 导入书签 | furl import bookmarks.html |
furl open这个命令看起来多余,其实是最提升体验的一个。搜索之后直接用默认浏览器打开第一条结果,省掉了复制链接这一步,整个动作从"搜→看→复制→切窗口→粘贴"压缩成"搜→回车"。实现上只是调一次系统默认打开命令,几行代码,但使用频率极高。
furl check的并发数默认设成 8,这个数字是权衡出来的。太低,一万条链接要跑半小时;太高,容易被对方服务器限速甚至临时封禁,反而更慢。8 个并发配合每条 5 秒超时,一万条大概七八分钟跑完,是个比较舒服的平衡点。
3.4 导入导出与数据迁移
导入这块最大的价值是接管历史包袱。浏览器导出的书签文件是标准 Netscape Bookmark 格式,本质上是嵌套的<DL><DT><A>结构,用标准库的解析器就能处理,不需要额外依赖。导入时我做了两件事:把原来的文件夹层级转换为标签(因为层级本身携带分类信息,直接丢掉太可惜),以及跳过那些明显是内网地址或本地文件的链接。
def walk_bookmarks(node, path_tags, out): for child in node: if child.tag == "DT": a = child.find("A") if a is not None and a.get("href", "").startswith(("http://", "https://")): out.append({ "url": a.get("href"), "title": (a.text or "").strip(), "tags": list(path_tags), "created_at": int(a.get("add_date", "0") or 0), }) sub = child.find("DL") if sub is not None: name = (child.findtext("H3") or "").strip() walk_bookmarks(sub, path_tags + ([name] if name else []), out)导出格式我做了三种。JSONL 用于备份和迁移,一行一条记录,流式处理不怕内存爆;CSV 用于丢进表格软件做人工梳理;Markdown 用于生成一份可读的链接清单,按标签分组,直接贴进笔记软件就能看。三种格式覆盖了绝大多数下游需求,不需要再为某个特定软件单独写适配器。
提示:导入之前先备份现有数据库。批量导入因为有去重逻辑,理论上不会产生重复,但如果导入源里本身有大量带跟踪参数的变体,规范化之后仍可能产生一批"看起来重复"的记录。先备份,导完检查一遍再决定要不要清理。
4. 常见问题与排查技巧实录
4.1 高频问题速查表
用了大半年,遇到并解决掉的问题大概有二十来个,挑出最有代表性的整理成表。这些问题有个共同特点:不查不知道,一查都是几分钟能解决的小事,但第一次遇到时足够让人卡半天。
| 现象 | 可能原因 | 排查方向 | 处理方式 |
|---|---|---|---|
| 中文关键词搜不到 | 分词器配置不对 | 用单字搜索测试是否命中 | 建立 trigram 索引兜底 |
| 批量导入后大量重复 | 规范化规则变更 | 检查 normalizer_version | 重跑迁移脚本 |
| 标题全是空的 | 抓取被拦截或超时 | 单条手动测试请求 | 放宽超时或改用手填 |
| 链接巡检误报失效 | 站点拒绝 HEAD 请求 | 看返回码是否 405/403 | 改为 GET 只读首字节 |
| 搜索越来越慢 | 索引未命中或表膨胀 | 看执行计划 | 补索引、清理归档记录 |
| 导入时间戳全是 1970 | 书签文件缺 add_date | 抽查源文件 | 用文件修改时间兜底 |
| 数据库文件锁住 | 多进程同时写入 | 检查是否有残留进程 | 启用 WAL 模式 |
| 标签出现大量近似重复 | 未做标签规范化 | 查标签表统计 | 跑一次标签合并脚本 |
这张表里最值得展开的是"链接巡检误报失效"。我一开始用 HEAD 请求做存活性检测,结果发现一批确实能正常访问的站点全被标成了失效。原因很简单:一些站点的服务端不支持 HEAD 方法,直接返回 405;还有些站点的防护策略会对 HEAD 请求返回 403。改成 GET 但只读取响应头、读完立刻断开连接,误报率立刻降下来了。代价是流量略增,但换来准确性,值得。
4.2 数据一致性的排查思路
所有个人数据工具最怕的就是丢数据。Furl 在这块做了三层保护,第一层是 SQLite 的 WAL 模式配合事务,保证单次写入的原子性;第二层是每次启动时自动做一次轻量的表完整性校验;第三层是提供一个furl doctor命令,手动触发全量检查,包括哈希与 URL 是否匹配、关联表是否有孤儿记录、FTS 索引是否与主表同步。
排查重复数据时,我常用的一个查询是找出"规范化后相同但哈希不同"的记录组:
SELECT url, COUNT(*) AS c, GROUP_CONCAT(id) FROM urls GROUP BY url HAVING c > 1 ORDER BY c DESC LIMIT 50;正常情况下这个查询应该返回空。一旦有结果,几乎可以断定是规范化规则升级之后没跑迁移。修复思路很直接:把这些记录分组,保留visit_count最高的那条,其余合并进去(标签取并集,visit_count求和),然后重算哈希。整个过程包在一个事务里,失败就回滚。
注意:合并操作前一定要导出备份。我第二次做迁移的时候,合并脚本里有个逻辑写错,把标签关联表的一批记录删掉了却没写回去,虽然 URL 还在,但标签全丢了。后来靠前一天导出的 JSONL 才恢复。从那以后我的每个写操作脚本开头都强制先 export 一次。
4.3 几个让我印象深刻的坑
第一个坑是编码问题。抓取网页时如果响应头里没声明编码,httpx会按默认字符集解码,中文页面就会出现大片乱码,导致提取出来的标题完全是垃圾。解决办法是从 HTML 的 meta 标签里读 charset,读不到就用 charset_normalizer 之类的库做字节级猜测,两条路都走不通再退回 UTF-8。这个处理现在写在了抓取模块最前面,任何新加的抓取逻辑都必须先过这一步。
第二个坑是搜索排序。最初的实现是单纯按创建时间倒序,结果搜一个常见词,排在最前面的全是几个月前的老链接,最近加的反而沉底。改成综合打分之后就顺手多了:相关性权重 0.6、访问次数权重 0.25、时间新鲜度权重 0.15,三个分量各自归一化后加权求和。参数是凭手感调的,没什么理论依据,但用起来明显更符合直觉——常被收藏的、最近添加的、关键词匹配度高的,自然浮到前面。
第三个坑是数据库文件位置。最初把数据库放在项目目录里,结果每次换工作目录就找不到数据了,还以为数据丢了。后来改成固定的用户数据目录,通过环境变量FURL_HOME可以覆盖。这个改动很小,但解决了一类"明明添加过却搜不到"的困惑。凡是这类需要持久化的个人工具,数据目录一定要独立于代码目录,这是一条几乎通用的经验。
5. 长期使用后的场景延展与心得
5.1 不同使用方式带来的意外价值
工具写完之后,实际用法和我最初设想的并不完全一样,有些延展出来的场景反而更有意思。比如我把 Furl 当成了一个轻量的阅读队列:所有暂存的链接默认打上inbox标签,每周固定时间过一遍,处理完的把标签改成done。这个流程简单到只有两个标签,但配合furl ls --tag inbox命令,确实解决了"收藏了就等于看过了"的自欺欺人。
另一个场景是做资料索引。写东西的时候需要找参考资料,我会在建文档的同时用 Furl 批量录入,打上项目专属的标签。等文档写完,用furl export --tag 某项目 --format markdown直接导出一份带标题和描述的参考链接列表,贴到文末就是现成的参考资料章节。这个用法省掉的重复劳动比想象中多。
还有一个偏技术的用法:把 Furl 的数据当作个人浏览偏好的样本。visit_count排前几十的域名,基本勾勒出我信息摄入的主要来源;按月份统计新增链接的标签分布,能看出关注点的迁移轨迹。这些统计都不需要额外开发,几条 SQL 就能跑出来。
5.2 维护个人工具的几个实感
写这类工具最大的体会是:功能克制比功能丰富难得多。开发过程中冒出来的想法至少有三四十个,真正落地的不到十个,剩下的都被"三十秒解释不清就不做"这条规矩挡回去了。挡回去的每一个当时都觉得可惜,用久了才发现那些没做的功能,一个都没想念过。
第二个体会是数据格式的重要性远超代码。代码可以重写,工具可以换,数据一旦锁死在某个私有格式里就很难脱身。所以 Furl 的数据表结构设计得尽量朴素,字段含义直白,导出格式用最通用的 JSONL 和 CSV。哪怕哪天这个工具彻底不维护了,数据拿出来用任何脚本都能处理。
第三个体会是自动化的边界。去重、规范化、元信息抓取这些确定性的机械劳动,交给程序毫无争议;但分类、打标签、判断某条链接值不值得留,这些必须人来做。我试过用关键词规则自动打标签,准确率大概七成,剩下三成错误标签造成的干扰比不打标签还大。最终方案是程序只提供候选标签建议,点不点采纳由人决定。
用到现在,数据库里大概存了三千多条记录,搜索响应还是几十毫秒,同步目录备份也没有出过问题。如果非要再给一句建议:先把你现在书签栏里的前一百条导入进去,跑一遍去重和巡检,看看能清理掉多少重复和失效链接。这个动作花不了十分钟,但结果通常会很直观地说明,为什么值得给链接管理单独做个工具。