# 本地 API

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

---

LLMS 索引： [llms.txt](/llms.txt)

---

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

## 启用 {#enable}

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

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

## 发现文件 {#discovery}

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

```filetree {title="~/Library/Application Support/Crib/api/" wrap=true}
- status.json   # base_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](/docs/integrations/mcp/) 程序自动使用新密钥；手动复制过密钥的工具需要重新复制。

## 先验证服务端，再发送密钥 {#server-proof}

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

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

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

## 请求规则 {#rules}

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

## 接口 {#endpoints}

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

### 搜索参数 {#search-params}

| 参数 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `text` | string | | 搜索文本，支持 Crib 的过滤语法和 `PG96:` 这类版本前缀；文本里的过滤优先于下面的参数 |
| `project` | string | `postgresql` | 项目 |
| `branch` | string | `18` | 版本线；`*` 表示所有已启用版本 |
| `locale` | string | `zh-Hans` | `zh-Hans` 或 `en`；`*` 表示所有语言 |
| `domain` | string | | 文档领域 |
| `collection` | string | | Wiki 集合 |
| `limit` | integer | `20` | 每页条数，1–50 |
| `offset` | integer | `0` | 偏移，0–10000 |
| `generation` | string | | 期望的搜索世代 |
{.fields meta="type default"}

兼容 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://` 链接](/docs/integrations/deep-links/)。

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

### 读取章节 {#sections}

`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`，重新搜索后再读。

### 时间限制 {#limits}

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

### 错误码 {#errors}

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

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

## Python 示例 {#example}

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

```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 的差异 {#dash}

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

---

反链：

- [文档](/docs/)
- [深链接](/docs/integrations/deep-links/)
- [MCP](/docs/integrations/mcp/)
- [工作原理](/docs/intro/architecture/)
- [版本与别名](/docs/library/versions/)
