跳转到主要内容

本地 API

让本机脚本和工具通过 HTTP 搜索、读取 Crib 中已启用的文档:启用方法、发现文件、密钥与服务端验证、接口和一个 Python 示例。

本地 API 默认关闭。启用后只监听 127.0.0.1,只读,只能访问当前已启用的资料包。笔记、书签、阅读历史、片段和更正都不对外开放。Crib 自己的搜索和阅读不经过这个接口。

启用

  1. 打开“设置 → 集成”,在“本地 API”中打开“启用本地 API”。
  2. “端口”填 0 自动选择可用端口(默认),或指定 1024–65535 中的一个,再点“应用”。
  3. 启动后,设置页显示“地址”,并提供“复制 Schema 地址”“复制密钥”“更换密钥…”。

关闭开关或退出 Crib 后停止监听。资料库不可用时 API 不会启动,设置页显示原因和“重试”。

发现文件

客户端从用户目录读取地址和密钥,不要缓存自动分配的端口:

~/Library/Application Support/Crib/api/

  • status.jsonbase_url、port、pid、instance_id、access_key_file、api_version
  • access-key访问密钥
  • server.lock正在服务的实例持有这把锁
  • api/ 目录权限是 0700,两个文件是 0600。
  • status.json 在服务就绪后才出现,正常关闭时删除。Crib 异常退出可能留下旧的 status.json;下次启动时,只要没有实例持有 server.lock,就会删掉它。
  • 密钥文件是符号链接、有多个硬链接、属于其他用户或权限公开时,Crib 拒绝使用。
  • “更换密钥…”让旧密钥立即失效。随 App 附带的 MCP 程序自动使用新密钥;手动复制过密钥的工具需要重新复制。

先验证服务端,再发送密钥

Crib 崩溃后,原来的端口可能被其他本地进程占用。发送密钥之前,客户端必须确认对端持有同一把密钥:

  1. 检查 status.json 中的 pid 进程仍然存在。
  2. 不带任何凭据请求 GET /auth/challenge?nonce=<nonce>,nonce 是 32 个随机字节的 64 位小写十六进制。
  3. 服务端返回 {"algorithm":"hmac-sha256","proof":"<64 hex>","version":1}。
  4. 客户端计算 HMAC-SHA256(key = 访问密钥的 ASCII 字节, data = "crib-local-api/server-proof/v1\n127.0.0.1:<端口>\n<nonce>"),用常量时间比较与 proof 是否相等。相等才发送密钥。

端口写进了证明,占用端口的进程无法把挑战转发给别处的真实 Crib 来冒充。返回 404 的服务端应当拒绝,不能默认信任。

请求规则

  • /health、/schema、/auth/challenge 不需要密钥;其余接口需要 Authorization: Bearer <访问密钥>。密钥不要放进查询串。
  • 只支持 GET。响应是 JSON,带 Cache-Control: no-store,发送后关闭连接。
  • Host 必须是 127.0.0.1:<端口> 或 localhost:<端口>。带 Origin 头的请求返回 403,所以网页里的脚本不能调用它。
  • 查询串按表单编码解析:+ 是空格,字面加号写成 %2B。未知参数、重复参数、非法转义都返回 400。

接口

路径作用
GET /health服务状态与 API 版本
GET /schemaOpenAPI 描述
GET /auth/challenge服务端身份证明,见上文
GET /docsets/list当前搜索世代中已启用的资料包、版本、语言、摘要和修订
GET /search在已启用的资料中搜索
GET /sections/{key}读取一个章节的纯文本,必须带 generation

搜索参数

text , string
搜索文本,支持 Crib 的过滤语法和 PG96: 这类版本前缀;文本里的过滤优先于下面的参数
project , string , defaultpostgresql
项目
branch , string , default18
版本线;* 表示所有已启用版本
locale , string , defaultzh-Hans
zh-Hans 或 en;* 表示所有语言
domain , string
文档领域
collection , string
Wiki 集合
limit , integer , default20
每页条数,1–50
offset , integer , default0
偏移,0–10000
generation , string
期望的搜索世代

兼容 Dash 的参数名 query、lang、max_results 分别等同于 text、locale、limit。同时给出一对时,值相同可以接受,值不同返回 400。请求的版本或语言没有安装或启用时返回错误,不会悄悄换成默认版本。

每条结果带 section_key、document_key、path、fragment、docset、pack_digest 等字段,以及两个地址:section_url 是带 generation 的章节接口路径,load_url 是在 Crib 中打开该章节的 crib:// 链接。

响应里的 has_more 和 next_offset 用于翻页;timed_out、cancelled、candidate_truncated 为真时结果不完整,不能当作完整结果使用。

读取章节

GET /sections/{key}?generation=<id>&offset=0&limit=16000

  • offset 和 limit 按 Unicode 标量计数,不是字节,也不是 UTF-16 单元。默认 16,000,最大 50,000。
  • 跟随 next_offset 继续读,直到它为 null。
  • 启用、停用或更新资料包后搜索世代会变。用旧 generation 请求返回 409 generation_changed,重新搜索后再读。

时间限制

  • 每个请求有 3 秒执行预算。搜索本身超时返回 200 和 timed_out=true,带上已完成的部分结果;准备阶段就耗尽预算返回 504。
  • 传输层另有 7 秒处理超时、8 秒连接截止;2 秒内没发完请求头的连接被关闭。最多同时 32 个连接。

错误码

状态码含义
400参数或查询无效
401缺少密钥或密钥错误
403来源受限,例如带 Origin 头
404路径或章节不存在
409搜索世代已变,或请求的范围没有安装、启用
503资料库不可用,或请求被取消
504执行或传输超时

响应中的 error 和 message 说明具体原因。

Python 示例

示例从发现文件读取地址和密钥,先验证服务端,再搜索并读取第一条结果。密钥不出现在终端或进程参数里。

from pathlib import Path
import hashlib, hmac, json, os, secrets
import urllib.parse, urllib.request

user_root = Path.home() / "Library/Application Support/Crib"
status = json.loads((user_root / "api/status.json").read_text())
key = Path(status["access_key_file"]).read_text().strip()
client = urllib.request.build_opener(urllib.request.ProxyHandler({}))

def verify_server():
    os.kill(status["pid"], 0)  # 进程不存在时抛出异常
    nonce = secrets.token_hex(32)
    with client.open(status["base_url"] + "/auth/challenge?nonce=" + nonce, timeout=8) as r:
        reply = json.load(r)
    port = urllib.parse.urlsplit(status["base_url"]).port
    message = f"crib-local-api/server-proof/v1\n127.0.0.1:{port}\n{nonce}".encode()
    expected = hmac.new(key.encode(), message, hashlib.sha256).hexdigest()
    if reply.get("algorithm") != "hmac-sha256" or not hmac.compare_digest(reply.get("proof", ""), expected):
        raise SystemExit("not a genuine Crib listener; key not sent")

def get(path):
    request = urllib.request.Request(status["base_url"] + path,
                                     headers={"Authorization": "Bearer " + key})
    with client.open(request, timeout=8) as r:
        return json.load(r)

verify_server()
query = urllib.parse.urlencode({"text": "SELECT", "locale": "zh-Hans", "limit": 3})
result = get("/search?" + query)
for hit in result["results"]:
    print(hit["name"], hit["branch"], hit["locale"])
if result["results"]:
    section = get(result["results"][0]["section_url"] + "&limit=1000")
    print(section["body"])

与 Dash API 的差异

Crib 与 Dash 8.1.1 的本地 API 都有健康检查、Schema、资料包列表和搜索入口,并兼容 query、max_results 参数名。Crib 不支持 Dash 的 docset_identifiers、search_snippets 和 /docsets/enable_fts,默认范围、结果格式和分页上限也不同。依赖完整 Dash API 的客户端不能直接换成 Crib。