最近想把写稿场地搬到 Notion,同时让 Astro 博客自动吃到新文章、图片走七牛,还能一 键在 Actions 里发布。过程里踩了几个坑,顺手记一下。
需求拆解
- Notion 里的数据库是中文列:状态(已发布/待发布/草稿)、发布日期、Slug、描述、 封面、作者、标签、分类、是否固定。
- 草稿也要落地,但 frontmatter draft: true,方便本地看。
- 待发布自动改成已发布(可关)。
- 图片用 Notion 临时链接下载,上传七牛,末尾追加 -small.webp。
- 行为可 dry-run,支持直接用七牛 upload token(无 AK/SK 时)。
核心脚本
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 ``; return `{${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, ``); } 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)
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 文章自动落盘并提交, 主线体验终于顺滑了。