一直以来,我在 macOS 上使用思源写科研笔记、日志、阅读笔记时,都希望能像 Spotlight 或 Raycast 那样:
- 输入几个字
- 立刻从思源所有内容中搜索
- 立即跳转到对应块
- 或者快速复制内容 / 获取思源链接
官方搜索界面很好,但有时候手不在思源界面,切过去再搜就有点慢。
于是借助 AI,我创作了这个 Alfred 工作流,让思源真正进入系统级快捷搜索层面。
✨ 功能特点
✅ 1. 三级搜索模式(对应不同关键字)
| 关键字 | 范围 | 示例 |
|---|---|---|
sy |
搜索全部内容(标题 + 正文块) | sy ecology |
syt |
只搜索标题(Heading blocks) | syt pine |
syb |
只搜索正文块 | syb trait |
✅ 2. 强力模糊搜索
- 支持多关键字(AND 匹配):
sy climate warming - 使用
difflib.SequenceMatcher模糊评分 - 自动排序最相关结果(越像越靠前)
- 可匹配路径,结构化内容也能搜到
✅ 3. 强力去重(解决思源多条相似块的问题)
- 很多笔记中有大量类似的引用、注释、摘要块。
- 本工作流会自动对结果,按 snippet 去重,只展示最高匹配度的搜索结果 这样不会出现几十条一模一样的候选项。
✅ 4. 多动作支持
| 按键 | 动作 |
|---|---|
| 回车(Enter) | 打开思源并跳转到块 |
| ⌘ + Enter | 复制块链接(siyuan://blocks/...) |
| ⌥ + Enter | 复制块的完整内容 |
✅ 5. 零依赖安装
- 🌟 无需 pip、无需 requests、无需 jq
- 纯 Python 标准库 + curl + Alfred 内置功能即可运行。
- 这对 macOS 用户非常友好。
📸 效果预览
你可以在这里加几张图:


🔧 安装步骤(非常简单)
1. 确认思源 API 已启用
在思源设置:
设置 → 关于 → API Token
确认:
- Token 已生成
- 本地 API 服务已开启
- 端口是
6806(你修改过的话,用自己的)
2. 创建 Alfred Workflow(空的就行)
添加 三个 Script Filter:
| Script Filter | Keyword | 模式 |
|---|---|---|
| 全部内容 | sy |
MODE="all" |
| 仅标题 | syt |
MODE="title" |
| 仅正文块 | syb |
MODE="body" |
注意:它们都使用同一份 Python 脚本,只需要改一行 MODE = ...。
💻 具体脚本
🧠 Script Filter(Python 版本 以 sy 关键词为例)
- Alfred 中添加 script filter,然后右键选 Configure Object
- Keyword 中设置 sy
- Language:/usr/bin/python3
- Script 拷贝下面的代码,需要使用自己的 API token
- 修改后记得保存
#!/usr/bin/python3
# -*- coding: utf-8 -*-
import sys
import json
import urllib.request
import urllib.error
from difflib import SequenceMatcher
HOST = "127.0.0.1"
PORT = 6806
TOKEN = "使用自己的token"
# 这里根据不同 Script Filter 修改:
# sy -> MODE = "all" (所有内容)
# syt -> MODE = "title" (只标题)
# syb -> MODE = "body" (只正文块、非标题)
MODE = "all" # <- 每个 Script Filter 自己改这一行
def build_alfred(items):
return json.dumps({"items": items}, ensure_ascii=False)
def fuzzy_score(query, content, path):
q = (query or "").lower()
c = (content or "").lower()
p = (path or "").lower()
if not q:
return 0.0
s1 = SequenceMatcher(None, q, c).ratio() if c else 0.0
s2 = SequenceMatcher(None, q, p).ratio() if p else 0.0
return max(s1, s2)
def main():
query = sys.argv[1] if len(sys.argv) > 1 else ""
query = query.strip()
if not query:
mode_label = {
"all": "全部内容",
"title": "仅标题",
"body": "仅正文块"
}.get(MODE, "全部内容")
print(build_alfred([{
"title": f"输入关键字搜索思源笔记({mode_label})",
"subtitle": "例如:sy oak / syt oak / syb oak",
"arg": ""
}]))
return
# 多词支持:按空白切分,构造 AND 条件
words = [w for w in query.split() if w]
if not words:
words = [query]
like_clauses = []
for w in words:
w_sql = w.replace("'", "''")
like_clauses.append(f"content LIKE '%{w_sql}%'")
where_sql = " AND ".join(like_clauses)
# 根据 MODE 加上 type 条件
type_condition = ""
if MODE == "title":
type_condition = " AND type = 'h'"
elif MODE == "body":
type_condition = " AND type <> 'h'"
stmt = (
"SELECT id, content, box, path, type "
"FROM blocks "
f"WHERE {where_sql}{type_condition} "
"ORDER BY updated DESC "
"LIMIT 200;"
)
body = json.dumps({"stmt": stmt}).encode("utf-8")
url = f"http://{HOST}:{PORT}/api/query/sql"
headers = {
"Content-Type": "application/json",
"Authorization": f"token {TOKEN}",
}
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=3) as resp:
raw = resp.read().decode("utf-8", errors="replace")
except urllib.error.URLError as e:
print(build_alfred([{
"title": "无法连接思源 API",
"subtitle": f"{e}",
"arg": ""
}]))
return
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
print(build_alfred([{
"title": "思源返回了非法 JSON",
"subtitle": f"{e}",
"arg": ""
}]))
return
if data.get("code") != 0:
msg = data.get("msg") or "未知错误"
print(build_alfred([{
"title": "思源 API 返回错误",
"subtitle": msg,
"arg": ""
}]))
return
rows = data.get("data") or []
if not rows:
mode_label = {
"all": "全部内容",
"title": "仅标题",
"body": "仅正文块"
}.get(MODE, "全部内容")
print(build_alfred([{
"title": "未找到相关内容",
"subtitle": f"关键字:{query}({mode_label})",
"arg": ""
}]))
return
# ==== 强力去重:按 snippet 去重,只保留评分最高的那条 ====
best_by_snippet = {} # snippet -> {id, snippet, subtitle, score}
for row in rows:
block_id = row.get("id", "")
if not block_id:
continue
content_full = (row.get("content") or "").replace("\n", " ")
snippet = content_full[:80].strip()
if not snippet:
snippet = "(空内容块)"
box = row.get("box") or ""
path = row.get("path") or ""
subtitle = f"{box} / {path}".strip(" /")
score = fuzzy_score(query, content_full, path)
prev = best_by_snippet.get(snippet)
if (prev is None) or (score > prev["score"]):
best_by_snippet[snippet] = {
"id": block_id,
"snippet": snippet,
"subtitle": subtitle,
"score": score,
}
if not best_by_snippet:
print(build_alfred([{
"title": "未找到非重复结果",
"subtitle": f"关键字:{query}",
"arg": ""
}]))
return
scored = list(best_by_snippet.values())
scored.sort(key=lambda x: x["score"], reverse=True)
top = scored[:20]
items = []
for r in top:
items.append({
"title": r["snippet"],
"subtitle": r["subtitle"],
"arg": r["id"]
})
print(build_alfred(items))
if __name__ == "__main__":
main()

🔧 Run Script(默认动作:跳转到块)
使用
/bin/zsh
#!/bin/zsh
BLOCK_ID="$1"
if [[ -z "$BLOCK_ID" ]]; then
exit 0
fi
open "siyuan://blocks/${BLOCK_ID}"

🔧 Run Script(⌘ + Enter:复制块链接)
#!/bin/zsh
BLOCK_ID="$1"
if [[ -z "$BLOCK_ID" ]]; then
exit 0
fi
echo "siyuan://blocks/${BLOCK_ID}" | pbcopy
🔧 Run Script(⌥ + Enter:复制块内容)
使用
/usr/bin/python3
##!/usr/bin/python3
# -*- coding: utf-8 -*-
import sys
import json
import urllib.request
import urllib.error
import subprocess
HOST = "127.0.0.1"
PORT = 6806
TOKEN = "使用你自己的API token"
def get_block_content(block_id: str) -> str:
# 防止 SQL 注入,简单转义单引号
bid = block_id.replace("'", "''")
stmt = f"SELECT content FROM blocks WHERE id = '{bid}' LIMIT 1;"
body = json.dumps({"stmt": stmt}).encode("utf-8")
url = f"http://{HOST}:{PORT}/api/query/sql"
headers = {
"Content-Type": "application/json",
"Authorization": f"token {TOKEN}",
}
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=3) as resp:
raw = resp.read().decode("utf-8", errors="replace")
except urllib.error.URLError:
return ""
try:
data = json.loads(raw)
except json.JSONDecodeError:
return ""
if data.get("code") != 0:
return ""
rows = data.get("data") or []
if not rows:
return ""
return rows[0].get("content") or ""
def main():
if len(sys.argv) < 2:
return
block_id = sys.argv[1].strip()
if not block_id:
return
content = get_block_content(block_id)
# 复制到剪贴板(macOS)
try:
subprocess.run(["pbcopy"], input=content, text=True)
except Exception:
pass
if __name__ == "__main__":
main()
🎯 使用方式
使用关键字 + 搜索词进行搜索(下面是几个例子)
- sy trait → 搜索所有内容
- syt ecology → 只搜索标题
- syb moult → 只搜索正文块
候选出来后:
- Enter → 跳转到思源对应块
- ⌘ + Enter → 复制思源块链接
- ⌥ + Enter → 复制块内容文本
📦 下载链接
也可以下载我制作的工作流包,安装之后需要修改每个脚本的 token
SiYuanSearchId246.alfredworkflow.zip
🙌 结语
使用这个插件可以一键跳到思源对应的位置,大幅加快笔记检索与利用效率。
欢迎大家在此基础上继续改进和分享(如按目录搜索、拼音匹配、标签过滤、自动生成 Anki 卡片等)。由于个人精力有限,本帖主要分享思路,不喜勿喷,不提供“伸手式”定制服务,望理解。
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于