1. 为什么我要把浏览器历史记录和书签搬进 Alfred
用 Mac 的人大概都有过这种体验:想找前几天看过的一篇文章,记得标题里有个关键词,但翻遍浏览器历史记录面板,要么加载慢,要么搜索功能形同虚设。Chrome 的历史记录页面打开要等好几秒,360 浏览器更是连个像样的搜索框都不给你。更别提书签了,几百个书签堆在文件夹里,找起来全靠肉眼扫。
我每天的工作流里,浏览器历史记录和书签的检索频率其实非常高。写东西的时候要引用之前看过的资料,做项目的时候要翻之前收藏的工具站,这些操作如果每次都要切到浏览器、打开历史面板、输入关键词、等结果渲染,一天下来浪费的时间相当可观。Alfred 作为 Mac 上最老牌的效率启动器,它的 Workflow 机制允许你把任意数据源接进来,用同一个输入框完成检索。把浏览器历史记录和书签做成 Alfred Workflow,本质上就是把“打开浏览器→找到历史/书签→搜索→点击”这条链路压缩成“呼出 Alfred→输入关键词→回车”。
这个方案适合几类人:一是每天在浏览器里泡着、历史记录动辄上万条的开发者或文字工作者;二是书签收藏癖,收藏了几百上千个链接但从来找不到的人;三是已经习惯用 Alfred 作为统一入口、希望把更多检索动作收拢进来的效率玩家。实现难度不算高,核心工作量在于搞清楚 Chrome 和 360 浏览器各自把历史记录和书签存在哪里、用什么格式存、怎么用脚本读出来并喂给 Alfred。
我前后折腾了几个版本,踩过一些坑,也总结出一套比较稳的做法。下面从数据存储结构讲起,一直讲到 Alfred Workflow 的完整配置和实际使用中的注意事项。
2. 浏览器把历史记录和书签藏在了哪里
2.1 Chrome 的数据存储结构
Chrome 在 macOS 上的用户数据目录位于~/Library/Application Support/Google/Chrome/。这个目录下会有Default、Profile 1、Profile 2等文件夹,对应不同的用户配置文件。如果你只用一个配置文件,那基本就是Default。
历史记录存在Default/History这个文件里,它是一个 SQLite 数据库。书签存在Default/Bookmarks,这是一个 JSON 格式的纯文本文件。这两个文件的格式差异决定了读取方式完全不同:History 要用 SQLite 查询,Bookmarks 直接解析 JSON 就行。
History 数据库里最核心的表是urls,字段包括id、url、title、visit_count、last_visit_time等。其中last_visit_time是 Chrome 特有的时间戳格式,它是从 1601 年 1 月 1 日开始计算的微秒数。这个细节很关键,如果你直接拿它当 Unix 时间戳用,结果会离谱到让你怀疑人生。转换公式是:Unix 时间戳 = (Chrome 时间戳 / 1000000) - 11644473600。
Bookmarks 文件的 JSON 结构稍微复杂一点,根节点下有bookmark_bar、other、synced三个主要分支,每个分支里是嵌套的children数组,每个书签项包含name、url、type、date_added等字段。递归遍历这个树形结构就能把所有书签拉平成一个列表。
2.2 360 浏览器的数据存储结构
360 浏览器在 Mac 上的情况要复杂一些。它的用户数据目录通常在~/Library/Application Support/360Browser/下面,但具体路径会因版本不同而有差异。有些版本用的是~/Library/Application Support/com.360.browser/,还有些版本会把数据放在~/Library/Containers/下的沙盒目录里。
历史记录同样是一个 SQLite 文件,一般叫History或History.db,表结构和 Chrome 高度相似,因为 360 浏览器早期就是基于 Chromium 内核做的。书签文件可能叫Bookmarks或bookmarks.json,格式也基本沿用 Chromium 的那套 JSON 结构。
这里有个实际会遇到的问题:360 浏览器的数据目录命名不太规范,不同版本之间可能不一样。我的做法是先写一个探测脚本,在几个常见路径下依次查找,找到哪个就用哪个。这个探测逻辑后面会详细讲。
2.3 为什么不能直接读数据库文件
Chrome 和 360 浏览器在运行的时候,会锁定 History 数据库文件。如果你直接用 SQLite 打开它,可能会遇到database is locked的错误。解决办法有两个:一是先把文件复制到临时目录再读,二是用 SQLite 的只读模式打开并设置超时。
我推荐第一种做法,因为复制文件几乎没有副作用,而且能避免各种锁竞争问题。复制的时候要注意,History 文件可能还有-wal和-shm两个附属文件,如果只复制主文件,可能会丢失最近的记录。稳妥的做法是把三个文件一起复制过去。
# 复制 History 数据库及其附属文件到临时目录 TEMP_DIR=$(mktemp -d) cp ~/Library/Application\ Support/Google/Chrome/Default/History "$TEMP_DIR/" cp ~/Library/Application\ Support/Google/Chrome/Default/History-wal "$TEMP_DIR/" 2>/dev/null cp ~/Library/Application\ Support/Google/Chrome/Default/History-shm "$TEMP_DIR/" 2>/dev/null这段脚本里2>/dev/null的作用是忽略文件不存在时的报错,因为不是每次都有 wal 和 shm 文件。
3. 用 Python 脚本把数据抽出来喂给 Alfred
3.1 脚本的整体设计思路
Alfred Workflow 支持多种脚本语言,我选 Python 是因为它处理 SQLite 和 JSON 都特别顺手,标准库就够用,不需要额外装依赖。脚本的核心逻辑分三步:定位数据文件、查询匹配记录、按 Alfred 要求的 JSON 格式输出。
Alfred Script Filter 对输出格式有明确要求,必须是一个 JSON 对象,包含items数组,每个 item 有title、subtitle、arg、uid等字段。arg是用户回车后传给下一个动作的值,通常就是 URL。uid用于 Alfred 的学习排序,同一个 uid 的条目被你选得越多,它排得越靠前。
3.2 定位浏览器数据目录的探测逻辑
因为 360 浏览器的路径不固定,我写了一个探测函数,按优先级依次检查几个候选路径:
import os def find_360_data_dir(): home = os.path.expanduser("~") candidates = [ os.path.join(home, "Library/Application Support/360Browser"), os.path.join(home, "Library/Application Support/com.360.browser"), os.path.join(home, "Library/Application Support/360Chrome"), ] for path in candidates: if os.path.isdir(path): # 进一步确认目录下有 History 或 Bookmarks 文件 for root, dirs, files in os.walk(path): if "History" in files or "Bookmarks" in files: return root return None这个函数会返回第一个包含 History 或 Bookmarks 文件的目录。实际用下来,大部分 360 浏览器版本都能被前两个候选路径覆盖。
3.3 查询历史记录并转换时间戳
查询历史记录的核心 SQL 很简单,用LIKE做模糊匹配就行:
import sqlite3 import shutil import tempfile def query_history(db_path, keyword, limit=20): temp_dir = tempfile.mkdtemp() temp_db = os.path.join(temp_dir, "History") shutil.copy2(db_path, temp_db) conn = sqlite3.connect(temp_db) cursor = conn.cursor() sql = """ SELECT url, title, last_visit_time, visit_count FROM urls WHERE title LIKE ? OR url LIKE ? ORDER BY last_visit_time DESC LIMIT ? """ pattern = f"%{keyword}%" cursor.execute(sql, (pattern, pattern, limit)) rows = cursor.fetchall() conn.close() shutil.rmtree(temp_dir, ignore_errors=True) return rows时间戳转换单独写一个函数,把 Chrome 的微秒时间戳转成可读的日期字符串:
from datetime import datetime, timedelta def chrome_time_to_datetime(chrome_time): if not chrome_time: return "" try: unix_ts = (chrome_time / 1000000) - 11644473600 dt = datetime(1970, 1, 1) + timedelta(seconds=unix_ts) return dt.strftime("%Y-%m-%d %H:%M") except Exception: return ""这里用try/except包一层是因为偶尔会遇到异常值,直接崩掉不如返回空字符串。
3.4 解析书签 JSON 并拉平树形结构
书签的 JSON 是嵌套的,需要递归遍历。我写了一个生成器函数,把树形结构拉平成一个个书签项:
import json def iter_bookmarks(node, folder_path=""): if node.get("type") == "url": yield { "name": node.get("name", ""), "url": node.get("url", ""), "folder": folder_path, "date_added": node.get("date_added", "") } elif node.get("type") == "folder": new_path = f"{folder_path}/{node.get('name', '')}" if folder_path else node.get("name", "") for child in node.get("children", []): yield from iter_bookmarks(child, new_path) def load_bookmarks(file_path): with open(file_path, "r", encoding="utf-8") as f: data = json.load(f) roots = data.get("roots", {}) all_bookmarks = [] for key in ["bookmark_bar", "other", "synced"]: root_node = roots.get(key) if root_node: all_bookmarks.extend(iter_bookmarks(root_node)) return all_bookmarksfolder字段记录了书签所在的文件夹路径,显示在 subtitle 里能帮用户快速判断来源。
3.5 输出 Alfred 能识别的 JSON
最后把所有结果组装成 Alfred 要求的格式:
import json def build_alfred_output(items): output = {"items": []} for item in items: output["items"].append({ "uid": item.get("url", ""), "title": item.get("title", "无标题"), "subtitle": item.get("subtitle", ""), "arg": item.get("url", ""), "quicklookurl": item.get("url", ""), "text": { "copy": item.get("url", ""), "largetype": item.get("title", "") } }) return json.dumps(output, ensure_ascii=False)ensure_ascii=False很重要,不然中文标题会被转义成\uXXXX的形式,虽然 Alfred 也能解析,但调试的时候看着难受。
4. Alfred Workflow 的完整配置过程
4.1 创建 Workflow 并添加 Script Filter
打开 Alfred 的 Preferences,切到 Workflows 标签,点左下角的加号,选 Blank Workflow。给它起个名字,比如“Browser History Search”,然后点右键选 Inputs → Script Filter。
Script Filter 的配置项里,Keyword 填你想要的触发词,我用的bh(browser history 的缩写)。Language 选/usr/bin/python3,因为 macOS 自带 Python 3。Script 框里粘贴你的脚本内容,或者写一个调用外部脚本文件的命令。
这里有个细节:Alfred 的 Script Filter 默认会把用户输入作为{query}传进来。如果你用外部脚本文件,可以在 Script 框里写:
/usr/bin/python3 /path/to/your/script.py "{query}"注意{query}要加引号,不然用户输入里有空格的时候参数会被拆开。
4.2 配置关键词触发和参数传递
Script Filter 的 Keyword 设置里,有个 “Argument Optional” 选项,勾上之后即使不输入关键词也能触发,这时候脚本会返回最近的历史记录。我建议勾上,因为有时候你就是想看看最近看了什么。
还有个 “Run Behaviour” 选项,默认是 “Argument Required”,改成 “Argument Optional” 后,空输入时{query}会是空字符串,脚本里判断一下就行。
4.3 接上 Open URL 动作
Script Filter 输出的是 JSON,用户回车后需要有个动作来打开 URL。从 Script Filter 的右侧拖一条线出来,选 Actions → Open URL。这样用户选中某个条目按回车,就会用默认浏览器打开那个链接。
如果你想让回车后复制 URL 而不是打开,那就接 Actions → Copy to Clipboard。我个人的习惯是接 Open URL,因为检索历史记录的目的通常就是重新访问。
4.4 调试 Workflow 的实用技巧
Alfred 自带一个调试面板,在 Workflow 编辑界面点右上角的虫子图标就能打开。所有脚本的输出和报错都会显示在这里,非常方便。
调试的时候最常见的错误是 JSON 格式不对。Alfred 对 JSON 的要求比较严格,多一个逗号或者少一个引号都会导致整个 Workflow 不工作。我的做法是在脚本里加一个try/except,出错时输出一个包含错误信息的 item,这样至少能在 Alfred 里看到问题所在:
try: # 主逻辑 pass except Exception as e: print(json.dumps({ "items": [{ "title": "脚本出错", "subtitle": str(e), "valid": False }] }, ensure_ascii=False))valid: False表示这个条目不可选,用户回车也不会有动作,纯粹用来展示错误信息。
5. 实际使用中遇到的坑和应对方案
5.1 数据库锁定导致的查询失败
前面提到过,浏览器运行时 History 数据库会被锁定。我一开始图省事,直接用只读模式打开原文件,结果十次里有三次报database is locked。后来改成先复制再读,就再也没遇到过这个问题。
复制的时候有个小坑:如果 History 文件很大(比如超过 100MB),复制本身也要花一点时间。我的做法是在脚本里加一个判断,如果文件超过 50MB,就只复制最近的一部分数据。具体做法是复制之后用 SQLite 的DELETE删掉旧记录,或者查询的时候加时间范围限制。
5.2 中文关键词匹配不准确
SQLite 的LIKE对中文的支持其实还行,但有个问题是它默认不区分大小写只对 ASCII 字符有效。中文没有大小写概念,所以这个不影响。真正的问题是,如果用户输入的关键词里包含%或_这些 SQL 通配符,会导致匹配结果异常。
解决办法是在拼接 pattern 之前先转义这些特殊字符:
def escape_like(keyword): return keyword.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")然后在 SQL 里用ESCAPE '\'指定转义符。
5.3 360 浏览器路径变动导致脚本失效
360 浏览器更新版本后,数据目录有时候会变。我遇到过从360Browser变成com.360.browser的情况。应对方案就是前面写的那个探测函数,把已知的路径都列进去,按顺序找。
如果哪天所有候选路径都失效了,可以在脚本里加一个手动指定路径的配置项,让用户自己填。这个配置可以放在 Workflow 的环境变量里,Alfred 支持在 Workflow 设置里定义变量,脚本里用os.environ.get()读取。
5.4 书签数量过多导致 Alfred 卡顿
书签如果超过几千条,一次性全部输出给 Alfred 会导致界面卡顿。我的做法是限制返回数量,默认只返回匹配度最高的 30 条。Alfred 本身也有结果数量限制,可以在 Script Filter 的设置里调整。
另外,书签的uid字段我用的是 URL,这样 Alfred 的学习排序能正常工作。但有些书签的 URL 可能重复(比如同一个页面被收藏了两次),这时候 uid 冲突会导致排序混乱。解决办法是在 uid 后面拼一个索引值,保证唯一性。
5.5 时间戳显示为 1970 年的问题
前面强调过 Chrome 时间戳的转换公式,但实际写代码的时候还是容易忘。我踩过的坑是直接把last_visit_time除以 1000000 就当 Unix 时间戳用,结果所有记录都显示 1970 年 1 月 1 日。正确的做法是减去11644473600这个偏移量,这个数字是 1601 年到 1970 年之间的秒数。
如果你用的是 360 浏览器,它的时间戳格式可能和 Chrome 一样,也可能不一样。稳妥的做法是先查几条记录,看看数值范围。如果数值在13000000000000000这个量级,那就是 Chrome 格式;如果数值在1600000000这个量级,那就是 Unix 时间戳,直接用就行。
6. 让检索结果更符合直觉的几个优化
6.1 按访问频率和时间综合排序
单纯按last_visit_time排序有个问题:你昨天偶然点开的一个页面,可能排在你经常访问的页面前面。更好的排序策略是综合访问次数和最近访问时间。我用了一个简单的加权公式:
ORDER BY (visit_count * 0.3 + (last_visit_time / 1000000.0) * 0.7) DESC这个权重可以根据个人习惯调整。如果你更看重最近访问,就把时间权重调高;如果你更看重访问频率,就把次数权重调高。
6.2 在 subtitle 里展示来源和访问信息
Alfred 的 subtitle 字段很适合放辅助信息。我的做法是历史记录显示“访问次数 + 最后访问时间”,书签显示“所在文件夹 + 添加时间”。这样用户不用打开链接就能判断是不是自己要找的。
subtitle = f"访问 {visit_count} 次 · {formatted_time}"书签的 subtitle:
subtitle = f"书签 · {folder} · {formatted_date}"6.3 支持多配置文件切换
Chrome 支持多个用户配置文件,每个配置文件的 History 和 Bookmarks 是独立的。如果你同时用多个配置文件,可以在 Alfred Workflow 里加一个变量来指定要查询哪个配置文件。
我的做法是在脚本里读取环境变量CHROME_PROFILE,默认值是Default。然后在 Alfred Workflow 的设置里定义这个变量,想切换的时候改一下就行。
6.4 用 Quick Look 预览链接内容
Alfred 的quicklookurl字段允许用户按 Shift 键预览链接内容。对于历史记录和书签来说,这个功能特别实用,因为你可以快速预览页面内容再决定要不要打开。
配置方法就是在输出的 JSON 里加上quicklookurl字段,值就是 URL。Alfred 会自动调用系统的 Quick Look 来渲染。
7. 我日常使用这个 Workflow 的真实感受
这套东西我用了大概半年多,最大的感受是“回不去了”。以前找历史记录要切浏览器、打开面板、等加载、输入关键词、在一堆结果里翻找,现在就是Alfred 呼出 → bh 关键词 → 回车,整个过程不到三秒。
有几个使用场景特别高频:写文章的时候要引用之前看过的资料,直接搜关键词就能找到;做项目的时候要翻之前收藏的工具站,书签检索比浏览器自带的书签管理器快得多;有时候只是想找回昨天关掉的一个标签页,搜一下标题关键词就出来了。
也有几个我觉得还能改进的地方。一是 360 浏览器的路径探测还是不够优雅,每次它更新版本我都担心路径会变。二是书签的文件夹路径显示有时候太长,在 Alfred 的小窗口里会被截断。三是如果同时用 Chrome 和 360 浏览器,两个数据源的结果会混在一起,虽然可以在 subtitle 里区分,但视觉上还是有点乱。
如果你也想搭一套类似的工具,我的建议是先从 Chrome 开始,因为它的数据格式最规范,踩坑最少。跑通之后再加入 360 浏览器的支持,这样出问题的时候容易定位是哪个环节的毛病。脚本不用写得太复杂,能查能显示能打开就行,后面用着用着自然知道该优化哪里。