效率工具

Notion + GitHub Actions:自动同步 RSS 到阅读数据库

写一个 Node.js 脚本,用 GitHub Actions 每 6 小时自动把你关注的博客和更新日志(RSS)同步进 Notion 数据库,每篇文章一条记录,带标题、链接、来源、发布时间、摘要和“未读”状态,在 Notion 里就能当作自己的阅读收件箱。

BINBHO · 图文教程

写一个 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>&lt;p&gt;你好&lt;/p&gt;</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 加一行并提交

项目资源

用途 地址
Notion API 入门 https://developers.notion.com/docs/getting-started
创建集成 https://developers.notion.com/docs/create-a-notion-integration
数据源查询接口 https://developers.notion.com/reference/query-a-data-source
创建页面接口 https://developers.notion.com/reference/post-page
请求限制 https://developers.notion.com/reference/request-limits
Notion JS SDK https://github.com/makenotion/notion-sdk-js
rss-parser https://github.com/rbren/rss-parser
GitHub Actions 定时触发 https://docs.github.com/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#schedule

建议先在本地跑通 npm test,再用真实密钥同步一次,确认 Notion 里出现文章、并且第二次运行新增为 0。

本地没问题后再推到 GitHub,手动触发一次工作流,最后才依赖定时运行。