我之前讲过将「哈勃半径」作为一种私人信息宇宙,并将它接入 Notion AI。
但问题是,Notion AI 如果不使用 Custom Agents 似乎无法接入第三方 API,而 Custom Agents 有高昂的使用费,如何解决这个问题呢?
这里补一个更工程化的实用技巧:通过 Cloudflare Worker 将任意第三方 API 接入 Notion AI。
我用博查搜索,来讲解如何操作。
它的核心原理很简单:
- 第三方 API 通常要求
POST请求,并且需要在请求头里放 API Key。 - Notion AI 当前更适合通过
webpage.load读取一个GET可访问的网页。 - Notion AI 的计算机工具可以做 SHA-256 之类的哈希计算。
- 所以中间需要一个 Cloudflare Worker 网关:对 Notion AI 暴露一个带签名的
GET页面,对第三方服务发起真正的POSTAPI 请求,再把结果渲染成 HTML。
换句话说,它不是让 Notion AI 直接调用第三方 API,而是给 Notion AI 搭一座「可阅读的桥」。
如果你觉得下面的内容读起来很麻烦,也可以将这个文章直接丢给你的 Notion AI,让它教你如何设置。
一、为什么需要 Worker 网关
很多 API 的调用方式是这样的:
POST <https://api.example.com/v1/search>
Authorization: Bearer API_KEY
Content-Type: application/json
{
"query": "搜索词",
"count": 5
}
但 Notion AI 更容易处理的是一个普通网页:
Worker 做的事情,就是把后者翻译成前者。
Notion AI 访问的是一个 GET URL。Worker 收到 URL 参数,验证签名,把参数组装成 POST 请求,调用真正的第三方 API,最后把返回结果变成 HTML 页面。这样 Notion AI 不需要理解 API 的认证细节,也不需要使用 Post 方法来接触真正的第三方 API。
这实现了在不给 Notion 额外交钱(使用 Custom Agents)的情况下,使用默认版本的 Notion AI 来接入第三方 API。
二、最小可用架构
这个方案里有三个角色:
- Notion AI:负责理解用户意图、生成查询词、计算动态签名、读取网页结果。
- Cloudflare Worker:负责鉴权、参数转换、调用第三方 API、渲染 HTML。
- 第三方 API:负责真正提供搜索、数据库查询、模型推理或其他能力。
它的链路是:
用户问题
→ Notion AI 生成 query
→ 计算 ts + sign
→ webpage.load 访问 Worker 的 GET URL
→ Worker 验签
→ Worker 向第三方 API 发起 POST
→ Worker 把 JSON 渲染成 HTML
→ Notion AI 阅读页面并总结
这里最关键的一点是:API Key 不应该出现在 Prompt 里,也不应该出现在 Notion 页面里。它应该存放在 Cloudflare Worker 的环境变量中。
三、以博查搜索为例
博查 API 原本是一个搜索接口。我们希望 Notion AI 可以这样使用它:
搜索:2024 年诺贝尔物理学奖得主
返回:5 条结果
需要摘要:是
对应到 Worker 暴露出来的 URL,大概是:
其中:
query是搜索词,必须 URL 编码。summary控制是否让博查返回长摘要。count控制返回条数。freshness可以限制时间范围,比如一天、一周、一个月、一年。include可以限制搜索域名。exclude可以排除搜索域名。ts是 10 位 Unix 时间戳。sign是ts + SECRET_KEY的 SHA-256 哈希。
这个签名机制不是为了做到银行级安全,而是为了避免这个 Worker URL 被随便滥用。时间戳通常只允许几分钟内有效。
四、Worker 示例代码
你需要先在 Cloudflare Woker 上建立一个新的 Worker,然后填入以下代码:
// 辅助函数:计算 SHA-256 哈希值
async function generateSHA256(message) {
const msgUint8 = new TextEncoder().encode(message);
// 使用更严格的对象参数格式,避免部分 Worker 环境抛出 TypeError
const hashBuffer = await crypto.subtle.digest({ name: 'SHA-256' }, msgUint8);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
export default {
async fetch(request, env, ctx) {
// ==========================================
// 0. 拦截并放行 OPTIONS 跨域预检请求
// ==========================================
if (request.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
"Access-Control-Max-Age": "86400",
}
});
}
// ==========================================
// 全局错误捕获边界,把 1101 错误转为可见的 HTML 报错
// ==========================================
try {
const url = new URL(request.url);
// ==========================================
// 1. 配置项 (请替换为你的真实数据)
// ==========================================
const BOCHA_API_KEY = '博查 API Key';
const SECRET_KEY = '你生成的一个 32 位随机 Key';
const THRESHOLD_SECONDS = 300;
// ==========================================
// 2. 动态 Token 验证逻辑
// ==========================================
const tsParam = url.searchParams.get('ts');
const signParam = url.searchParams.get('sign');
let isAuthenticated = false;
if (tsParam && signParam) {
const requestTimestamp = parseInt(tsParam, 10);
if (!isNaN(requestTimestamp)) {
const currentTimestamp = Math.floor(Date.now() / 1000);
if (Math.abs(currentTimestamp - requestTimestamp) <= THRESHOLD_SECONDS) {
const messageToHash = tsParam + SECRET_KEY;
const expectedSign = await generateSHA256(messageToHash);
if (signParam.toLowerCase() === expectedSign.toLowerCase()) {
isAuthenticated = true;
}
}
}
}
if (!isAuthenticated) {
return new Response('<h1>403 Forbidden</h1><p>Invalid Signature or Expired Timestamp</p>', {
status: 403,
headers: { 'Content-Type': 'text/html;charset=UTF-8' }
});
}
// ==========================================
// 3. 提取参数与构建 Payload
// ==========================================
const query = url.searchParams.get('query');
if (!query) {
return new Response('<h1>400 Bad Request</h1><p>Missing parameter: query</p>', {
status: 400,
headers: { 'Content-Type': 'text/html;charset=UTF-8' }
});
}
const payload = { query: query };
if (url.searchParams.has('freshness')) payload.freshness = url.searchParams.get('freshness');
if (url.searchParams.has('summary')) payload.summary = url.searchParams.get('summary') === 'true';
if (url.searchParams.has('include')) payload.include = url.searchParams.get('include');
if (url.searchParams.has('exclude')) payload.exclude = url.searchParams.get('exclude');
if (url.searchParams.has('count')) {
const countVal = parseInt(url.searchParams.get('count'), 10);
if (!isNaN(countVal)) payload.count = countVal;
}
// ==========================================
// 4. 发起请求并渲染 HTML
// ==========================================
const bochaResponse = await fetch("https://api.bocha.cn/v1/web-search", {
method: "POST",
headers: {
"Authorization": `Bearer ${BOCHA_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
const bochaJson = await bochaResponse.json();
let htmlContent = `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Search Results: ${query}</title></head>
<body>
<h1>搜索结果:${query}</h1>
<hr>
`;
if (bochaJson.data && bochaJson.data.webPages && Array.isArray(bochaJson.data.webPages.value)) {
const results = bochaJson.data.webPages.value;
if (results.length === 0) {
htmlContent += `<p>未能找到相关网页结果。</p>`;
} else {
results.forEach((item, index) => {
htmlContent += `
<article style="margin-bottom: 24px;">
<h2><a href="${item.url || ''}">${index + 1}. ${item.name || '无标题'}</a></h2>
<p><strong>来源:</strong> ${item.siteName || '未知'} | <strong>时间:</strong> ${item.datePublished || item.dateLastCrawled || '未知'}</p>
<p><strong>摘要:</strong> ${item.snippet || ''}</p>
`;
if (item.summary) {
htmlContent += `<p><strong>总结:</strong> ${item.summary}</p>`;
}
htmlContent += `</article><hr>`;
});
}
} else {
htmlContent += `<h2>API 请求出错或返回结构异常</h2><pre>${JSON.stringify(bochaJson, null, 2)}</pre>`;
}
htmlContent += `</body></html>`;
return new Response(htmlContent, {
status: 200,
headers: {
"Content-Type": "text/html;charset=UTF-8",
"Access-Control-Allow-Origin": "*"
}
});
} catch (error) {
// 如果再遇到致命错误,会在这里直接被拦截并打印在网页上
return new Response(`
<h1>Worker Error (1101 Prevented)</h1>
<p>内部发生了未捕获的错误:</p>
<pre style="background:#f4f4f4; padding:15px;">${error.stack || error.message || error}</pre>
`, {
status: 500,
headers: { 'Content-Type': 'text/html;charset=UTF-8' }
});
}
},
};
五、给 Notion AI 的调用 Prompt
Worker 只是桥。真正让它变成 Notion AI 技能的,是一段清晰的调用说明。
可以这样写:
接口地址:`https://your-bocha-worker.example.workers.dev` 【URL 参数说明(必须进行 URL 编码)】 - `query` (必填): 你的搜索词。 - `summary` (可选): 是否返回长摘要,传 "true" 或 "false"。建议查阅复杂资料时设为 "true"。 - `count` (可选): 返回条数,1-50。默认 10。 - `freshness` (可选): 时间范围限制。可选值:"noLimit"(默认), "oneDay", "oneWeek", "oneMonth", "oneYear" 或指定日期格式如 "2025-01-01..2025-04-06"。 - `include` (可选): 限制在指定域名内搜索,多个用 `|` 分隔(如 [qq.com|m.163.com](http://qq.com%7Cm.163.com))。 - `exclude` (可选): 排除指定域名,格式同上。 【强制鉴权步骤】 每次发起请求前,必须按以下步骤生成动态鉴权参数: 1. 获取当前准确的 10 位 Unix 时间戳(精确到秒)。 2. 将该时间戳与字符串 "你设计的 32 位随机 Key" 拼接(格式:时间戳+密钥)。 3. 使用计算工具计算拼接字符串的 SHA-256 哈希值(输出小写)。 4. 将时间戳作为 `ts` 参数,哈希值作为 `sign` 参数。 5. 在完成权鉴 URL 的拼接后,不再使用计算机工具,而改为使用浏览网页的工具(web.loadPage)发起搜索。 【调用格式示例】 如果你要搜索 "2024年诺贝尔物理学奖得主",并需要摘要,返回 5 条结果,最终请求的 URL 格式应如下: `https://bocha-notionai.xiaoyao-f87.workers.dev/?query=2024%E5%B9%B4%E8%AF%BA%E8%B4%9D%E5%B0%94%E7%89%A9%E7%90%86%E5%AD%A6%E5%A5%96%E5%BE%97%E4%B8%BB&summary=true&count=5&ts=1715000000&sign=计算出的哈希值` 获取到 JSON 响应后,请解析其中的 `data.webPages.value` 数组,提取 `name`、`url`、`snippet` 和 `summary` 字段,整理并总结后回答用户的问题。
在这个 Prompt 里,Notion AI 只需要知道「如何生成签名」和「如何拼 URL」。真正的 API Key 留在 Worker 里。
如果担心把 SECRET_KEY 也写进 Prompt,可以进一步做一层更保守的设计:让 Worker 接收一个固定的内部 Token,或者改用 Cloudflare Access、IP 限制、一次性短链等方式。但对个人使用场景来说,时间戳 + 哈希签名 已经足够轻量。
六、这个方法可以接入什么
博查只是一个例子。只要第三方服务能被 Worker 调用,就可以用类似方式接进 Notion AI:
- 搜索 API:中文搜索、垂直站点搜索、私有搜索引擎。
- 数据 API:自建数据库、Notion 之外的表格、CRM、日志系统。
- 模型 API:转录、摘要、分类、向量检索、图像理解。
- 自动化 API:Webhook、内部工具、个人服务器上的脚本。
它们都可以被包装成一个 Notion AI 能读懂的网页。
这件事的意义不只是「让 Notion AI 多一个工具」。更准确地说,它让个人可以把自己的外部系统,变成 Notion AI 的可观测边界。
当这些 API 接进来以后,Notion AI 不再只是访问 Notion 页面和公共网页。它可以访问你的订阅源、你的私有搜索、你的自动化流水线、你的本地知识系统。



评论