- Relocate seed.sql to scripts/ so wrangler d1 migrations apply doesn't treat it as a migration; update package.json, setup.mjs, README, and all AGENTS.md references. - Add src/lib/schema-org.ts builders (LocalBusiness, FAQPage, BlogPosting, BreadcrumbList) and emit JSON-LD via Base.astro. - Add lastmod to sitemap entries. - Set real site domain, worker route, and D1/KV ids. - Improve image loading hints and heading semantics across site components; switch BlogIndex to client:idle.
48 KiB
結構優化 + AI Blog + 部署 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 為盈豐太陽能官網加入 Zod schema-first 驗證層、AI 生成 Blog(DeepSeek + Tavily + 關鍵字佇列),同簡化 Cloudflare 部署。
Architecture: 沿用 Astro 7 SSR + D1 + Drizzle 單 Worker 架構。Phase 1 加 src/schemas/ + parseForm helper,將 admin 表單驗證集中。Phase 2 加 blog_keywords 表 + src/lib/ai.ts(OpenAI 相容 chat completions + Tavily),新 /admin/ai 頁同步生成草稿。Phase 3 加 scripts/setup.mjs 一鍵 provision,並用 Cloudflare Workers Builds 做 Git push 自動 deploy。
Tech Stack: Astro 7 / React 19 / Chakra UI v3(前台)/ Drizzle ORM / Cloudflare D1 + KV / Zod v4 / marked / DeepSeek(OpenAI 相容)/ Tavily。
驗證方式: 本 repo 冇 test / lint / typecheck script,npm run build 係唯一 build/type 檢查(見 AGENTS.md)。以下每個 Task 以 npm run build 收尾,需要時加 npm run dev 目測。本 repo 預設唔自動 commit;如要 commit,喺每個 Task 最後自行執行(訊息格式見範例)。
Spec: docs/superpowers/specs/2026-09-11-structure-ai-deploy-design.md
2026-09-12 更新: AI Blog 提示詞已通用化、預設供應商轉 DeepSeek 官方(
https://api.deepseek.com/deepseek-flash),詳見docs/superpowers/specs/2026-09-12-ai-blog-template-deepseek-design.md。本計劃內舊嘅api.deepinfra.com/deepseek-ai/DeepSeek-V3-0324值已過時。
檔案結構
新增:
src/schemas/post.ts postInput
src/schemas/content.ts contentItemInput
src/schemas/case.ts caseInput
src/schemas/settings.ts settingsInput(由 SETTINGS_GROUPS 動態砌)
src/schemas/ai.ts aiSettingsInput + aiGeneratedPost
src/schemas/keyword.ts keywordInput + keywordStatus
src/schemas/index.ts barrel
src/lib/form.ts parseForm(formData, schema)
src/lib/ai.ts AI 生成流程(Tavily + DeepSeek + 解析)
src/pages/admin/ai.astro AI 設定 + 關鍵字佇列 + 生成
scripts/setup.mjs 一鍵 Cloudflare provision
修改:
package.json + zod、+ "setup" script
src/db/schema.ts posts.focusKeyword + blog_keywords 表
src/lib/env.ts + AI_API_KEY / TAVILY_API_KEY
src/data/content.ts + getKeywords / uniqueSlug
src/layouts/AdminLayout.astro + "AI" 導覽、+ .field-error CSS
src/pages/admin/post/[id].astro 用 parseForm + uniqueSlug
src/pages/admin/content/[kind].astro 用 parseForm
src/pages/admin/cases.astro 用 parseForm
src/pages/admin/settings.astro 用 parseForm
src/pages/admin/index.astro 加入口連結
scripts/seed.sql 加 AI 設定 + 示範關鍵字
.dev.vars.example 加 AI_API_KEY / TAVILY_API_KEY
README.md / AGENTS.md 各層 同步文件
Phase 1 — Zod schema-first 驗證層
Task 1: 加 zod 同建立 schemas
Files:
-
Modify:
package.json -
Create:
src/schemas/post.ts、content.ts、case.ts、settings.ts、ai.ts、keyword.ts、index.ts -
Create:
src/lib/form.ts -
Step 1: 加 zod 依賴
npm install zod@^4.6.1
Expected: package.json dependencies 加入 "zod": "^4.6.1"(現時 zod 只係 astro 嘅 transitive dep)。
- Step 2: 建
src/schemas/post.ts
import { z } from "zod";
export const postStatus = z.enum(["draft", "published"]);
export const postInput = z.object({
title: z.string().trim().min(1, "標題唔可以留空。"),
slug: z.string().trim().default(""),
excerpt: z.string().trim().default(""),
content: z.string().default(""),
coverImage: z.string().trim().default(""),
tags: z.string().trim().default(""),
metaDescription: z.string().trim().default(""),
status: postStatus.default("draft"),
});
export type PostInput = z.infer<typeof postInput>;
- Step 3: 建
src/schemas/content.ts
import { z } from "zod";
import { CONTENT_KINDS } from "../db/schema";
export const contentItemInput = z.object({
kind: z.enum(CONTENT_KINDS),
title: z.string().trim().min(1, "名稱唔可以留空。"),
description: z.string().default(""),
extra: z.string().trim().default(""),
status: z.enum(["draft", "published"]).default("published"),
});
export type ContentItemInput = z.infer<typeof contentItemInput>;
- Step 4: 建
src/schemas/case.ts
import { z } from "zod";
export const caseInput = z.object({
title: z.string().trim().min(1, "標題唔可以留空。"),
location: z.string().trim().default(""),
completedAt: z.string().trim().default(""),
description: z.string().default(""),
imageUrl: z.string().trim().default(""),
status: z.enum(["draft", "published"]).default("published"),
});
export type CaseInput = z.infer<typeof caseInput>;
- Step 5: 建
src/schemas/settings.ts
import { z } from "zod";
import { ALL_SETTING_KEYS } from "../data/settings-fields";
export const settingsInput = z.object(
Object.fromEntries(
ALL_SETTING_KEYS.map((key) => [key, z.string().trim().default("")]),
),
);
export type SettingsInput = z.infer<typeof settingsInput>;
- Step 6: 建
src/schemas/ai.ts
import { z } from "zod";
export const aiSettingsInput = z.object({
ai_enabled: z.enum(["1", "0"]).default("1"),
ai_base_url: z.url("Base URL 要係有效網址。").default("https://api.deepinfra.com/v1/openai"),
ai_chat_model: z.string().trim().min(1, "請填模型名稱。").default("deepseek-ai/DeepSeek-V3-0324"),
ai_context_prompt: z.string().default(""),
ai_business_context: z.string().default(""),
ai_web_search_enabled: z.enum(["1", "0"]).default("0"),
ai_web_search_max_results: z.coerce.number().int().min(1).max(10).default(5),
});
export type AiSettingsInput = z.infer<typeof aiSettingsInput>;
export const aiGeneratedPost = z.object({
title: z.string().trim().min(1),
content: z.string().trim().min(1),
metaDescription: z.string().trim().default(""),
excerpt: z.string().trim().default(""),
});
export type AiGeneratedPost = z.infer<typeof aiGeneratedPost>;
- Step 7: 建
src/schemas/keyword.ts
import { z } from "zod";
export const keywordStatus = z.enum(["pending", "generated", "skipped"]);
export const keywordInput = z.object({
keyword: z.string().trim().min(1, "關鍵字唔可以留空。"),
});
export type KeywordInput = z.infer<typeof keywordInput>;
- Step 8: 建
src/schemas/index.ts
export * from "./post";
export * from "./content";
export * from "./case";
export * from "./settings";
export * from "./ai";
export * from "./keyword";
- Step 9: 建
src/lib/form.ts
import type { ZodType } from "zod";
export type ParseResult<T> =
| { ok: true; data: T }
| { ok: false; errors: Record<string, string> };
/** 將 FormData 抽成物件再交畀 Zod 驗證;錯誤以 field -> 訊息 回傳。 */
export function parseForm<T>(form: FormData, schema: ZodType<T>): ParseResult<T> {
const raw: Record<string, unknown> = {};
for (const [key, value] of form.entries()) {
if (typeof value === "string") raw[key] = value;
}
const result = schema.safeParse(raw);
if (result.success) return { ok: true, data: result.data };
const errors: Record<string, string> = {};
for (const issue of result.error.issues) {
const key = issue.path.join(".") || "_form";
if (!errors[key]) errors[key] = issue.message;
}
return { ok: false, errors };
}
- Step 10: 驗證
npm run build
Expected: build 成功(schemas 未有 callers,理應無 type error)。
Task 2: 抽出 uniqueSlug + 遷移文章編輯頁
Files:
-
Modify:
src/data/content.ts(加uniqueSlug) -
Modify:
src/pages/admin/post/[id].astro -
Step 1: 喺
src/data/content.ts尾加uniqueSlug
改 import 行(第 1 行)加入 ne:
import { and, asc, desc, eq, ne } from "drizzle-orm";
喺檔案最後加:
/** slug 撞咗就加 -2、-3…;selfId 用喺更新自己時排除自己。 */
export async function uniqueSlug(db: Db, base: string, selfId?: string): Promise<string> {
let candidate = base;
for (let i = 2; i < 50; i++) {
const rows = await db
.select({ id: posts.id })
.from(posts)
.where(selfId ? and(eq(posts.slug, candidate), ne(posts.id, selfId)) : eq(posts.slug, candidate))
.limit(1);
if (rows.length === 0) return candidate;
candidate = `${base}-${i}`;
}
return `${base}-${Date.now()}`;
}
- Step 2: 改
src/pages/admin/post/[id].astro的 imports
將第 2–7 行改成:
import { eq } from "drizzle-orm";
import AdminLayout from "../../../layouts/AdminLayout.astro";
import { getDb } from "../../../lib/db";
import { posts } from "../../../db/schema";
import { slugify, autoExcerpt } from "../../../lib/markdown";
import { getEnv } from "../../../lib/env";
import { uniqueSlug } from "../../../data/content";
import { parseForm } from "../../../lib/form";
import { postInput } from "../../../schemas";
(刪走原本嘅 and, ne 同本頁 local uniqueSlug 函式。)
- Step 3: 刪走本頁 local
uniqueSlug
移除原本第 25–42 行(/** slug 撞咗就加 -2、-3… */ async function uniqueSlug(...))整個函式,改用 Task 2 Step 1 嘅共用版本(呼叫方式不變:uniqueSlug(db, base, selfId))。
- Step 4: 改 error 變數同 POST 驗證邏輯
將 let error = ""; 改成:
let error = "";
let errors: Record<string, string> = {};
將 POST 內 if (action === "delete" ...) 之後嘅整段驗證(原本第 53–100 行,由 const title = ... 到 redirect 前)換成:
const parsed = parseForm(form, postInput);
if (!parsed.ok) {
errors = parsed.errors;
error = Object.values(parsed.errors)[0] ?? "輸入有誤。";
} else {
const data = parsed.data;
const status = data.status;
const slug = await uniqueSlug(
db,
data.slug ? slugify(data.slug) : slugify(data.title),
isNew ? undefined : id,
);
const excerpt = data.excerpt || autoExcerpt(data.content);
const metaDescription = data.metaDescription || excerpt.slice(0, 155);
const coverImage = data.coverImage || null;
const tags = data.tags || null;
const now = new Date();
if (isNew) {
await db.insert(posts).values({
id: crypto.randomUUID(),
slug,
title: data.title,
excerpt,
content: data.content,
coverImage,
tags,
metaDescription,
focusKeyword: null,
status,
publishedAt: status === "published" ? now : null,
updatedAt: now,
});
} else {
await db
.update(posts)
.set({
slug,
title: data.title,
excerpt,
content: data.content,
coverImage,
tags,
metaDescription,
status,
publishedAt: existing!.publishedAt ?? (status === "published" ? now : null),
updatedAt: now,
})
.where(eq(posts.id, id!));
}
return Astro.redirect("/admin");
}
注意:
focusKeyword: null呢個欄位喺 Phase 2 Task 7 加;Phase 1 執行時先唔好加呢行,等 Phase 2 加咗 schema 先補返。Phase 1 版本省略focusKeyword。
- Step 5: 表單加 per-field 錯誤顯示
將標題、slug、excerpt、coverImage、tags、metaDescription、content 每個 <label> 之後加(以標題為例):
<label for="title">標題</label>
{errors.title && <span class="field-error">{errors.title}</span>}
<input id="title" name="title" type="text" value={post.title} required placeholder="文章標題" />
其餘欄位用對應 key(errors.slug、errors.excerpt、errors.coverImage、errors.tags、errors.metaDescription、errors.content)。
- Step 6: 驗證
npm run build
Expected: PASS。再 npm run db:migrate:local && npm run db:seed:local && npm run dev,開 /admin/post/post-001 改標題儲存、新增一篇、測標題留空睇 error。
Task 3: 遷移內容通用編輯器
Files:
-
Modify:
src/pages/admin/content/[kind].astro -
Step 1: 加 imports
喺第 5 行後加:
import { parseForm } from "../../../lib/form";
import { contentItemInput } from "../../../schemas";
- Step 2: 加 errors 變數
喺 const meta = META[kind]; 之後加:
let errors: Record<string, string> = {};
- Step 3: 改 add / save 分支
將 if (action === "add") { ... } else if (action === "save") { ... } 兩段換成:
if (action === "add" || action === "save") {
form.set("kind", kind);
const parsed = parseForm(form, contentItemInput);
if (!parsed.ok) {
errors = parsed.errors;
} else if (action === "add") {
const [row] = await db
.select({ m: max(contentItems.sortOrder) })
.from(contentItems)
.where(eq(contentItems.kind, kind));
await db.insert(contentItems).values({
id: crypto.randomUUID(),
kind,
title: parsed.data.title,
description: parsed.data.description,
extra: parsed.data.extra || null,
sortOrder: (row?.m ?? 0) + 1,
status: parsed.data.status,
updatedAt: new Date(),
});
} else {
const id = str(form, "id");
await db
.update(contentItems)
.set({
title: parsed.data.title,
description: parsed.data.description,
extra: parsed.data.extra || null,
status: parsed.data.status,
updatedAt: new Date(),
})
.where(and(eq(contentItems.id, id), eq(contentItems.kind, kind)));
}
} else if (action === "delete") {
(delete / up / down 分支維持原狀。)
- Step 4: 新增與編輯表單加錯誤顯示
喺新增表單 {meta.titleLabel} label 後加 {errors.title && <span class="field-error">{errors.title}</span>};編輯表單同樣喺標題 label 後加。
- Step 5: 驗證
npm run build
Expected: PASS。npm run dev → /admin/content/service 新增、編輯、排序、刪除。
Task 4: 遷移完成案例頁
Files:
-
Modify:
src/pages/admin/cases.astro -
Step 1: 加 imports
喺第 6 行後加:
import { parseForm } from "../../lib/form";
import { caseInput } from "../../schemas";
- Step 2: 加 errors 變數
喺 const db = getDb(getEnv().DB); 之後加:
let errors: Record<string, string> = {};
- Step 3: 改 add / save 分支
將 if (action === "add") { ... } else if (action === "save") { ... } 換成:
if (action === "add" || action === "save") {
const parsed = parseForm(form, caseInput);
if (!parsed.ok) {
errors = parsed.errors;
} else if (action === "add") {
const [row] = await db.select({ m: max(cases.sortOrder) }).from(cases);
await db.insert(cases).values({
id: crypto.randomUUID(),
title: parsed.data.title,
location: parsed.data.location || null,
completedAt: parsed.data.completedAt || null,
description: parsed.data.description || null,
imageUrl: parsed.data.imageUrl || null,
sortOrder: (row?.m ?? 0) + 1,
status: parsed.data.status,
updatedAt: new Date(),
});
} else {
const id = str(form, "id");
await db
.update(cases)
.set({
title: parsed.data.title,
location: parsed.data.location || null,
completedAt: parsed.data.completedAt || null,
description: parsed.data.description || null,
imageUrl: parsed.data.imageUrl || null,
status: parsed.data.status,
updatedAt: new Date(),
})
.where(eq(cases.id, id));
}
} else if (action === "delete") {
(delete / up / down 維持。)
- Step 4: 新增與編輯表單加錯誤顯示
喺新增表單標題 label 後加 {errors.title && <span class="field-error">{errors.title}</span>};編輯表單同樣。
- Step 5: 驗證
npm run build
Expected: PASS。npm run dev → /admin/cases 新增、編輯、排序、刪除。
Task 5: 遷移網站設定頁
Files:
-
Modify:
src/pages/admin/settings.astro -
Step 1: 加 imports
喺第 7 行後加:
import { parseForm } from "../../lib/form";
import { settingsInput } from "../../schemas";
- Step 2: 改 POST 邏輯
將第 13–25 行(let saved = false; 至 POST 區塊完)換成:
let saved = false;
let error = "";
if (Astro.request.method === "POST") {
const form = await Astro.request.formData();
const parsed = parseForm(form, settingsInput);
if (!parsed.ok) {
error = Object.values(parsed.errors)[0] ?? "設定有誤。";
} else {
const now = new Date();
for (const key of ALL_SETTING_KEYS) {
const value = String((parsed.data as Record<string, unknown>)[key] ?? "");
await db
.insert(siteSettings)
.values({ key, value, updatedAt: now })
.onConflictDoUpdate({ target: siteSettings.key, set: { value, updatedAt: now } });
}
saved = true;
}
}
- Step 3: 顯示 error
喺 {saved && ...} 之前加:
{error && <p class="field-error" style="margin-bottom:16px">{error}</p>}
- Step 4: 驗證
npm run build
Expected: PASS。npm run dev → /admin/settings 儲存後前台見到更新。
Task 6: AdminLayout 加 .field-error 樣式
Files:
-
Modify:
src/layouts/AdminLayout.astro -
Step 1: 加 CSS
喺 .muted { ... } 之後(約第 109 行)加:
.field-error { color: #a32d2d; font-size: 12.5px; font-weight: 600; margin-left: 8px; }
- Step 2: 驗證
npm run build
Expected: PASS。npm run dev → 提交一個空標題,見到紅色錯誤字。
Phase 2 — AI Blog + 關鍵字佇列
Task 7: DB schema(posts.focusKeyword + blog_keywords)
Files:
-
Modify:
src/db/schema.ts -
Modify:
scripts/seed.sql -
Generate:
migrations/0002_*.sql(由 drizzle-kit) -
Step 1:
posts加focusKeyword
喺 posts 定義 tags 之後加:
focusKeyword: text("focus_keyword"),
- Step 2: 加
blog_keywords表
喺 cases 定義之後、export type Post ... 之前加:
export const KEYWORD_STATUSES = ["pending", "generated", "skipped"] as const;
export type KeywordStatus = (typeof KEYWORD_STATUSES)[number];
/**
* AI 生成用嘅關鍵字佇列(手動維護,冇 Google Ads discovery)。
*/
export const blogKeywords = sqliteTable(
"blog_keywords",
{
id: text("id").primaryKey(),
keyword: text("keyword").notNull(),
status: text("status", { enum: KEYWORD_STATUSES }).notNull().default("pending"),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.notNull()
.$defaultFn(() => new Date()),
usedAt: integer("used_at", { mode: "timestamp_ms" }),
postId: text("post_id"),
},
(t) => [index("idx_keywords_status").on(t.status)],
);
- Step 3: 加型別匯出
喺檔案最後加:
export type BlogKeyword = typeof blogKeywords.$inferSelect;
export type NewBlogKeyword = typeof blogKeywords.$inferInsert;
- Step 4: 產生 migration
npm run db:generate
Expected: migrations/ 出現 0002_*.sql,內含 ALTER TABLE posts ADD COLUMN focus_keyword 同 CREATE TABLE blog_keywords。
- Step 5: 套用到本機
npm run db:migrate:local
Expected: 成功,無 error。
- Step 6:
seed.sql加 AI 設定
喺第一個 INSERT OR REPLACE INTO site_settings ...;(第 22 行 og_image)之後、content_items 之前,加:
INSERT OR REPLACE INTO site_settings (key, value, updated_at) VALUES
('ai_enabled', '1', strftime('%s','now')*1000),
('ai_base_url', 'https://api.deepinfra.com/v1/openai', strftime('%s','now')*1000),
('ai_chat_model', 'deepseek-ai/DeepSeek-V3-0324', strftime('%s','now')*1000),
('ai_context_prompt', '用香港繁體中文(廣東話書面語)書寫,語氣專業、親切、務實,避免誇大。', strftime('%s','now')*1000),
('ai_business_context', '盈豐太陽能工程有限公司專營香港村屋太陽能系統:現場評估、合法鋁合金支架、代辦中電/港燈上網電價(FiT)申請、專業安裝及驗收認證。着重透明報價、專人跟進、安全合規。', strftime('%s','now')*1000),
('ai_web_search_enabled', '0', strftime('%s','now')*1000),
('ai_web_search_max_results', '5', strftime('%s','now')*1000);
- Step 7:
seed.sql加示範關鍵字
喺 cases 嘅 insert(第 55 行)之後加:
INSERT OR REPLACE INTO blog_keywords (id, keyword, status, created_at, used_at, post_id) VALUES
('kw-001', '村屋太陽能回本期', 'pending', strftime('%s','now')*1000, NULL, NULL),
('kw-002', '村屋天台安裝太陽能注意事項', 'pending', strftime('%s','now')*1000, NULL, NULL),
('kw-003', '上網電價 FiT 申請流程', 'pending', strftime('%s','now')*1000, NULL, NULL);
- Step 8: 補返 post 頁嘅
focusKeyword
喺 Task 2 Step 4 嘅 insert(posts).values({...}) 入面,tags, 之後加返:
focusKeyword: null,
- Step 9: 匯入種子並驗證
npm run db:seed:local && npm run build
Expected: seed 成功;build PASS。
Task 8: env 加 AI secrets
Files:
-
Modify:
src/lib/env.ts -
Modify:
.dev.vars.example -
Step 1: 改
AppEnv
export type AppEnv = {
DB: D1Database;
CACHE: KVNamespace;
ADMIN_PASSWORD?: string;
AI_API_KEY?: string;
TAVILY_API_KEY?: string;
};
- Step 2: 更新
.dev.vars.example
ADMIN_PASSWORD=改成你自己嘅密碼
AI_API_KEY=
TAVILY_API_KEY=
- Step 3: 本機
.dev.vars補 key
喺本機 .dev.vars(已 gitignore)加 AI_API_KEY=...(測試生成先需要)。
- Step 4: 驗證
npm run build
Expected: PASS。
Task 9: src/lib/ai.ts
Files:
-
Create:
src/lib/ai.ts -
Step 1: 寫檔案
import { asc, eq } from "drizzle-orm";
import type { Db } from "../data/content";
import { getSettings, uniqueSlug } from "../data/content";
import { blogKeywords, posts } from "../db/schema";
import type { AppEnv } from "./env";
import { autoExcerpt, slugify } from "./markdown";
import { aiGeneratedPost } from "../schemas";
type AiSettings = {
enabled: boolean;
baseUrl: string;
model: string;
contextPrompt: string;
businessContext: string;
webSearchEnabled: boolean;
webSearchMaxResults: number;
};
export async function getAiSettings(db: Db): Promise<AiSettings> {
const s = await getSettings(db);
return {
enabled: s.ai_enabled !== "0",
baseUrl: s.ai_base_url || "https://api.deepinfra.com/v1/openai",
model: s.ai_chat_model || "deepseek-ai/DeepSeek-V3-0324",
contextPrompt: s.ai_context_prompt ?? "",
businessContext: s.ai_business_context ?? "",
webSearchEnabled: s.ai_web_search_enabled === "1",
webSearchMaxResults: Number(s.ai_web_search_max_results) || 5,
};
}
type TavilyResult = { title?: string; url?: string; content?: string };
/** Tavily 搜尋,失敗回空字串(唔中斷生成)。 */
async function tavilyResearch(query: string, apiKey: string, maxResults: number): Promise<string> {
try {
const res = await fetch("https://api.tavily.com/search", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ api_key: apiKey, query, max_results: maxResults, search_depth: "basic" }),
});
if (!res.ok) return "";
const data = (await res.json()) as { results?: TavilyResult[] };
const results = data.results ?? [];
return results
.map((r, i) => `${i + 1}. ${r.title ?? ""}(${r.url ?? ""})\n${r.content ?? ""}`)
.join("\n\n");
} catch {
return "";
}
}
function buildSystemPrompt(opts: {
contextPrompt: string;
businessContext: string;
research: string;
topic: string;
}): string {
const parts: string[] = [];
if (opts.contextPrompt) parts.push(opts.contextPrompt);
if (opts.businessContext) parts.push(`## 公司背景資料\n${opts.businessContext}`);
if (opts.research) parts.push(`## 網上研究資料(只作參考,唔好照抄)\n${opts.research}`);
parts.push(`你係香港村屋太陽能公司「盈豐太陽能」嘅 SEO 內容寫手。請就以下主題寫一篇 800–1200 字嘅繁體中文(香港廣東話書面語)博客文章。
主題:${opts.topic}
要求:
- 標題要放焦點關鍵字,首 100 字內再出現一次,關鍵字密度約 1–2%。
- 用 ## / ### 分段,段落清晰易讀。
- 開頭先畀一段總結(summary-first),再展開。
- 內容要對香港村屋太陽能實用、準確,唔好作出未經證實嘅承諾或數字。
- 適合 SEO 同 AI 搜尋(GEO):用問答式小標題、必要時用列表。
輸出格式(必須嚴格跟隨,唔要加額外說明):
TITLE: <文章標題>
CONTENT:
<Markdown 正文,唔需要重複標題>
META_DESC: <150 字以內 SEO 描述>`);
return parts.join("\n\n");
}
async function callChat(settings: AiSettings, apiKey: string, systemPrompt: string): Promise<string> {
const res = await fetch(`${settings.baseUrl.replace(/\/$/, "")}/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
model: settings.model,
messages: [{ role: "user", content: systemPrompt }],
max_tokens: 2200,
temperature: 0.7,
}),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`AI 供應商回應 ${res.status}:${body.slice(0, 200)}`);
}
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
const text = data.choices?.[0]?.message?.content ?? "";
if (!text) throw new Error("AI 冇回覆內容。");
return text;
}
export type ParsedGenerated =
| { ok: true; data: { title: string; content: string; metaDescription: string; excerpt: string } }
| { ok: false; error: string };
export function parseGeneratedPost(text: string): ParsedGenerated {
const titleMatch = text.match(/TITLE:\s*(.+)/i);
const contentMatch = text.match(/CONTENT:\s*([\s\S]*?)(?:\nMETA_DESC:|$)/i);
const metaMatch = text.match(/META_DESC:\s*([\s\S]+)/i);
const content = (contentMatch?.[1] ?? "").trim();
const metaDescription = (metaMatch?.[1] ?? "").trim().replace(/\s+/g, " ").slice(0, 160);
const candidate = {
title: (titleMatch?.[1] ?? "").trim(),
content,
metaDescription,
excerpt: metaDescription || autoExcerpt(content),
};
const parsed = aiGeneratedPost.safeParse(candidate);
if (!parsed.success) return { ok: false, error: "AI 輸出格式唔正確,請再試。" };
return { ok: true, data: parsed.data };
}
export type GenerateResult = { ok: true; postId: string } | { ok: false; error: string };
export async function generateBlogPost(
db: Db,
env: AppEnv,
input: { mode: "topic"; topic: string } | { mode: "next" },
): Promise<GenerateResult> {
const settings = await getAiSettings(db);
if (!settings.enabled) return { ok: false, error: "AI 生成未啟用。" };
const apiKey = env.AI_API_KEY;
if (!apiKey) return { ok: false, error: "未設定 AI_API_KEY。" };
let topic: string;
let keywordId: string | null = null;
if (input.mode === "next") {
const [next] = await db
.select()
.from(blogKeywords)
.where(eq(blogKeywords.status, "pending"))
.orderBy(asc(blogKeywords.createdAt))
.limit(1);
if (!next) return { ok: false, error: "關鍵字佇列冇 pending 項目。" };
topic = next.keyword;
keywordId = next.id;
} else {
topic = input.topic.trim();
if (!topic) return { ok: false, error: "請輸入主題。" };
}
let research = "";
if (settings.webSearchEnabled && env.TAVILY_API_KEY) {
research = await tavilyResearch(topic, env.TAVILY_API_KEY, settings.webSearchMaxResults);
}
let generated;
try {
const text = await callChat(
settings,
apiKey,
buildSystemPrompt({
contextPrompt: settings.contextPrompt,
businessContext: settings.businessContext,
research,
topic,
}),
);
const parsed = parseGeneratedPost(text);
if (!parsed.ok) return parsed;
generated = parsed.data;
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : "生成失敗。" };
}
const now = new Date();
const slug = await uniqueSlug(db, slugify(generated.title));
const id = crypto.randomUUID();
const excerpt = generated.excerpt || autoExcerpt(generated.content);
await db.insert(posts).values({
id,
slug,
title: generated.title,
excerpt,
content: generated.content,
coverImage: null,
tags: null,
focusKeyword: topic,
metaDescription: generated.metaDescription || excerpt.slice(0, 155),
status: "draft",
publishedAt: null,
updatedAt: now,
});
if (keywordId) {
await db
.update(blogKeywords)
.set({ status: "generated", usedAt: now, postId: id })
.where(eq(blogKeywords.id, keywordId));
}
return { ok: true, postId: id };
}
- Step 2: 驗證
npm run build
Expected: PASS(ai.ts 未接 UI,但要通過 type check)。
Task 10: data 層加關鍵字查詢
Files:
-
Modify:
src/data/content.ts -
Step 1: 加
getKeywords
改 schema import(第 3–12 行)加入 blogKeywords 同 type BlogKeyword:
import {
blogKeywords,
cases,
contentItems,
posts,
siteSettings,
type BlogKeyword,
type CaseStudy,
type ContentItem,
type ContentKind,
type Post,
} from "../db/schema";
喺 getPostBySlug 之後加:
export async function getKeywords(db: Db): Promise<BlogKeyword[]> {
return db.select().from(blogKeywords).orderBy(asc(blogKeywords.createdAt));
}
- Step 2: 驗證
npm run build
Expected: PASS。
Task 11: 新 /admin/ai 頁
Files:
-
Create:
src/pages/admin/ai.astro -
Step 1: 寫檔案
---
import { asc, eq } from "drizzle-orm";
import AdminLayout from "../../layouts/AdminLayout.astro";
import { getDb } from "../../lib/db";
import { getEnv } from "../../lib/env";
import { getKeywords, getSettings } from "../../data/content";
import { blogKeywords, siteSettings } from "../../db/schema";
import { parseForm } from "../../lib/form";
import { aiSettingsInput, keywordInput } from "../../schemas";
import { generateBlogPost } from "../../lib/ai";
export const prerender = false;
const env = getEnv();
const db = getDb(env.DB);
let error = "";
let notice = "";
if (Astro.request.method === "POST") {
const form = await Astro.request.formData();
const action = String(form.get("action") ?? "");
if (action === "save-settings") {
form.set("ai_enabled", form.get("ai_enabled") ? "1" : "0");
form.set("ai_web_search_enabled", form.get("ai_web_search_enabled") ? "1" : "0");
const parsed = parseForm(form, aiSettingsInput);
if (!parsed.ok) {
error = Object.values(parsed.errors)[0] ?? "設定有誤。";
} else {
const now = new Date();
for (const [key, value] of Object.entries(parsed.data)) {
const v = String(value ?? "");
await db
.insert(siteSettings)
.values({ key, value: v, updatedAt: now })
.onConflictDoUpdate({ target: siteSettings.key, set: { value: v, updatedAt: now } });
}
notice = "已儲存 AI 設定。";
}
} else if (action === "add-keyword") {
const parsed = parseForm(form, keywordInput);
if (!parsed.ok) {
error = parsed.errors.keyword ?? "請輸入關鍵字。";
} else {
await db.insert(blogKeywords).values({
id: crypto.randomUUID(),
keyword: parsed.data.keyword,
status: "pending",
createdAt: new Date(),
});
notice = "已加入關鍵字。";
}
} else if (action === "delete-keyword") {
await db.delete(blogKeywords).where(eq(blogKeywords.id, String(form.get("id") ?? "")));
notice = "已刪除。";
} else if (action === "skip-keyword") {
await db
.update(blogKeywords)
.set({ status: "skipped" })
.where(eq(blogKeywords.id, String(form.get("id") ?? "")));
notice = "已標記略過。";
} else if (action === "generate-topic" || action === "generate-next") {
const result = await generateBlogPost(
db,
env,
action === "generate-next"
? { mode: "next" }
: { mode: "topic", topic: String(form.get("topic") ?? "") },
);
if (result.ok) return Astro.redirect(`/admin/post/${result.postId}`);
error = result.error;
}
}
const settings = await getSettings(db);
const keywords = await getKeywords(db);
const secrets = { ai: Boolean(env.AI_API_KEY), tavily: Boolean(env.TAVILY_API_KEY) };
---
<AdminLayout title="AI 生成">
<h1>AI 生成 Blog</h1>
<p class="sub">設定 AI 供應商、維護關鍵字佇列,一按生成完整草稿(生成後會開啟文章編輯頁覆核)。</p>
{error && <p class="field-error" style="display:block;margin-bottom:16px">{error}</p>}
{
notice && (
<p class="badge published" style="display:inline-block;margin-bottom:16px;padding:6px 14px">
✓ {notice}
</p>
)
}
<div class="card">
<h2>快速生成</h2>
<form method="post" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end">
<div style="flex:1;min-width:240px">
<label for="topic">自由輸入主題</label>
<input id="topic" name="topic" type="text" placeholder="例:村屋太陽能回本期點計?" />
</div>
<button type="submit" name="action" value="generate-topic">生成草稿</button>
<button type="submit" name="action" value="generate-next" class="secondary">生成下一篇(佇列)</button>
</form>
<p class="muted" style="margin:10px 0 0">生成需時約 10–40 秒,請耐心等候,唔好重複提交。</p>
</div>
<div class="card">
<h2>AI 設定</h2>
<form method="post">
<input type="hidden" name="action" value="save-settings" />
<div class="grid2">
<div>
<label for="ai_enabled">AI 生成</label>
<select id="ai_enabled" name="ai_enabled">
<option value="1" selected={settings.ai_enabled !== "0"}>啟用</option>
<option value="0" selected={settings.ai_enabled === "0"}>停用</option>
</select>
</div>
<div>
<label for="ai_chat_model">模型</label>
<input id="ai_chat_model" name="ai_chat_model" type="text" value={settings.ai_chat_model ?? "deepseek-ai/DeepSeek-V3-0324"} />
</div>
<div>
<label for="ai_base_url">OpenAI 相容 Base URL</label>
<input id="ai_base_url" name="ai_base_url" type="text" value={settings.ai_base_url ?? "https://api.deepinfra.com/v1/openai"} />
</div>
<div>
<label for="ai_web_search_enabled">上網研究(Tavily)</label>
<select id="ai_web_search_enabled" name="ai_web_search_enabled">
<option value="1" selected={settings.ai_web_search_enabled === "1"}>啟用</option>
<option value="0" selected={settings.ai_web_search_enabled !== "1"}>停用</option>
</select>
</div>
<div>
<label for="ai_web_search_max_results">Tavily 結果數(1–10)</label>
<input id="ai_web_search_max_results" name="ai_web_search_max_results" type="text" value={settings.ai_web_search_max_results ?? "5"} />
</div>
<div>
<label>API Key 狀態</label>
<p class="muted" style="margin:10px 0 0">
AI_API_KEY:{secrets.ai ? "已設定" : "未設定"} · TAVILY_API_KEY:{secrets.tavily ? "已設定" : "未設定"}
</p>
</div>
<div style="grid-column:1/-1">
<label for="ai_context_prompt">寫作風格 / 語氣</label>
<textarea id="ai_context_prompt" name="ai_context_prompt">{settings.ai_context_prompt ?? ""}</textarea>
</div>
<div style="grid-column:1/-1">
<label for="ai_business_context">公司背景資料(grounding)</label>
<textarea id="ai_business_context" name="ai_business_context">{settings.ai_business_context ?? ""}</textarea>
</div>
</div>
<div class="actions"><button type="submit">儲存 AI 設定</button></div>
</form>
</div>
<div class="card">
<h2>關鍵字佇列({keywords.length})</h2>
<form method="post" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end">
<input type="hidden" name="action" value="add-keyword" />
<div style="flex:1;min-width:240px">
<label for="keyword">新增關鍵字 / 主題</label>
<input id="keyword" name="keyword" type="text" placeholder="例:太陽能板保養" />
</div>
<button type="submit" class="secondary">+ 加入</button>
</form>
{
keywords.length === 0 ? (
<p class="muted" style="margin-top:14px">暫時未有關鍵字。</p>
) : (
<table style="margin-top:14px">
<thead>
<tr>
<th>關鍵字</th>
<th style="width:100px">狀態</th>
<th style="width:120px"></th>
</tr>
</thead>
<tbody>
{keywords.map((k) => (
<tr>
<td>{k.keyword}</td>
<td>
<span class={`badge ${k.status === "generated" ? "published" : "draft"}`}>
{k.status === "pending" ? "待生成" : k.status === "generated" ? "已生成" : "已略過"}
</span>
</td>
<td>
<form method="post" style="display:inline">
<input type="hidden" name="id" value={k.id} />
{k.status === "pending" && (
<button class="secondary mini" name="action" value="skip-keyword">略過</button>
)}
<button class="danger mini" name="action" value="delete-keyword" onclick="return confirm('確定刪除?')">
刪除
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)
}
</div>
</AdminLayout>
- Step 2: 驗證
npm run build
Expected: PASS。
- Step 3: 手動測試生成
本機 .dev.vars 設定 AI_API_KEY 後:
npm run dev
開 /admin/ai → 輸入主題 → 「生成草稿」→ 應 redirect 去新草稿編輯頁。若 key 未設定,應顯示明確錯誤訊息。
Task 12: Admin 導覽入口
Files:
-
Modify:
src/layouts/AdminLayout.astro -
Modify:
src/pages/admin/index.astro -
Step 1: 加導覽連結
喺 links 陣列 { href: "/admin/settings", label: "設定" }, 之後加:
{ href: "/admin/ai", label: "AI 生成" },
- Step 2: 喺
/admin內容總覽加入口
喺 首頁內容 卡片嘅 actions 入面,網站設定 連結之後加:
<a class="btn secondary" href="/admin/ai">AI 生成</a>
- Step 3: 驗證
npm run build
Expected: PASS。npm run dev → 後台導覽見到「AI 生成」,撳到去 /admin/ai。
Phase 3 — 部署便利
Task 13: 一鍵本機 setup script
Files:
-
Create:
scripts/setup.mjs -
Modify:
package.json -
Step 1: 建
scripts/setup.mjs
#!/usr/bin/env node
import { execSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
const D1_PLACEHOLDER = "PASTE_D1_DATABASE_ID_HERE";
const KV_PLACEHOLDER = "PASTE_KV_NAMESPACE_ID_HERE";
const WRANGLER = "wrangler.jsonc";
const DB_NAME = "yingfung-solar-db";
const rl = createInterface({ input, output });
function run(cmd) {
return execSync(cmd, { encoding: "utf8", stdio: ["inherit", "pipe", "inherit"] });
}
function runSoft(cmd) {
try {
return { ok: true, out: run(cmd) };
} catch (err) {
return { ok: false, out: err.stdout ?? "" };
}
}
async function main() {
console.log("\n=== 盈豐太陽能 — Cloudflare 一鍵設定 ===\n");
console.log("檢查 wrangler 登入狀態…");
const who = runSoft("npx wrangler whoami");
if (!who.ok) {
console.error("未登入 Cloudflare。請先執行: npm run login");
process.exit(1);
}
let config = readFileSync(WRANGLER, "utf8");
if (config.includes(D1_PLACEHOLDER)) {
console.log(`建立 D1 database「${DB_NAME}」…`);
const res = runSoft(`npx wrangler d1 create ${DB_NAME}`);
const out = res.out ?? "";
const match = out.match(/database_id\s*=\s*"([0-9a-f-]+)"/i);
if (!match) {
console.error("成功建立但解析唔到 database_id。請手動將 id 填入 wrangler.jsonc。");
console.error(out);
process.exit(1);
}
config = config.replace(D1_PLACEHOLDER, match[1]);
console.log(` database_id = ${match[1]}`);
} else {
console.log("D1 id 已設定,略過。");
}
if (config.includes(KV_PLACEHOLDER)) {
console.log("建立 KV namespace「CACHE」…");
const res = runSoft("npx wrangler kv namespace create CACHE");
const out = res.out ?? "";
const match = out.match(/id\s*=\s*"([0-9a-f]+)"/i);
if (!match) {
console.error("成功建立但解析唔到 KV id。請手動將 id 填入 wrangler.jsonc。");
console.error(out);
process.exit(1);
}
config = config.replace(KV_PLACEHOLDER, match[1]);
console.log(` kv id = ${match[1]}`);
} else {
console.log("KV id 已設定,略過。");
}
writeFileSync(WRANGLER, config, "utf8");
console.log("已更新 wrangler.jsonc。\n");
const password = await rl.question("設定後台密碼 ADMIN_PASSWORD(留空略過):");
if (password.trim()) {
execSync("npx wrangler secret put ADMIN_PASSWORD", { input: password + "\n", stdio: ["pipe", "inherit", "inherit"] });
console.log("ADMIN_PASSWORD 已設定。");
} else {
console.log("略過 ADMIN_PASSWORD。");
}
const migrate = await rl.question("而家套用雲端 migration + seed?(y/N):");
if (migrate.trim().toLowerCase() === "y") {
execSync(`npx wrangler d1 migrations apply ${DB_NAME} --remote`, { stdio: "inherit" });
execSync(`npx wrangler d1 execute ${DB_NAME} --remote --file=./scripts/seed.sql`, { stdio: "inherit" });
}
console.log("\n提示:上線前記得改 astro.config.mjs 嘅 site 做真域名。");
const deploy = await rl.question("而家 build + deploy?(y/N):");
if (deploy.trim().toLowerCase() === "y") {
execSync("npm run build", { stdio: "inherit" });
execSync("npx wrangler deploy", { stdio: "inherit" });
}
console.log("\n完成。\n");
rl.close();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
- Step 2: 加
package.jsonscript
喺 "types": "wrangler types" 之後加:
"setup": "node scripts/setup.mjs"
(記得前一項加逗號。)
- Step 3: 驗證
npm run build
Expected: PASS。node --check scripts/setup.mjs 無語法錯誤(node --check 只檢查語法)。
Task 14: README + Workers Builds / AGENTS 文件同步
Files:
-
Modify:
README.md -
Modify:
AGENTS.md(root) -
Modify:
src/AGENTS.md -
Modify:
src/db/AGENTS.md -
Modify:
src/data/AGENTS.md -
Modify:
src/lib/AGENTS.md -
Modify:
migrations/AGENTS.md -
Step 1: README 加 AI + setup + Workers Builds
更新「部署到 Cloudflare」一節,改成:
## 部署到 Cloudflare
### 首次設定(一鍵)
```bash
npm run login # 瀏覽器授權
npm run setup # 建立 D1 / KV、寫入 wrangler.jsonc、設 secret、套 migration + seed
```
`npm run setup` 會逐步問你:後台密碼、要唔要套 migration/seed、要唔要 build + deploy。
### 之後更新
改完 `astro.config.mjs` 嘅 `site` 做真域名後:
```bash
npm run deploy
```
### Git push 自動部署(Cloudflare Workers Builds)
1. Cloudflare Dashboard → Workers & Pages → 連接 GitHub repo
2. Build command:`npm run build`
3. Deploy command:`npx wrangler deploy`
4. 喺 Dashboard 設 secrets:`ADMIN_PASSWORD`、`AI_API_KEY`、`TAVILY_API_KEY`
**Migration 唔會喺 CI 自動執行**:改完 `src/db/schema.ts` 要手動跑
`npm run db:generate` → `npm run db:migrate`(雲端)→ `npm run db:seed`(如有新 seed)。
## AI 生成 Blog
後台 `/admin/ai`:
- 設定 AI 供應商(OpenAI 相容 Base URL + 模型)、寫作風格、公司背景
- 維護關鍵字佇列
- 「生成草稿」= 自由輸入主題;「生成下一篇」= 攞佇列下一個 pending
- 生成結果一律存為**草稿**,去文章編輯頁覆核後先發布
- 需要 secret:`AI_API_KEY`(必需)、`TAVILY_API_KEY`(如開上網研究)
-
Step 2: root
AGENTS.md更新 -
「指令」一節加
npm run setup。 -
「關鍵慣例與陷阱」加:AI 生成用
AI_API_KEY/TAVILY_API_KEYsecret;Zod schema 喺src/schemas/;Workers Builds 部署但 migration 手動。 -
Child DOX Index 加
src/schemas/?—— schemas 由src/AGENTS.md覆蓋,將src/AGENTS.md嘅 ownership 描述更新即可。 -
Step 3:
src/AGENTS.md更新 -
Ownership 加入
src/schemas/(Zod 驗證層)同src/lib/form.ts、src/lib/ai.ts。 -
Routes & SSR 加入
/admin/ai。 -
說明 AI 生成係同步 await、
Astro.locals.cfContext為 ExecutionContext。 -
Step 4:
src/db/AGENTS.md更新 -
表清單加
blog_keywords;KEYWORD_STATUSES。 -
說明
posts.focusKeyword。 -
Step 5:
src/data/AGENTS.md更新 -
加
getKeywords、uniqueSlug。 -
型別來源改為「由
src/schemas/re-export /z.infer」。 -
Step 6:
src/lib/AGENTS.md更新 -
加
ai.ts(Tavily + DeepSeek + 解析)、form.ts(parseForm)。 -
env.ts加AI_API_KEY/TAVILY_API_KEY。 -
Step 7:
migrations/AGENTS.md更新 -
Seed 內容加 AI 設定 keys 同示範關鍵字。
-
說明 migration 唔入 CI。
-
Step 8: 最終驗證
npm run build
Expected: PASS。人手對照 spec §2–§4 同實作。
Self-Review 摘要
- Spec coverage:§2(Zod)→ Task 1–6;§3(AI)→ Task 7–12;§4(部署)→ Task 13–14。§8 文件同步 → Task 14。全部有對應 task。
- Placeholder:無 TBD / TODO;每個 code step 都有完整代碼。
- Type 一致性:
uniqueSlug(db, base, selfId?)、parseForm、getAiSettings、generateBlogPost、getKeywords、aiGeneratedPost前後一致。 - 已知取捨:同步生成(非背景);
focusKeyword喺 Task 7 先加(Task 2/4 執行時唔好加);settings.astro用as Record<string, unknown>索引係為咗動態 key。
實作偏差(review 後修正,實作為準)
- Admin 驗證錯誤顯示:
content/[kind].astro/cases.astro原本尾段無條件redirect會蓋過錯誤;改為只喺errors為空時 redirect,並用failedId/failedAdd分辨係新增定邊一行出錯(避免同一訊息顯示 N 次)。 settings.astro只寫有提交嘅 key(submittedset),防止未提交欄位被""蓋走。/admin/ai用 PRG:save-settings/add-keyword/delete-keyword/skip-keyword成功後redirect("/admin/ai?ok=…"),避免 refresh 重複提交;驗證失敗照樣 render 錯誤。- 「生成中…」:生成表單用 inline
onsubmit設pointer-events:none+ 改按鈕文字;唔用disabled(disabled 會令提交唔帶action值)。 ai.ts:parseGeneratedPost先正規化(去 code fence、全/半角冒號、粗體標籤);Tavily 用Authorization: Bearer;兩個 fetch 加AbortSignal.timeout(chat 90s、Tavily 15s);DB 寫入包 try/catch;ai_base_url限制 http/https 並喺 UI 加憑證警告。scripts/setup.mjs:id 解析同時支援 JSON / TOML;登入檢查用whoami --json;建立失敗會查d1 list/kv namespace list回收現有 id(idempotent);密碼交畀wrangler secret put(唔回顯);site仍係example.com時跳過 deploy。.gitignore:加入.playwright-mcp/。