feat: add build-time content sync scaffold

This commit is contained in:
SepComet 2026-05-05 09:04:11 +08:00
parent 1ab1717e64
commit d85c8ac85d
7 changed files with 420 additions and 53 deletions

40
.env.example Normal file
View File

@ -0,0 +1,40 @@
# -----------------------------------------------------------------------------
# Build-time content sync
# Copy this file to `.env` and fill in the real values in your build environment.
# Tokens must only exist in the server/build environment and must never be
# exposed to browser-side code.
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# Gitea
# -----------------------------------------------------------------------------
GITEA_BASE_URL=https://gitea.sepcomet.xyz
GITEA_TOKEN=
GITEA_USERNAME=sepcomet
# -----------------------------------------------------------------------------
# Seafile
# -----------------------------------------------------------------------------
SEAFILE_BASE_URL=https://seafile.sepcomet.xyz
SEAFILE_TOKEN=
# -----------------------------------------------------------------------------
# Output
# Relative paths are resolved from the repo root.
# -----------------------------------------------------------------------------
SYNC_OUTPUT_DIR=src/data/generated
# -----------------------------------------------------------------------------
# Optional behavior
# When STRICT_SYNC=true, the sync step should fail loudly instead of silently
# falling back to cached/seed data.
# -----------------------------------------------------------------------------
STRICT_SYNC=false
# -----------------------------------------------------------------------------
# Optional future behavior
# First phase only needs metadata + links. If one day you want to mirror actual
# download files into the site, these variables can be enabled.
# -----------------------------------------------------------------------------
SEAFILE_MIRROR_DOWNLOADS=false
DOWNLOADS_OUTPUT_DIR=public/downloads

6
.gitignore vendored
View File

@ -36,4 +36,8 @@ Desktop.ini
*.pid
*.seed
public/images/gallery
public/images/gallery
# generated sync data
src/data/generated/*.json
!src/data/generated/.gitkeep

View File

@ -2,9 +2,16 @@
## 1. 项目范围
**只做 Web 应用本身。** AstrBot 插件、Gitea/Seafile API 接入属于独立项目
本项目是一个**公开静态站点 + 构建时数据同步层**
主页是一个**公开的静态站点**,通过 markdown/JSON 文件驱动内容(这些文件可由 AstrBot 等其他系统写入,但主页本身不关心来源)。
站点运行时仍然是纯静态页面,但在构建阶段由本项目主动向 Gitea / Seafile 拉取所需数据,清洗后生成本地只读数据文件,再交给 Astro 渲染。
也就是说:
- **运行时**:纯静态站点,对外公开访问
- **构建时**:服务端脚本拉取远程数据、生成内容文件
AstrBot、定时任务、手动脚本等都可以作为“触发构建”的外部入口但**同步逻辑本身属于这个仓库的一部分**。
- 域名:`sepcomet.xyz`(备案中)
- 部署:自有服务器 Docker 环境
@ -28,25 +35,31 @@
- 每条日志卡片:标题、日期、标签、摘要
- 点击进入日志详情页(/logs/[slug]
- 日志详情Markdown 全文渲染,关联的仓库名和 commit 链接
- 内容来自 `src/content/logs/` 下的 `.md` 文件(文件来源不在本范围内,由 AstrBot 或其他系统写入)
- 内容来自 `src/content/logs/` 下的 `.md` 文件
- 首版允许日志继续由手动维护或外部系统写入;日志生成逻辑不要求在本项目内实现
### 2.3 项目展示(/projects
- 项目卡片网格布局
- 每个项目卡片:名称、描述、封面图、标签
- 点击可展开/跳转详情演示视频如有、Gitea 仓库链接、Seafile 下载链接(如有)
- 数据来源:`src/content/projects/index.json`(手动维护)
- 数据来源:构建时从 Gitea / Seafile 拉取并生成的结构化数据文件
- 页面层只读取最终生成数据,不直接处理上游 API 响应
### 2.4 Gitea 统计(可嵌入首页或独立页)
- 贡献热力图(客户端 JS 调 Gitea API 渲染)
- 贡献热力图
- 最近活动列表
- Gitea 地址通过站点配置注入
- 仓库简要信息(名称、描述、更新时间、链接等)
- 数据由构建脚本从 Gitea 拉取并清洗
- 运行时不依赖浏览器直接访问带 token 的私有 API
### 2.5 Seafile 分享(/shares 或首页侧栏)
- 文件分享链接列表,每条含:文件名、描述、链接、时间
- 数据来源:`src/content/shares/index.json`(手动维护)
- 数据来源:构建时从 Seafile 拉取并生成的结构化数据文件
- 支持展示特定目录或特定文件(如项目打包产物)
- 首版优先展示文件元数据与下载链接;如确有需要,可在后续增加“构建时镜像文件到站点静态目录”的能力
---
@ -56,15 +69,18 @@
- 首页 Hero 区(个人简介 + 链接)
- 日志列表 + 详情页Markdown 渲染)
- 项目展示卡片
- 构建时 Gitea / Seafile 数据同步入口
**P1很重要**
- Gitea 贡献热力图(客户端 API 调用)
- Gitea 贡献热力图(基于构建期抓取的数据渲染)
- 仓库摘要信息同步
- Seafile 分享列表同步
- 响应式布局
**P2锦上添花**
- Seafile 分享链接列表
- 日志按标签/时间筛选
- 项目演示视频嵌入
- 构建时镜像特定 Seafile 文件到站点静态目录
- 深色模式
---
@ -74,11 +90,12 @@
| 层 | 选型 | 理由 |
|----|------|------|
| **框架** | [Astro](https://astro.build) | 静态站点生成,原生 `.md` 支持,零 JS 默认 + 可选交互岛 |
| **样式** | Tailwind CSS | 原子化 CSS快速出效果不需要手写大量样式 |
| **交互组件** | React按需引入 | 仅 Gitea 热力图等需交互处使用Astro Islands |
| **内容存储** | 文件系统(`.md` + `.json` | 无需数据库,日志/项目数据直接文件 |
| **样式** | CSS当前 / Tailwind CSS可选 | 首版以简单直接、低心智负担为主,是否接 Tailwind 以后续实现成本决定 |
| **交互组件** | Astro 原生 + 按需引入 React | 默认静态渲染;只有热力图等确需交互时再引入前端组件 |
| **内容存储** | 文件系统(`.md` + `.json` | 构建脚本先把远程数据清洗为本地文件,再统一消费 |
| **数据同步** | Node.js 构建脚本 | 服务端拉取 Gitea / Seafile 数据,避免浏览器暴露 token |
| **部署** | Nginx + Docker | 静态文件服务,单容器 |
| **构建触发** | 内容文件变更后手动/脚本 rebuild | 日志写入 → `npm run build` → nginx reload |
| **构建触发** | 手动 / 定时任务 / AstrBot | `npm run content:sync``npm run build` → 部署静态产物 |
---
@ -97,7 +114,7 @@ summary: "搭建 Astro 项目骨架,确定目录结构与部署方案"
正文内容Markdown — 客观 diff 分析 + 主观动机,由 AstrBot 生成后写入
```
### 项目数据(src/content/projects/index.json
### 项目数据(示例:`src/data/generated/projects.json`
```json
[
@ -105,11 +122,50 @@ summary: "搭建 Astro 项目骨架,确定目录结构与部署方案"
"name": "personal-homepage",
"description": "个人主页 Dashboard",
"gitea_repo": "sepcomet/personal-homepage",
"repo_url": "https://gitea.sepcomet.xyz/sepcomet/personal-homepage",
"cover_image": "/images/projects/homepage.png",
"demo_video": "https://...",
"download_link": "https://seafile.sepcomet.xyz/...",
"tags": ["astro", "web"],
"featured": true
"featured": true,
"updated_at": "2026-05-05T00:00:00Z",
"source": "gitea+seafile"
}
]
```
### Gitea 活动数据(示例:`src/data/generated/gitea-activity.json`
```json
{
"updatedAt": "2026-05-05T00:00:00Z",
"days": [
{ "date": "2026-05-01", "count": 3 },
{ "date": "2026-05-02", "count": 0 }
],
"recent": [
{
"type": "push",
"repo": "sepcomet/personal-homepage",
"message": "更新首页模块结构",
"url": "https://gitea.sepcomet.xyz/...",
"time": "2026-05-05T00:00:00Z"
}
]
}
```
### 分享 / 下载数据(示例:`src/data/generated/shares.json`
```json
[
{
"name": "personal-homepage-demo.zip",
"description": "项目打包产物",
"url": "https://seafile.sepcomet.xyz/...",
"time": "2026-05-05T00:00:00Z",
"size": 12345678,
"source": "seafile"
}
]
```
@ -156,20 +212,25 @@ personal-homepage/
│ │ ├── ProjectCard.astro
│ │ └── GiteaHeatmap.jsx # React Island
│ ├── content/
│ │ ├── logs/ # *.md外部系统写入
│ │ ├── projects/
│ │ │ └── index.json
│ │ └── shares/
│ │ └── index.json
│ │ └── logs/ # *.md 日志内容
│ ├── data/
│ │ └── generated/ # 构建时同步生成的 JSON
│ │ ├── gitea-activity.json
│ │ ├── projects.json
│ │ └── shares.json
│ ├── config.ts
│ └── styles/
│ └── global.css
├── scripts/
│ ├── fetch-gitea.ts
│ ├── fetch-seafile.ts
│ └── sync-content.ts
├── public/
│ ├── images/
│ │ └── avatar.png
│ └── downloads/ # 可选:构建时镜像的下载文件
│ └── favicon.svg
├── astro.config.mjs
├── tailwind.config.mjs
├── Dockerfile
├── docker-compose.yml
└── package.json
@ -193,8 +254,10 @@ services:
```
- Docker 构建阶段:`npm run build` 生成静态文件
- Docker 构建阶段:先执行 `npm run content:sync`,再执行 `npm run build`
- 运行阶段Nginx 托管 `dist/` 目录
- 更新日志AstrBot 写入新的 `.md` → 触发 rebuild → nginx 热加载
- 日常更新:定时任务 / AstrBot 触发同步与重建
- Gitea / Seafile token 只存在于构建环境,不进入前端产物
---
@ -202,19 +265,18 @@ services:
| Phase | 内容 | 产出 |
|-------|------|------|
| **1** | Astro 项目初始化 + Tailwind + Header/Footer/Hero + Docker | 可访问的骨架页面 |
| **1** | Astro 项目初始化 + 首页/日志/项目/分享骨架 | 可访问的骨架页面 |
| **2** | 日志系统:列表页 + 详情页 + frontmatter 解析 + 示例内容 | 日志模块 |
| **3** | 项目展示:卡片组件 + JSON 数据 + 布局 | 项目模块 |
| **4** | Gitea 热力图 React Island + 站点配置 | 统计模块 |
| **5** | Seafile 分享 + 响应式打磨 + 深色模式 + SEO | 收尾 |
| **3** | 数据同步层Gitea / Seafile 拉取脚本、环境变量、生成文件 | 构建期数据链路 |
| **4** | 页面改读生成数据 + Gitea 活动真实化 + 仓库摘要接入 | 统计与项目模块 |
| **5** | Docker、定时重建、响应式打磨、深色模式、SEO | 收尾 |
---
## 9. 不在此项目范围内
- AstrBot 插件开发(独立项目)
- Gitea/Seafile API 的调用与数据拉取(独立脚本或 AstrBot 插件负责)
- 日志 markdown 文件的生成逻辑AstrBot 负责)
- 日志 markdown 文件的生成逻辑(可由 AstrBot 或其他系统负责)
- 自动化部署流程CI/CD可后续补充
主页只负责:**读取已有数据文件 → 渲染为 HTML → 静态托管**。
本项目负责:**构建时拉取远程数据 / 读取本地日志 → 生成站点数据文件 → 渲染为 HTML → 静态托管**。

70
TODO.md
View File

@ -1,6 +1,14 @@
# TODO
基于当前仓库现状整理的最小落地清单。目标不是加复杂后端,而是补齐“安全的数据同步层 + 定时重建入口”。
基于当前仓库现状整理的最小落地清单。当前项目定位已经明确为:
> **静态站点 + 构建时数据同步层**
目标不是加长期在线后端,而是补齐:
- 构建时从 Gitea / Seafile 主动拉数据
- 生成站点内部统一消费的只读数据
- 提供稳定的重建入口与失败兜底
## 当前已具备
@ -8,8 +16,7 @@
- [x] 首页、项目页、分享页、日志列表页、日志详情页
- [x] `src/config.ts` 站点基础配置
- [x] `src/content/logs/*.md` 日志内容集合
- [x] `src/content/projects/index.json` 项目数据入口
- [x] `src/content/shares/index.json` 分享数据入口
- [x] 项目 / 分享静态种子数据入口(当前仍为手工 JSON
- [x] `npm run build` 静态构建命令
## P0先补齐可运行的数据同步链
@ -20,18 +27,16 @@
- [ ] 新建 `scripts/fetch-seafile.ts`
- [ ] 新建 `scripts/sync-content.ts`
- [ ] 约定职责:
- `fetch-gitea.ts`:拉取 Gitea 数据
- `fetch-seafile.ts`:拉取 Seafile 数据
- `sync-content.ts`:统一清洗、转换、写入站点数据文件
- `fetch-gitea.ts`:拉取贡献热力图、最近活动、选定仓库摘要
- `fetch-seafile.ts`:拉取特定目录 / 特定文件元数据与分享链接
- `sync-content.ts`:统一清洗、转换、校验、写入站点数据文件
### 2. 明确生成数据写入位置
- [ ] 决定是否继续覆盖:
- `src/content/projects/index.json`
- `src/content/shares/index.json`
- [ ] 或改为单独输出到:
- `data/generated/projects.json`
- `data/generated/shares.json`
- [ ] 改为统一输出到:
- `src/data/generated/projects.json`
- `src/data/generated/shares.json`
- `src/data/generated/gitea-activity.json`
- [ ] 页面层统一只读最终生成数据,不直接处理上游原始响应
### 3. 增加环境变量约定
@ -40,9 +45,14 @@
- [ ] 至少定义:
- `GITEA_BASE_URL=`
- `GITEA_TOKEN=`
- `GITEA_USERNAME=`
- `SEAFILE_BASE_URL=`
- `SEAFILE_TOKEN=`
- [ ] 确保 token 只在服务端构建环境 / Astrbot 环境中使用
- `SYNC_OUTPUT_DIR=`
- [ ] 如需镜像下载文件,再补:
- `SEAFILE_MIRROR_DOWNLOADS=`
- `DOWNLOADS_OUTPUT_DIR=`
- [ ] 确保 token 只在服务端构建环境 / AstrBot 环境中使用
### 4. 增加 package scripts
@ -56,8 +66,8 @@
### 5. 明确失败策略
- [ ] 约定 Gitea / Seafile 拉取失败时如何处理:
- 直接构建失败
- 或保留上次成功数据继续构建
- 默认保留上次成功数据继续构建
- 必要时可通过参数切换为严格失败
- [ ] 在同步脚本里输出明确日志,便于 Astrbot 告警
## P1强烈建议尽快补上
@ -66,6 +76,7 @@
- [ ] 为 projects 数据建立 schema 校验
- [ ] 为 shares 数据建立 schema 校验
- [ ] 为 gitea activity 数据建立 schema 校验
- [ ] 在构建前先校验数据格式,避免远程脏数据直接打爆页面
### 7. 增加同步元数据
@ -92,26 +103,38 @@
### 10. 把 Gitea Activity 从静态占位改成真实数据
- [ ] 当前 `src/components/GiteaActivity.astro` 仍是静态示例数据
- [ ] 后续改为读取同步后生成的数据
- [ ] 改为读取 `src/data/generated/gitea-activity.json`
- [ ] 再决定是否需要客户端渲染热力图
### 11. 补部署文件
### 11. 仓库数据与页面映射收口
- [ ] 明确“哪些仓库进入 projects”
- [ ] 明确“哪些仓库只用于 activity 不进入 projects”
- [ ] 决定项目封面图是继续本地维护,还是允许远程字段补充
### 12. Seafile 下载策略收口
- [ ] 首版默认只展示元数据 + 下载链接
- [ ] 评估是否需要构建时同步特定打包文件到 `public/downloads/`
- [ ] 如同步文件本体,补充大小限制、覆盖策略、清理策略
### 13. 补部署文件
- [ ] 增加 `Dockerfile`
- [ ] 增加 `docker-compose.yml`
- [ ] 明确静态产物部署方式
### 12. 补文档
### 14. 补文档
- [ ] 在 README 或单独文档里写清:
- Astrbot 如何触发
- AstrBot / cron 如何触发
- 环境变量如何配置
- 同步脚本职责
- 构建失败如何排查
## 推荐最小闭环
- [ ] Astrbot 每天 00:00 触发
- [ ] AstrBot 每天 00:00 触发
- [ ] 执行 `npm run content:sync`
- [ ] 执行 `npm run build`
- [ ] 部署静态产物
@ -121,6 +144,7 @@
1. 先补 `.env.example`
2. 再补 `scripts/sync-content.ts`
3. 再补 `package.json` scripts
4. 再补 projects / shares schema 校验
5. 最后接 Astrbot 定时任务
3. 再补 `fetch-gitea.ts` / `fetch-seafile.ts`
4. 再补 `package.json` scripts
5. 再补 projects / shares / activity schema 校验
6. 最后接 AstrBot 定时任务

View File

@ -8,7 +8,9 @@
},
"scripts": {
"dev": "astro dev",
"content:sync": "node --experimental-strip-types ./scripts/sync-content.ts",
"build": "astro build",
"rebuild": "npm run content:sync && npm run build",
"preview": "astro preview",
"astro": "astro"
},

235
scripts/sync-content.ts Normal file
View File

@ -0,0 +1,235 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
type ProjectSeed = {
name: string;
description: string;
gitea_repo: string;
cover_image: string;
demo_video?: string;
download_link?: string;
tags: string[];
featured: boolean;
};
type ShareSeed = {
name: string;
description: string;
url: string;
time: string;
};
type GeneratedProject = ProjectSeed & {
repo_url?: string;
updated_at: string;
source: 'seed' | 'gitea' | 'gitea+seafile';
};
type GeneratedShare = ShareSeed & {
size?: number;
source: 'seed' | 'seafile';
updated_at: string;
};
type ActivityDay = {
date: string;
count: number;
};
type RecentActivity = {
type: string;
repo: string;
message: string;
url: string;
time: string;
};
type GeneratedGiteaActivity = {
updatedAt: string;
source: 'placeholder' | 'gitea';
itemCount: number;
days: ActivityDay[];
recent: RecentActivity[];
};
type SyncConfig = {
rootDir: string;
outputDir: string;
strictSync: boolean;
gitea: {
baseUrl: string;
token: string;
username: string;
};
seafile: {
baseUrl: string;
token: string;
mirrorDownloads: boolean;
downloadsOutputDir: string;
};
};
async function main() {
const config = loadConfig(process.cwd());
const syncedAt = new Date().toISOString();
console.log('[sync-content] start');
console.log(`[sync-content] output dir: ${config.outputDir}`);
await mkdir(config.outputDir, { recursive: true });
if (config.seafile.mirrorDownloads) {
console.warn(
'[sync-content] SEAFILE_MIRROR_DOWNLOADS=true, but file mirroring is not implemented yet.',
);
}
const [projects, shares, giteaActivity] = await Promise.all([
resolveProjects(config, syncedAt),
resolveShares(config, syncedAt),
resolveGiteaActivity(config, syncedAt),
]);
await Promise.all([
writeJson(path.join(config.outputDir, 'projects.json'), projects),
writeJson(path.join(config.outputDir, 'shares.json'), shares),
writeJson(path.join(config.outputDir, 'gitea-activity.json'), giteaActivity),
]);
console.log(`[sync-content] wrote ${projects.length} projects`);
console.log(`[sync-content] wrote ${shares.length} shares`);
console.log(`[sync-content] wrote ${giteaActivity.itemCount} activity items`);
console.log('[sync-content] done');
}
function loadConfig(rootDir: string): SyncConfig {
return {
rootDir,
outputDir: resolveFromRoot(rootDir, process.env.SYNC_OUTPUT_DIR ?? 'src/data/generated'),
strictSync: getBooleanEnv('STRICT_SYNC', false),
gitea: {
baseUrl: process.env.GITEA_BASE_URL?.trim() ?? '',
token: process.env.GITEA_TOKEN?.trim() ?? '',
username: process.env.GITEA_USERNAME?.trim() ?? '',
},
seafile: {
baseUrl: process.env.SEAFILE_BASE_URL?.trim() ?? '',
token: process.env.SEAFILE_TOKEN?.trim() ?? '',
mirrorDownloads: getBooleanEnv('SEAFILE_MIRROR_DOWNLOADS', false),
downloadsOutputDir: resolveFromRoot(
rootDir,
process.env.DOWNLOADS_OUTPUT_DIR ?? 'public/downloads',
),
},
};
}
async function resolveProjects(
config: SyncConfig,
syncedAt: string,
): Promise<GeneratedProject[]> {
const seedProjects = await readJson<ProjectSeed[]>(
path.join(config.rootDir, 'src/content/projects/index.json'),
);
if (hasGiteaConfig(config) || hasSeafileConfig(config)) {
console.warn(
'[sync-content] remote project sync not implemented yet, falling back to local seed data.',
);
}
return seedProjects.map((project) => ({
...project,
repo_url:
project.gitea_repo && config.gitea.baseUrl
? `${trimTrailingSlash(config.gitea.baseUrl)}/${project.gitea_repo}`
: undefined,
updated_at: syncedAt,
source: 'seed',
}));
}
async function resolveShares(config: SyncConfig, syncedAt: string): Promise<GeneratedShare[]> {
const seedShares = await readJson<ShareSeed[]>(
path.join(config.rootDir, 'src/content/shares/index.json'),
);
if (hasSeafileConfig(config)) {
console.warn(
'[sync-content] remote share sync not implemented yet, falling back to local seed data.',
);
}
return seedShares.map((share) => ({
...share,
source: 'seed',
updated_at: syncedAt,
}));
}
async function resolveGiteaActivity(
config: SyncConfig,
syncedAt: string,
): Promise<GeneratedGiteaActivity> {
if (hasGiteaConfig(config)) {
if (config.strictSync) {
throw new Error(
'STRICT_SYNC=true, but remote Gitea activity sync is not implemented yet.',
);
}
console.warn(
'[sync-content] remote Gitea activity sync not implemented yet, writing placeholder output.',
);
}
return {
updatedAt: syncedAt,
source: 'placeholder',
itemCount: 0,
days: [],
recent: [],
};
}
function hasGiteaConfig(config: SyncConfig) {
return Boolean(config.gitea.baseUrl && config.gitea.token && config.gitea.username);
}
function hasSeafileConfig(config: SyncConfig) {
return Boolean(config.seafile.baseUrl && config.seafile.token);
}
function getBooleanEnv(name: string, fallback: boolean) {
const value = process.env[name];
if (value == null || value.trim() === '') {
return fallback;
}
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
}
function resolveFromRoot(rootDir: string, targetPath: string) {
return path.isAbsolute(targetPath) ? targetPath : path.join(rootDir, targetPath);
}
function trimTrailingSlash(value: string) {
return value.replace(/\/+$/, '');
}
async function readJson<T>(filePath: string): Promise<T> {
const raw = await readFile(filePath, 'utf-8');
return JSON.parse(raw) as T;
}
async function writeJson(filePath: string, data: unknown) {
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf-8');
}
main().catch((error) => {
console.error('[sync-content] failed');
console.error(error);
process.exitCode = 1;
});

View File