Source & license
Upstream license: MIT
License TL;DR
You can use it, change it, self-host it and sell it. Keep the original copyright and license notice with copies of the code. You don’t have to publish your changes. The authors don’t promise it will work.
Explain MIT in plain English →Summary of the main license. Separate packages and assets can have different terms.
Inspect repository ↗Read this project’s actual license ↗Repository owner
See the upstream repository for the original creator and contributors.
Maintain this project? Maintainer verification →Cloudflare hosting
Free tier eligible within limits
The README states the Workers, D1, KV and R2 free allowances are enough for a personal blog. Subscriber email uses Resend, which is a separate account with its own limits and pricing.
Hosting requirements
- Subscriber and comment-notification email is optional and sent through Resend, not Cloudflare.
- Two deployment paths exist: Pages with Git integration (wrangler.toml) and a Worker deployed by GitHub Actions (wrangler.workers.toml).
- Set an admin password secret before first login; otherwise a random default is generated on first visit.
- Source and configuration review establishes a deployment path and conditional costs; this candidate was not executed or load-tested.
- Official Cloudflare pricing pages saved 31 Oct 2026 (Markdown editions of developers.cloudflare.com pricing documentation).
Review findings & limitations
- Subscriber and comment-notification email is optional and sent through Resend, not Cloudflare.
- Two deployment paths exist: Pages with Git integration (wrangler.toml) and a Worker deployed by GitHub Actions (wrangler.workers.toml).
- Set an admin password secret before first login; otherwise a random default is generated on first visit.
- Competitor links describe the stated workflow overlap, not feature parity or a verified price saving.
- Source review at the pinned commit; deployment, workload CPU and application security have not been independently tested.
Sources checked 31/10/2026
Repository snapshot: 5cf652d. Hosting eligibility reflects the deployment documentation and listed assumptions.
- ghost ↗
Qingyu'Blog(轻语博客)是一个**纯原生 JavaScript** 编写的个人博客系统,不依赖任何前端框架(React / Vue / Svelte)和构建工具(Webpack / Vite),也没有任何运行时第三方依赖。
- wordpress ↗
Qingyu'Blog(轻语博客)是一个**纯原生 JavaScript** 编写的个人博客系统,不依赖任何前端框架(React / Vue / Svelte)和构建工具(Webpack / Vite),也没有任何运行时第三方依赖。
- pages ↗
=== name = "azhz" # Workers 部署默认 worker 名(线上绑定自定义域名 https://www.2024921.xyz) compatibility_date = "2025-02-01" # Cloudflare Pages:静态资源目录(也是构建输出目录) pages_build_output_dir = "public" # KV 命名空间(云端数据:文章/评论/统计 + 管理员密码哈希) # 【注意·GitHub Actions 部署】仓库内不存放任何敏感值: # · id 用 {env.BLOG_KV_ID} 内插 → 由 GitHub Secrets 提供(见 .github/workflows/deploy.yml) # · 设置密钥 / 写入令牌等运行时变量 → 由 Actions 的 # `wrangler pages secret put` 持久化到 Pages 项目(同样不进仓库) # 原生 Pages「Git 连接」读不到 GitHub Secrets,若要用 Secrets 请改用 Actions 部署。 # 若坚持原生 Git 连接:请自行创建命
- d1 ↗
s 的 secrets.BLOG_KV_ID 内插(部署时不落仓库) id = "{env.BLOG_KV_ID}" # D1 数据库(主存储;表结构见 migrations/0001_init.sql,部署前由 Actions 自动执行) [[d1_databases]] binding = "DB" database_name = "blog" database_id = "{env.BLOG_D1_ID}"
- kv ↗
are Pages:静态资源目录(也是构建输出目录) pages_build_output_dir = "public" # KV 命名空间(云端数据:文章/评论/统计 + 管理员密码哈希) # 【注意·GitHub Actions 部署】仓库内不存放任何敏感值: # · id 用 {env.BLOG_KV_ID} 内插 → 由 GitHub Secrets 提供(见 .github/workflows/deploy.yml) # · 设置密钥 / 写入令牌等运行时变量 → 由 Actions 的 # `wrangler pages secret put` 持久化到 Pages 项目(同样不进仓库) # 原生 Pages「Git 连接」读不到 GitHub Secrets,若要用 Secrets 请改用 Actions 部署。 # 若坚持原生 Git 连接:请自行创建命名空间后,把真实 id 直接填到下方(会进仓库,注意保密)。 [[kv_namespaces]] binding = "BLOG" # id 由 GitHub Actions 的 secrets.BLOG_KV
- free-tier-eligible ↗
| **零成本** | Cloudflare Workers + D1 + KV + R2 的免费额度完全够个人博客使用 |
- free-tier-eligible ↗
| **Free** | 100,000 per day | No charge for duration | 10 milliseconds of CPU time per invocation |
- free-tier-eligible ↗
| Rows read | 5 million / day | First 25 billion / month included + $0.001 / million rows |
- free-tier-eligible ↗
| Keys read | 100,000 / day | 10 million/month, + $0.50/million |
- MIT ↗
MIT License Copyright (c) 2026 kejiland Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
- architecture ↗
| **云端模式** | 部署到 Cloudflare Workers + D1,数据存云端数据库 | 正式发布、多人访问 |
- architecture ↗
=== name = "azhz" # Workers 部署默认 worker 名(线上绑定自定义域名 https://www.2024921.xyz) compatibility_date = "2025-02-01" # Cloudflare Pages:静态资源目录(也是构建输出目录) pages_build_output_dir = "public" # KV 命名空间(云端数据:文章/评论/统计 + 管理员密码哈希) # 【注意·GitHub Actions 部署】仓库内不存放任何敏感值: # · id 用 {env.BLOG_KV_ID} 内插 → 由 GitHub Secrets 提供(见 .github/workflows/deploy.yml) # · 设置密钥 / 写入令牌等运行时变量 → 由 Actions 的 # `wrangler pages secret put` 持久化到 Pages 项目(同样不进仓库) # 原生 Pages「Git 连接」读不到 GitHub Secrets,若要用 Secrets 请改用 Actions 部署。 # 若坚持原生 Git 连接:请自行创建命
- architecture ↗
s 的 secrets.BLOG_KV_ID 内插(部署时不落仓库) id = "{env.BLOG_KV_ID}" # D1 数据库(主存储;表结构见 migrations/0001_init.sql,部署前由 Actions 自动执行) [[d1_databases]] binding = "DB" database_name = "blog" database_id = "{env.BLOG_D1_ID}"
- architecture ↗
are Pages:静态资源目录(也是构建输出目录) pages_build_output_dir = "public" # KV 命名空间(云端数据:文章/评论/统计 + 管理员密码哈希) # 【注意·GitHub Actions 部署】仓库内不存放任何敏感值: # · id 用 {env.BLOG_KV_ID} 内插 → 由 GitHub Secrets 提供(见 .github/workflows/deploy.yml) # · 设置密钥 / 写入令牌等运行时变量 → 由 Actions 的 # `wrangler pages secret put` 持久化到 Pages 项目(同样不进仓库) # 原生 Pages「Git 连接」读不到 GitHub Secrets,若要用 Secrets 请改用 Actions 部署。 # 若坚持原生 Git 连接:请自行创建命名空间后,把真实 id 直接填到下方(会进仓库,注意保密)。 [[kv_namespaces]] binding = "BLOG" # id 由 GitHub Actions 的 secrets.BLOG_KV
Upstream screenshot · kejiland/qingyu-blog repository contributors ↗. Depicts the upstream project. We have not deployed and tested a fresh installation here.
What it can replace
Compare the workflow you need. These mappings describe overlap; full feature parity requires a separate comparison.
Writing and publishing a personal or small multi-author blog with comments and stats; themes and plugins marketplaces, memberships and paid newsletters are not included
See supporting source ↗Writing and publishing a personal or small multi-author blog with comments and stats; themes and plugins marketplaces, memberships and paid newsletters are not included
See supporting source ↗How it works
The shape of Qingyu Blog on Cloudflare, and how it stacks up against the rented tools it replaces.
Architecture
Diagram of deployment declarations at the reviewed commit. Each app has its own entrypoint; declared resources do not prove runtime calls. Follow file and line sources below.
View upstream source ↗Configuration and workflow sources
Reviewed commit 5cf652d5c641. This configuration evidence comes from reading source files. Execution checks, when available, appear in the project's runtime review.
Deployment configuration · 2 files
Cloudflare Workers · compatibility 2025-02-01
kejiland · default
Entrypoint: worker.js
Static assets: ./public · single-page-application
Cron triggers (UTC): */5 * * * * · 0 19 * * *
DB→ D1BLOG→ KVAI→ Workers AIASSETS→ Static assets
Named environments are separate deployments. Bindings are shown only where declared. Configured routes are URL patterns, not verified application endpoints.
Runtime source · handlers, binding usage and workflow steps
Observed TypeScript/JavaScript declarations from Worker entrypoints and resolved relative imports. Calls and workflow steps may run conditionally; their listed order is not a proven end-to-end request flow. Router declarations may be mounted under a prefix or may not be registered. This shows code wiring, not a successful deployment or runtime test. Dynamic wiring, aliases and generated code may not resolve.
- L104 · fetch handler exported · calls handle, console.error, JSON.stringify, Object.assign, getCorsHeaders, securityHeaders
- L126 · scheduled handler exported · calls Date.now, publishScheduledPosts, console.log, result.ids.join, processMailOutbox, createBackup, console.error
- L52 · guardIp calls (conditional paths may differ): replace, String, request.headers.get
- L57 · bumpGuard calls (conditional paths may differ): Math.floor, Date.now, Number, env.BLOG.get, env.BLOG.put, String
- L69 · apiGuard calls (conditional paths may differ): guardIp, bumpGuard, guardTooMany, pathname.indexOf
- L81 · guardTooMany calls (conditional paths may differ): JSON.stringify, Object.assign, String, getCorsHeaders, securityHeaders
- L92 · isKnownSpaRoute calls (conditional paths may differ): test, pathname.indexOf
Environment references: env.BLOG · env.DB · env.ASSETS
- L44 · aiChat calls (conditional paths may differ): aiEnabled, env.AI.run, Object.assign, trim, console.warn
- L62 · aiRate calls (conditional paths may differ): console.warn, Number, env.BLOG.get, env.BLOG.put, String
- L87 · aiCacheGet calls (conditional paths may differ): env.BLOG.get
- L92 · aiCachePut calls (conditional paths may differ): env.BLOG.put
- L99 · buildSummaryMessages calls (conditional paths may differ): langName, slice, String
- L106 · buildAssistMessages calls (conditional paths may differ): slice, String, langName
- L134 · buildCommentSummaryMessages calls (conditional paths may differ): join, map, slice, String, lines.slice
- L144 · buildCommentScreenMessages calls (conditional paths may differ): slice, String
- L152 · extractJson calls (conditional paths may differ): exec, raw.indexOf, raw.lastIndexOf, JSON.parse, raw.slice
- L163 · normalizeLang calls (conditional paths may differ): test, String
- L168 · clientIp calls (conditional paths may differ): replace, String, request.headers.get
Environment references: env.AI · env.DB · env.BLOG_AI_ENABLED · env.BLOG_AI_PUBLIC · env.BLOG
- L11 · rangeDays calls (conditional paths may differ): toLowerCase, String
- L17 · cutoffDate calls (conditional paths may differ): d.setDate, d.getDate, slice, d.toISOString
- L22 · parseTags calls (conditional paths may differ): JSON.parse, Array.isArray, tags.map, String
- L28 · metricsFor calls (conditional paths may differ): Number, String, parseTags
- L47 · handlePostAnalytics calls (conditional paths may differ): json, corsPreflight, isWriteAuthed, unauthorized, rangeDays, url.searchParams.get, dbAll, cutoffDate, dailyRows.forEach, String, Number, statsRows.forEach, commentRows.forEach, trendDates.push, trendDates.forEach, map, metricsFor, trendDates.map, items.sort, localeCompare
Environment references: env.DB
- L35 · purgeTags calls (conditional paths may differ): fetch, JSON.stringify, console.warn
- L49 · dbAll calls (conditional paths may differ): all, bind, db.prepare
- L53 · dbFirst calls (conditional paths may differ): first, bind, db.prepare
- L56 · dbRun calls (conditional paths may differ): run, bind, db.prepare
- L60 · dbBatch calls (conditional paths may differ): Array.isArray, db.batch, stmts.map, bind, db.prepare
- L72 · getCorsHeaders calls (conditional paths may differ): request.headers.get, allowed.push, replace, String, allowed.indexOf
- L100 · securityHeaders calls (conditional paths may differ): join
- L123 · json calls (conditional paths may differ): Object.assign, getCorsHeaders, securityHeaders, JSON.stringify
- L134 · corsPreflight calls (conditional paths may differ): getCorsHeaders
- L141 · unauthorized calls (conditional paths may differ): catch, adminAuthState, json
- L158 · normalizePublishAt calls (conditional paths may differ): Number, Number.isFinite, Math.floor
- L164 · normalizeSeo calls (conditional paths may differ): JSON.parse, slice, trim, String
- L177 · normalizePost calls (conditional paths may differ): normalizePostStatus, normalizePublishAt, String, trim, slice, Math.max, Math.floor, Number, normalizeSeo, Array.isArray, filter, out.tags.map, map, split, t.trim
- L208 · postFromRow calls (conditional paths may differ): JSON.parse, String, Number, normalizePostStatus, normalizePublishAt, normalizeSeo
- L241 · postToParams calls (conditional paths may differ): JSON.stringify, normalizePostStatus, normalizePublishAt, normalizeSeo
- L263 · sortByDateDesc calls (conditional paths may differ): sort, posts.slice
- L272 · xmlEscape calls (conditional paths may differ): replace, String
- L279 · rfc822 calls (conditional paths may differ): trim, String, s.slice, isNaN, d.getTime, toUTCString, d.toUTCString
- L293 · buildFeedXml calls (conditional paths may differ): replace, String, slice, filter, sortByDateDesc, isPublicPost, join, list.map, encodeURIComponent, xmlEscape, rfc822, content.replace, toUTCString
- L327 · handleFeed calls (conditional paths may differ): json, buildFeedXml, readPosts, getCorsHeaders, securityHeaders
- L339 · readPosts calls (conditional paths may differ): dbAll, rows.map, dbFirst, readStaticPosts
- L358 · readStaticPosts calls (conditional paths may differ): env.ASSETS.fetch, res.text, exec, JSON.parse, Array.isArray, arr.map
- L374 · commentGuardEnabled calls (conditional paths may differ): dbFirst, JSON.parse, String
- L385 · handlePosts calls (conditional paths may differ): json, corsPreflight, url.searchParams.get, toLowerCase, trim, String, Math.min, Math.max, Math.floor, Number, isWriteAuthed, unauthorized, sortByDateDesc, readPosts, all.filter, list.filter, indexOf, Array.isArray, p.tags.join, Math.ceil
- L457 · handlePostId calls (conditional paths may differ): json, corsPreflight, isWriteAuthed, unauthorized, dbFirst, postFromRow, catch, request.json, normalizePost, String, dbRun, postToParams, recordPostRevision, normalizePostStatus, queuePostNotifications, purgeTags, dbBatch, recordAudit
- L519 · commentBlocklistHit calls (conditional paths may differ): toLowerCase, String, trim, hay.indexOf
- L524 · commentBlocklist calls (conditional paths may differ): filter, map, split, String, trim, dbFirst, JSON.parse, Array.isArray, raw.split, list.map
- L541 · sanitizeText calls (conditional paths may differ): replace, String
- L546 · getCommentDepth calls (conditional paths may differ): dbFirst
- L559 · handleComments calls (conditional paths may differ): json, corsPreflight, request.method.toUpperCase, url.searchParams.get, Math.max, Math.floor, Number, Math.min, dbAll, list.forEach, push, roots.push, Math.ceil, out.push, forEach, roots.slice, request.headers.get, replace, String, clientIp
- L583 · walk calls (conditional paths may differ): out.push, forEach
- L686 · handleCommentLike calls (conditional paths may differ): json, corsPreflight, catch, dbFirst, clientIp, Math.floor, Date.now, Number, env.BLOG.get, env.BLOG.put, String, dbRun
- L706 · handleCommentId calls (conditional paths may differ): json, corsPreflight, isWriteAuthed, unauthorized, dbFirst, dbRun
- L719 · queueCommentNotification calls (conditional paths may differ): trim, String, dbFirst, JSON.parse, test, catch, JSON.stringify, dbRun, Date.now
- L737 · queuePostNotifications calls (conditional paths may differ): catch, dbAll, Date.now, subscribers.map, dbBatch, stmts.slice
- L753 · revisionFromRow calls (conditional paths may differ): JSON.parse, Number, String, normalizePostStatus, normalizePublishAt
- L781 · revisionMetaFromRow calls (conditional paths may differ): revisionFromRow
- L788 · revisionFingerprint calls (conditional paths may differ): JSON.stringify, Number, normalizePostStatus
- L796 · recordPostRevision calls (conditional paths may differ): dbFirst, revisionFingerprint, revisionFromRow, Date.now, dbRun, JSON.stringify, Number, normalizePostStatus, normalizePublishAt, catch, dbAll, rows.slice
- L817 · handlePostRevisions calls (conditional paths may differ): json, corsPreflight, isWriteAuthed, unauthorized, catch, dbAll, filter, rows.map
Environment references: env.CF_API_TOKEN · env.CF_ZONE_ID · env.SITE_URL · env.DB · env.ASSETS · env.COMMENT_BLOCKLIST · env.BLOG · env.RESEND_API_KEY · env.BLOG_MAIL_FROM · env.BLOG_ADMIN_EMAIL · env.BLOG_MAIL_REPLY_TO · env.LOGIN_LIMITER · env.BLOG_WRITE_TOKEN · env.BLOG_ADMIN_SETUP_KEY · env.BLOG_PREVIEW_SECRET · env.R2_ACCESS_KEY_ID · env.R2_SECRET_ACCESS_KEY · env.R2_ENDPOINT · env.R2_MEDIA_BUCKET · env.R2_BACKUP_BUCKET · env.AI
- L28 · backupId calls (conditional paths may differ): toString, Date.now, slice, Math.random
- L31 · bytesOf calls (conditional paths may differ): encode, String
- L35 · normalizeBackupRow calls (conditional paths may differ): JSON.parse, String, Number
- L44 · readTable calls (conditional paths may differ): catch, dbAll, cols.join
- L49 · pruneBackups calls (conditional paths may differ): catch, dbAll, rows.slice, r2DeleteObject, dbRun
- L56 · createBackup calls (conditional paths may differ): backupConfigured, readTable, toISOString, JSON.stringify, backupId, replace, presignPut, fetch, catch, res.text, detail.slice, Date.now, dbRun, bytesOf, pruneBackups
- L90 · loadBackup calls (conditional paths may differ): dbFirst, presignGet, fetch, res.json
- L100 · insertStatements calls (conditional paths may differ): cols.join, join, cols.map, rows.map
- L106 · replaceTable calls (conditional paths may differ): dbBatch, insertStatements, stmts.slice
- L113 · restoreBackup calls (conditional paths may differ): backupConfigured, loadBackup, createBackup, dbBatch, replaceTable, catch, dbRun
- L123 · handleBackups calls (conditional paths may differ): json, corsPreflight, isWriteAuthed, unauthorized, catch, dbAll, backupConfigured, filter, rows.map, createBackup, recordAudit
- L143 · handleBackupId calls (conditional paths may differ): json, corsPreflight, isWriteAuthed, unauthorized, backupConfigured, loadBackup, dbFirst, r2DeleteObject, dbRun, recordAudit
- L167 · handleBackupRestore calls (conditional paths may differ): json, corsPreflight, isWriteAuthed, unauthorized, backupConfigured, restoreBackup, recordAudit
Environment references: env.DB · env.R2_BACKUP_BUCKET · env.R2_ACCESS_KEY_ID · env.R2_SECRET_ACCESS_KEY · env.R2_ENDPOINT
- L26 · randomId calls (conditional paths may differ): toString, Date.now, slice, Math.random
- L32 · extractMediaR2Key calls (conditional paths may differ): String, replace, u.pathname.indexOf, u.pathname.slice
- L51 · handleMediaUploadUrl calls (conditional paths may differ): json, corsPreflight, isWriteAuthed, unauthorized, r2Configured, catch, request.json, trim, String, Number, exec, toLowerCase, randomId, presignPut, replace
- L86 · deleteMediaObject calls (conditional paths may differ): filter, extractMediaR2Key, r2DeleteObject
Environment references: env.R2_MEDIA_BUCKET · env.R2_ACCESS_KEY_ID · env.R2_SECRET_ACCESS_KEY · env.R2_ENDPOINT · env.R2_MEDIA_PUBLIC_BASE · env.DB
- L23 · hexify calls (conditional paths may differ): padStart, toString
- L29 · hmac calls (conditional paths may differ): crypto.subtle.importKey, crypto.subtle.sign, enc.encode
- L33 · signingKey calls (conditional paths may differ): hmac, enc.encode
- L42 · s3Path calls (conditional paths may differ): join, map, split, String, replace, encodeURIComponent, toUpperCase, toString, ch.charCodeAt
- L50 · r2SignParams calls (conditional paths may differ): replace, String, toISOString, amzDate.slice
- L60 · signS3 calls (conditional paths may differ): r2SignParams, join, method.toUpperCase, crypto.subtle.digest, enc.encode, hexify, signingKey, hmac
- L73 · presignPut calls (conditional paths may differ): r2SignParams, s3Path, trim, String, join, map, sort, Object.keys, encodeURIComponent, signS3
- L101 · presignGet calls (conditional paths may differ): r2SignParams, s3Path, String, join, map, sort, Object.keys, encodeURIComponent, signS3
- L124 · trimBase calls (conditional paths may differ): replace, String
- L127 · originOf calls (conditional paths may differ): trimBase
- L131 · musicStorage calls (conditional paths may differ): trim, String, trimBase, originOf
- L142 · r2Configured calls (conditional paths may differ): musicStorage
- L148 · randomId calls (conditional paths may differ): toString, Date.now, slice, Math.random
- L156 · sigv4AuthHeader calls (conditional paths may differ): r2SignParams, signS3
- L168 · r2DeleteObject calls (conditional paths may differ): replace, String, s3Path, sigv4AuthHeader, fetch, catch, res.text, detail.slice
- L189 · resolveR2Object calls (conditional paths may differ): String, u.pathname.indexOf, u.pathname.slice, trim, originOf, candidates.push
- L209 · extractR2Key calls (conditional paths may differ): resolveR2Object
- L213 · normalizeTrack calls (conditional paths may differ): Number
- L225 · handleMusic calls (conditional paths may differ): json, corsPreflight, dbAll, map, isWriteAuthed, unauthorized, catch, request.json, slice, trim, String, randomId, Number, toISOString, dbRun, normalizeTrack
- L260 · handleMusicUploadUrl calls (conditional paths may differ): json, corsPreflight, isWriteAuthed, unauthorized, r2Configured, catch, request.json, trim, String, Number, exec, toLowerCase, randomId, musicStorage, presignPut
- L290 · handleMusicId calls (conditional paths may differ): json, corsPreflight, isWriteAuthed, unauthorized, catch, request.json, slice, trim, String, Number, dbRun, console.error, dbFirst, resolveR2Object, r2Configured, r2DeleteObject
Environment references: env.R2_ENDPOINT · env.R2_SECRET_ACCESS_KEY · env.R2_BUCKET · env.R2_ACCESS_KEY_ID · env.R2_PUBLIC_BASE · env.R2_MEDIA_BUCKET · env.R2_MEDIA_PUBLIC_BASE · env.DB
- L8 · storage calls (conditional paths may differ): trim, String, replace
- L16 · ogConfigured calls (conditional paths may differ): storage
- L20 · handleOgUploadUrl calls (conditional paths may differ): json, corsPreflight, isWriteAuthed, unauthorized, ogConfigured, catch, request.json, slice, trim, String, postId.replace, toString, Date.now, storage, presignPut
Environment references: env.R2_MEDIA_BUCKET · env.R2_MEDIA_PUBLIC_BASE · env.R2_BUCKET · env.R2_PUBLIC_BASE · env.R2_ACCESS_KEY_ID · env.R2_SECRET_ACCESS_KEY · env.R2_ENDPOINT
- L13 · rangeDays calls (conditional paths may differ): toLowerCase, String
- L20 · cutoffDate calls (conditional paths may differ): d.setDate, d.getDate, slice, d.toISOString
- L26 · parseTags calls (conditional paths may differ): JSON.parse, Array.isArray, tags.map, String
- L33 · publicPost calls (conditional paths may differ): Number
- L37 · meta calls (conditional paths may differ): String, parseTags, Number
- L56 · handlePopular calls (conditional paths may differ): json, corsPreflight, rangeDays, url.searchParams.get, Math.min, Math.max, Number, dbAll, cutoffDate, dailyRows.forEach, String, statsRows.forEach, commentRows.forEach, slice, sort, map, filter, meta, localeCompare
Environment references: env.DB
- L15 · normalizeText calls (conditional paths may differ): replace, toLowerCase, trim, String
- L19 · slug calls (conditional paths may differ): slice, replace, toLowerCase, String
- L23 · parseTags calls (conditional paths may differ): JSON.parse, Array.isArray, filter, tags.map, trim, String
- L30 · publicPostRows calls (conditional paths may differ): filter, String, Number
- L38 · buildLookup calls (conditional paths may differ): forEach, String, normalizeText, slug
- L55 · resolveRef calls (conditional paths may differ): trim, String, decodeURIComponent, normalizeText, slug
- L66 · postIdFromUrl calls (conditional paths may differ): replace, trim, String, value.indexOf, value.slice, test, value.split, filter, pathname.split, decodeURIComponent
- L82 · parsePostLinkRefs calls (conditional paths may differ): String, wikiRe.exec, refs.push, trim, split, mdRe.exec, postIdFromUrl, refs.filter
- L97 · uniqueTargets calls (conditional paths may differ): forEach, resolveRef, out.push
- L107 · relationMeta calls (conditional paths may differ): String, Number, parseTags
- L122 · relationScore calls (conditional paths may differ): parseTags, otherTags.forEach, normalizeText, currentTags.filter, String
- L132 · sortRecent calls (conditional paths may differ): localeCompare, String
- L137 · handlePostRelations calls (conditional paths may differ): json, corsPreflight, dbAll, find, String, Number, publicPostRows, buildLookup, concat, others.forEach, parsePostLinkRefs, uniqueTargets, targets.indexOf, backlinks.push, relationMeta, relationScore, backlinks.sort, map, slice, filter
Environment references: env.DB
- L16 · clampInt calls (conditional paths may differ): Math.floor, Number, Number.isFinite, Math.min, Math.max
- L22 · normalizeSearchQuery calls (conditional paths may differ): slice, trim, replace, String
- L30 · termLength calls (conditional paths may differ): Array.from, String
- L35 · toFtsQuery calls (conditional paths may differ): join, map, filter, split, trim, String, term.replace
- L44 · canUseFts calls (conditional paths may differ): filter, split, trim, String, terms.every, termLength
- L49 · escapeLike calls (conditional paths may differ): replace, String
- L53 · searchResultFromRow calls (conditional paths may differ): JSON.parse, Array.isArray, String, Number, tags.map
- L84 · searchWithFts calls (conditional paths may differ): toFtsQuery, dbAll, Number
- L107 · searchWithLike calls (conditional paths may differ): escapeLike, dbAll, Number
- L142 · handleSearch calls (conditional paths may differ): json, corsPreflight, normalizeSearchQuery, url.searchParams.get, clampInt, canUseFts, searchWithFts, searchWithLike, Math.max, Math.ceil, result.rows.map
Environment references: env.DB
- L11 · randomToken calls (conditional paths may differ): crypto.getRandomValues, join, map, Array.from, padStart, x.toString
- L16 · normalizeEmail calls (conditional paths may differ): toLowerCase, trim, String, test
- L23 · parseSubscriberGroups calls (conditional paths may differ): Array.isArray, filter, v.map, trim, String, JSON.parse, arr.map
- L30 · normalizeSubscriberGroups calls (conditional paths may differ): Array.isArray, split, String, slice, trim, out.push
- L42 · escHtml calls (conditional paths may differ): replace, String
- L48 · publicBase calls (conditional paths may differ): replace, String
- L51 · sendEmail calls (conditional paths may differ): mailConfigured, fetch, JSON.stringify, catch, res.text, detail.slice
- L69 · sendConfirmation calls (conditional paths may differ): publicBase, encodeURIComponent, escHtml, sendEmail
- L78 · handleSubscribe calls (conditional paths may differ): json, corsPreflight, mailConfigured, catch, request.json, normalizeEmail, slice, String, dbFirst, randomToken, Date.now, dbRun, sendConfirmation
- L100 · handleSubscribeConfirm calls (conditional paths may differ): json, searchParams.get, dbFirst, publicBase, redirectPage, dbRun, Date.now
- L109 · handleUnsubscribe calls (conditional paths may differ): json, searchParams.get, dbFirst, publicBase, redirectPage, dbRun, Date.now
- L118 · sendPostNotification calls (conditional paths may differ): publicBase, encodeURIComponent, slice, String, escHtml, sendEmail
- L131 · sendCommentNotification calls (conditional paths may differ): JSON.parse, publicBase, encodeURIComponent, escHtml, sendEmail
- L148 · sendBroadcastEmail calls (conditional paths may differ): JSON.parse, publicBase, encodeURIComponent, String, replace, escHtml, sendEmail
- L161 · processMailOutbox calls (conditional paths may differ): mailConfigured, dbAll, Math.max, Math.min, Number, sendCommentNotification, dbRun, Date.now, slice, String, dbFirst, sendBroadcastEmail, sendPostNotification
- L211 · handleSubscribersAdmin calls (conditional paths may differ): json, corsPreflight, isWriteAuthed, unauthorized, dbAll, rows.map, parseSubscriberGroups, groups.forEach, Object.assign, map, sort, Object.keys, a.localeCompare, mailConfigured
- L228 · handleSubscriberId calls (conditional paths may differ): json, corsPreflight, isWriteAuthed, unauthorized, catch, request.json, normalizeSubscriberGroups, dbRun, JSON.stringify
- L243 · handleSubscriberBroadcast calls (conditional paths may differ): json, corsPreflight, isWriteAuthed, unauthorized, mailConfigured, catch, request.json, slice, trim, String, normalizeSubscriberGroups, dbAll, rows.filter, some, parseSubscriberGroups, groups.indexOf, toString, Date.now, randomToken, JSON.stringify
Environment references: env.RESEND_API_KEY · env.BLOG_MAIL_FROM · env.SITE_URL · env.BLOG_MAIL_REPLY_TO · env.DB
- L61 · Cloudflare Pages ALL request handler exported · calls corsPreflight, url.pathname.indexOf, env.ASSETS.fetch, securityHeaders, re.test, json
- L61 · onRequest calls (conditional paths may differ): corsPreflight, url.pathname.indexOf, env.ASSETS.fetch, securityHeaders, re.test, json
Environment references: env.ASSETS
- L2 · Cloudflare Pages ALL request handler exported · calls handleBackupRestore
- L3 · Cloudflare Pages OPTIONS request handler exported · calls handleBackupRestore
- L2 · onRequest calls (conditional paths may differ): handleBackupRestore
- L3 · onRequestOptions calls (conditional paths may differ): handleBackupRestore
- L5 · Cloudflare Pages GET request handler exported · calls handleErrorsAdmin
- L6 · Cloudflare Pages DELETE request handler exported · calls handleErrorsAdmin
- L7 · Cloudflare Pages OPTIONS request handler exported · calls handleErrorsAdmin
- L5 · onRequestGet calls (conditional paths may differ): handleErrorsAdmin
- L6 · onRequestDelete calls (conditional paths may differ): handleErrorsAdmin
- L7 · onRequestOptions calls (conditional paths may differ): handleErrorsAdmin
- L5 · Cloudflare Pages ALL request handler exported · calls handleAdminLogout
- L8 · Cloudflare Pages OPTIONS request handler exported · calls handleAdminLogout
- L5 · onRequest calls (conditional paths may differ): handleAdminLogout
- L8 · onRequestOptions calls (conditional paths may differ): handleAdminLogout
- L2 · Cloudflare Pages ALL request handler exported · calls handleOgUploadUrl
- L3 · Cloudflare Pages OPTIONS request handler exported · calls handleOgUploadUrl
- L2 · onRequest calls (conditional paths may differ): handleOgUploadUrl
- L3 · onRequestOptions calls (conditional paths may differ): handleOgUploadUrl
- L5 · Cloudflare Pages POST request handler exported · calls handlePreviewLink
- L6 · Cloudflare Pages OPTIONS request handler exported · calls handlePreviewLink
- L5 · onRequestPost calls (conditional paths may differ): handlePreviewLink
- L6 · onRequestOptions calls (conditional paths may differ): handlePreviewLink
- L2 · Cloudflare Pages ALL request handler exported · calls handleSubscribersAdmin
- L3 · Cloudflare Pages OPTIONS request handler exported · calls handleSubscribersAdmin
- L2 · onRequest calls (conditional paths may differ): handleSubscribersAdmin
- L3 · onRequestOptions calls (conditional paths may differ): handleSubscribersAdmin
- L2 · Cloudflare Pages ALL request handler exported · calls handleSubscriberId
- L3 · Cloudflare Pages PUT request handler exported · calls handleSubscriberId
- L4 · Cloudflare Pages DELETE request handler exported · calls handleSubscriberId
- L5 · Cloudflare Pages OPTIONS request handler exported · calls handleSubscriberId
- L2 · onRequest calls (conditional paths may differ): handleSubscriberId
- L3 · onRequestPut calls (conditional paths may differ): handleSubscriberId
- L4 · onRequestDelete calls (conditional paths may differ): handleSubscriberId
- L5 · onRequestOptions calls (conditional paths may differ): handleSubscriberId
- L5 · Cloudflare Pages ALL request handler exported · calls handleSubscriberBroadcast
- L6 · Cloudflare Pages OPTIONS request handler exported · calls handleSubscriberBroadcast
- L5 · onRequest calls (conditional paths may differ): handleSubscriberBroadcast
- L6 · onRequestOptions calls (conditional paths may differ): handleSubscriberBroadcast
- L5 · Cloudflare Pages GET request handler exported · calls handleWebmentionsAdmin
- L6 · Cloudflare Pages OPTIONS request handler exported · calls handleWebmentionsAdmin
- L5 · onRequestGet calls (conditional paths may differ): handleWebmentionsAdmin
- L6 · onRequestOptions calls (conditional paths may differ): handleWebmentionsAdmin
- L5 · Cloudflare Pages DELETE request handler exported · calls handleWebmentionsAdmin
- L6 · Cloudflare Pages OPTIONS request handler exported · calls handleWebmentionsAdmin
- L5 · onRequestDelete calls (conditional paths may differ): handleWebmentionsAdmin
- L6 · onRequestOptions calls (conditional paths may differ): handleWebmentionsAdmin
- L11 · Cloudflare Pages ALL request handler exported · calls corsPreflight, json, aiEnabled, isWriteAuthed, unauthorized, catch, request.json, text.trim, buildAssistMessages, normalizeLang, clientIp, aiRate, aiChat
- L11 · onRequest calls (conditional paths may differ): corsPreflight, json, aiEnabled, isWriteAuthed, unauthorized, catch, request.json, text.trim, buildAssistMessages, normalizeLang, clientIp, aiRate, aiChat
- L14 · Cloudflare Pages ALL request handler exported · calls corsPreflight, json, aiEnabled, isWriteAuthed, unauthorized, catch, request.json, clientIp, aiRate, aiCacheGet, all, env.DB.prepare, reverse, aiChat, buildCommentSummaryMessages, aiCachePut, body.text.trim, buildCommentScreenMessages, extractJson
- L14 · onRequest calls (conditional paths may differ): corsPreflight, json, aiEnabled, isWriteAuthed, unauthorized, catch, request.json, clientIp, aiRate, aiCacheGet, all, env.DB.prepare, reverse, aiChat, buildCommentSummaryMessages, aiCachePut, body.text.trim, buildCommentScreenMessages, extractJson
Environment references: env.DB
- L14 · Cloudflare Pages ALL request handler exported · calls corsPreflight, aiEnabled, json, u.searchParams.get, normalizeLang, aiCacheGet, catch, request.json, String, aiPublicGenerate, isWriteAuthed, unauthorized, first, bind, env.DB.prepare, Number, clientIp, aiRate, aiChat, buildSummaryMessages, aiCachePut
- L14 · onRequest calls (conditional paths may differ): corsPreflight, aiEnabled, json, u.searchParams.get, normalizeLang, aiCacheGet, catch, request.json, String, aiPublicGenerate, isWriteAuthed, unauthorized, first, bind, env.DB.prepare, Number, clientIp, aiRate, aiChat, buildSummaryMessages
Environment references: env.DB
- L5 · Cloudflare Pages GET request handler exported · calls handleCommentsList
- L8 · Cloudflare Pages OPTIONS request handler exported · calls handleCommentsList
- L5 · onRequestGet calls (conditional paths may differ): handleCommentsList
- L8 · onRequestOptions calls (conditional paths may differ): handleCommentsList
- L5 · Cloudflare Pages PUT request handler exported · calls handleCommentUpdate
- L8 · Cloudflare Pages POST request handler exported · calls handleCommentUpdate
- L11 · Cloudflare Pages DELETE request handler exported · calls handleCommentDeleteGlobal
- L14 · Cloudflare Pages OPTIONS request handler exported · calls handleCommentUpdate
- L5 · onRequestPut calls (conditional paths may differ): handleCommentUpdate
- L8 · onRequestPost calls (conditional paths may differ): handleCommentUpdate
- L11 · onRequestDelete calls (conditional paths may differ): handleCommentDeleteGlobal
- L14 · onRequestOptions calls (conditional paths may differ): handleCommentUpdate
- L5 · Cloudflare Pages POST request handler exported · calls handleErrorReport
- L6 · Cloudflare Pages OPTIONS request handler exported · calls handleErrorReport
- L5 · onRequestPost calls (conditional paths may differ): handleErrorReport
- L6 · onRequestOptions calls (conditional paths may differ): handleErrorReport
- L7 · Cloudflare Pages ALL request handler exported · calls catch, dbFirst, deleteMediaObject, handleMediaId
- L7 · onRequest calls (conditional paths may differ): catch, dbFirst, deleteMediaObject, handleMediaId
Environment references: env.DB
- L7 · Cloudflare Pages GET request handler exported · calls handlePosts
- L11 · Cloudflare Pages POST request handler exported · calls handlePosts
- L15 · Cloudflare Pages OPTIONS request handler exported · calls handlePosts
- L7 · onRequestGet calls (conditional paths may differ): handlePosts
- L11 · onRequestPost calls (conditional paths may differ): handlePosts
- L15 · onRequestOptions calls (conditional paths may differ): handlePosts
- L7 · Cloudflare Pages GET request handler exported · calls handlePostId
- L11 · Cloudflare Pages PUT request handler exported · calls handlePostId
- L15 · Cloudflare Pages DELETE request handler exported · calls handlePostId
- L19 · Cloudflare Pages OPTIONS request handler exported · calls handlePostId
- L7 · onRequestGet calls (conditional paths may differ): handlePostId
- L11 · onRequestPut calls (conditional paths may differ): handlePostId
- L15 · onRequestDelete calls (conditional paths may differ): handlePostId
- L19 · onRequestOptions calls (conditional paths may differ): handlePostId
- L5 · Cloudflare Pages GET request handler exported · calls handlePostRelations
- L9 · Cloudflare Pages OPTIONS request handler exported · calls handlePostRelations
- L5 · onRequestGet calls (conditional paths may differ): handlePostRelations
- L9 · onRequestOptions calls (conditional paths may differ): handlePostRelations
- L3 · Cloudflare Pages ALL request handler exported · calls handlePostRevisions
- L6 · Cloudflare Pages OPTIONS request handler exported · calls handlePostRevisions
- L3 · onRequest calls (conditional paths may differ): handlePostRevisions
- L6 · onRequestOptions calls (conditional paths may differ): handlePostRevisions
- L3 · Cloudflare Pages ALL request handler exported · calls handlePostRevision
- L6 · Cloudflare Pages OPTIONS request handler exported · calls handlePostRevision
- L3 · onRequest calls (conditional paths may differ): handlePostRevision
- L6 · onRequestOptions calls (conditional paths may differ): handlePostRevision
- L3 · Cloudflare Pages ALL request handler exported · calls handlePostRevisionRestore
- L6 · Cloudflare Pages OPTIONS request handler exported · calls handlePostRevisionRestore
- L3 · onRequest calls (conditional paths may differ): handlePostRevisionRestore
- L6 · onRequestOptions calls (conditional paths may differ): handlePostRevisionRestore
- L5 · Cloudflare Pages GET request handler exported · calls handlePreviewGet
- L6 · Cloudflare Pages OPTIONS request handler exported · calls handlePreviewGet
- L5 · onRequestGet calls (conditional paths may differ): handlePreviewGet
- L6 · onRequestOptions calls (conditional paths may differ): handlePreviewGet
- L5 · Cloudflare Pages GET request handler exported · calls handleWebmentionList
- L6 · Cloudflare Pages POST request handler exported · calls handleWebmention
- L7 · Cloudflare Pages OPTIONS request handler exported · calls handleWebmention
- L5 · onRequestGet calls (conditional paths may differ): handleWebmentionList
- L6 · onRequestPost calls (conditional paths may differ): handleWebmention
- L7 · onRequestOptions calls (conditional paths may differ): handleWebmention
Build and deployment pipeline · 2 GitHub Actions workflows
Repository CI declarations, separate from runtime request processing. Job dependencies and conditions are shown as written; long commands are shortened with an ellipsis; a workflow file does not prove a recent successful run.
Triggers: push, workflow_dispatch
deploy · no job dependencies declared
- Checkout
actions/checkout@v4 - Setup Node
actions/setup-node@v4 - Setup wrangler CLI
# 固定 wrangler 3.90.0:实测 4.x 在本项目配置下部署失败(Deploy Worker 步骤报错), # 暂不升级;run_worker_first(静态资源先过 Worker)因此也无法启用,见 wrangler.workers.toml 注释 if [ -n "$RL_BINDING" ]; then npm install -g 'wrangler@^4.36.0' echo "✅ 已启用边缘限流绑定 → 使用 wrangler 4.x(该绑定要求 4.36.0+)" else npm install -g wrangler@3.90.0 echo "✅ 使用 wrangler 3.90.0(未启用边缘限流绑定)" fi - Run tests (smoke + verify)
node smoke-test.js node gb-verify.js node search-verify.js - Check required secrets
MISSING="" # BLOG_ADMIN_SETUP_KEY 为可选(见文件头注释:配置后 setup 需 X-Setup-Key; # 未配置回退旧随机密码流程),不做必填校验 for v in BLOG_KV_ID BLOG_D1_ID CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID; do if [ -z "${!v}" ]; then MISSING="$MISSING $v"; fi done if [ -n "$MISSING" ]; then echo "::error::缺少必要的 GitHub Secrets:$MISSING" echo "请到 仓库 Settings → Secrets and variables → Actions 添加后重试。" echo "(BLOG_ADMIN_SETUP_KEY 可选:配置后 /api/admin/setup 需安装密钥;未配置时首次部署自动生成随机默认密码)" exit 1 fi echo "✅ 必要 Secrets 齐全(值不会打印;BLOG_ADMIN_SETUP_KEY… - Diagnose account & worker
echo "=== 账户 $CLOUDFLARE_ACCOUNT_ID ===" wrangler whoami 2>&1 | head -20 || trueCondition: github.event_name == 'workflow_dispatch' - Prepare deploy config with real KV id
python3 - <<'PY' import os kv_id = os.environ['BLOG_KV_ID'].strip() if not kv_id or len(kv_id) != 32 or not all(c in '0123456789abcdef' for c in kv_id.lower()): print('::error::BLOG_KV_ID 不是有效的 32 位十六进制 KV id(去除多余空格/换行后重试)') raise SystemExit(1) src = open('wrangler.workers.toml', encoding='utf-8').read() dst = src.replace('{env.BLOG_KV_ID}', kv_id) # D1 database_id 是 UUID(如 0fa366ac-f04a-4be2-8e11-5adc6ee6d686),与 KV 的 32 位十六进制不同 import re d1_raw = os.environ['BLOG_D1_ID'].strip().strip('"').str… - Apply D1 schema (migrations/*.sql with schema_migrations ledger)
# 建记账表(幂等;不静默,失败可见便于诊断) wrangler d1 execute blog --remote --config wrangler.workers.ci.toml \ --command "CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at TEXT DEFAULT (datetime('now')))" # 已应用的迁移名清单(读失败按空处理,下轮重试) applied=$(wrangler d1 execute blog --remote --config wrangler.workers.ci.toml \ --command "SELECT name FROM schema_migrations" --json 2>/dev/null \ | python3 -c "import sys,json; d=json.load(sys.stdin); print('\n'.join(r['name'] for r in (d[0]['results'] … - Deploy Worker
# 直接调用上面安装的 wrangler(版本与「Setup wrangler CLI」保持一致) wrangler deploy --config wrangler.workers.ci.toml $NAME_ARG - Write runtime secrets to Worker
if [ -n "$BLOG_ADMIN_SETUP_KEY" ]; then echo "$BLOG_ADMIN_SETUP_KEY" | wrangler secret put BLOG_ADMIN_SETUP_KEY --config wrangler.workers.ci.toml $NAME_ARG fi if [ -n "$BLOG_WRITE_TOKEN" ]; then echo "$BLOG_WRITE_TOKEN" | wrangler secret put BLOG_WRITE_TOKEN --config wrangler.workers.ci.toml $NAME_ARG fi # CF_API_TOKEN 同时作为「发布即清缓存」的运行时凭证(purgeTags 需要 Zone·Cache Purge 权限) if [ -n "$CLOUDFLARE_API_TOKEN" ]; then echo "$CLOUDFLARE_API_TOKEN" | wrangler secret put CF_API_TOKEN --config wrangler.wor…
Triggers: workflow_dispatch
migrate · no job dependencies declared
- Checkout
actions/checkout@v4 - Setup Node
actions/setup-node@v4 - Run migration (KV -> D1)
if [ "${{ inputs.mode }}" = "dry-run" ]; then echo "▶ dry-run 模式:只预览 SQL,不写库" node scripts/migrate-kv-to-d1.mjs --dry-run else echo "▶ migrate 模式:正式写入 D1" node scripts/migrate-kv-to-d1.mjs fi
Repository README
View original on GitHub ↗Full upstream document by @kejiland · docs/README.md · snapshot 5cf652d
🌐 中文 · English
Qingyu'Blog
零框架 · 零构建 · 零依赖 —— 双击 index.html 就能用的个人博客
📖 项目介绍
Qingyu'Blog(轻语博客)是一个纯原生 JavaScript 编写的个人博客系统,不依赖任何前端框架(React / Vue / Svelte)和构建工具(Webpack / Vite),也没有任何运行时第三方依赖。
它支持两种运行模式:
| 模式 | 说明 | 适用场景 |
|---|---|---|
| 静态模式 | 双击 public/index.html 即可使用,数据存浏览器 localStorage |
本地写作、临时预览 |
| 云端模式 | 部署到 Cloudflare Workers + D1,数据存云端数据库 | 正式发布、多人访问 |
整个博客本体就在 public/ 目录:前台 index.html + style.css + app.js + posts.js + music-player.js + bg-anim.js,后台 admin.js + admin.css,国际化 i18n.js + locales/。
🆕 当前版本
v2.10.109。除写作 / 评论 / 统计等基础能力外,还内置:文章加密(AES-GCM 纯前端)、文章级 SEO(标题 / 描述 / canonical / noindex)、草稿预览分享链接(HMAC 签名)、一键导出静态站、打印 / 导出 PDF、Webmention、多作者与作者页、Mermaid 图表 + KaTeX 公式(本地化按需加载)、订阅分组群发、前端错误日志、评论反机器人、访问国家 / 设备识别,以及后台「功能开关」等。
🖼️ 首页有两套主题:经典(紧凑列表)与 编辑部杂志(精选大图 + 双栏 + 实时时间天气),在「后台 → 博客设置 → 站点基础信息 → 首页风格」用可视化卡片随时切换。
完整变更历史见仓库 提交记录。
💡 仓库根目录的
index.html只是一个跳转页,会自动打开public/index.html(Workers / Pages 的部署目录)。本地双击public/index.html同样可用。
🆕 第一次部署 Cloudflare? 请直接看 Cloudflare 配置完全指南(新手版) —— 从注册账号、创建 D1/KV、申请 API Token,到 R2 桶与 CORS、绑定域名、首次初始化管理员,每一步都写了在控制台的哪个位置点什么。
✅ 优点
| 优点 | 说明 |
|---|---|
| 零门槛 | 不需要 Node.js、不需要 npm、不需要构建,双击即可运行 |
| 零成本 | Cloudflare Workers + D1 + KV + R2 的免费额度完全够个人博客使用 |
| 零依赖 | 核心(前台 / 后台 / i18n)不引入任何第三方运行时库;表情 / 图表 / 公式等重量级库按需加载且已本地化在 public/libs/,页面用不到就一个字节都不下载 |
| 零锁定 | 文章是 Markdown 文本,数据在标准 SQLite(D1),随时可以迁走 |
| 双通道 | 静态导出 + 云端 API,同一份代码两种部署方式 |
| 视觉细节 | 清理了全站标题下方、底部导航、页脚等处的装饰性小圆点分隔符;评论区与底部导航之间留出间距 |
| 响应式 | 前台 + 后台均支持手机 / 平板 / 桌面全适配 |
| 多语言 | 内置中文 / English / 日本語 / 한국어 / हिन्दी 五语界面,自动识别浏览器语言 |
| 书卷美学 | 宋体正文 + 仿宋引用装饰,零 webfont 下载;四季背景动画(春樱 / 夏光 / 秋叶 / 冬雪) |
| 安全 | 密码 PBKDF2-SHA256 加盐哈希(10 万次迭代)、会话令牌鉴权、登录失败限流锁定、CSP 等安全响应头 |
| AI 增强 | Workers AI 提供文章摘要 / 写作助手 / 评论汇总 / 垃圾检测;未配置或关闭时自动隐藏入口、对博客零影响 |
| 内容工具链 | 文章加密、SEO 覆盖、草稿自动保存与崩溃恢复、预览分享链接、静态站导出、打印 / PDF、Webmention 引用、多作者页——写作与分发闭环 |
| 可观测 | 后台仪表盘(趋势 / 来源 / 国家 / 设备 / 品牌)、站点健康检查、操作审计日志、前端错误日志(自动捕获访客端异常并按相同错误聚合) |
🚀 快速开始
方式一:本地静态(零安装)
git clone https://github.com/kejiland/qingyu-blog.git
cd qingyu-blog
双击 public/index.html,或启动本地服务器:
# Python
python -m http.server 8080 -d public
# Node.js
npx serve public
打开 http://localhost:8080/admin,设置密码即可开始写作。
方式二:Cloudflare Workers(推荐·正式发布)
只需要四件事:创建 D1 数据库 → 创建 KV 命名空间 → 申请 API Token → 填进 GitHub Secrets,剩下的交给 GitHub Actions。
# 登录 Cloudflare
npx wrangler login
# 创建 D1 数据库(主存储:文章 / 评论 / 统计 / 密码 / 设置)
npx wrangler d1 create blog
# 记下输出的 database_id(是 UUID,不是数据库名,也不是 KV 的 id)
# 创建 KV 命名空间(限流 / 去重 / AI 缓存)
npx wrangler kv namespace create BLOG
# 记下输出的 id(32 位十六进制)
在仓库 Settings → Secrets and variables → Actions → Secrets 中添加:
必填(缺一个部署就停):
| Secret | 说明 |
|---|---|
CLOUDFLARE_API_TOKEN |
Cloudflare Account API Token(需 Workers Scripts / D1 / KV 的 Edit 权限) |
CLOUDFLARE_ACCOUNT_ID |
Cloudflare 账户 ID(控制台右侧可见,或看地址栏) |
BLOG_D1_ID |
D1 数据库 ID(上一步创建获得,UUID 格式) |
BLOG_KV_ID |
KV 命名空间 ID(上一步创建获得,32 位十六进制) |
推荐:
| Secret | 说明 |
|---|---|
BLOG_ADMIN_SETUP_KEY |
安装密钥。配置后只有拿着这串密钥的人才能初始化管理员密码(防抢注,强烈建议);未配置则退化为"首次登录自动生成随机默认密码",存在先到先得竞态 |
SITE_URL |
站点对外域名,如 https://blog.example.com(用于收紧 CORS / RSS / Sitemap,末尾不要加 /) |
可选(按功能分组):
📧 邮件订阅与通知(Resend,三项齐全才启用)
| Secret | 说明 |
|---|---|
RESEND_API_KEY |
Resend API Key |
BLOG_MAIL_FROM |
发件人地址(域名需在 Resend 验证),如 blog@yourdomain.com |
BLOG_MAIL_REPLY_TO |
可选:回信地址 |
BLOG_ADMIN_EMAIL |
可选:新评论通知收件邮箱;不填回退到「个人资料 → 邮箱」 |
🔐 文章加密与预览链接
| Secret | 说明 |
|---|---|
BLOG_PREVIEW_SECRET |
草稿预览分享链接的 HMAC 签名密钥。不填则由管理员密码哈希派生 —— 改密码会让所有已发出的预览链接立即失效 |
💬 评论
| Secret | 说明 |
|---|---|
COMMENT_BLOCKLIST |
评论敏感词(换行 / 逗号分隔),优先级高于后台词库 |
BLOG_RATE_LIMIT_BINDING |
启用 Worker 内的边缘登录限流(值为正整数命名空间,如 1001);会让部署改用 wrangler 4.x。账户不支持时删掉即可 |
🤖 AI(Workers AI)
| Secret | 说明 |
|---|---|
BLOG_AI_ENABLED |
设为 0 / false / off 可整体关闭 AI;不填 = 有绑定就开启 |
BLOG_AI_PUBLIC |
设为 0 / false / off 可禁止匿名访客生成摘要(登录后仍可用,只读缓存与 ping 不受影响) |
🗂️ R2 对象存储(音乐 / 媒体 / 备份)
| Secret | 说明 |
|---|---|
R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY / R2_ENDPOINT |
R2 S3 兼容凭据(音乐、媒体、备份共用同一对) |
R2_BUCKET / R2_PUBLIC_BASE |
音乐桶:桶名 + 公开域名(不能为空,否则整组 R2 配置不写入) |
R2_MEDIA_BUCKET / R2_MEDIA_PUBLIC_BASE |
媒体桶:桶名 + 公开域名 |
R2_BACKUP_BUCKET |
私有备份桶:保存自动 / 手动备份 JSON,建议不要绑定公开域名;不填则备份与恢复功能停用 |
⚙️ 其他
| Secret | 说明 |
|---|---|
CF_ZONE_ID |
自定义域名的 Zone ID;配合 Token 的 Cache Purge 权限 → 发布即清边缘缓存(运行时变量 CF_API_TOKEN 由工作流自动写入) |
PAGES_PROJECT_NAME |
名字有误导性:实际作用是覆盖 Worker 名称。不填则用 wrangler.workers.toml 里的 kejiland。新手建议不填 |
BLOG_WRITE_TOKEN |
旧式写入令牌,新部署不需要 |
运行时环境变量总表
代码实际读取的全部变量;标 自动 的由上面的 GitHub Secret 自动写入 Worker。
| 变量 | 类型 | 未配置时的行为 |
|---|---|---|
DB |
D1 绑定 | 所有接口返回「数据库未配置」 |
ASSETS |
静态资源绑定 | 静态页面 404(Workers 部署自动注入) |
BLOG |
KV 绑定 | 评论 / 点赞 / 浏览的限流与去重失效(功能仍可用) |
SITE_URL |
变量 | 跨域请求一律被拒;RSS / Sitemap 改用请求来源 |
CF_ZONE_ID + CF_API_TOKEN |
变量 + Secret(自动) | 不做边缘缓存清除(新内容延迟 1~5 分钟生效) |
BLOG_ADMIN_SETUP_KEY |
Secret | 首次登录自动生成随机默认密码(有先到先得竞态);仍是登录限流的应急通道 |
BLOG_WRITE_TOKEN |
Secret | 只能走会话登录 |
BLOG_RATE_LIMIT_BINDING → LOGIN_LIMITER |
Secret → 绑定 | 不注入边缘限流,登录限流回退到 D1 计数 |
AI |
Workers AI 绑定 | /api/ai/* 全部 404,前端自动隐藏 AI 入口 |
BLOG_AI_ENABLED |
Secret / 变量 | 有 AI + DB 时即视为开启 |
BLOG_AI_PUBLIC |
Secret / 变量 | 允许匿名生成 AI 摘要 |
RESEND_API_KEY / BLOG_MAIL_FROM / SITE_URL |
Secret(自动) | 邮件订阅、新文章通知、评论通知、订阅者群发全部停用 |
BLOG_MAIL_REPLY_TO |
Secret(自动) | 不影响发送,仅无 reply-to |
BLOG_ADMIN_EMAIL |
Secret(自动) | 评论通知回退到「个人资料 → 邮箱」 |
COMMENT_BLOCKLIST |
Secret(自动) | 只使用后台维护的词库 |
BLOG_PREVIEW_SECRET |
Secret(自动) | 预览链接密钥由管理员密码哈希派生(改密码即失效) |
R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY / R2_ENDPOINT |
Secret(自动) | 图片 / 音乐上传与备份接口返回 503 |
R2_BUCKET / R2_PUBLIC_BASE |
Secret(自动) | 音乐上传不可用(不影响图片) |
R2_MEDIA_BUCKET / R2_MEDIA_PUBLIC_BASE |
Secret(自动) | 图片上传不可用(不影响音乐) |
R2_BACKUP_BUCKET |
Secret(自动) | 备份与恢复停用(后台备份页显示未配置) |
⚠️ 添加或修改 Secret 后一定要重新跑一次部署(Deploy 工作流),新值才会写入 Worker。可选 Secret 留空不影响部署——工作流只写入非空的那些。
推送代码或手动运行 Actions,工作流会自动:
- ✅ 安装 Wrangler CLI
- ✅ 运行三套测试(
smoke-test.js183 例 /gb-verify.js18 例 /search-verify.js25 例,失败即中止不部署) - ✅ 校验必要 Secrets 与 ID 格式
- ✅ 执行 D1 迁移(
schema_migrations记账表 + 列预检 + 报错兜底,三层幂等) - ✅ 部署 Worker 到 Cloudflare
- ✅ 写入运行时 Secret(安装密钥 / R2 凭据 / 清缓存凭证,配置了才写)
部署完成后访问 https://你的域名/admin:配置了安装密钥就点「首次部署?使用安装密钥初始化」;没配置就用任意密码登录一次、拿页面上弹出的随机默认密码登录,然后立刻改密。
详细的每一步(含控制台截图级别的路径说明、权限勾选表、R2 CORS 配置、故障排查速查表)见 CLOUDFLARE_SETUP_GUIDE.md。 只想搞清 Secrets 与 R2 令牌的区别,看 DEPLOYMENT_SECRETS_GUIDE.md。
从 KV 迁移到 D1(旧数据)
若你此前使用 KV 单 key 存储,可把线上数据搬到 D1:
# 本地执行(需 CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_API_TOKEN / BLOG_KV_ID / BLOG_D1_ID)
node scripts/migrate-kv-to-d1.mjs --dry-run # 仅预览 SQL
node scripts/migrate-kv-to-d1.mjs # 正式写入 D1
或在仓库 Actions 标签页手动触发 Migrate KV to D1 工作流(支持 dry-run / migrate 两种模式)。
✨ 特色功能
前台
| 功能 | 说明 |
|---|---|
| 真实路径路由 | 无 hash:/、/archive、/tags、/about、/guestbook、/posts/<别名>/、/admin、/write,刷新不 404 |
| Markdown 渲染 | 标题 / 表格 / 引用 / 列表 / 代码块(js、ts、python、bash、css 语法高亮)/ 行内代码 / 加粗斜体删除线 / 图片 / 链接;输入先转义,不执行原始 HTML |
| 正文目录 TOC | 自动编号(1 / 1.1 / 1.2 …)、标题锚点、可折叠,点击平滑滚动 |
| 阅读体验 | 阅读时长估算、浏览数、点赞(含本机去重)、置顶徽章、一键分享(复制链接 / 系统分享 / 微博 / X / Facebook / Telegram / 邮件)、阅读位置记忆(下次打开自动回到上次位置)、正文划线高亮(选中即高亮,可加备注,本机保存;支持备注、汇总浮层一键跳转、导出 / 导入 JSON、一键清除)、返回顶部 |
| 阅读字号与浮动目录 | 文章页可一键调整正文字号(A− / A / A+,本地记忆);移动端右下角提供浮动目录按钮,抽屉式展开全部章节 |
| 沉浸式阅读 | 正文图片点击打开全屏灯箱,支持左右切换与键盘浏览;文章顶部显示阅读进度条,目录自动高亮当前章节 |
| 站点公告栏 | 后台「站点信息」开启后,在顶栏下方显示公告与可选链接;访客可一键关闭,本地记住状态 |
| 热门文章排行 | /popular 按浏览×1 + 点赞×3 + 评论×5 展示综合热度,云端支持全部 / 近 7 天 / 近 30 天范围切换 |
| 评论系统 | 云端 D1 全局评论 + 审核模式;评论列表服务端分页(每页 8 条顶层评论,含各自的回复),支持「最热 / 最新」两种排序;最多 3 层嵌套回复;孤儿回复自动提升;删除文章级联清理评论/点赞/浏览量;重复发送拦截(同分区同昵称同内容 409)与频率限制(每 IP 5 条/分钟);评论区与底部导航之间保留留白,评论不再紧贴页脚 |
| 评论互动 | 评论支持点赞、精选和置顶;前台可在「最热 / 最新」间切换,切换即刻高亮反馈并显示骨架占位(不再出现空白或「无数据」闪烁后才出内容),列表连贯不跳动;按顶层分页(每页 8 条,可加载更多),后台可一键切换精选与置顶 |
| 留言板 | 导航直达 /guestbook,「留言 / 优化方案」双分区,云端存储,复用评论安全管道;支持 Ctrl/⌘ + Enter 发送 |
| 全文搜索 | 云端基于 D1 FTS5(trigram)索引标题 / 标签 / 摘要 / 正文,支持中英文子串、相关度排序、关键字高亮和“加载更多”分页;短关键词自动回退 LIKE,静态模式仍有本地搜索 |
| 友情链接页 | 独立的 /links 页面展示后台配置的友链(卡片式、外链新窗口打开);页脚默认新增「友链」入口 |
| 阅读历史 / 稍后读 | 自动记录本机阅读历史(最多 100 篇),文章页可收藏稍后读;历史页汇总两个列表 |
| 分类 | 前台分类页(含每类文章数);首页支持按分类筛选,卡片区显示当前分类并可一键清除;编辑器可填写分类、自动汇总已有分类作候选 |
| 标签与归档 | 首页标签筛选(?tag=,支持回退清除)、标签云(含计数)、归档按「年 → 月」分组 |
| 精选文章 | 评论区下方自动推荐(点赞×3 + 浏览×1 + 评论×5,取前 2 篇,排除当前文章与草稿) |
| 双向链接与相关文章 | 正文可用 [[文章标题]] 建立双向链接;详情页显示“引用本文”,并按共同标签、同系列和发布时间推荐相关文章 |
| RSS / Sitemap | /feed.xml、/sitemap.xml 云端动态生成,随文章增删改查自动更新;草稿与加密文章自动排除 |
| 上一篇 / 下一篇 | 只有一条时自动隐藏空位;点击后新文章一律从开头开始阅读(不会被上一页的滚动位置带过去,仅手动刷新时才恢复上次阅读位置);两篇文章之间交叉淡入淡出过渡,不做整页翻书式切换 |
| 相关文章位置 | 相关文章统一排在上一篇 / 下一篇之下,随正文滚动顺序阅读,不再插在正文中间 |
| 卡片式列表 | 封面缩略图(自动取 cover 或正文首图)、置顶徽章、标签贴底、加载骨架屏、分页(?page=) |
| 深色 / 浅色主题 | 一键切换,跟随系统偏好,无首屏闪白;顶栏会随滚动加深阴影 |
| 主题色切换 | 4 种强调色:赭橙(terra)/ 黛蓝(indigo)/ 竹青(bamboo)/ 凝夜紫(dusk)。桌面为图标按钮 + 色板弹层,手机端为原生下拉;每种配色会连带调整背景与边框色 |
| 多语言界面 | 中文 / English / 日本語 / 한국어 / हिन्दी(各 993 个语言键),自动识别 + 手动切换,桌面为 🌐 弹层 + SVG 国旗,手机端为原生下拉 |
| PWA 离线阅读与写作 | 可安装到桌面或手机;首页、核心资源与已访问文章离线可打开;云端编辑断网时先保存到本地队列,网络恢复后自动同步 |
| 背景动画 | canvas 手绘四季粒子(春日樱花瓣 / 夏日嫩绿叶片 / 秋叶 / 六向分叉雪花),仅首页运行、页面隐藏时暂停;默认关闭(顶栏一键开启,选过一次后记到本机),尊重 prefers-reduced-motion。预览:/?season=spring|summer|autumn|winter&bg=1 |
| 图表 / 公式 | 正文支持 Mermaid 图表(```mermaid 代码块)与 KaTeX 数学公式($…$ / $$…$$);两个库都本地化在 public/libs/(离线可用、无需放开 CDN),且只在页面真的用到时才加载,普通页面零额外请求;可在「功能开关」整体关闭 |
| Smoji 表情 | 评论、留言板、编辑器三处都内置表情选择器,按需懒加载,支持正文内联渲染 |
| AI 文章摘要 | 文章页一键生成内容摘要(单篇缓存 30 天);AI 不可用时入口自动隐藏 |
| 全站音乐播放器 | 右下角悬浮音符按钮,平时缩进窗口外只露出一点圆弧、悬停 / 点击滑出;面板含曲目信息 · 可拖动进度条 · 上一首 / 播放暂停 / 下一首 · 音量 · 播放列表(当前项高亮 + 均衡动画)。自动连播、记忆上次曲目与进度、音量持久化,刷新后恢复但不自动出声;无音乐时完全隐藏,后台路由自动收起;样式与脚本不在首屏关键路径上 |
| 书卷风衬线排版 | 正文 / 标题 / 大标题统一走系统宋体栈,引用装饰走系统仿宋;iOS 走原生宋体 / 仿宋;不加载任何 webfont,无首屏字体下载 |
| 老文章兼容 | 早期从 WordPress 导入的文章正文里混着原始 HTML: 等实体会被还原成真实字符(不再是"字面的 ")、<!--more--> 等注释自动清除、<embed>/<object> 等 Flash 播放器整块移除、font/span/div/p/a/img 等老排版标签转成正常段落。围栏代码块里的示例 HTML 不受影响,<script> 等标签仍按原样转义(不会执行,也不会丢内容) |
| 首屏弱网兜底 | 关键资源(config / posts / i18n / app / style)显式 preload,不再靠探测器猜;云端接口超时改为 8 秒截止(不再无限等待),文章正文另设 10 秒超时保护;失败时渲染可手动重试的失败页,不再自动循环拉取 |
| 云端快照兜底 | 每次成功拉到云端文章 / 设置后写一份快照到本地(最长用于 12 小时的首屏);下次打开先用快照立即出内容,后台并行刷新真实数据(拿到后覆盖重渲染)。接口慢或丢包时不再一直转圈,快照不影响数据准确性 |
| 无障碍与细节 | 弹层带 role / aria-*;图标按钮有 title / aria-label;图片 loading="lazy" + 加载淡入;CSP 等安全响应头 |
管理后台
后台是一个独立切片(admin.js + admin.css),只在进入后台路由时懒加载,前台首屏零成本。
| 功能 | 说明 |
|---|---|
| 路由 | /admin(仪表盘)、/admin/posts、/admin/posts/new、/admin/posts/:id/edit、/admin/tags、/admin/comments、/admin/comments/pending、/admin/media、/admin/music、/admin/settings;未知 /admin/* 回退到仪表盘 |
| 登录门禁 | 云端:密码登录,或「首次部署?使用安装密钥初始化」(安装密钥走 X-Setup-Key);触发登录限流时页面会自动展开「被限流?用安装密钥登录」应急入口(只跳过限流,不跳过密码校验)。静态:本地门禁。任何接口返回 401 会提示「登录已过期」并自动跳回登录页 |
| 仪表盘 | 7 张统计卡(文章总数 / 已发布 / 定时发布 / 草稿 / 置顶 / 评论总数 / 待审核)+ 近 30 天访问与评论两张折线图(内联 SVG,支持悬停预览与点击固定数值)+ 最新发布 / 最新评论(自动滚动、悬停暂停)+ 存储与订阅概览(媒体数量与占用空间 / 音乐 / 订阅者与活跃数 / 备份份数与最近备份时间) |
| 文章数据 | 独立的文章分析页展示每篇内容的浏览、点赞、评论和综合得分,支持全部 / 近 7 天 / 近 30 天范围、每篇趋势图与 CSV 导出,并包含草稿 |
| 文章管理 | 关键字搜索(标题 + 标签,250ms 防抖)、状态筛选(全部 / 已发布 / 定时发布 / 草稿)、每页 10 篇分页、置顶切换(乐观更新)、多选批量置顶 / 取消置顶 / 删除(带二次确认)、删除无感刷新(行级淡出 + 服务端级联清理评论/统计) |
| 定时发布 | 云端模式可指定未来发布时间,Worker Cron 每 5 分钟检查一次,到点自动发布;定时文章不会提前出现在首页、RSS 或 Sitemap |
| 版本历史 | 每次保存自动保留最多 50 个文章快照;可查看版本列表、正文差异,并一键恢复到任意历史版本 |
| 备份与恢复 | 云端可手动备份,Worker Cron 每天北京时间 03:00 自动备份文章、版本、评论、媒体元数据、音乐、设置和统计到私有 R2;保留最近 30 份,列表分页(每页 10)并显示每份备份的内容摘要(文章 / 评论 / 媒体 / 音乐 / 订阅数量),支持下载、删除和一键恢复(恢复前自动生成安全快照) |
| 系列 / 专栏 | 文章可设置系列名称和系列序号;前台 /series 汇总全部系列,系列详情按序号排列并显示系列内上一篇/下一篇;后台支持系列重命名和移除 |
| 邮件订阅 | 前台 /subscribe 邮箱订阅;双重确认后,新文章发布自动进入发件箱并由 Cron 异步发送;后台支持邮箱搜索 + 状态筛选 + 分页(每页 20),可按当前筛选导出 CSV、删除订阅者 |
| 订阅分组与群发 | 订阅者可标注分组(多标签);后台可按分组筛选,并向指定分组(或全部已确认订阅者)群发自定义主题/正文邮件,写入发件箱后由 Cron 异步发送,邮件底部自动带退订入口 |
| 自动分享图 | 编辑器可根据标题、日期、系列和标签自动生成 1200×630 PNG 分享图,上传到 R2;前台自动输出 og:image 与 twitter:image,无分享图时回退封面 |
| 图片压缩与粘贴上传 | 媒体库上传时自动压缩、转为 WebP 并生成缩略图;编辑器支持直接粘贴截图,压缩上传后自动插入 Markdown 图片 |
| 编辑器 | 标题 / 发布时间(精确到分钟,编辑时保留原时间)/ 标签 / 封面(可从媒体库选)/ 置顶 / Markdown 正文;本地草稿自动保存(输入停顿 1.5s / 每 10s 一次,误关标签页或崩溃后重进可一键恢复;加密文章不落本地明文);右侧实时预览、实时字数与预计阅读时长、快捷键(Ctrl/⌘ + B / I / K / S)、输入框自动增高、工具栏(加粗、斜体、标题、引用、代码、列表、链接、图片、表情、表格、任务列表、分割线、代码块(含语言选择));存草稿或直接发布 |
| 多作者 / 作者页 | 每篇文章可指定作者(编辑器有作者输入 + 已有作者候选);文章页显示作者署名;前台 /authors 汇总全部作者及其文章数,/authors/<作者名> 是该作者的文章列表;作者字段随文章一起备份 / 导出(历史版本恢复不会丢失) |
| Webmention | 支持 W3C Webmention:页面 <link rel="webmention"> 声明端点;外站引用你的文章并通知本站后,会抓取来源页校验确实链接到该文章再收录,在文章底部展示来源标题 / 摘要 / 作者;来源页撤链不会自动消失,可在后台「引用管理」删除 |
| 打印 / 导出 PDF | 文章页一键「打印 / PDF」:专门的打印样式会隐藏导航、评论、广告、播放器、目录浮层等一切干扰,只留标题 + 正文;代码块 / 表格 / 图片 / 公式 / 图表自动避免跨页断开,外链自动附上完整网址,页脚附站点名与原文链接,浏览器「另存为 PDF」即可存档 |
| 草稿预览分享链接 | 编辑器一键为未发布文章生成带 HMAC 签名的预览链接(默认 7 天有效,可复制/直接打开);拿到链接的人无需登录即可查看,页面标记 noindex、不统计、不加载评论;修改站点密码会让所有已发出的链接立即失效 |
| 文章级 SEO | 每篇文章可单独覆盖 SEO 标题 / 描述 / Canonical / noindex(留空自动生成);生效于 <title>、meta description、Open Graph、Twitter Card 与 JSON-LD;恢复历史版本不会丢失 SEO 设置 |
| 文章加密 | 编辑器可一键开启正文加密:AES-GCM-256 + PBKDF2-SHA256(10 万次迭代)纯前端加密;明文不会上传,服务器只保存密文 enc;读者打开详情页需输入密码解锁,服务器无法解密。后台编辑器会把密码记住在本机浏览器,可点「显示」查看(仅本机、换设备或清缓存后不可见) |
| AI 写作助手 | 编辑器内一键:标题建议 / 润色 / 翻译(5 种目标语言),结果可应用到标题、替换原文、插入正文末尾或复制;AI 不可用时整条栏位不渲染 |
| 评论敏感词 | 后台可维护敏感词表(每行一个);命中昵称或内容的评论直接拒绝,不进入数据库 |
| 评论反机器人 | 隐藏蜜罐字段 + 表单时间戳校验:蜜罐被填写视为机器人静默丢弃(返回成功但不入库),表单填入不足 2 秒即提交会被拒绝;对正常访客无感,可在「功能开关」关闭 |
| 评论批量操作 | 评论列表支持多选(含全选本页)后批量通过 / 转待审 / 批量删除,服务端一次请求完成并写入操作日志 |
| 评论管理 | 全局评论列表(评论人 / 内容 / 所属文章 / 时间 / 状态 / 操作)、关键字搜索、状态筛选、通过审核(就地更新徽章,不整表重载)、删除(行级淡出);侧栏「待审核评论」带实时数量徽章 |
| AI 评论工具 | 一键汇总近期评论要点(1 小时缓存)+ 单条评论垃圾检测(红 / 绿判定 + 原因) |
| 评论邮件通知 | 新评论和回复自动进入发件箱,由 Cron 异步发送到站长邮箱;收件地址优先取 BLOG_ADMIN_EMAIL,其次为个人资料邮箱 |
| 标签管理 | 标签列表由文章实时统计得出;支持重命名 / 删除(批量更新所有相关文章) |
| 媒体资源库 | 图片上传(浏览器直传 R2 预签名 URL,元数据存 D1)、网格预览、点击缩略图看大图(左右切换 / Esc 关闭)、文件名搜索 + 分页(每页 24)、多选批量删除、复制 URL / 复制 Markdown 图片语法、删除(先删 R2 对象再删 D1 行);静态模式与非云环境给出提示 |
| 音乐管理 | 音频上传(直传 R2,带百分比进度条,支持拖拽);Workers 与 Pages 两种部署形态均可用;**文件名自动识别「歌曲名-歌手」**预填;歌名 / 歌手搜索 + 分页(每页 15);列表行内试听(播放 / 暂停 / 拖动进度 / 显示当前时间与总时长)、重命名、删除(与 R2 对象同步);曲目多时列表卡片内滚动 + 表头吸顶 |
| 功能开关 | 后台「设置 → 功能开关」把原本改 config.js 才能生效的开关搬到网页端:首页每页文章数(0 = 不分页)、首页显示的标签(自选哪些标签出现在首页)、新增导航项显示开关、广告位总开关 + AdSense 客户端 ID + 三个广告位代码(列表上方 / 列表间隔 + 间隔篇数 / 文章底部)、阅读字号(隐藏 A− / A / A+ 按钮)、划线高亮工具(一起管住「高亮汇总 / 清除 / 导出 / 导入」)、收藏(稍后读)按钮、音乐播放器(右下角悬浮播放器,关闭后完全不加载)、内容渲染(图表公式)、评论反机器人、前端错误上报、站点健康诊断;关闭只是隐藏界面,已保存的高亮与收藏记录不会被删除;保存即时生效,无需改代码重新部署 |
| 博客设置 | 6 个标签页:站点基础信息(站点名称 / 简介 / 头像 Logo / 关于页内容 / 页脚版权署名 / 页脚声明 / 新评论默认需审核)、功能开关(首页分页 / 首页标签 / 新增导航项 / 广告位 / 阅读字号 / 划线高亮 / 收藏 / 音乐播放器 / 内容渲染 / 前端错误上报 / 评论反机器人 / 图表公式 / 站点健康诊断)、个人资料(昵称 / 简介 / 头像 / 邮箱)、导航菜单(可视化增删 + 二级子项 + 一键重置)、底部导航、友情链接 |
| 前端错误日志 | 自动捕获访客浏览器里的未处理异常 / Promise 拒绝,匿名上报到后台 /admin/errors;按相同错误聚合计数(含源码位置、页面、UA),可搜索、一键清空;默认开启,可在「功能开关」关闭 |
| 站点健康检查 | 后台 /admin/health 一键检查 D1 数据表可读性(含各表行数)、KV 读写、R2 媒体桶 / 备份桶、AI 与邮件(Resend)绑定状态 |
| 访问来源 / 设备 | 记录每次浏览的来源域名、国家/地区(Cloudflare 边缘按 IP 地理识别,只存两位国家码、不落原始 IP)、设备类型(桌面 / 手机 / 平板 / 爬虫)、系统(iOS / Android / HarmonyOS / Windows / macOS / Linux)与设备品牌(Apple / Samsung / Xiaomi / Huawei / OPPO / vivo …),按天聚合;仪表盘卡片用国旗 + 占比展示近 30 天国家、来源、设备、系统与品牌 Top 榜 |
| 操作审计日志 | 记录关键后台操作(创建 / 恢复 / 删除备份、删除文章、删除媒体、更新设置),支持类型筛选 + 日期范围筛选 + 分页(每页 20)+ 导出 CSV + 一键清空;记录来源 IP 与时间,出问题可追溯 |
| 顶栏 | 侧栏折叠按钮、面包屑、预览站点、🌐 语言切换、账户菜单(个人资料 / 修改密码 / 退出登录) |
| 导入 / 导出 | 后台支持单篇 Markdown、批量 ZIP、全部导出和 JSON 完整备份;可导入单个文件、多个文件或整个文件夹,静态与云端模式均兼容 |
| 导出静态站 | 「导入/导出」页一键把整站打包成纯静态 ZIP(点击后先弹出系统的「另存为」对话框,由你自己选择保存位置,确认后才开始导出):预渲染首页 / 每篇文章 / 归档 / 标签 / 关于 / 404 页,每个路由都有独立 index.html(任意静态托管都不需要 SPA 重写规则);附带全部静态资源、只含已发布文章的数据、RSS/Sitemap 与部署说明;草稿与定时文章不会导出 |
| 一键导出 | 静态模式特有:编辑器里的「一键导出全部」同时导出 posts.js + feed.xml + sitemap.xml,覆盖 public/ 即发布(云端模式由服务端动态生成 RSS/Sitemap,因此没有导出入口) |
| 响应式 | PC 固定侧栏(可折叠为 72px 图标栏)/ 移动端抽屉导航;断点 1100 / 991 / 640 / 420px |
🖼️ 项目截图
前台
| 首页(浅色 · 含标签筛选、置顶徽章、封面缩略图) | 文章详情(TOC、表格、代码块) | 站内搜索(关键字高亮 + 句子上下文) |
|---|---|---|
![]() |
![]() |
![]() |
| 标签云 | 归档(按年月分组) | 留言板 |
|---|---|---|
![]() |
![]() |
![]() |
| 首页(深色) | 文章(深色) | 移动端 |
|---|---|---|
![]() |
![]() |
![]() |
| 全站音乐播放器(点击右下角按钮弹出的面板) |
|---|
![]() |
管理后台
| 登录门禁(首次部署可用安装密钥初始化) | 仪表盘(6 张统计卡 + 30 天趋势图) | 深色模式 |
|---|---|---|
![]() |
![]() |
![]() |
| 文章管理 | 编辑器(Markdown 实时预览 + AI 写作助手) | 评论管理 |
|---|---|---|
![]() |
![]() |
![]() |
| 媒体资源库 | 音乐管理(行内试听 + 表头吸顶) | 博客设置 | 标签管理 |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
衬线排版预览
| 首页(浅色) | 文章(浅色) | 文章(深色) |
|---|---|---|
![]() |
![]() |
![]() |
🔧 截图由
scripts/screenshots/capture.mjs用无头 Chrome 打开本地演示服务器(内置示例文章 / 评论 / 音乐数据)自动生成,界面是public/的真实代码,数据为演示内容。 重新生成:npm i -D puppeteer-core && node scripts/screenshots/capture.mjs
📁 目录结构
├── public/ # 站点本体(静态资源,部署目录)
│ ├── index.html # 页面入口(双击 / 部署起点)
│ ├── config.js / config.min.js # 全站配置(模式 / 站点地址 / 页脚 / 广告)
│ ├── style.css / style.min.css # 前台样式(明暗主题 + 4 种强调色 + 响应式 + 衬线字体栈)
│ ├── app.js / app.min.js # 前台逻辑(路由 / Markdown / 搜索 / 评论 / 留言板 / 统计 / i18n / AI 摘要)
│ ├── manifest.webmanifest # PWA 安装清单
│ ├── sw.js # 离线缓存 Service Worker
│ ├── icons/ # PWA 桌面 / 手机图标
│ ├── admin.js / admin.min.js # 后台管理 SPA(按需懒加载)
│ ├── admin.css / admin.min.css # 后台样式(玻璃拟态,响应式)
│ ├── music-player.js / .min.js # 全站音乐播放器(悬浮按钮 + 面板 + 播放列表 + 进度记忆)
│ ├── music-player.css / .min.css # 播放器样式(不在首屏关键路径,空闲时加载)
│ ├── bg-anim.js / bg-anim.min.js # 四季 canvas 背景动画
│ ├── i18n.js / i18n.min.js # 国际化模块(中/英/日/韩/印地,内置中文兜底)
│ ├── posts.js / posts.min.js # 静态模式文章数据(由「导出 posts.js」生成)
│ ├── static-export.json # 「导出静态站」的资源清单(列出需打包的静态文件)
│ ├── locales/ # 语言包(zh-CN / en / ja / ko / hi,各 993 键)
│ ├── flags/ # 语言切换用的 SVG 国旗(cn / gb / jp / kr / in)
│ ├── libs/smoji/ # Smoji 表情选择器(按需加载)
│ ├── libs/katex/ # KaTeX 数学公式(本地化,按需加载)
│ ├── libs/mermaid/ # Mermaid 图表(本地化,按需加载)
│ ├── robots.txt # 爬虫规则(禁止抓取后台,声明 Sitemap)
│ ├── llms.txt # 面向 LLM / 代理的站点说明
│ ├── ads.txt # 广告声明(可选,配合 config.js ads)
│ ├── _headers # Cloudflare 响应头(CSP / 安全头 / 各类资源缓存策略)
│ ├── _redirects # Cloudflare 路由(SPA 回退 + /public 前缀 301)
│ ├── .well-known/ # ard.json(ARD v0.91)与 ai-catalog.json(兼容入口)
│ # feed.xml / sitemap.xml 由云端动态生成(见 functions/)
├── functions/ # Cloudflare API(Pages Functions / Workers 共用)
│ ├── api/
│ │ ├── posts.js # 文章列表 / 创建(?all=1 含草稿、?full=1 含正文、?page 服务端分页)
│ │ ├── posts/[id].js # 单篇文章 GET / PUT / DELETE(删除级联清理评论与统计)
│ │ ├── posts/[id]/comments.js # 文章评论 GET / POST(3 层嵌套 + 服务端分页)
│ │ ├── posts/[id]/comments/[cid].js # 单条评论删除
│ │ ├── posts/[id]/stats.js # 阅读 / 点赞(同时记录来源、国家、设备、系统、品牌)
│ │ ├── posts/[id]/relations.js # 双向链接 + 相关文章推荐
│ │ ├── posts/[id]/revisions*.js # 文章版本历史:列表 / 单版本 / 恢复
│ │ ├── comments.js # 全局评论列表(后台,可按状态过滤)
│ │ ├── comments/[id].js # 评论审核(通过 / 待审 / 精选 / 置顶)/ 删除
│ │ ├── comments/[id]/like.js # 评论点赞
│ │ ├── ai/{ping,summary,assist,comments}.js # AI 探测 / 摘要 / 写作助手 / 评论汇总与垃圾检测
│ │ ├── media.js · media/upload-url.js · media/[id].js # 媒体库:列表 / R2 直传签名 / 删除
│ │ ├── music.js · music/upload-url.js · music/[id].js # 音乐:列表 / 直传签名 / 重命名 / 删除
│ │ ├── subscribe.js · subscribe/{confirm,unsubscribe}.js # 订阅:申请 / 双重确认 / 退订
│ │ ├── settings.js # 站点设置 GET / PUT(含「功能开关」features)
│ │ ├── search.js # 全文搜索(FTS5 trigram,短词回退 LIKE)
│ │ ├── popular.js # 热门文章排行(全部 / 近 7 天 / 近 30 天)
│ │ ├── stats/trend.js # 近 N 天访问 / 点赞 / 评论趋势
│ │ ├── errors.js # 前端错误上报(公开、限流、按指纹聚合)
│ │ ├── webmention.js # Webmention:接收通知 / 查询某篇文章的引用
│ │ ├── preview.js # 草稿预览(凭 HMAC 签名 token 读取未发布文章)
│ │ ├── site-files/index.js · site-files/[name].js # 站点产物(feed / sitemap):列出 / 下载
│ │ ├── admin/{setup,login,logout,password}.js # 初始化 / 登录 / 登出 / 改密
│ │ ├── admin/{health,audit-log,tags,comments/bulk,post-analytics,og-upload-url}.js # 健康检查 / 审计 / 标签批处理 / 评论批处理 / 文章数据 / 分享图
│ │ ├── admin/{stats/sources,errors,preview-link,webmentions}.js # 来源统计 / 错误日志 / 预览链接 / 引用管理
│ │ ├── admin/{subscribers,subscribers/[id],subscribers/broadcast}.js # 订阅者列表 / 分组 / 群发
│ │ ├── admin/{backups,backups/[id],backups/[id]/restore}.js # 备份列表 / 下载删除 / 恢复
│ │ ├── feed.xml.js · sitemap.xml.js # /api/feed.xml · /api/sitemap.xml
│ │ └── [[path]].js # /api/* 兜底:未知接口一律返回 JSON 404(绝不回退 HTML)
│ ├── feed.xml.js · sitemap.xml.js # 根路径 /feed.xml · /sitemap.xml
│ └── _lib/
│ ├── api-core.js # API 核心:文章 / 评论 / 统计 / 设置 / 鉴权 / 安全头 / 限流 / RSS / Sitemap
│ ├── search.js · popular.js · relations.js · analytics.js # 全文搜索 / 热门 / 关联 / 文章数据
│ ├── subscribe.js # 邮件订阅、订阅者分组、群发与发件箱投递
│ ├── backup.js # R2 备份与恢复(含预恢复快照)
│ ├── media.js · music.js · og.js # R2 直传 / 音乐元数据 / 分享图生成
│ └── ai.js # Workers AI 封装(模型 / 提示词 / 限流 / 缓存 / 降级)
├── worker.js # Cloudflare Workers 入口(路由分发 + 静态资源 + SPA 回退 + 缓存头)
├── migrations/ # D1 迁移(CI 自动执行,记账表幂等)
│ ├── 0001_init.sql # 基础表(posts / comments / stats / admin_auth / admin_se…
│ ├── 0002_site_files.sql # 站点生成产物(feed.xml / sitemap.xml):随文章发布/删除一同写入云端,供下载与备份
│ ├── 0003_cover_column.sql # 封面图列:为已存在部署的 posts 表补列(新部署已含于 0001_init.sql,此脚本幂等)
│ ├── 0004_post_meta.sql # 文章补充字段:分类(category) 与 发布状态(status)
│ ├── 0005_comment_status.sql # 评论补充字段:审核状态(status)
│ ├── 0006_media.sql # 媒体资源库(图片上传库):后台「媒体库」页面使用
│ ├── 0007_settings.sql # 站点设置(键值对):后台「博客设置」页面持久化配置
│ ├── 0008_stats_daily.sql # 每日阅读/点赞聚合:用于后台「近 N 天访问趋势 / 点赞趋势」图表
│ ├── 0009_comment_status_index.sql # 评论审核状态索引(幂等:CREATE INDEX IF NOT EXISTS 可安全重复执行)。
│ ├── 0010_admin_must_change.sql # 管理员认证:新增 must_change 字段
│ ├── 0011_comment_reply.sql # 评论回复功能:添加 parent_id 字段
│ ├── 0012_clear_orphaned_nav.sql # 清理被移除的「导航菜单」配置模块遗留在 site_settings 的 nav 键值。
│ ├── 0013_music.sql # 音乐播放列表(元数据存 D1;音频文件本体在 Cloudflare R2,url 为公开可读地址)
│ ├── 0014_purge_base64_media.sql # 清理历史遗留的「图片存 D1」记录:早期媒体库以 data URL(base64)把图片内容直接存进
│ ├── 0015_hot_path_indexes.sql # 热点路径索引(评论 / 音乐 / 媒体 / 会话)
│ ├── 0016_scheduled_publishing.sql # 定时发布:在 status='scheduled' 时保存预计发布时间(UTC 毫秒时间戳)
│ ├── 0017_post_revisions.sql # 文章版本历史:每篇文章最多保留 50 个快照
│ ├── 0018_backups.sql # R2 备份元数据:备份文件本体存私有 R2_BACKUP_BUCKET
│ ├── 0019_post_series.sql # 文章系列 / 专栏:一篇文章归属一个系列,series_order 控制系列内顺序
│ ├── 0020_subscribers.sql # 邮件订阅与通知发件箱(双重确认 + 异步发送)
│ ├── 0021_post_og_image.sql # 自动分享图(OG Image):R2 公开地址
│ ├── 0022_media_thumb.sql # 媒体缩略图:浏览器端压缩上传时同时生成 WebP 缩略图
│ ├── 0023_post_fts.sql # 文章 FTS5 全文索引(trigram,自动同步 posts)
│ ├── 0024_comment_notifications.sql # 邮件发件箱:区分文章订阅通知与站长评论通知
│ ├── 0025_comment_interactions.sql # 评论互动:点赞、精选、置顶
│ ├── 0026_audit_log.sql # 后台操作审计日志:记录备份/恢复/删除/设置变更等关键操作
│ ├── 0027_stats_sources.sql # 访问来源 / 设备统计:按天聚合
│ ├── 0028_subscriber_groups.sql # 订阅者分组:单列存 JSON 数组字符串(例如 ["newsletter","vip"]),用于后台分组…
│ ├── 0029_error_logs.sql # 前端错误日志(浏览器运行时异常上报,按 fingerprint 聚合去重)
│ ├── 0030_post_seo.sql # 文章级 SEO 覆盖(JSON:{title,desc,canonical,noindex}),空对象表…
│ ├── 0031_webmentions.sql # Webmention:外站引用你的文章时,来源页主动通知本端点,校验后展示
│ ├── 0032_post_author.sql # 文章作者(可留空,前台回退到站点署名 / 个人昵称)
├── scripts/
│ ├── migrate-kv-to-d1.mjs # 一次性迁移:KV 数据 → D1
│ ├── minify.mjs # 用 terser / clean-css 生成 public/ 下的 *.min.*
│ └── screenshots/ # README 截图生成工具(可选,需 puppeteer-core)
│ ├── demo-content.mjs # 截图用示例数据(文章 / 评论 / 音乐 / 媒体 / 设置)
│ ├── demo-server.mjs # 本地演示服务器(静态资源 + mock /api)
│ └── capture.mjs # 无头 Chrome 截图脚本
├── .github/workflows/
│ ├── deploy.yml # GitHub Actions 自动部署到 Workers
│ └── migrate-kv-to-d1.yml # 手动触发 KV → D1 迁移
├── seed.js # 导入示例文章到云端 API
├── index.html # 根跳转页(自动跳 public/index.html)
├── wrangler.toml # Cloudflare Pages 配置
├── wrangler.workers.toml # Cloudflare Workers 配置(部署使用)
├── smoke-test.js # 冒烟测试(183 例)
├── gb-verify.js # 留言板专项验证(18 例)
├── search-verify.js # 搜索专项验证(25 例)
├── docs/ # 项目说明文档(中英双语)
│ ├── README.md # 中文说明(本文件)
│ ├── README_EN.md # 英文说明
│ ├── DEVELOPMENT.md # 开发与架构指南(中文)
│ ├── DEVELOPMENT_EN.md # 开发与架构指南(英文)
│ ├── CLOUDFLARE_SETUP_GUIDE.md # Cloudflare 配置完全指南(新手版·中文)
│ ├── CLOUDFLARE_SETUP_GUIDE_EN.md # 同上 · 英文
│ ├── DEPLOYMENT_SECRETS_GUIDE.md # GitHub Secrets 与 R2 令牌详解·中文
│ ├── DEPLOYMENT_SECRETS_GUIDE_EN.md # 同上 · 英文
│ ├── SECURITY.md / SECURITY_EN.md # 安全策略(中 / 英)
│ ├── CODE_OF_CONDUCT.md # 行为准则 · 中文
│ ├── CODE_OF_CONDUCT_EN.md # 行为准则 · 英文
│ ├── ABOUT.md / ABOUT_EN.md # 项目简介(中 / 英)
│ └── CONTRIBUTING.md # 贡献指南(单文件内中英对照)
└── LICENSE
☁️ Cloudflare 服务说明
完整的控制台操作步骤见 CLOUDFLARE_SETUP_GUIDE.md,本节只讲"代码里怎么用"。
Workers(计算 + 静态资源)
- 入口:
worker.js(路由分发);functions/下的文件同时可被 Pages Functions 复用 - 静态资源:
public/通过[assets]绑定提供,SPA 回退 + 缓存头都在 Worker 里处理 - 兼容性日期:
2025-02-01
wrangler.workers.toml 关键配置:
name = "kejiland"
main = "worker.js"
[assets]
directory = "./public"
binding = "ASSETS"
not_found_handling = "single-page-application" # 无扩展名路径回退 index.html
html_handling = "auto-trailing-slash"
[[kv_namespaces]]
binding = "BLOG"
id = "{env.BLOG_KV_ID}" # KV 的 id 不支持 {env.} 内插,CI 部署前用脚本替换
[[d1_databases]]
binding = "DB"
database_name = "blog"
database_id = "{env.BLOG_D1_ID}"
[ai] # Workers AI(绑定名必须是 AI)
binding = "AI"
[vars]
SITE_URL = "{env.SITE_URL}"
CF_ZONE_ID = "{env.CF_ZONE_ID}"
静态资源的缓存策略(Worker 里统一设置):
| 资源 | Cache-Control |
|---|---|
带 ?v= 版本号,或 /fonts/、/flags/、/libs/ 下的文件 |
public, max-age=31536000, immutable |
| 其他带扩展名的静态文件 | public, max-age=3600, stale-while-revalidate=86400 |
| 无扩展名的 HTML 入口 | no-cache(靠 ETag 命中 304) |
API 边界:未知 /api/* 一律返回 JSON 404,绝不回退到 index.html;非 GET/HEAD 请求也不会被 SPA 回退。
KV(限流 / 去重 / AI 缓存)
KV 不是文章存储,而是限流与去重设施:
| Key 模式 | 用途 | TTL |
|---|---|---|
rate:cmt:<ip>:<分钟窗口> |
评论频率限制(5 条/分钟) | 120s |
rate:like:<ip>:<分钟窗口> |
点赞频率限制(10 次/分钟) | 120s |
rate:view:<ip>:<分钟窗口> |
浏览频率限制(30 次/分钟) | 120s |
liked:<ip>:<postId> |
同一 IP 对同一文章只计一次赞 | 30 天 |
viewed:<ip>:<postId> |
同一 IP 对同一文章每小时只计一次浏览 | 1 小时 |
ai:sum:ip:<ip> / ai:sum:day:g |
AI 摘要用量(8 次/时·IP;300 次/天·全站) | 1 小时 / 1 天 |
ai:assist:day:<ip> / ai:cmt:day:<ip> |
AI 写作助手 / 评论工具每日用量 | 1 天 |
ai:sum:<slug>:<lang> / ai:cmt:sum |
AI 摘要(30 天)与评论汇总(1 小时)缓存 | 见左 |
⚠️ KV 是最终一致性存储(全球传播有延迟),只适合计数与缓存;需要强一致性的数据都在 D1。 ⚠️ KV 未绑定时,上面的限流与去重会静默失效(日志里有一条 warning),所以建议配置。
D1(SQLite 数据库·主存储)
| 表 | 说明 | 关键字段 |
|---|---|---|
posts |
文章 | id, title, date, excerpt, content, cover, og_image, pinned, protected, enc, tags(JSON), category, series, series_order, author, status, publish_at, seo(JSON) |
comments |
评论 | id, post_id, author, content, date, status(approved/pending), parent_id, likes, featured, pinned |
stats |
阅读 / 点赞 | post_id, likes, views |
stats_daily |
每日聚合(趋势图) | post_id, date, views, likes(PRIMARY KEY(post_id,date)) |
admin_auth |
管理员密码 | k, salt, hash, iter, must_change |
admin_sessions |
登录会话 | token, exp(7 天,绝对时间戳) |
admin_fails |
登录失败限流 | ip, n, until |
media |
媒体元数据 | id, name, url, type, size, created_at |
site_settings |
站点设置 | k, v |
site_files |
站点产物 | name, content, updated_at |
music |
音乐元数据 | id, title, artist, url, cover, size, duration, sort, created_at |
post_revisions |
文章版本历史(每篇最多 50 个快照) | id, post_id, title, content, tags, status, reason, created_at |
subscribers |
邮件订阅者 | id, email, status(pending/active/unsubscribed), token, locale, groups(JSON), confirmed_at |
mail_outbox |
邮件发件箱(文章通知 / 评论通知 / 群发,由 Cron 异步发送) | post_id, to_email, status, kind, payload, sent_at |
backups |
R2 备份元数据 | id, object_key, size, reason, created_at, counts |
audit_log |
后台操作审计 | id, action, target, detail, ip, created_at |
stats_sources |
访问来源聚合(按天) | post_id, date, kind(ref/device/country/platform/vendor), name, views |
error_logs |
前端错误日志(按 fingerprint 聚合去重) | fingerprint, kind, message, source, stack, url, ua, hits, last_at |
webmentions |
外站引用(Webmention) | id, source, target, post_id, author_name, title, excerpt, status, created_at |
posts_fts |
D1 FTS5 全文索引(external-content,自动同步 posts) | id, title, excerpt, content, tags |
schema_migrations |
CI 记账表 | name, applied_at |
迁移按文件名升序由 CI 幂等执行(schema_migrations 记账 + 加列类迁移预检,重复执行安全)。0015_hot_path_indexes.sql 补充了 5 个热点索引:idx_comments_post_id、idx_comments_status_date、idx_music_sort、idx_media_created_id、idx_admin_sessions_exp;0023_post_fts.sql 建 posts_fts 全文索引;0027~`0032` 陆续加入来源统计、订阅分组、错误日志、文章 SEO、Webmention 与文章作者字段。
R2(对象存储:音乐 + 媒体图片)
文件本体存 R2,D1 只存元数据;上传走浏览器直传,文件不经过 Worker。
| 能力 | 说明 |
|---|---|
| 浏览器直传 | 后端签发 SigV4 预签名 PUT URL(签名含 content-type;host,使用 UNSIGNED-PAYLOAD,有效期 3600 秒),文件直传 R2,前端显示进度 |
| 公开读取 | 绑定 R2 自定义域名(如 music.example.com / media.example.com),前台直接拉流 / 显示图片 |
| 同步删除 | 删除时 Worker 侧用签名发起 R2 DELETE(签名头 host;x-amz-content-sha256;x-amz-date),成功后再删 D1 行 |
| 桶选择 | 图片固定写 R2_MEDIA_BUCKET;音乐优先写 R2_BUCKET,仅当 R2_BUCKET 或 R2_PUBLIC_BASE 为空时回退 R2_MEDIA_BUCKET |
| 音乐白名单 | mp3 / m4a / ogg / oga / wav / aac / opus / flac,单文件 ≤ 30MB |
| 图片白名单 | png / jpg / jpeg / webp / gif / svg / avif / bmp / ico,单文件 ≤ 10MB |
| 降级 | 未配置 R2 凭据时上传接口返回 503;读取与其余功能不受影响 |
| 凭据 | 只有一组:音乐与图片共用 R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY(必须同时有权限访问两个桶) |
💡 缩略图 / 封面 / 音频首次加载偏慢? 浏览器直传的 R2 对象默认不带长缓存头,首次加载需要回源。 建议在 Cloudflare 控制台 → 你的域名 → 缓存 → Cache Rules 新建一条规则: Hostname 匹配
media.你的域与music.你的域→ Cache Everything / Eligible for cache → Edge TTL 与 Browser TTL 都设 1 个月。 保存后第一次访问仍回源一次,之后浏览器与边缘都直接命中。 前端侧也已配合优化:首屏前 2 张缩略图fetchpriority="high"、全部图片loading="lazy" decoding="async"并做加载淡入。
Workers AI(推理)
默认模型 @cf/meta/llama-3.2-3b-instruct,按 Neurons 计费,免费额度约 10,000 Neurons/天:
| 接口 | 用途 | 限制 |
|---|---|---|
GET /api/ai/ping |
可用性探测(前端据此显示 / 隐藏全部 AI 入口) | — |
GET/POST /api/ai/summary |
文章摘要(单篇缓存 30 天;force 重新生成需登录,且「重新生成」按钮只有服务端确认的管理员才看得到) |
IP 8 次/时;全站 300 次/天 |
POST /api/ai/assist |
写作助手:标题建议 / 润色 / 翻译(需登录) | 200 次/天 |
POST /api/ai/comments |
评论汇总(缓存 1 小时)与单条垃圾检测(需登录) | 100 次/天 |
- 配置:
wrangler.workers.toml里的[ai] binding = "AI"(部署时自动创建绑定);环境变量BLOG_AI_ENABLED设为0/false/off可整体关闭,BLOG_AI_PUBLIC设为同样值可禁止匿名生成摘要 - 优雅降级:未绑定 AI、未配置 D1 或开关关闭时,相关接口返回 404,前端(
aiProbe)自动隐藏全部 AI 入口,博客其余功能完全不受影响 - 前端记忆策略:探测到「可用」缓存 10 分钟、「不可用」只缓存 30 秒,AI 上线 / 修复后刷新页面即恢复
- 隐私提示:AI 摘要 / 写作助手 / 评论汇总会把相应明文内容发送给 Cloudflare Workers AI 做推理;涉及敏感内容请关闭相关入口
⚠️
[ai]是 Workers 专属绑定。wrangler.toml(Pages 配置)里没有这一项,所以在 Pages 部署下 AI 接口恒为 404。
⚙️ 配置文件说明
config.js
window.BLOG_CONFIG = {
// ====== 基础配置 ======
mode: 'auto', // 'auto' | 'static' | 'api'
apiBase: '', // API 基础地址,留空 = 同源
siteUrl: 'https://www.example.com', // 站点对外地址(RSS / Sitemap / canonical)
writeToken: '', // 旧版静态令牌(建议用登录替代)
pageSize: 5, // 首页每页文章数(0 = 不分页;非数字时回退 8)
adminPwd: '', // 静态模式本地密码(云端模式请留空)
// ====== 页脚(云端模式下会被 D1 设置覆盖)======
footer: {
text: '',
icp: '', // 备案号
contact: [], // 底部导航(云端由「博客设置 → 底部导航」覆盖)
links: [], // 友情链接(云端由「博客设置 → 友情链接」覆盖)
decl: '', // 站点声明(云端由「页脚声明」覆盖)
email: '', // 联系邮箱(云端由「个人资料 → 邮箱」覆盖)
startYear: 2019, // 版权起始年
copyrightName: "Qingyu'Blog" // 版权署名(云端由「页脚版权署名」覆盖)
},
// ====== 广告位(默认关闭)======
ads: {
enabled: false, // 总开关
client: '', // AdSense 发布商 ID(ca-pub-xxxx),启用时自动加载 adsbygoogle.js
belowSearch: '', // 首页列表上方
between: '', // 列表间隔插入
betweenEvery: 3, // 每 N 篇插入
content: '' // 文章详情底部
}
};
提示:「首页每页文章数」(
pageSize)与全部广告位(ads)都可以在后台 设置 → 功能开关 里直接改(写入 D1site_settings.features覆盖这里的默认值,无需改代码、无需重新部署);同一个页面还可以隐藏新增导航项、关闭前端错误上报、评论反机器人、图表 / 公式渲染。
导航项的优先级:云端「博客设置 → 导航菜单」(存 D1 site_settings.nav_menu)> app.js 里的 NAV 兜底数组。底部导航与友情链接同理(D1 优先,config.js 兜底)。
mode 说明
| 值 | 行为 |
|---|---|
'auto' |
推荐。自动检测:请求 /api/posts 成功 → 云端;失败 → 静态 |
'static' |
强制静态模式,只用 posts.js |
'api' |
强制云端模式,需要后端 API |
多语言(i18n)
i18n.js 内置 5 种语言(中文 / English / 日本語 / 한국어 / हिन्दी),每种 993 个语言键。默认按 localStorage('blog.locale') → navigator.language 的顺序识别,并提供手动切换。语言包放在 public/locales/<lang>.json,中文同时内嵌兜底(确保 file:// 本地预览时核心文字始终可读)。
🛡️ 安全设计
| 层 | 机制 |
|---|---|
| 密码存储 | PBKDF2-SHA256 加盐哈希(100,000 次迭代,16 字节随机盐),永不存明文 |
| 首次部署 | 配置了 BLOG_ADMIN_SETUP_KEY:必须用 X-Setup-Key 显式初始化,未初始化时登录一律 403(防抢注);未配置:首次登录自动生成随机默认密码(xxxx-xxxx,标记 must_change=1),存在先到先得竞态。云端密码最少 8 位;改密前除改密/登出外的后台 API 一律返回 403 PASSWORD_CHANGE_REQUIRED |
| 静态模式 | 密码以 sha256: 前缀哈希存储(兼容旧明文,登录后自动升级),最少 4 位(仅防君子) |
| 会话管理 | 32 字节随机 Token(64 位十六进制),7 天有效,存 D1 admin_sessions;登出即销毁;修改密码会清空所有会话;must_change 会话只能调用改密接口 |
| 登录限流 | 三层维度,且不做全局长锁定:单 IP 5 次失败 → 15 分钟;同一子网(IPv4 /24、IPv6 /64)15 次失败 → 60 秒冷却;全站 30 次失败 → 仅 10 秒冷却 + 告警日志。计数 1 小时老化;锁定期/冷却期内请求直接早退,不查库也不写库(顺带挡住「用失败登录刷爆 D1 免费写额度」) |
| 应急通道 | 登录请求携带正确的 X-Setup-Key(BLOG_ADMIN_SETUP_KEY)可跳过全部限流(不跳过密码校验);后台登录页触发限流时会自动展开该入口。保证「攻击者最多让你 10 秒登不进,但永远挡不死你」 |
| 边缘限流(可选) | 配 BLOG_RATE_LIMIT_BINDING 后启用 Workers Rate Limiting binding(env.LOGIN_LIMITER),在 Worker 入口按 IP 限流,被拦下的请求不查库不写库;也可用 Cloudflare Access 或 WAF 限流规则把 /api/admin/login 挡在边缘,见下方「加固后台登录入口」 |
| 接口鉴权 | 所有写操作校验 Authorization: Bearer <token>;常量时间比较(SHA-256 摘要后异或) |
| 评论安全 | 输入先做控制字符清洗与 HTML 转义、SQL 全参数化、每 IP 频率限制、Origin 校验、重复内容拦截(409)、单篇最多 300 条、最多 3 层嵌套 |
| 文章加密 | AES-GCM-256 + PBKDF2-SHA256(10 万次迭代、16 字节随机盐)纯前端加解密;服务器只存密文 enc,明文永不上传;后台可在本机浏览器记住密码(仅本机可见) |
| 预览分享链接 | HMAC-SHA256 签名(密钥取 BLOG_PREVIEW_SECRET,未配置时由管理员密码哈希派生)+ 过期时间;修改站点密码会让所有已发出的链接立即失效;预览页 noindex、不统计、不加载评论 |
| 评论反机器人 | 隐藏蜜罐字段(被填写即静默丢弃)+ 表单时间戳(提交不足 2 秒判为机器人);对正常访客无感,可在「功能开关」关闭 |
| 错误上报限流 | 公开的 /api/errors 每 IP 每分钟最多 20 次,字段全部截断,按错误指纹聚合去重,最多保留 300 条 |
| 依赖本地化 | KaTeX / Mermaid / Smoji 全部自托管在 public/libs/,CSP 的 script-src 仍限 'self',不引入任何第三方脚本 |
| 媒体 URL | 仅接受 http(s)(R2 公开地址或外链),拒绝 javascript: / data: 等,杜绝脚本类内容登记 |
| 接口边界 | 未知 /api/* 返回 JSON 404,绝不回退到 index.html;非 GET/HEAD 不做 SPA 回退 |
| 安全响应头 | Worker 侧对 API 与静态响应统一注入:CSP、X-Content-Type-Options: nosniff、Referrer-Policy、X-Frame-Options、Cross-Origin-Opener-Policy;Pages 纯静态路径由 public/_headers 提供同一套 |
| CORS | 配置 SITE_URL 后只允许本站来源 + 请求自身来源;未配置时 fail-closed(不返回 ACAO),而不是回显任意来源 |
| 错误信息 | 未捕获异常统一返回「服务端内部错误」,内部细节只进服务端日志 |
| 客户端 IP | 只信任 CF-Connecting-IP,不使用可被伪造的 X-Forwarded-For |
加固后台登录入口(建议做)
内置限流默认就能挡住爆破,若想从源头再消掉风险,可以在 Cloudflare 边缘加一道门。三种方案任选,都对访客无感:
| 方案 | 怎么做 | 效果 |
|---|---|---|
| Cloudflare Access(最推荐) | Zero Trust → Access → Applications → Add → Self-hosted;给你的域名建两条:Path 分别为 admin 与 api/admin;策略 = Allow → Emails → 你的邮箱;登录方式选 One-time PIN |
没通过身份验证的请求根本到不了 Worker 的登录接口 |
| WAF 限流规则 | 域名 → Security → Security rules → Rate limiting rules → Create rule;匹配 URI Path equals /api/admin/login,10 秒内超过 10 次 → Block 10 秒 |
拦在边缘,不消耗 Worker 请求额度,也不碰 D1/KV 配额 |
| Workers 限流绑定 | 加 GitHub Secret BLOG_RATE_LIMIT_BINDING(正整数,如 1001)后重新部署 |
Worker 入口按 IP 限流 10 次/分钟/位置,被拦下的请求不查库不写库 |
逐步操作(含控制台路径、注意事项与排查)见 Cloudflare 配置完全指南 · 第 9 节。
🧪 测试
node smoke-test.js # 冒烟测试 183 例
node gb-verify.js # 留言板专项验证 18 例
node search-verify.js # 搜索专项验证 25 例
三套测试都只用 Node 内置模块(无网络、无凭据依赖),在每次 CI 部署前自动运行,失败即中止不部署。 覆盖范围包括:Markdown 渲染 / 目录 / 高亮、导入导出与备份、加密文章(加解密往返 + 锁屏 + 兼容回归)、文章级 SEO、多作者、后台新版 UI 运行时挂载(所有页面逐个挂载,捕获跨作用域变量笔误等运行时报错)、评论(嵌套 / 分页 / 敏感词 / 反机器人 / 批量操作)、订阅与分组群发、Webmention、错误日志、草稿预览链接、图表 / 公式本地化、静态站导出、打印样式、i18n 完整性(5 语言包与兜底键完全一致、无重复键)、PWA、RSS / Sitemap、云端 API 与缓存策略等。
导入示例文章到已部署的云端实例:
node seed.js https://www.example.com [--token <会话或写入令牌>]
重新生成 public/ 下的压缩资源:
node scripts/minify.mjs # 需要 npx terser / clean-css-cli
⚠️ 已知限制
| 限制 | 说明 |
|---|---|
| 加密密码无法找回 | 文章加密在浏览器端完成(AES-GCM + PBKDF2),服务器只保存密文;密码仅存于你的浏览器,一旦忘记无法解密,请务必自行备份密码 |
| 编辑文章保留原日期 | 未显式修改日期时沿用原值(服务端与编辑器双重兜底),不会因编辑而跳到最新 |
| 标签改名走服务端批量接口 | 云端为一次请求批量更新(POST /api/admin/tags);静态模式仍需本地逐篇改写后导出 posts.js |
| 被限流时要等一小会儿 | 触发登录限流后,即使密码正确也要等 10 秒(全局冷却)/ 60 秒(同一子网)/ 15 分钟(你自己的 IP)才能登录,除非带上安装密钥走应急通道。这是有意取舍:锁定期间照常跑 PBKDF2 会把「登录 DoS」变成「CPU / 额度 DoS」 |
| 后台列表已服务端化 | 文章列表的搜索 / 状态筛选 / 分页改为服务端查询;其余列表(媒体、音乐、订阅、备份、日志)仍在前端过滤 |
| 图表 / 公式按需加载 | Mermaid 单文件约 2.7 MB(gzip 后约 900 KB)、KaTeX 约 560 KB(含字体),只有正文真的用到才下载并随后被浏览器缓存;不想用可在「功能开关 → 内容渲染」整体关闭 |
| Webmention 需对方主动通知 | 只有支持 Webmention 的来源站主动发送通知才会被收录;来源页事后撤链不会自动删除已有记录,可在后台「引用管理」手动清理 |
| 静态导出不含媒体本体 | 「导出静态站」打包的是页面与静态资源;图片 / 音频若存放在 R2 等对象存储,需保证这些对象仍然可公开访问(ZIP 里不含媒体文件) |
| 预览链接有有效期 | 草稿预览链接默认 7 天有效(可生成 1~30 天),且修改站点密码会让所有已发出的链接立即失效 |
| 访问统计不存原始 IP | 只记录由 Cloudflare 边缘按 IP 解析出的两位国家码,以及 UA 推断出的设备 / 系统 / 品牌;不落原始 IP(隐私优先) |
| 错误日志有上限 | 按「相同错误」聚合计数,最多保留最近 300 条;超过后自动淘汰最旧的记录 |
| 未知路径返回 404 | 已知前端路由(首页 / 归档 / 标签 / 关于 / 留言板 / 热门 / 订阅 / 系列 / 文章 / 后台)仍 200;其余无扩展名路径返回真实 404 状态并渲染「内容不存在」页 |
📚 文档索引
所有文档均提供中英双语:长文档采用 X.md + X_EN.md 成对文件,短文档(贡献指南、Issue / PR 模板)在单个文件内中英对照(LICENSE 除外——MIT 许可证原文为英文,属法律文本,不做翻译)。
| 中文 | English | 内容 |
|---|---|---|
| README.md | README_EN.md | 项目总览:快速开始、功能、目录结构、Cloudflare 服务、配置、安全、测试 |
| DEVELOPMENT.md | DEVELOPMENT_EN.md | 开发与架构:运行时结构、目录职责、API 地图、D1 数据模型、配置、测试、发版与扩展 |
| CLOUDFLARE_SETUP_GUIDE.md | CLOUDFLARE_SETUP_GUIDE_EN.md | Cloudflare 配置完全指南(新手版):账号 / D1 / KV / API Token / R2 / Workers AI / 自定义域名 / Secrets / 部署 / 自检 / 排查 / 免费额度 |
| DEPLOYMENT_SECRETS_GUIDE.md | DEPLOYMENT_SECRETS_GUIDE_EN.md | 只聚焦 GitHub Secrets 与 R2 令牌:每个 Secret 从哪来、怎么填、两个桶用一个还是两个 Token |
| SECURITY.md | SECURITY_EN.md | 安全问题反馈方式与内置安全措施 |
| CONTRIBUTING.md | 同一文件(文内中英对照) | 参与贡献的流程 |
| CODE_OF_CONDUCT.md | CODE_OF_CONDUCT_EN.md | 社区行为准则 |
| ABOUT.md | ABOUT_EN.md | 关于项目与作者 |
📄 许可证
如果 Qingyu'Blog 对你有帮助,欢迎 ⭐ Star / Fork,或到 Issues 提建议。
如果你觉得不错,请给个 ⭐ Star 支持一下!这会帮助更多人发现这个项目。
Frequently asked about Qingyu Blog
What is Qingyu Blog?+
Qingyu Blog is a self-hosted Ghost/WordPress alternative built on the Cloudflare developer platform. A framework-free personal blog with an admin, comments and stats on Cloudflare
What does Qingyu Blog replace?+
Qingyu Blog is listed as an alternative to Ghost, WordPress. Compare the features and tradeoffs before migrating.
What Cloudflare primitives does Qingyu Blog use?+
Qingyu Blog is built on D1, KV, Pages.
How much does Qingyu Blog cost to run?+
The README states the Workers, D1, KV and R2 free allowances are enough for a personal blog. Subscriber email uses Resend, which is a separate account with its own limits and pricing. Subscriber and comment-notification email is optional and sent through Resend, not Cloudflare. Two deployment paths exist: Pages with Git integration (wrangler.toml) and a Worker deployed by GitHub Actions (wrangler.workers.toml). Set an admin password secret before first login; otherwise a random default is generated on first visit. Source and configuration review establishes a deployment path and conditional costs; this candidate was not executed or load-tested. Official Cloudflare pricing pages saved 31 Oct 2026 (Markdown editions of developers.cloudflare.com pricing documentation). Check current Cloudflare pricing before deploying.
Is Qingyu Blog open source?+
The upstream repository declares the MIT license. Read its terms at https://raw.githubusercontent.com/kejiland/qingyu-blog/5cf652d5c6414ea8c67ea87a83f5da1a408fc508/LICENSE. Source code and contributor credit are available at https://github.com/kejiland/qingyu-blog.
Community rating
No ratings yet. Tried this project? Share your experience.
One rating per verified account. You can change or remove yours. Accounts are email verified; use of the software is self-reported.
























Discussion · 0
sign in to comment →