Furl:本地命令行 URL 管理与去重检索实践
浏览器书签栏塞了四百多条链接找一份三个月前存的技术文档得靠翻历史记录碰运气聊天窗口里同事甩过来的一串 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 会是空的后面全错。参数排序这一步容易被忽略?a1b2和?b2a1语义相同但字符串不同排序之后才能保证哈希一致。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_valuesTrue): 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都当成跟踪参数剥掉结果把不少内容站的分页链接和合法参数搞坏了比如?page3被删掉之后所有分页都指向第一页。后来改成白名单前缀匹配只处理明确是统计用途的那些。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. 实操过程从零搭一个可用的 Furl3.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 更直观性能也更好控制。数据表设计如下字段不多但每个都有明确用途字段名类型说明idINTEGER自增主键hashTEXT规范化 URL 的 blake2b 摘要唯一索引urlTEXT规范化后的完整 URLraw_urlTEXT用户原始输入便于回溯titleTEXT页面标题可手动覆盖descriptionTEXT摘要抓取或手填domainTEXT从 URL 提取单独建索引用于按站筛选created_atINTEGERUnix 时间戳秒级last_seen_atINTEGER最后一次被重复添加的时间visit_countINTEGER重复添加次数衡量关注度statusTEXTactive / dead / archivednormalizer_versionINTEGER规范化规则版本号visit_count这个字段是后加的起因是我发现有些链接反复被收藏说明它真的很重要但我自己完全没意识到。把它暴露在搜索结果里按次数排序相当于给收藏夹做了一次隐性的价值排序。last_seen_at则解决了这条链接是不是已经过时了的判断问题配合定期巡检可以标出长期没人碰的僵尸记录。3.2 添加流程的完整实现furl add是整个工具最高频的操作路径必须短。完整流程是参数校验 → 规范化 → 计算哈希 → 查重 → 抓取元信息 → 落库 → 输出结果。查重放在抓取之前这一步顺序很关键因为抓取要走网络慢且可能失败先查重能省掉大量无谓请求。def add_url(conn, raw_url, tagsNone, titleNone, fetch_metaTrue): url normalize(raw_url) digest blake2b(url.encode(utf-8), digest_size16).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(timeouttimeout, follow_redirectsTrue, 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 20furl ls最近列表furl ls --since 7dfurl open打开第一条结果furl open 缓存穿透furl check巡检失效链接furl check --older-than 90d --concurrency 8furl export导出数据furl export --format jsonl -o out.jsonlfurl import导入书签furl import bookmarks.htmlfurl open这个命令看起来多余其实是最提升体验的一个。搜索之后直接用默认浏览器打开第一条结果省掉了复制链接这一步整个动作从搜→看→复制→切窗口→粘贴压缩成搜→回车。实现上只是调一次系统默认打开命令几行代码但使用频率极高。furl check的并发数默认设成 8这个数字是权衡出来的。太低一万条链接要跑半小时太高容易被对方服务器限速甚至临时封禁反而更慢。8 个并发配合每条 5 秒超时一万条大概七八分钟跑完是个比较舒服的平衡点。3.4 导入导出与数据迁移导入这块最大的价值是接管历史包袱。浏览器导出的书签文件是标准 Netscape Bookmark 格式本质上是嵌套的DLDTA结构用标准库的解析器就能处理不需要额外依赖。导入时我做了两件事把原来的文件夹层级转换为标签因为层级本身携带分类信息直接丢掉太可惜以及跳过那些明显是内网地址或本地文件的链接。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。哪怕哪天这个工具彻底不维护了数据拿出来用任何脚本都能处理。第三个体会是自动化的边界。去重、规范化、元信息抓取这些确定性的机械劳动交给程序毫无争议但分类、打标签、判断某条链接值不值得留这些必须人来做。我试过用关键词规则自动打标签准确率大概七成剩下三成错误标签造成的干扰比不打标签还大。最终方案是程序只提供候选标签建议点不点采纳由人决定。用到现在数据库里大概存了三千多条记录搜索响应还是几十毫秒同步目录备份也没有出过问题。如果非要再给一句建议先把你现在书签栏里的前一百条导入进去跑一遍去重和巡检看看能清理掉多少重复和失效链接。这个动作花不了十分钟但结果通常会很直观地说明为什么值得给链接管理单独做个工具。