用 Notion 拉稿、传七牛、推 Astro:我的自动化折腾

最近想把写稿场地搬到 Notion,同时让 Astro 博客自动吃到新文章、图片走七牛,还能一 键在 Actions 里发布。过程里踩了几个坑,顺手记一下。

需求拆解

  • Notion 里的数据库是中文列:状态(已发布/待发布/草稿)、发布日期、Slug、描述、 封面、作者、标签、分类、是否固定。
  • 草稿也要落地,但 frontmatter draft: true,方便本地看。
  • 待发布自动改成已发布(可关)。
  • 图片用 Notion 临时链接下载,上传七牛,末尾追加 -small.webp。
  • 行为可 dry-run,支持直接用七牛 upload token(无 AK/SK 时)。

核心脚本

scripts/sync-notion-posts.mjs
28 collapsed lines
#!/usr/bin/env node
/**
* Sync published / scheduled Notion posts into local markdown files.
*
* Behavior:
* - Status = 已发布 → if content changed, update local markdown.
* - Status = 待发布 → publish immediately (write markdown) and optionally flip status to 已发布.
* - Status = 草稿 → sync with `draft: true` frontmatter (keep as draft locally).
*
* Env vars:
* NOTION_TOKEN (required) Notion internal integration token.
* NOTION_DATABASE_ID (default: 2c0ba7bb26c88020b2f7e2783df40ca3)
* OUTPUT_DIR (default: src/content/posts)
* NOTION_STATUS_FIELD (default: Status; falls back to first status/select property)
* NOTION_PUBLISHED_VALUE (default: 已发布)
* NOTION_PENDING_VALUE (default: 待发布)
* NOTION_DRAFT_VALUE (default: 草稿)
* NOTION_SLUG_FIELD (default: Slug; if missing, slugify title)
* NOTION_DATE_FIELD (default: PublishDate; fallback to page created_time)
* NOTION_TAGS_FIELD (default: Tags)
* NOTION_CATEGORIES_FIELD (default: Categories)
* NOTION_SUMMARY_FIELD (default: Summary)
* NOTION_COVER_FIELD (default: Cover; fallback to page.cover)
* NOTION_AUTHOR_FIELD (default: Author)
* UPDATE_PENDING_TO_PUBLISHED (default: true)
* QINIU_ACCESS_KEY / QINIU_SECRET_KEY / QINIU_BUCKET / QINIU_HOST / QINIU_UPLOAD_URL / QINIU_PREFIX
*/
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
const NOTION_API_BASE = "https://api.notion.com/v1";
const NOTION_VERSION = "2022-06-28";
const DEFAULT_DATABASE_ID = "2c0ba7bb26c88020b2f7e2783df40ca3";
const ARGS = new Set(process.argv.slice(2));
const DRY_RUN =
837 collapsed lines
process.env.DRY_RUN === "1" ||
process.env.DRY_RUN === "true" ||
ARGS.has("--dry-run") ||
ARGS.has("-n");
const CONFIG = {
notionToken: process.env.NOTION_TOKEN,
databaseId: process.env.NOTION_DATABASE_ID || DEFAULT_DATABASE_ID,
outputDir: path.resolve(
process.env.OUTPUT_DIR || "src/content/posts",
),
// Database currently uses 中文字段;默认对齐现有库
statusField: process.env.NOTION_STATUS_FIELD || "状态",
publishedValue: process.env.NOTION_PUBLISHED_VALUE || "已发布",
pendingValue: process.env.NOTION_PENDING_VALUE || "待发布",
draftValue: process.env.NOTION_DRAFT_VALUE || "草稿",
slugField: process.env.NOTION_SLUG_FIELD || "Slug",
dateField: process.env.NOTION_DATE_FIELD || "发布日期",
tagsField: process.env.NOTION_TAGS_FIELD || "标签",
categoriesField: process.env.NOTION_CATEGORIES_FIELD || "分类",
summaryField: process.env.NOTION_SUMMARY_FIELD || "描述",
coverField: process.env.NOTION_COVER_FIELD || "封面",
authorField: process.env.NOTION_AUTHOR_FIELD || "作者",
pinnedField: process.env.NOTION_PINNED_FIELD || "是否固定",
syncTimeField: process.env.NOTION_SYNC_TIME_FIELD || "上次同步时间",
syncHashField: process.env.NOTION_SYNC_HASH_FIELD || "上次同步哈希",
updatePendingToPublished:
process.env.UPDATE_PENDING_TO_PUBLISHED !== "false",
qiniu: {
accessKey: process.env.QINIU_ACCESS_KEY,
secretKey: process.env.QINIU_SECRET_KEY,
bucket: process.env.QINIU_BUCKET,
uploadToken: process.env.QINIU_UPLOAD_TOKEN,
host: process.env.QINIU_HOST || "https://cdn.hluvmiku.tech",
uploadUrl:
process.env.QINIU_UPLOAD_URL ||
"http://up-z2.qiniup.com", // match existing Scriptable uploader (华南)
prefix: process.env.QINIU_PREFIX || "image/",
styleSuffix: process.env.QINIU_STYLE_SUFFIX || "-small.webp",
},
};
if (!CONFIG.notionToken) {
console.error("Missing NOTION_TOKEN.");
process.exit(1);
}
const headers = {
Authorization: `Bearer ${CONFIG.notionToken}`,
"Notion-Version": NOTION_VERSION,
};
const listTypes = new Set([
"bulleted_list_item",
"numbered_list_item",
"to_do",
]);
const blockChildrenCache = new Map();
const imageUploadCache = new Map();
let resolvedStatusField = null;
let resolvedStatusType = null;
let hasDateProperty = false;
let hasSyncTimeField = false;
let hasSyncHashField = false;
async function notionRequest(endpoint, options = {}) {
const resp = await fetch(`${NOTION_API_BASE}${endpoint}`, {
...options,
headers: {
...headers,
"Content-Type": "application/json",
...options.headers,
},
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(
`Notion API error ${resp.status} ${resp.statusText} on ${endpoint}: ${text}`,
);
}
return resp.json();
}
async function fetchDatabaseSchema(databaseId) {
return notionRequest(`/databases/${databaseId}`);
}
function resolveStatusProperty(database) {
if (database.properties[CONFIG.statusField]) {
const prop = database.properties[CONFIG.statusField];
return { name: CONFIG.statusField, type: prop.type };
}
// Fallback: pick the first status/select property.
const entry = Object.entries(database.properties).find(
([, prop]) => prop.type === "status" || prop.type === "select",
);
if (!entry) {
throw new Error(
"Unable to find a status/select property on the Notion database.",
);
}
const [name, prop] = entry;
console.warn(
`Using "${name}" as status field (type: ${prop.type}) because NOTION_STATUS_FIELD was not found.`,
);
return { name, type: prop.type };
}
function statusFilter(type, value) {
if (type === "select") {
return { select: { equals: value } };
}
if (type === "status") {
return { status: { equals: value } };
}
// Fallback to text equality (unlikely).
return { rich_text: { equals: value } };
}
function statusUpdate(type, value) {
if (type === "select") {
return { select: { name: value } };
}
if (type === "status") {
return { status: { name: value } };
}
return { rich_text: [{ text: { content: value } }] };
}
async function queryDatabase(databaseId) {
const filters = [];
filters.push({
property: resolvedStatusField,
...statusFilter(resolvedStatusType, CONFIG.publishedValue),
});
filters.push({
property: resolvedStatusField,
...statusFilter(resolvedStatusType, CONFIG.pendingValue),
});
filters.push({
property: resolvedStatusField,
...statusFilter(resolvedStatusType, CONFIG.draftValue),
});
const sorts = hasDateProperty
? [
{
property: CONFIG.dateField,
direction: "descending",
},
]
: [
{
timestamp: "created_time",
direction: "descending",
},
];
const payload = {
filter: { or: filters },
sorts,
};
const pages = [];
let hasMore = true;
let startCursor = undefined;
while (hasMore) {
const resp = await notionRequest(
`/databases/${databaseId}/query`,
startCursor
? { method: "POST", body: JSON.stringify({ ...payload, start_cursor: startCursor }) }
: { method: "POST", body: JSON.stringify(payload) },
);
pages.push(...resp.results);
hasMore = resp.has_more;
startCursor = resp.next_cursor;
}
return pages;
}
async function fetchBlockChildren(blockId) {
if (blockChildrenCache.has(blockId)) {
return blockChildrenCache.get(blockId);
}
const results = [];
let hasMore = true;
let startCursor = undefined;
while (hasMore) {
const resp = await notionRequest(
`/blocks/${blockId}/children?page_size=100${
startCursor ? `&start_cursor=${startCursor}` : ""
}`,
);
results.push(...resp.results);
hasMore = resp.has_more;
startCursor = resp.next_cursor;
}
blockChildrenCache.set(blockId, results);
return results;
}
function plainTextFromRichText(richText = []) {
return richText.map((t) => t.plain_text || "").join("");
}
function sanitizeInstructionText(text = "") {
return text.replace(/^这里应该转换成\s*/u, "");
}
function renderRichText(richText = []) {
return richText
.map((item) => {
const text = sanitizeInstructionText(item.plain_text || "");
const annotations = item.annotations || {};
const link = item.href;
let rendered = text;
if (annotations.code) rendered = `\`${rendered}\``;
if (annotations.bold) rendered = `**${rendered}**`;
if (annotations.italic) rendered = `*${rendered}*`;
if (annotations.strikethrough) rendered = `~~${rendered}~~`;
if (annotations.underline) rendered = `<u>${rendered}</u>`;
if (link) {
const normalized = link
.replace(/^https?:\/\//i, "")
.replace(/\/$/, "");
rendered = `[${rendered}](${normalized})`;
}
return rendered;
})
.join("");
}
function indent(text, depth) {
const pad = " ".repeat(depth);
return text
.split("\n")
.map((line) => (line ? `${pad}${line}` : line))
.join("\n");
}
function prefixBlockquote(text) {
return text
.split("\n")
.map((line) => `> ${line}`)
.join("\n");
}
async function renderBlocks(blocks, depth = 0) {
const lines = [];
let prevWasList = false;
for (const block of blocks) {
const rendered = await renderBlock(block, depth);
if (!rendered) continue;
const isList = listTypes.has(block.type);
if (lines.length && !(prevWasList && isList)) {
lines.push("");
}
lines.push(rendered);
prevWasList = isList;
}
return lines.join("\n");
}
async function renderBlock(block, depth) {
const { type, has_children: hasChildren, id } = block;
const data = block[type];
switch (type) {
case "paragraph": {
const text = renderRichText(data.rich_text);
if (!text.trim() && !hasChildren) return "";
const child = hasChildren
? `\n${await renderBlocks(await fetchBlockChildren(id), depth)}`
: "";
return `${text}${child}`;
}
case "heading_1":
return `# ${renderRichText(data.rich_text)}`;
case "heading_2":
return `## ${renderRichText(data.rich_text)}`;
case "heading_3":
return `### ${renderRichText(data.rich_text)}`;
case "quote": {
const text = renderRichText(data.rich_text);
const normalized = text.replace(/^>\s?/, "");
return normalized
.split("\n")
.map((line) => `> ${line}`)
.join("\n");
}
case "bulleted_list_item": {
const text = renderRichText(data.rich_text);
const children = hasChildren
? `\n${indent(
await renderBlocks(await fetchBlockChildren(id), depth + 1),
depth + 1,
)}`
: "";
return `${" ".repeat(depth)}- ${text}${children}`;
}
case "numbered_list_item": {
const text = renderRichText(data.rich_text);
const children = hasChildren
? `\n${indent(
await renderBlocks(await fetchBlockChildren(id), depth + 1),
depth + 1,
)}`
: "";
return `${" ".repeat(depth)}1. ${text}${children}`;
}
case "to_do": {
const text = renderRichText(data.rich_text);
const checked = data.checked ? "x" : " ";
const children = hasChildren
? `\n${indent(
await renderBlocks(await fetchBlockChildren(id), depth + 1),
depth + 1,
)}`
: "";
return `${" ".repeat(depth)}- [${checked}] ${text}${children}`;
}
case "toggle": {
const label = renderRichText(data.rich_text) || "toggle";
const children = hasChildren
? await renderBlocks(await fetchBlockChildren(id), depth + 1)
: "";
const cleanedChildren = children
.split("\n")
.map((line, idx) => {
const stripped = line.replace(/^>\s?/, "");
return stripped.trimEnd();
})
.filter((line) => line.trim() !== `[!${label}]`)
.join("\n")
.trim();
const renderedChildren = cleanedChildren
? `\n${prefixBlockquote(cleanedChildren)}`
: "";
return `> [!${label}]${renderedChildren}`.trimEnd();
}
case "callout": {
const emoji = data.icon?.emoji ? `${data.icon.emoji} ` : "";
const text = renderRichText(data.rich_text);
const content = `> ${emoji}${text}`;
const children = hasChildren
? `\n${indent(
await renderBlocks(await fetchBlockChildren(id), depth + 1),
depth + 1,
)}`
: "";
return content + (children ? `\n${children}` : "");
}
case "code": {
const lang = data.language || "";
const captionText = plainTextFromRichText(data.caption);
const meta =
captionText
?.replace(/[“”]/g, '"')
.replace(/\s+/g, " ")
.trim() || "";
const text = sanitizeInstructionText(
data.rich_text.map((t) => t.plain_text || "").join(""),
);
if (text.trim().startsWith("```")) {
return text.trim();
}
const fence = ["```" + lang, meta].filter(Boolean).join(" ").trim();
return [fence, text, "```"].join("\n");
}
case "divider":
return "---";
case "image": {
const imageInfo = data;
const caption = plainTextFromRichText(imageInfo.caption);
const url = await resolveImageUrl(imageInfo);
if (!caption) return `![](${url})`;
return `![${caption}](${url}){${caption}}`;
}
case "synced_block": {
const children = await fetchBlockChildren(id);
return await renderBlocks(children, depth);
}
case "column_list": {
const columns = await fetchBlockChildren(id);
const renderedColumns = await Promise.all(
columns.map(async (col) =>
renderBlocks(await fetchBlockChildren(col.id), depth),
),
);
return renderedColumns.filter(Boolean).join("\n\n");
}
case "column": {
const children = await fetchBlockChildren(id);
return await renderBlocks(children, depth);
}
case "bookmark":
case "embed": {
const url = data.url;
return url ? `[${url}](${url})` : "";
}
case "equation": {
const expression = data.expression || "";
return ["$$", expression, "$$"].join("\n");
}
case "table": {
// Minimal table support: render header row + rows.
const rows = await fetchBlockChildren(id);
const matrix = [];
for (const row of rows) {
if (row.type !== "table_row") continue;
matrix.push(
(row.table_row.cells || []).map((cell) => renderRichText(cell)),
);
}
if (!matrix.length) return "";
const header = matrix[0];
const separator = header.map(() => "---");
const body = matrix.slice(1);
const parts = [
`| ${header.join(" | ")} |`,
`| ${separator.join(" | ")} |`,
...body.map((r) => `| ${r.join(" | ")} |`),
];
return parts.join("\n");
}
default:
console.warn(`Unsupported block type "${type}", skipped.`);
return "";
}
}
function slugify(input) {
return input
.normalize("NFKD")
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.trim()
.replace(/[\s_-]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function pickStatusName(property) {
if (!property) return "";
if (property.type === "select") return property.select?.name || "";
if (property.type === "status") return property.status?.name || "";
return plainTextFromRichText(property.rich_text || []);
}
function pickText(property) {
if (!property) return "";
if (property.type === "title") return plainTextFromRichText(property.title);
if (property.type === "rich_text")
return plainTextFromRichText(property.rich_text);
if (property.type === "url") return property.url || "";
return "";
}
function pickDate(property, fallbackDate) {
if (property?.type === "date" && property.date?.start) {
return property.date.start;
}
return fallbackDate;
}
function pickSyncTime(property) {
const value = pickDate(property, null);
return value ? new Date(value) : null;
}
function pickMulti(property) {
if (!property) return [];
if (property.type === "multi_select") {
return property.multi_select.map((item) => item.name).filter(Boolean);
}
return [];
}
function pickFiles(property) {
if (!property || property.type !== "files") return [];
return property.files || [];
}
function padArrays(value) {
return Array.isArray(value) ? value : [];
}
function buildFrontmatter(data) {
const lines = ["---"];
for (const [key, value] of Object.entries(data)) {
if (value === undefined || value === null) continue;
if (typeof value === "string") {
const escaped = value.replace(/"/g, '\\"');
lines.push(`${key}: "${escaped}"`);
} else if (typeof value === "boolean" || typeof value === "number") {
lines.push(`${key}: ${value}`);
} else if (Array.isArray(value)) {
const serialized = value
.map((v) => `"${String(v).replace(/"/g, '\\"')}"`)
.join(", ");
lines.push(`${key}: [${serialized}]`);
}
}
lines.push("---", "");
return lines.join("\n");
}
async function readExistingMetadata(filePath) {
try {
const content = await fs.readFile(filePath, "utf8");
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (!frontmatterMatch) return {};
const metaText = frontmatterMatch[1];
const meta = {};
for (const line of metaText.split("\n")) {
const [rawKey, ...rest] = line.split(":");
if (!rawKey || rest.length === 0) continue;
const key = rawKey.trim();
const value = rest.join(":").trim().replace(/^"|"$/g, "");
meta[key] = value;
}
return meta;
} catch (error) {
if (error.code === "ENOENT") return {};
throw error;
}
}
function summarizeFromBlocks(blocks) {
const plainPieces = [];
for (const block of blocks) {
if (block.type === "paragraph" || block.type?.startsWith("heading_")) {
const text = plainTextFromRichText(block[block.type]?.rich_text);
if (text) plainPieces.push(text);
}
if (plainPieces.join(" ").length > 200) break;
}
return plainPieces.join(" ").slice(0, 200);
}
function resolveExtensionFromUrl(url, contentType) {
const urlExtMatch = url.match(/\.(\w{1,5})(?:\?|$)/);
if (urlExtMatch) return urlExtMatch[1];
if (!contentType) return "png";
if (contentType.includes("jpeg")) return "jpg";
if (contentType.includes("png")) return "png";
if (contentType.includes("gif")) return "gif";
if (contentType.includes("webp")) return "webp";
return "png";
}
function base64url(input) {
return Buffer.from(input)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
function applyStyleSuffix(url) {
if (!CONFIG.qiniu.styleSuffix) return url;
if (url.endsWith(CONFIG.qiniu.styleSuffix)) return url;
return `${url}${CONFIG.qiniu.styleSuffix}`;
}
function buildQiniuUploadToken(scope) {
if (CONFIG.qiniu.uploadToken) {
return CONFIG.qiniu.uploadToken;
}
const { accessKey, secretKey } = CONFIG.qiniu;
const deadline = Math.floor(Date.now() / 1000) + 3600;
const putPolicy = { scope, deadline };
const encodedPolicy = base64url(JSON.stringify(putPolicy));
const sign = crypto
.createHmac("sha1", secretKey)
.update(encodedPolicy)
.digest();
const encodedSign = base64url(sign);
return `${accessKey}:${encodedSign}:${encodedPolicy}`;
}
async function uploadBufferToQiniu(buffer, key, contentType) {
const { bucket, host, uploadUrl } = CONFIG.qiniu;
const token = buildQiniuUploadToken(bucket);
const form = new FormData();
form.append("key", key);
form.append("token", token);
form.append("file", new Blob([buffer], { type: contentType }), key);
const resp = await fetch(uploadUrl, {
method: "POST",
body: form,
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`Qiniu upload failed: ${resp.status} ${text}`);
}
const result = await resp.json();
const prefix = host.endsWith("/") ? host : `${host}/`;
return applyStyleSuffix(`${prefix}${result.key}`);
}
async function resolveImageUrl(imageInfo) {
const url =
imageInfo.type === "external"
? imageInfo.external.url
: imageInfo.file?.url;
if (!url) return "";
if (
(!CONFIG.qiniu.uploadToken &&
(!CONFIG.qiniu.accessKey ||
!CONFIG.qiniu.secretKey ||
!CONFIG.qiniu.bucket)) ||
!CONFIG.qiniu.host
) {
return url;
}
// DRY_RUN: simulate target CDN url without uploading.
if (DRY_RUN) {
const ext = resolveExtensionFromUrl(url, "");
const key = `${CONFIG.qiniu.prefix}${Date.now()}-${crypto
.randomBytes(4)
.toString("hex")}.${ext}`;
const prefix = CONFIG.qiniu.host.endsWith("/")
? CONFIG.qiniu.host
: `${CONFIG.qiniu.host}/`;
const target = applyStyleSuffix(`${prefix}${key}`);
console.log(`[dry-run] Qiniu would upload ${url} as ${target}`);
return target;
}
if (imageUploadCache.has(url)) {
return imageUploadCache.get(url);
}
const resp = await fetch(url);
if (!resp.ok) {
console.warn(`Failed to fetch image for upload (${resp.status}), using original URL.`);
return url;
}
const contentType = resp.headers.get("content-type") || "application/octet-stream";
const buffer = Buffer.from(await resp.arrayBuffer());
const ext = resolveExtensionFromUrl(url, contentType);
const key = `${CONFIG.qiniu.prefix}${Date.now()}-${crypto
.randomBytes(4)
.toString("hex")}.${ext}`;
try {
const uploadedUrl = await uploadBufferToQiniu(buffer, key, contentType);
imageUploadCache.set(url, uploadedUrl);
return uploadedUrl;
} catch (error) {
console.warn(`Upload failed (${error.message}), using original URL.`);
return url;
}
}
function extractCoverFromPage(page) {
if (!page?.cover) return null;
if (page.cover.type === "external")
return { url: page.cover.external.url, type: "external" };
if (page.cover.type === "file")
return { url: page.cover.file.url, type: "file" };
return null;
}
function normalizeNewline(str) {
return str.replace(/\r\n/g, "\n").trimEnd() + "\n";
}
async function processPage(page) {
const titleProp = Object.values(page.properties).find(
(prop) => prop.type === "title",
);
const title = titleProp ? plainTextFromRichText(titleProp.title) : "Untitled";
const slugProp = page.properties[CONFIG.slugField];
const slugValue = slugProp ? slugify(pickText(slugProp)) : "";
const slug =
slugValue || slugify(title) || page.id.replace(/-/g, "");
const statusProp = page.properties[resolvedStatusField];
const statusName = pickStatusName(statusProp);
if (!statusName) {
console.warn(`Skip page ${title}: no status found.`);
return null;
}
const isDraft = statusName === CONFIG.draftValue;
const blocks = await fetchBlockChildren(page.id);
const bodyMarkdown = await renderBlocks(blocks);
const summaryProp = page.properties[CONFIG.summaryField];
const summary = pickText(summaryProp) || "";
const dateProp = page.properties[CONFIG.dateField];
const tzNow = new Date(
new Date().toLocaleString("en-US", { timeZone: "Asia/Shanghai" }),
);
const publishDate =
pickDate(dateProp, null) ||
tzNow.toISOString();
const tags = pickMulti(page.properties[CONFIG.tagsField]);
const categories = pickMulti(page.properties[CONFIG.categoriesField]);
const author = pickText(page.properties[CONFIG.authorField]) || undefined;
const pinnedProp = page.properties[CONFIG.pinnedField];
const pinned =
pinnedProp && pinnedProp.type === "checkbox" ? pinnedProp.checkbox : false;
const coverFiles = pickFiles(page.properties[CONFIG.coverField]);
const coverFromPropInfo = coverFiles.length > 0 ? coverFiles[0] : null;
const coverSource =
coverFromPropInfo && coverFromPropInfo[coverFromPropInfo.type]?.url
? {
url: coverFromPropInfo[coverFromPropInfo.type].url,
type: coverFromPropInfo.type,
}
: extractCoverFromPage(page);
const existingMeta = await readExistingMetadata(
path.join(CONFIG.outputDir, `${slug}.md`),
);
// If Notion记录了上次同步时间,且最后编辑时间 <= 同步时间+1min,则认为未变化。
const syncTimeProp = page.properties[CONFIG.syncTimeField];
const lastSyncDate = hasSyncTimeField ? pickSyncTime(syncTimeProp) : null;
const lastEdited = new Date(page.last_edited_time);
if (lastSyncDate) {
const threshold = new Date(lastSyncDate.getTime() + 60_000);
if (lastEdited <= threshold) {
console.log(`Unchanged (within sync window): ${title}`);
return { skipWrite: true, slug, statusName };
}
} else if (existingMeta.notion_last_edited_time === page.last_edited_time) {
console.log(`Unchanged: ${title}`);
return { skipWrite: true, slug, statusName };
}
const coverUrl =
coverSource &&
(await resolveImageUrl({
type:
coverSource.type ||
(coverSource.url.startsWith("http") ? "external" : "file"),
external: { url: coverSource.url },
file: { url: coverSource.url },
caption: [],
}));
const processedBody = await replaceImagesInMarkdown(bodyMarkdown);
const finalFrontmatter = buildFrontmatter({
title,
description: summary || "",
date: publishDate,
image: coverUrl || coverSource?.url || "",
categories: padArrays(categories),
author: author || "",
tags: padArrays(tags),
draft: isDraft,
meta_title: title,
keywords: padArrays(tags),
pinned,
notion_page_id: page.id,
notion_status: statusName,
notion_last_edited_time: page.last_edited_time,
});
const markdown = normalizeNewline(`${finalFrontmatter}${processedBody}`);
return { slug, markdown, statusName };
}
async function replaceImagesInMarkdown(markdown) {
const imageRegex = /!\[(.*?)\]\((https?:[^)\s]+)\)/g;
let match;
let result = markdown;
while ((match = imageRegex.exec(markdown)) !== null) {
const [full, alt, url] = match;
if (CONFIG.qiniu.host && url.startsWith(CONFIG.qiniu.host)) continue;
const uploaded = await resolveImageUrl({
type: "external",
external: { url },
caption: alt ? [{ plain_text: alt, annotations: {}, type: "text" }] : [],
});
result = result.replace(full, `![${alt}](${uploaded})`);
}
return result;
}
async function updatePageStatus(pageId, value) {
if (!CONFIG.updatePendingToPublished) return;
const properties = {
[resolvedStatusField]: statusUpdate(resolvedStatusType, value),
};
try {
await notionRequest(`/pages/${pageId}`, {
method: "PATCH",
body: JSON.stringify({ properties }),
});
console.log(`Updated status to ${value} for page ${pageId}.`);
} catch (error) {
console.warn(`Failed to update status for ${pageId}: ${error.message}`);
}
}
async function updatePageSyncMeta(pageId, hash) {
if ((!hasSyncTimeField && !hasSyncHashField) || DRY_RUN) return;
const properties = {};
const nowIso = new Date().toISOString();
if (hasSyncTimeField) {
properties[CONFIG.syncTimeField] = { date: { start: nowIso } };
}
if (hasSyncHashField) {
properties[CONFIG.syncHashField] = {
rich_text: [{ text: { content: hash || "" } }],
};
}
try {
await notionRequest(`/pages/${pageId}`, {
method: "PATCH",
body: JSON.stringify({ properties }),
});
console.log(`Updated sync meta for ${pageId}.`);
} catch (error) {
console.warn(`Failed to update sync meta for ${pageId}: ${error.message}`);
}
}
async function ensureOutputDir() {
await fs.mkdir(CONFIG.outputDir, { recursive: true });
}
async function main() {
if (DRY_RUN) {
console.log("Running in DRY_RUN mode: no files will be written, no status updates.");
}
const database = await fetchDatabaseSchema(CONFIG.databaseId);
const statusInfo = resolveStatusProperty(database);
resolvedStatusField = statusInfo.name;
resolvedStatusType = statusInfo.type;
hasDateProperty = Boolean(database.properties[CONFIG.dateField]);
hasSyncTimeField = Boolean(database.properties[CONFIG.syncTimeField]);
hasSyncHashField = Boolean(database.properties[CONFIG.syncHashField]);
await ensureOutputDir();
const pages = await queryDatabase(CONFIG.databaseId);
console.log(`Found ${pages.length} candidate pages.`);
for (const page of pages) {
const processed = await processPage(page);
if (!processed) continue;
if (processed.skipWrite) {
if (processed.statusName === CONFIG.pendingValue) {
await updatePageStatus(page.id, CONFIG.publishedValue);
}
continue;
}
const filePath = path.join(CONFIG.outputDir, `${processed.slug}.md`);
if (DRY_RUN) {
console.log(`[dry-run] Would write: ${filePath} (status: ${processed.statusName})`);
continue;
}
await fs.writeFile(filePath, processed.markdown, "utf8");
console.log(`Written: ${filePath}`);
if (processed.statusName === CONFIG.pendingValue) {
await updatePageStatus(page.id, CONFIG.publishedValue);
}
await updatePageSyncMeta(page.id, processed.currentHash);
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

GitHub Actions 工作流(.github/workflows/notion-posts.yml)

.github/workflows/notion-posts.yml
name: 📝 Sync Notion Posts
on:
schedule:
- cron: "5 19 * * *" # 03:05 Asia/Shanghai
workflow_dispatch:
permissions:
contents: write
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: 📥 Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GH_PAGES_TOKEN }}
- name: 🛠️ Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: 🔎 Sync Notion posts
env:
NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }}
QINIU_ACCESS_KEY: ${{ secrets.QINIU_ACCESS_KEY }}
QINIU_SECRET_KEY: ${{ secrets.QINIU_SECRET_KEY }}
QINIU_BUCKET: ${{ secrets.QINIU_BUCKET }}
QINIU_HOST: ${{ secrets.QINIU_HOST }}
QINIU_UPLOAD_URL: ${{ secrets.QINIU_UPLOAD_URL }}
QINIU_PREFIX: ${{ secrets.QINIU_PREFIX }}
UPDATE_PENDING_TO_PUBLISHED: "true"
run: node scripts/sync-notion-posts.mjs
- name: 🚀 Commit and push updates
run: |
git status --short src/content/posts
if [ -z "$(git status --porcelain src/content/posts)" ]; then
echo "No changes to commit."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add src/content/posts
git commit -m "🤖 Sync Notion posts"
git push

踩坑小结

  • 未跟踪文件不算 diff:git diff —quiet 不会捕捉新文件,用 git status —porcelain 检查。
  • Notion 临时链接 403:下载图片时不要带 Notion headers,直接 fetch。
  • 无 AK/SK 上传:七牛 upload token 即可,脚本优先用 QINIU_UPLOAD_TOKEN。
  • 后缀样式:用 QINIU_STYLE_SUFFIX 统一追加 -small.webp。

触发方式

  • 手动:GitHub Actions 的 workflow_dispatch。
  • Notion 按钮:做个中间层 webhook(Zapier/Cloudflare Worker/Vercel),按钮调用 它,再去调 GitHub Actions API workflow_dispatch。

这套下来,我只在 Notion 写稿、改状态,图片自动上七牛,Astro 文章自动落盘并提交, 主线体验终于顺滑了。