静态博客的定时发布实现:Astro + GitHub Actions 自动化
使用 Astro 和 GitHub Pages 的静态博客中,如何像 WordPress 一样实现文章定时发布。利用 pubDate 过滤和定时工作流的完全自动化解决方案
静态网站的困境:定时发布
使用 Astro + GitHub Pages 运营博客的优势很明显:页面加载快速、零服务器成本、出色的 SEO 优化。但是,像 WordPress 这样的 CMS 中理所当然的文章定时发布功能的缺失让人不便。
想在空闲时间提前写好多篇文章,然后每天早上 9 点自动发布,但静态网站生成器只会部署构建时的文件。未来日期的文章呢?在构建时就已经生成为 HTML 并立即发布了。
本文将介绍如何通过结合 Astro 的 Content Collections 和 GitHub Actions 的定时工作流,在静态网站上实现完整的定时发布系统。基于实际应用在我博客上的代码进行说明,可以直接使用。
解决方案概述:三个核心要素
实现定时发布的核心在于以下三点:
1. 基于 pubDate 的内容过滤
在 Astro 的 Content Collections 模式(schema)中定义 pubDate 字段,构建时过滤掉日期晚于当前日期的文章。
// src/content.config.ts
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(), // 将字符串自动转换为 Date 对象
heroImage: image().optional(),
tags: z.array(z.string()).optional(),
}),
});
export const collections = { blog };
2. 智能过滤工具
在生产构建中只显示今天之前的文章,在开发环境中显示所有文章。
// src/lib/content.ts
import type { CollectionEntry } from 'astro:content';
/**
* 获取 JST(日本时区)基准的当前日期
* GitHub Actions 以 UTC 运行,因此显式转换为 JST
*/
function getJSTDate(): Date {
const now = new Date();
const jstOffset = 9 * 60; // JST = UTC+9
const utcTime = now.getTime() + (now.getTimezoneOffset() * 60000);
const jstTime = new Date(utcTime + (jstOffset * 60000));
return jstTime;
}
/**
* 将 Date 转换为 YYYY-MM-DD 格式
*/
function toDateString(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* 按发布日期过滤博客文章
* - 生产环境:仅 pubDate <= 今天(JST)的文章
* - 开发/测试环境:所有文章(TEST_FLG=true)
*/
export function filterPostsByDate(
posts: CollectionEntry<'blog'>[]
): CollectionEntry<'blog'>[] {
// 设置测试标志时显示所有文章
if (import.meta.env.TEST_FLG === 'true') {
return posts;
}
const today = toDateString(getJSTDate());
return posts.filter((post) => {
const postDate = toDateString(post.data.pubDate);
return postDate <= today;
});
}
核心要点:
- 时区一致性:GitHub Actions 以 UTC 运行,因此显式转换为 JST(UTC+9)
- 日期比较:比较到时间会很复杂,因此简化为 YYYY-MM-DD 格式
- 开发模式例外:设置
TEST_FLG=true可预览未来文章
3. GitHub Actions 定时工作流
每天在固定时间自动重新构建网站,发布当天的文章。
# .github/workflows/deploy.yml
name: Deploy to GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
# 每天韩国时间 00:00(UTC 前一天 15:00)自动构建
schedule:
- cron: "0 15 * * *"
permissions:
contents: read
pages: write
id-token: write
jobs:
build:
runs-on: ubuntu-latest
env:
TZ: 'Asia/Tokyo' # 明确指定 JST 时区
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install, build, and upload site
uses: withastro/action@v3
with:
node-version: 22
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
工作流说明:
- push 触发器:提交到
main分支时立即部署 - workflow_dispatch:可在 GitHub UI 中手动执行
- schedule 触发器:每天 UTC 15:00(JST 次日 00:00)自动执行
实战实现:分步指南
第 1 步:定义 Content Collections 模式
首先定义博客文章的类型模式。
// src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
// 加载 Markdown/MDX 文件
loader: glob({ base: './src/content/blog', pattern: '**/*.{md,mdx}' }),
// Frontmatter 模式
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(), // "2025-10-13" → 转换为 Date 对象
updatedDate: z.coerce.date().optional(),
heroImage: image().optional(),
tags: z.array(z.string()).optional(),
}),
});
export const collections = { blog };
现在编写博客文章时使用以下 frontmatter:
---
title: '定时发布测试文章'
description: '明天将发布的文章'
pubDate: '2025-10-14' # 设置为未来日期
heroImage: '../../../assets/blog/test-hero.jpg'
tags: ['test', 'scheduled']
---
## 这篇文章将于 2025 年 10 月 14 日发布!
第 2 步:创建过滤工具
在 src/lib/content.ts 中编写在所有页面重用的过滤逻辑。
// src/lib/content.ts
import type { CollectionEntry } from 'astro:content';
/**
* 检查 TEST_FLG 环境变量
* 开发/测试模式下也显示未来文章
*/
export function shouldShowFuturePost(): boolean {
return import.meta.env.TEST_FLG === 'true';
}
/**
* 返回基于 JST(Asia/Tokyo)的当前日期
*/
function getJSTDate(): Date {
const now = new Date();
const jstOffset = 9 * 60; // UTC+9 时区
const utcTime = now.getTime() + (now.getTimezoneOffset() * 60000);
const jstTime = new Date(utcTime + (jstOffset * 60000));
return jstTime;
}
/**
* 将 Date 对象转换为 YYYY-MM-DD 字符串
*/
function toDateString(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* 博客文章日期过滤
* - 生产环境:pubDate <= 今天(JST)
* - 测试环境:所有文章
*/
export function filterPostsByDate(
posts: CollectionEntry<'blog'>[]
): CollectionEntry<'blog'>[] {
if (shouldShowFuturePost()) {
return posts;
}
const today = toDateString(getJSTDate());
return posts.filter((post) => {
const postDate = toDateString(post.data.pubDate);
return postDate <= today;
});
}
第 3 步:更新博客索引页面
应用过滤函数,仅显示已发布的文章。
---
// src/pages/[lang]/blog/index.astro
import { getCollection } from 'astro:content';
import { filterPostsByDate } from '../../../lib/content';
import BlogCard from '../../../components/BlogCard.astro';
// 获取所有博客文章
const allPosts = await getCollection('blog');
// 日期过滤 + 语言过滤 + 排序
const posts = filterPostsByDate(allPosts)
.filter((post) => post.id.startsWith(`${lang}/`))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
---
<main>
<h1>博客</h1>
<div class="grid">
{posts.map((post) => (
<BlogCard
href={`/${lang}/blog/${post.id}/`}
title={post.data.title}
description={post.data.description}
pubDate={post.data.pubDate}
heroImage={post.data.heroImage}
tags={post.data.tags}
/>
))}
</div>
</main>
第 4 步:更新动态文章页面
单个文章页面也同样进行过滤。
---
// src/pages/[lang]/blog/[...slug].astro
import { type CollectionEntry, getCollection, render } from 'astro:content';
import { filterPostsByDate } from '../../../lib/content';
import BlogPost from '../../../layouts/BlogPost.astro';
export async function getStaticPaths() {
const allPosts = await getCollection('blog');
const posts = filterPostsByDate(allPosts); // 应用过滤
const langs = ['ko', 'ja', 'en'];
return posts.flatMap((post) => {
return langs.map((lang) => ({
params: { lang, slug: post.id },
props: post,
}));
});
}
type Props = CollectionEntry<'blog'>;
const { lang } = Astro.params;
const post = Astro.props;
const { Content } = await render(post);
---
<BlogPost {...post.data} lang={lang}>
<Content />
</BlogPost>
重要:如果不在 getStaticPaths() 中过滤,也会生成未来文章的路径,可以直接通过 URL 访问。必须在这里也进行过滤。
第 5 步:设置 GitHub Actions 工作流
创建 .github/workflows/deploy.yml 文件。
name: Deploy to GitHub Pages
on:
# 推送到 main 分支时部署
push:
branches: [main]
# 可手动执行
workflow_dispatch:
# 定时执行:每天 JST 00:00(UTC 前一天 15:00)
schedule:
- cron: "0 15 * * *"
permissions:
contents: read
pages: write
id-token: write
jobs:
build:
runs-on: ubuntu-latest
env:
TZ: 'Asia/Tokyo' # 明确指定时区
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install, build, and upload site
uses: withastro/action@v3
with:
node-version: 22
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
Cron 语法说明:
"0 15 * * *"
│ │ │ │ │
│ │ │ │ └─ 星期(0-6,周日-周六)
│ │ │ └─── 月份(1-12)
│ │ └───── 日期(1-31)
│ └──────── 小时(0-23,UTC)
└─────────── 分钟(0-59)
"0 15 * * *"= 每天 UTC 15:00(JST 次日 00:00)"0 9 * * *"= 每天 UTC 09:00(JST 18:00)"0 0 * * 1"= 每周一 UTC 00:00(JST 09:00)
第 6 步:本地测试
编写未来文章并在本地进行测试。
# 1. 编写未来日期的文章
# src/content/blog/ko/future-post.md
# pubDate: '2025-10-20'
# 2. 以测试模式运行开发服务器(显示所有文章)
TEST_FLG=true npm run dev
# 3. 生产构建测试(应用过滤)
npm run build
npm run preview
# 4. 检查构建结果:确认未来文章不显示
预期行为:
TEST_FLG=true:显示未来文章 ✓- 生产构建:隐藏未来文章 ✓
第 7 步:配置 GitHub Pages
-
GitHub 仓库设置:
- Settings → Pages → 将 Source 改为 “GitHub Actions”
-
首次部署:
git add . git commit -m "feat: add scheduled publishing" git push origin main -
在 Actions 标签中确认部署:
- 确认 “Deploy to GitHub Pages” 工作流执行
- 成功后访问网站确认未来文章不显示
-
确认计划:
- Actions 标签 → “Deploy to GitHub Pages” → 右侧菜单 → “View workflow runs”
- 确认下次执行时间
高级使用技巧
按时区定制设置
基于韩国时间(KST = UTC+9):
schedule:
- cron: "0 15 * * *" # 每天 KST 00:00
基于美国东部时间(EST = UTC-5):
schedule:
- cron: "0 14 * * *" # 每天 EST 09:00
基于欧洲中部时间(CET = UTC+1):
schedule:
- cron: "0 8 * * *" # 每天 CET 09:00
多时区构建
一天多次构建以实现更精确的定时发布:
schedule:
- cron: "0 0 * * *" # JST 09:00(早上)
- cron: "0 6 * * *" # JST 15:00(下午)
- cron: "0 12 * * *" # JST 21:00(晚上)
注意:GitHub Actions 免费计划每月限制 2,000 分钟。如果构建时间为 5 分钟,一天构建 3 次,每月使用 450 分钟(有余量)。
RSS Feed 过滤
RSS feed 也应用过滤:
// src/pages/rss.xml.ts
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import { filterPostsByDate } from '../lib/content';
export async function GET(context) {
const allPosts = await getCollection('blog');
const posts = filterPostsByDate(allPosts) // 过滤
.filter((post) => post.id.startsWith('ko/'))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
return rss({
title: '博客标题',
description: '博客描述',
site: context.site,
items: posts.map((post) => ({
title: post.data.title,
pubDate: post.data.pubDate,
description: post.data.description,
link: `/ko/blog/${post.id}/`,
})),
});
}
Sitemap 过滤
Astro 的 @astrojs/sitemap 集成会自动将生成的页面添加到 sitemap。在 getStaticPaths() 中过滤后,sitemap 也会自动过滤。
// astro.config.mjs
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://yourdomain.com',
integrations: [
sitemap(), // 自动仅包含过滤后的页面
],
});
问题排查(Troubleshooting)
问题 1:未来文章立即发布
原因:未应用过滤
解决:
- 确认在
getStaticPaths()和getCollection()调用中都应用了filterPostsByDate() - 检查构建日志:
npm run build # 在输出中确认文章数量
问题 2:计划未执行
原因:GitHub Actions 设置问题
解决:
- 确认仓库激活:Actions 标签是否已激活
- 验证 cron 语法:在 Crontab.guru 中确认
- 最后提交日期:如果 60 天以上没有提交,计划会自动停止
- 解决:推送虚拟提交或手动执行
问题 3:时区不匹配
原因:UTC 和本地时区混淆
解决:
-
确认工作流
env.TZ:env: TZ: 'Asia/Tokyo' -
确认过滤函数时区:
function getJSTDate(): Date { const now = new Date(); const jstOffset = 9 * 60; // JST = UTC+9 // ... } -
测试:
# 在 GitHub Actions 日志中确认构建时间 date(执行时间是否为正确的时区)
问题 4:开发模式下未来文章不显示
原因:未设置 TEST_FLG 环境变量
解决:
# 创建 .env 文件
echo "TEST_FLG=true" > .env
# 或直接在命令中传递
TEST_FLG=true npm run dev
性能与成本
GitHub Actions 成本
免费计划:
- 每月 2,000 分钟免费
- 构建时间:约 2-5 分钟(根据项目大小)
- 每天构建 1 次:每月使用 60-150 分钟
- 结论:免费计划足够 ✓
付费计划:
- Team:每月 $4,3,000 分钟/月
- Enterprise:定制费用
构建优化
减少 Astro 构建时间的方法:
// astro.config.mjs
export default defineConfig({
// 1. 图像优化并行处理
image: {
service: {
entrypoint: 'astro/assets/services/sharp',
},
},
// 2. 构建缓存(Vercel/Netlify 中自动)
build: {
inlineStylesheets: 'auto',
},
});
额外优化:
- 依赖缓存:使用
actions/cache - 增量构建:Astro 4.0+ 支持
# 依赖缓存示例
- name: Cache dependencies
uses: actions/cache@v3
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
与其他方法比较
方法 1:Netlify/Vercel 定时构建
优点:
- 可在 GUI 中设置
- 平台集成缓存
缺点:
- 平台依赖
- 免费计划限制(Netlify:每月 300 分钟)
方法 2:外部 Cron 服务(例如:cron-job.org)
优点:
- 不消耗 GitHub Actions 配额
缺点:
- 需要 Webhook 设置
- 安全令牌管理
- 依赖额外服务
方法 3:Serverless 函数(例如:Cloudflare Workers)
优点:
- 可实现实时过滤
缺点:
- 不再是静态网站
- 复杂度增加
- 需要额外服务
推荐:GitHub Actions 方式最简单且免费,与 GitHub Pages 完美集成
结论
结合 Astro 和 GitHub Actions,即使在静态博客中也能像 WordPress 一样构建完全自动化的定时发布系统。
核心要点总结
✅ 在 Content Collections 模式中定义 pubDate ✅ 编写日期过滤工具(明确指定 JST 时区) ✅ 在所有页面应用过滤(索引、动态页面、RSS) ✅ 设置 GitHub Actions 定时工作流(cron 表达式) ✅ 本地测试(TEST_FLG=true) ✅ 生产部署和验证
这种方式的优点
- 零成本:GitHub Actions 免费计划足够
- 完全自动化:设置一次即可永久运行
- 时区控制:按所需时区精确发布
- 开发友好:可在测试模式下预览
- 平台独立:除 GitHub Pages 外,在 Netlify、Vercel 等任何平台都可运行
现在可以在空闲时间提前写好文章,每天早上自动为读者献上新文章。同时享受静态网站的速度和 WordPress 的便利性!
参考资料
阅读其他语言版本
- 🇰🇷 한국어
- 🇯🇵 日本語
- 🇺🇸 English
- 🇨🇳 中文(当前页面)
这篇文章有帮助吗?
您的支持能帮助我创作更好的内容。请我喝杯咖啡吧!☕