写一个 Node.js 脚本,用 GitHub Actions 每 6 小时自动把你关注的博客和更新日志(RSS)同步进 Notion 数据库,每篇文章一条记录,带标题、链接、来源、发布时间、摘要和“未读”状态,在 Notion 里就能当作自己的阅读收件箱。
先看清成品
成品是一个 GitHub 仓库:
feeds.json写订阅源,sync.mjs负责同步,.github/workflows/sync.yml定时运行。本文代码已在本地实际测试:用一个模拟 Notion 接口的本地服务验证了请求头Notion-Version: 2026-03-11、按链接去重(同步两次只新增一次)、遇到 429 限流自动重试、超长摘要截断到 2000 字符,2 个测试全部通过;两个示例 RSS 源也实际抓取成功。本文环境没有 Notion 账号,写入真实数据库和 GitHub Actions 定时运行没有实际执行。
注意:只订阅允许 RSS 阅读的公开源,同步内容仅供个人阅读,不要把别人的全文转载到公开页面。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
| 阅读收件箱 | Notion 数据库,一篇文章一条 |
| 自动同步 | GitHub Actions 每 6 小时运行 |
| 去重 | 按链接判断,重复运行不会重复写入 |
| 容错 | 单个源失败不影响其他源,限流自动重试 |
| 测试 | 不需要真实账号就能跑 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| Notion 账号 | 免费计划即可 | 需要能创建集成 |
| GitHub 账号 | 免费 | 私有仓库也可以用 Actions |
| Node.js | 22 | 本地测试用 |
| @notionhq/client | 5.x | 本文使用 5.27.1 |
| rss-parser | 3.x | 本文使用 3.13.0 |
第一步:怎样准备 Notion 数据库?
新建一个数据库“阅读收件箱”,属性名必须和代码一致:
| 属性 | 类型 |
|---|---|
| 标题 | 标题 |
| 链接 | 网址 |
| 来源 | 单选 |
| 发布时间 | 日期 |
| 状态 | 单选,选项:未读、已读、稍后 |
1. 创建内部集成
打开 Notion 的集成管理页(设置 → 连接 → 开发或管理集成,或直接访问 https://www.notion.so/profile/integrations),新建一个 内部集成,选择你的工作区。保存后复制 内部集成密钥(以 ntn_ 开头)。
2. 把数据库共享给集成
打开数据库所在页面,点右上角 ••• → 连接,搜索并添加刚才的集成。没有这一步,API 会返回 object_not_found。
3. 复制数据源 ID
从 2025 年 9 月的 API 版本开始,一个数据库可以包含多个数据源,读写数据要用 数据源 ID,而不是数据库 ID。打开数据库的 ••• → 管理数据源,在数据源的菜单里选择 复制数据源 ID。
注意:集成密钥能读写所有共享给它的页面。只共享需要的数据库,密钥只放在 GitHub Secrets 或本机环境变量里。
第二步:代码怎么写?
1. 项目结构
rss-to-notion/
├── .github/
│ └── workflows/
│ └── sync.yml
├── feeds.json
├── sync.mjs
├── sync.test.mjs
└── package.json
2. package.json
{
"name": "rss-to-notion",
"private": true,
"type": "module",
"scripts": {
"sync": "node sync.mjs",
"test": "node --test"
},
"dependencies": {
"@notionhq/client": "^5.27.1",
"rss-parser": "^3.13.0"
}
}
3. feeds.json
[
{ "name": "Cloudflare 博客", "url": "https://blog.cloudflare.com/rss/" },
{ "name": "GitHub 更新日志", "url": "https://github.blog/changelog/feed/" }
]
本文实际抓取这两个源:Cloudflare 博客返回 20 篇,GitHub 更新日志返回 10 篇。
4. sync.mjs
// 把 RSS 新文章同步到 Notion 数据源;按“链接”去重,重复运行不会产生重复条目
import { readFileSync } from "node:fs";
import { Client } from "@notionhq/client";
import Parser from "rss-parser";
const NOTION_VERSION = "2026-03-11";
const MAX_TEXT = 2000; // Notion 单段富文本最多 2000 字符
export function makeClient(env = process.env) {
return new Client({ auth: env.NOTION_TOKEN, notionVersion: NOTION_VERSION, baseUrl: env.NOTION_BASE_URL });
}
const clip = (s, n = MAX_TEXT) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
const plain = (html = "") => html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
export async function exists(notion, dataSourceId, link) {
const r = await notion.dataSources.query({
data_source_id: dataSourceId,
filter: { property: "链接", url: { equals: link } },
page_size: 1,
});
return r.results.length > 0;
}
export function toPage(dataSourceId, feedName, item) {
const summary = clip(plain(item.contentSnippet ?? item.content ?? ""));
const when = item.isoDate ?? (item.pubDate ? new Date(item.pubDate).toISOString() : null);
return {
parent: { type: "data_source_id", data_source_id: dataSourceId },
properties: {
标题: { title: [{ text: { content: clip(item.title ?? "(无标题)") } }] },
链接: { url: item.link },
来源: { select: { name: feedName } },
...(when ? { 发布时间: { date: { start: when } } } : {}),
状态: { select: { name: "未读" } },
},
children: summary ? [{ object: "block", type: "paragraph", paragraph: { rich_text: [{ text: { content: summary } }] } }] : [],
};
}
export async function syncFeed(notion, parser, dataSourceId, feed, { maxItems = 20 } = {}) {
const parsed = feed.xml ? await parser.parseString(feed.xml) : await parser.parseURL(feed.url);
let added = 0;
for (const item of parsed.items.slice(0, maxItems)) {
if (!item.link || (await exists(notion, dataSourceId, item.link))) continue;
await notion.pages.create(toPage(dataSourceId, feed.name, item));
added++;
}
return added;
}
async function main() {
const { NOTION_DATA_SOURCE_ID: ds } = process.env;
if (!process.env.NOTION_TOKEN || !ds) throw new Error("缺少 NOTION_TOKEN 或 NOTION_DATA_SOURCE_ID 环境变量");
const notion = makeClient();
const parser = new Parser({ timeout: 15000 });
const feeds = JSON.parse(readFileSync(new URL("./feeds.json", import.meta.url), "utf8"));
let total = 0;
for (const feed of feeds) {
try {
const n = await syncFeed(notion, parser, ds, feed);
console.log(`${feed.name}:新增 ${n} 篇`);
total += n;
} catch (e) {
console.error(`${feed.name} 同步失败:${e.code ?? ""} ${e.message}`); // 单个源失败不影响其他源
}
}
console.log(`完成,共新增 ${total} 篇`);
}
if (import.meta.url === `file://${process.argv[1]}`) main().catch((e) => { console.error(e.message); process.exit(1); });
| 关键点 | 说明 |
|---|---|
notionVersion: "2026-03-11" |
SDK 默认版本较旧,这里显式指定当前版本 |
dataSources.query |
新版 API 用数据源查询,替代旧的数据库查询 |
parent.data_source_id |
新建页面时父级填数据源 ID |
clip(..., 2000) |
单个富文本对象最多 2000 字符 |
| SDK 自动重试 | 遇到 429 限流会按 Retry-After 等待后重试 |
maxItems = 20 |
每个源每次最多处理 20 篇,首次同步不会一下写入几百条 |
第三步:怎样测试?
1. sync.test.mjs
// 用本地假 Notion 服务测试:校验请求头、去重、429 重试
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import Parser from "rss-parser";
import { makeClient, syncFeed, toPage } from "./sync.mjs";
const RSS = `<?xml version="1.0"?><rss version="2.0"><channel><title>t</title>
<item><title>第一篇</title><link>https://example.com/1</link><pubDate>Tue, 20 Oct 2026 08:00:00 GMT</pubDate><description><p>你好</p></description></item>
<item><title>第二篇</title><link>https://example.com/2</link><description>${"长".repeat(2500)}</description></item>
</channel></rss>`;
const pages = [];
const seenVersions = new Set();
let throttleOnce = true;
let server, base;
before(async () => {
server = http.createServer(async (req, res) => {
let body = "";
for await (const c of req) body += c;
seenVersions.add(req.headers["notion-version"]);
const send = (code, obj, headers = {}) => { res.writeHead(code, { "content-type": "application/json", ...headers }); res.end(JSON.stringify(obj)); };
if (req.url.startsWith("/v1/data_sources/ds1/query")) {
if (throttleOnce) { throttleOnce = false; return send(429, { object: "error", status: 429, code: "rate_limited", message: "slow down" }, { "retry-after": "1" }); }
const url = JSON.parse(body).filter.url.equals;
return send(200, { object: "list", results: pages.filter((p) => p.properties.链接.url === url), has_more: false, next_cursor: null });
}
if (req.url === "/v1/pages" && req.method === "POST") { const p = JSON.parse(body); pages.push(p); return send(200, { object: "page", id: "p" + pages.length }); }
send(404, { object: "error", status: 404, code: "object_not_found", message: req.url });
});
await new Promise((r) => server.listen(0, r));
base = `http://localhost:${server.address().port}`;
});
after(() => server.close());
test("toPage 截断超长摘要到 2000 字符", () => {
const p = toPage("ds1", "源", { title: "x", link: "https://e.com", contentSnippet: "字".repeat(3000) });
assert.equal(p.children[0].paragraph.rich_text[0].text.content.length, 2000);
assert.deepEqual(p.parent, { type: "data_source_id", data_source_id: "ds1" });
});
test("同步两次只新增一次,遇到 429 自动重试", async () => {
const notion = makeClient({ NOTION_TOKEN: "secret_test", NOTION_BASE_URL: base });
const parser = new Parser();
assert.equal(await syncFeed(notion, parser, "ds1", { name: "测试源", xml: RSS }), 2);
assert.equal(await syncFeed(notion, parser, "ds1", { name: "测试源", xml: RSS }), 0);
assert.equal(pages.length, 2);
assert.equal(pages[0].properties.发布时间.date.start, "2026-10-20T08:00:00.000Z");
assert.deepEqual([...seenVersions], ["2026-03-11"]);
});
测试启动一个本地 HTTP 服务假装是 Notion,第一次查询故意返回 429,检验 SDK 会重试。
2. 运行
npm install
npm test
本文实际输出:
ok 1 - toPage 截断超长摘要到 2000 字符
ok 2 - 同步两次只新增一次,遇到 429 自动重试
# pass 2
# fail 0
3. 连真实数据库试一次
export NOTION_TOKEN=ntn_你的密钥
export NOTION_DATA_SOURCE_ID=你的数据源ID
npm run sync
期望输出类似:
Cloudflare 博客:新增 20 篇
GitHub 更新日志:新增 10 篇
完成,共新增 30 篇
再运行一次,每个源都应该是“新增 0 篇”。
第四步:怎样让 GitHub 定时运行?
1. 工作流文件
name: RSS 同步到 Notion
on:
schedule:
- cron: "0 */6 * * *" # 每 6 小时一次(UTC 时间)
workflow_dispatch: # 允许在网页上手动运行
jobs:
sync:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run sync
env:
NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
NOTION_DATA_SOURCE_ID: ${{ secrets.NOTION_DATA_SOURCE_ID }}
cron 使用 UTC 时间。0 */6 * * * 对应北京时间 8 点、14 点、20 点和凌晨 2 点。
2. 配置 Secrets
在仓库 Settings → Secrets and variables → Actions 添加:
| 名称 | 值 |
|---|---|
NOTION_TOKEN |
集成密钥 |
NOTION_DATA_SOURCE_ID |
数据源 ID |
3. 手动运行一次
在 Actions 页面选择“RSS 同步到 Notion”,点 Run workflow。
4. 检查结果
| 位置 | 期望 |
|---|---|
| Actions 日志 | 每个源一行“新增 N 篇” |
| Notion 数据库 | 出现新记录,状态为“未读” |
| 第二次运行 | 新增数为 0 或只有新文章 |
出问题时该怎么调?
1. 报 object_not_found
Could not find data_source with ID: ...
| 原因 | 解决方法 |
|---|---|
| 数据库没共享给集成 | 页面 ••• → 连接 添加集成 |
| 用了数据库 ID | 改用数据源 ID |
| ID 复制不完整 | 重新复制 |
2. 报 validation_error,提示属性不存在
数据库里的属性名和代码不一致,比如“链接”写成了“网址”。改数据库属性名,或修改 toPage 和 exists 里的名称。
3. 某个源一直失败
日志里会显示 某某 同步失败:...。用浏览器打开该 RSS 地址确认是否还能访问;有的网站会拦截没有浏览器标识的请求。
4. Actions 没有按时运行
GitHub 的定时任务在高峰期可能延后;公开仓库 60 天没有活动时,定时工作流会被自动停用,需要在 Actions 页面重新启用。
常见问题怎么处理?
| 现象 | 原因 | 解决方法 |
|---|---|---|
| 429 rate_limited 频繁 | 一次写入太多 | 调小 maxItems,SDK 会自动重试 |
| 发布时间为空 | 源里没有日期 | 正常,Notion 里显示为空 |
| 摘要里有乱码符号 | 源里是 HTML 实体 | 用 contentSnippet 已经去掉大部分标签 |
| 想同步全文 | 版权与体积问题 | 只存摘要和链接 |
| 本地运行提示缺少变量 | 没导出环境变量 | 先 export 再运行 |
| 想加新源 | 在 feeds.json 加一行并提交 |
项目资源
建议先在本地跑通 npm test,再用真实密钥同步一次,确认 Notion 里出现文章、并且第二次运行新增为 0。
本地没问题后再推到 GitHub,手动触发一次工作流,最后才依赖定时运行。