用 CNB 云原生构建 + EdgeOne Pages + Hexo,实现浏览器里点鼠标发文 。
全程不需要服务器、不需要备案、不需要在本地装任何环境。
0. 这套方案是什么
最终效果
博客首页
https://你的域名/
线上后台
https://你的域名/admin/
发文方式
浏览器打开后台 → 输密码 → 写 → 点发布 → 约 1 分钟上线
手机能用吗
能,后台是响应式页面
服务器
不需要
花费
0
为什么这样搭
静态博客(Hexo)本身没有后台,传统做法要么忍着手敲 Markdown,要么自己买服务器跑 WordPress。这套方案用「静态站 + 一个托管在边缘的云函数」补上了后台能力:
静态站 (HTML/CSS/JS)放在 CDN 上 → 快、便宜、免运维、不容易被黑
云函数 (几十行 JS)当"中介",帮你把文章写进代码仓库
流水线 (CNB)负责构建和发布,你完全不用管
你会得到什么 / 不会得到什么
有 :文章增删改、标签、分类、归档、搜索、评论位(需另接第三方)、自定义域名、HTTPS。
没有 :多用户权限体系、数据库、动态交互(访问统计、登录用户)。静态博客的本质决定了这些要另接第三方服务。
1. 准备清单
账号(3 个,都免费)
平台
用途
地址
注意
CNB
代码托管 + 流水线 + WebIDE
cnb.cool
必须先完成实名认证 才能建组织,否则创建组织会失败
EdgeOne Pages
静态站托管 + 云函数
pages.edgeone.ai
腾讯云国际版;没备案就选「海外可访问」
域名(可选)
自己的域名
任意域名商
不备案也能用,只是国内访问会绕一下
要拿到手的 3 样东西
名称
从哪拿
长什么样
用在哪
EdgeOne Pages API Token
Pages 项目 → 设置 → API Token,创建时选永久有效
一长串 base64,通常以 = 结尾
.cnb.yml 里部署命令的 -t
CNB 访问令牌
CNB → 个人设置 → 访问令牌
一串大小写字母数字
EdgeOne 环境变量 CNB_TOKEN
后台密码
你自己定
随便定
EdgeOne 环境变量 ADMIN_PASSWORD
⚠️ 两个 Token 完全不同、不能混用 :
EdgeOne Pages API Token 用来部署静态站 (edgeone pages deploy)
CNB 访问令牌用来读写代码仓库 (相当于 git 密码)
这是最容易搞混、也最容易卡住的一点。
CNB 令牌需要的权限
在 CNB 建访问令牌时,勾选:
repo-code → 读写 (流水线里要 git push)
repo-cnb-trigger → 读写 (云函数要触发流水线)
不必勾 repo-basic-info、repo-cnb-history(只有查仓库元信息和构建日志才用得到,不影响功能)。
2. 整体架构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 ① 你在浏览器打开 /admin/ │ 输入密码 + 文章内容 ▼ ② 云函数 POST /api/ publish ← 跑在 EdgeOne 边缘节点 │ 校验密码,把 CNB 令牌从环境变量取出来用 │ (令牌永远不下发到浏览器) ▼ ③ CNB OpenAPI POST /{repo}/ -/ build/ start │ event= api_trigger,文章内容通过 env 传进去 ▼ ④ CNB 流水线 main: api_trigger │ stage 1 : publish- post.sh → 写 md 文件 → git push │ stage 2 : build- and- deploy.sh → hexo g → 组装 dist → edgeone pages deploy ▼ ⑤ EdgeOne Pages CDN 更新 → 约 1 分钟后线上可见
为什么要绕这么一圈? 有两个硬约束:
CNB 的 OpenAPI 对仓库文件是只读的 —— 没有"写文件/提交 commit"的接口(详见「踩坑 1」)。
api.cnb.cool 不返回任何 CORS 头 —— 浏览器不能直接调它(详见「踩坑 2」)。
所以必须有一个跑在服务端、能拿密钥 的中间人来转发。EdgeOne 自带的云函数正好补上这个位置,不用另外注册 Cloudflare、也不用占用你自己的服务器。
3. 搭建步骤
步骤 1:CNB 建私密仓库
打开 https://cnb.cool 登录
右上角 + → 新建仓库
填三项:
归属组织:你的组织
仓库名:blog
可见性:私密 ← 重要,.cnb.yml 里有 EdgeOne Token
不要 勾"初始化 README",留空仓库
为什么必须私密 :.cnb.yml 里要写 EdgeOne API Token 才能部署。虽然更安全的做法是用平台的密钥管理功能,但最省事的方案是"仓库私密 + Token 只放在这一个文件里"。
步骤 2:EdgeOne Pages 建项目
打开 https://pages.edgeone.ai,登录腾讯云国际账号
新建项目 → 选「直接上传 」(不要选 Git 集成,我们走 CLI 部署)
项目名:你的项目名(比如 myblog)—— 记住它,后面多处要填
访问区:没备案选「海外可访问 」;有备案可选国内
进项目设置 → API Token → 新建一个永久有效的 Token → 复制保存
步骤 3:初始化 Hexo 工程
在本地(或 CNB WebIDE 里)执行:
1 2 3 4 5 6 7 8 9 10 11 12 mkdir Blog && cd Blog npm install --registry=https://registry.npmmirror.commkdir -p themes scaffolds source /_postscp -R node_modules/hexo-theme-fluid themes/fluidcp themes/fluid/_config.yml ../_config.fluid.yml
Blog/package.json 最小内容 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 { "name" : "blog" , "private" : true , "hexo" : { "version" : "7.3.0" } , "dependencies" : { "hexo" : "^7.3.0" , "hexo-generator-archive" : "^2.0.0" , "hexo-generator-category" : "^2.0.0" , "hexo-generator-index" : "^4.0.0" , "hexo-generator-tag" : "^2.0.0" , "hexo-renderer-ejs" : "^2.0.0" , "hexo-renderer-marked" : "^7.0.0" , "hexo-renderer-stylus" : "^3.0.0" , "hexo-server" : "^3.0.0" , "hexo-theme-fluid" : "^1.9.8" } }
Blog/_config.yml 要改的几处 :
1 2 3 4 5 6 7 8 9 10 11 title: 你的博客名 subtitle: '' description: 一句话描述 author: 你的名字 language: zh-CN timezone: 'Asia/Shanghai' url: https://你的域名 theme: fluid
步骤 4:写根目录的配置文件
4.0 .ide/Dockerfile —— WebIDE 环境(可选)
配了它,就能在浏览器里 打开一个完整的 VSCode(带终端、Git 面板、语法高亮),不用在本地装任何环境。改完文件直接在 WebIDE 里 commit + push。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 FROM node:20 RUN curl -fsSL https://code-server.dev/install.sh | sh \ && code-server --install-extension cnbcool.cnb-welcome \ && code-server --install-extension redhat.vscode-yaml \ && code-server --install-extension dbaeumer.vscode-eslint \ && code-server --install-extension waderyan.gitblame \ && code-server --install-extension mhutchie.git-graph \ && code-server --install-extension donjayamanne.githistory \ && code-server --install-extension tencent-cloud.coding-copilot \ && echo done RUN apt-get update && apt-get install -y git wget unzip openssh-server ENV LANG C.UTF-8 ENV LANGUAGE C.UTF-8
用法:CNB 仓库页点「云原生开发」,第一次会按这个 Dockerfile 构建环境(约 2 分钟),之后直接拉起 WebIDE。
4.1 .cnb.yml —— 流水线定义
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 $: vscode: - docker: build: ./.ide/Dockerfile runner: cpus: 4 services: - vscode stages: - name: 预装依赖 script: | npm install -g hexo-cli || true if [ -d Blog ]; then cd Blog && npm install || true; fi - name: 拉起 WebIDE script: vscode:go main: push: - name: 构建并部署博客 docker: image: node:20 volumes: - /root/.npm:copy-on-write env: EDGEONE_API_TOKEN: <EDGEONE_PAGES_API_TOKEN> EDGEONE_PROJECT: 你的项目名 stages: - name: 渲染并部署 script: | sh ./scripts/build-and-deploy.sh api_trigger: - name: 发布新文章 docker: image: node:20 volumes: - /root/.npm:copy-on-write env: EDGEONE_API_TOKEN: <EDGEONE_PAGES_API_TOKEN> EDGEONE_PROJECT: 你的项目名 stages: - name: 写入文章并推送 script: | sh ./scripts/publish-post.sh - name: 渲染并部署 script: | sh ./scripts/build-and-deploy.sh
要点:
api_trigger 是挂在分支名下面 的合法事件,和 push 同级
流水线内置的 CNB_TOKEN 在 api_trigger 下拥有 repo-code:rw,所以脚本里能 git push
构建镜像用 node:20
4.2 build.sh —— 组装部署产物
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 #!/bin/sh set -e SCRIPT_DIR=$(cd "$(dirname "$0 " ) " && pwd )cd "$SCRIPT_DIR " if [ ! -d Blog/public ]; then echo "找不到 Blog/public,请先执行 hexo generate" exit 1fi if [ -d dist ]; then mv dist "dist_stale_$(date +%s) " fi mkdir -p distcp -R Blog/public/. dist/ mkdir -p dist/admincp admin/index.html dist/admin/index.html cp -R cloud-functions dist/cloud-functions cp functions /package.json dist/package.jsoncp edgeone.json dist/edgeone.jsonecho "===== dist 组装完成 =====" du -sh dist
4.3 scripts/build-and-deploy.sh —— 渲染 + 部署
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 #!/bin/sh set -e SCRIPT_DIR=$(cd "$(dirname "$0 " ) " && pwd ) ROOT=$(cd "$SCRIPT_DIR /.." && pwd )cd "$ROOT " if [ ! -d Blog/source ]; then echo "Blog/ 还没初始化,跳过构建。" exit 0fi if ! command -v hexo >/dev/null 2>&1; then npm install -g hexo-clifi echo "===== 1/3 渲染静态文件 =====" ( cd Blog npm install --no-audit --no-fund hexo clean hexo generate )echo "===== 2/3 组装 dist =====" sh ./build.shecho "===== 3/3 部署到 EdgeOne Pages =====" if [ -z "$EDGEONE_API_TOKEN " ]; then echo "缺少 EDGEONE_API_TOKEN,跳过部署(只构建不发布)。" exit 0fi npx --yes edgeone pages deploy dist \ -n "${EDGEONE_PROJECT:-你的项目名} " \ -t "$EDGEONE_API_TOKEN "
4.4 scripts/publish-post.sh —— 把文章写进仓库
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 #!/bin/sh set -e SCRIPT_DIR=$(cd "$(dirname "$0 " ) " && pwd ) ROOT=$(cd "$SCRIPT_DIR /.." && pwd )cd "$ROOT " : "${PUB_TITLE:?没有收到 PUB_TITLE(文章标题)} " : "${PUB_CONTENT_B64:?没有收到 PUB_CONTENT_B64(文章正文)} " TARGET_BRANCH="${CNB_BRANCH:-main} " POST_DIR="Blog/source/_posts" clean () { printf '%s' "$1 " | tr -d '\r\n' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | tr '"' "'" ; } SLUG=$(printf '%s' "$PUB_SLUG " \ | tr -d '\r\n' \ | sed 's#[/\\:*?"<>|[:space:]]#-#g; s/-\{2,\}/-/g; s/^[-.]*//; s/[-.]*$//' ) [ -n "$SLUG " ] || SLUG="post-$(date +%Y%m%d%H%M%S) " mkdir -p "$POST_DIR " printf '%s' "$PUB_CONTENT_B64 " | base64 -d > /tmp/post_body.md { printf -- '---\n' printf 'title: "%s"\n' "$(clean "$PUB_TITLE " ) " printf 'date: %s\n' "$(date '+%Y-%m-%d %H:%M:%S') " if [ -n "$PUB_TAGS " ]; then printf 'tags: [%s]\n' "$(clean "$PUB_TAGS " ) " ; fi if [ -n "$PUB_CATEGORIES " ]; then printf 'categories: [%s]\n' "$(clean "$PUB_CATEGORIES " ) " ; fi printf -- '---\n\n' cat /tmp/post_body.md } > "$POST_DIR /$SLUG .md" echo "===== 已写入 $POST_DIR /$SLUG .md =====" git config user.name "${CNB_BUILD_USER_NICKNAME:-博客} " git config user.email "${CNB_BUILD_USER_EMAIL:-blog@example.com} " git add "$POST_DIR /$SLUG .md" if git diff --cached --quiet; then echo "内容与仓库中的版本完全一致,无需提交。" exit 0fi git commit -m "post: $(clean "$PUB_TITLE " ) " if [ -n "$CNB_REPO_URL_HTTPS " ]; then REPO_HOST_PATH="${CNB_REPO_URL_HTTPS#https://} " elif [ -n "$CNB_REPO_SLUG " ]; then REPO_HOST_PATH="cnb.cool/$CNB_REPO_SLUG " else echo "错误:拿不到仓库地址" exit 1fi PUSH_URL="https://cnb:${CNB_TOKEN} @${REPO_HOST_PATH} " git push "$PUSH_URL " "HEAD:refs/heads/$TARGET_BRANCH " echo "===== 已推送到 $TARGET_BRANCH 分支 ====="
两个设计要点 :
正文用 base64 传,避免中文、换行、引号在多层 shell 传递中被破坏
publish-post.sh 里 git push 会再次触发 main: push 流水线 → 部署。这是故意的 ,形成闭环
步骤 5:写云函数(4 个文件)
放到 cloud-functions/ 目录。目录名必须是 cloud-functions (EdgeOne 已把旧的 node-functions 改名)。
5.1 cloud-functions/_cnb.js —— 公共工具(不会注册成路由)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 export const DEFAULT_API_BASE = 'https://api.cnb.cool' ;export const DEFAULT_REPO = '你的组织/你的仓库' ;export const DEFAULT_BRANCH = 'main' ;export function json (body, status = 200 ) { return new Response (JSON .stringify (body), { status, headers : { 'content-type' : 'application/json; charset=utf-8' , 'cache-control' : 'no-store' , }, }); }export function base64Utf8 (str ) { if (typeof Buffer !== 'undefined' ) { return Buffer .from (str, 'utf8' ).toString ('base64' ); } const bytes = new TextEncoder ().encode (str); let bin = '' ; for (let i = 0 ; i < bytes.length ; i += 1 ) bin += String .fromCharCode (bytes[i]); return btoa (bin); }export function slugify (input ) { const s = String (input || '' ) .trim () .toLowerCase () .replace (/[^a-z0-9\u4e00-\u9fa5]+/g , '-' ) .replace (/^-+|-+$/g , '' ) .slice (0 , 60 ); return s || `post-${Date .now()} ` ; }export function config (env = {} ) { return { apiBase : String (env.CNB_API_BASE || DEFAULT_API_BASE ).replace (/\/+$/ , '' ), repo : String (env.CNB_REPO || DEFAULT_REPO ), branch : String (env.CNB_BRANCH || DEFAULT_BRANCH ), token : String (env.CNB_TOKEN || '' ), password : String (env.ADMIN_PASSWORD || '' ), }; }export async function cnb (cfg, path, init = {} ) { const url = `${cfg.apiBase} /${cfg.repo} /-/${String (path).replace(/^\/+/, '' )} ` ; const res = await fetch (url, { ...init, headers : { Authorization : `Bearer ${cfg.token} ` , Accept : 'application/vnd.cnb.api+json' , 'Content-Type' : 'application/json' , ...(init.headers || {}), }, }); const text = await res.text (); let data = null ; try { data = text ? JSON .parse (text) : null ; } catch { data = { raw : text.slice (0 , 500 ) }; } return { ok : res.ok , status : res.status , data }; }
反直觉但很重要 :EdgeOne 的函数是按文件名注册路由 的。_cnb.js 没有导出 onRequest,所以它只是个普通模块,不会变成 /api/_cnb 这样的接口。
5.2 cloud-functions/api/publish.js —— 发布接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 import { json, base64Utf8, slugify, config, cnb } from '../_cnb.js' ;export async function onRequestPost (context ) { const { request, env } = context; const cfg = config (env); if (!cfg.password ) return json ({ ok : false , error : '服务端还没配置 ADMIN_PASSWORD 环境变量' }, 500 ); if (!cfg.token ) return json ({ ok : false , error : '服务端还没配置 CNB_TOKEN 环境变量' }, 500 ); let body; try { body = await request.json (); } catch { return json ({ ok : false , error : '请求体不是合法的 JSON' }, 400 ); } if (String (body.password || '' ) !== cfg.password ) { return json ({ ok : false , error : '管理密码不正确' }, 401 ); } const title = String (body.title || '' ).trim (); const content = String (body.content || '' ); if (!title) return json ({ ok : false , error : '文章标题不能为空' }, 400 ); if (!content.trim ()) return json ({ ok : false , error : '文章正文不能为空' }, 400 ); if (content.length > 60000 ) { return json ({ ok : false , error : `正文太长(${content.length} 字),请控制在 6 万字以内` }, 400 ); } const slug = slugify (body.slug || title); const payload = { event : 'api_trigger' , branch : cfg.branch , title : `发布文章: ${title} ` , sync : 'false' , env : { PUB_TITLE : title, PUB_SLUG : slug, PUB_CONTENT_B64 : base64Utf8 (content), PUB_TAGS : String (body.tags || '' ), PUB_CATEGORIES : String (body.categories || '' ), }, }; const r = await cnb (cfg, 'build/start' , { method : 'POST' , body : JSON .stringify (payload) }); if (!r.ok ) { return json ({ ok : false , error : `触发 CNB 构建失败(HTTP ${r.status} )` , hint : '检查 CNB_TOKEN 是否有效,以及令牌是否有 repo-cnb-trigger:rw 权限' , detail : r.data , }, 502 ); } return json ({ ok : true , slug, file : `Blog/source/_posts/${slug} .md` , build : r.data , message : '已触发发布,约 1~2 分钟后线上更新' , }); }export async function onRequestGet ( ) { return json ({ ok : false , error : '请用 POST 提交文章' }, 405 ); }
5.3 cloud-functions/api/posts.js —— 文章列表(只读)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 import { json, config, cnb } from '../_cnb.js' ;function normalize (data ) { let arr = []; if (Array .isArray (data)) { arr = data; } else if (data && typeof data === 'object' ) { for (const key of ['contents' , 'files' , 'entries' , 'list' , 'data' , 'items' ]) { if (Array .isArray (data[key])) { arr = data[key]; break ; } } } return arr .filter ((it ) => it && typeof it === 'object' ) .map ((it ) => { const path = it.path || it.name || '' ; return { name : it.name || path.split ('/' ).pop () || '' , path, sha : it.sha || it.object_sha || '' , size : typeof it.size === 'number' ? it.size : null , type : it.type || (it.is_dir ? 'dir' : 'file' ), }; }) .filter ((it ) => it.name && /\.(md|markdown)$/i .test (it.name )) .sort ((a, b ) => a.name .localeCompare (b.name )); }export async function onRequestGet (context ) { const cfg = config (context.env ); if (!cfg.token ) return json ({ ok : false , error : '服务端还没配置 CNB_TOKEN 环境变量' }, 500 ); const r = await cnb (cfg, `git/contents/Blog/source/_posts?ref=${encodeURIComponent (cfg.branch)} ` , { method : 'GET' }); if (!r.ok ) { return json ({ ok : false , error : `读取文章列表失败(HTTP ${r.status} )` , hint : '如果返回 404,通常是 Blog/source/_posts 目录还不存在——先在后台发布一篇就会有' , detail : r.data , }, 502 ); } const posts = normalize (r.data ); return json ({ ok : true , count : posts.length , posts }); }
5.4 cloud-functions/api/ping.js —— 自检接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 import { json, config, cnb } from '../_cnb.js' ;export async function onRequestGet (context ) { const cfg = config (context.env ); const out = { ok : true , server : { hasPassword : Boolean (cfg.password ), hasToken : Boolean (cfg.token ), apiBase : cfg.apiBase , repo : cfg.repo , branch : cfg.branch , }, cnb : null , }; if (!cfg.token ) { out.ok = false ; out.hint = '还没配置 CNB_TOKEN 环境变量' ; return json (out, 200 ); } if (!cfg.password ) { out.ok = false ; out.hint = '还没配置 ADMIN_PASSWORD 环境变量' ; return json (out, 200 ); } const r = await cnb (cfg, 'git/head' , { method : 'GET' }); out.cnb = { ok : r.ok , status : r.status , data : r.data }; if (!r.ok ) { out.ok = false ; out.hint = 'CNB_TOKEN 无效,或这个令牌没有该仓库的读取权限' ; } else { out.hint = '配置正常,可以进 /admin/ 写文章了' ; } return json (out, 200 ); }
5.5 functions/package.json —— 函数运行时声明
1 2 3 4 5 6 7 8 9 10 { "name" : "your-blog" , "version" : "1.0.0" , "private" : true , "type" : "module" , "description" : "静态博客 + 云函数" , "scripts" : { "build" : "echo \"静态文件已由流水线构建完成\"" } }
注意 "type": "module" —— 云函数用的是 ESM 语法(export / import)。
步骤 6:写线上后台页面 admin/index.html
一个单文件 页面(HTML + CSS + JS 全在里面,零外部依赖),部署后通过 https://你的域名/admin/ 访问。
核心逻辑只有三块:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 localStorage .setItem ('admin_pw' , pw);function render (md ) { return md .replace (/^### (.*)$/gm , '<h3>$1</h3>' ) .replace (/^## (.*)$/gm , '<h2>$1</h2>' ) .replace (/^# (.*)$/gm , '<h1>$1</h1>' ) .replace (/\*\*(.+?)\*\*/g , '<strong>$1</strong>' ) .replace (/`([^`]+)`/g , '<code>$1</code>' ) .replace (/^- (.*)$/gm , '<li>$1</li>' ) .replace (/\[(.+?)\]\((.+?)\)/g , '<a href="$2">$1</a>' ); }const res = await fetch ('/api/publish' , { method : 'POST' , headers : { 'content-type' : 'application/json' }, body : JSON .stringify ({ password, title, slug, tags, categories, content }), });
页面提供 3 个标签页:写文章 (标题/文件名/标签/分类/正文 + 实时预览)、文章列表 (读仓库)、设置 (接口地址、退出登录)。
完整代码较长(含样式),建议直接抄仓库里的 admin/index.html。写的时候注意:如果用 const $ = id => document.getElementById(id) 这种简写,调用时不要 带 #($('#title') 会把 # 当成 id 的一部分,返回 null)。
步骤 7:站点配置 edgeone.json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 { "headers" : [ { "source" : "/admin/*" , "headers" : [ { "key" : "X-Robots-Tag" , "value" : "noindex, nofollow" } ] } , { "source" : "/api/*" , "headers" : [ { "key" : "Cache-Control" , "value" : "no-store, max-age=0" } ] } ] , "rewrites" : [ { "source" : "/admin" , "destination" : "/admin/index.html" } ] }
/admin/* 加 noindex,不让搜索引擎收录你的后台
/api/* 禁缓存,否则自检和发布结果会读到旧值
rewrites 让 /admin(不带斜杠)也能打开后台
步骤 8:推代码 + 配环境变量 + 首次部署
8.1 推代码到 CNB
1 2 3 4 5 6 cd 你的项目目录 git init git add -A git commit -m "初始化博客" git remote add origin https://cnb.cool/你的组织/你的仓库.git git push -u origin main
(如果本机没装 git,也可以用 CNB 网页的"上传文件"功能,或 WebIDE 里的终端。)
8.2 配 4 个环境变量
EdgeOne Pages → 你的项目 → 设置 → 环境变量 → 生产环境,添加:
变量名
值
ADMIN_PASSWORD
你定的后台密码
CNB_TOKEN
你的 CNB 访问令牌
CNB_REPO
你的组织/你的仓库
CNB_BRANCH
main
也可以用 CLI 配 (更省事):
1 2 3 4 5 npx edgeone pages link -n 你的项目名 -t <EDGEONE_PAGES_API_TOKEN> npx edgeone pages env set ADMIN_PASSWORD '你的密码' -t <EDGEONE_PAGES_API_TOKEN> -e production npx edgeone pages env set CNB_TOKEN '你的CNB令牌' -t <EDGEONE_PAGES_API_TOKEN> -e production npx edgeone pages env set CNB_REPO '你的组织/你的仓库' -t <EDGEONE_PAGES_API_TOKEN> -e production npx edgeone pages env set CNB_BRANCH 'main' -t <EDGEONE_PAGES_API_TOKEN> -e production
⚠️ CLI 的 env set 会把变量写进本地 .env 文件 ,务必确保 .gitignore 里有 .env 和 .edgeone/(见「踩坑 4」)。
8.3 .gitignore(必须)
1 2 3 4 5 6 7 8 9 10 11 12 node_modules/ dist/ dist_stale_*/ _stale/ Blog/db.json Blog/public/ Blog/.deploy*/ # 密钥相关,绝对不能进仓库 .env .env.* .edgeone/
8.4 触发首次部署
推一次代码(或第一次提交就会触发 main: push 流水线),流水线会:
渲染 Hexo
组装 dist
edgeone pages deploy 到你的项目
等 1~2 分钟,打开 https://你的项目名.edgeone.dev/ 看效果。
8.5 自检
浏览器打开 https://你的域名/api/ping,应该看到:
1 2 3 4 5 6 { "ok" : true , "server" : { "hasPassword" : true , "hasToken" : true , "apiBase" : "https://api.cnb.cool" , "repo" : "你的组织/你的仓库" , "branch" : "main" } , "cnb" : { "ok" : true , "status" : 200 } , "hint" : "配置正常,可以进 /admin/ 写文章了" }
hasPassword: false → ADMIN_PASSWORD 没配上
hasToken: false → CNB_TOKEN 没配上
cnb.ok: false 且 status 是 401/403 → 令牌无效或权限不够
cnb.ok: false 且 status 是 406 → 代码里的 Accept 头写成了多个值(见「踩坑 3」)
8.6 绑自己的域名(可选)
域名商处加一条 CNAME,指向 你的项目名.edgeone.dev
EdgeOne Pages 项目里添加自定义域名
改 Blog/_config.yml 的 url,重新推送
4. 发文
方式 A:网页后台(推荐)
1 2 3 4 5 6 打开 https:// 你的域名/admin/ → 输入密码 → 写文章页:填标题 → 写正文(右侧实时预览) → 标签/分类可留空;文件名留空会自动生成 → 点「发布到线上」 → 等约 1 分钟,刷新博客首页即见
几个细节 :
草稿 :点「存草稿」把内容存在你本机浏览器(localStorage),换设备看不到
文件名 :留空时按标题生成,中文会保留;纯符号标题会退化成时间戳
正文长度上限 :6 万字
发布后能不能改 :可以,同样的文件名再发一次会覆盖;换文件名则是新文章
删文章 :后台不支持删除,需要到 CNB 网页里把那篇 .md 删掉(或在 WebIDE 里删),删完会自动触发重新部署
方式 B:让 AI 代发
如果你在用 AI 助手协作,也可以把标题 + 正文给它,让它调发布接口推上去,效果和后台点发布一样。
Markdown 速查
想要的效果
写法
一级标题
# 标题
二级标题
## 标题
加粗
**加粗**
斜体
*斜体*
无序列表
- 项目
有序列表
1. 项目
链接
[文字](https://...)
图片

行内代码
`code`
代码块
```js 换行 代码 换行 ```
引用
> 引用内容
表格
用 | 分隔的 Markdown 表格语法
分割线
---
发文时最常用的三条 :## 小标题、**加粗**、代码块。其余可以慢慢学。
5. 日常维护
改后台密码
1 npx edgeone pages env set ADMIN_PASSWORD '新密码' -t <EDGEONE_PAGES_API_TOKEN> -e production
不用重新部署,环境变量立即生效(在后台「设置」里点「退出登录」清掉本机旧密码再重进)。
换域名
域名商加 CNAME → 指向 EdgeOne 项目域名
EdgeOne Pages 里绑定自定义域名
改 Blog/_config.yml 的 url 为新域名
推送代码(或后台发一篇)触发重新部署
手动本地部署
不想等流水线时,本地构建完直接推:
1 2 3 cd Blog && hexo clean && hexo generate && cd .. sh ./build.sh npx edgeone pages deploy dist -n 你的项目名 -t <EDGEONE_PAGES_API_TOKEN> --skip-ai-gateway-sync --json
看构建状态
CNB 仓库页 → 构建 → 构建历史
触发构建后可以用 CNB OpenAPI 查状态:GET /{repo}/-/build/status/{构建号}
每次发文的实际耗时
实测一次发布全程 56~68 秒 :
阶段
耗时
写入文章并推送
0.3 秒
渲染并部署
56~62 秒
6. 踩坑记录
这一节是全文最值钱的部分 —— 下面每一个坑都是实测撞出来的。
坑 1:CNB OpenAPI 只能读不能写
现象 :想直接在浏览器里调 CNB API 写文章,接口返回 404。
实测结果 :
接口
方法
结果
读分支 / 读目录 / 读文件 / 读 commit
GET
✅ 200
/-/git/blobs(创建散对象)
POST
403(有路由,但组不成 commit)
/-/git/commits(建提交)
POST
❌ 404 路由不存在
/-/git/contents/{path}(写文件)
PUT
❌ 404 路由不存在
扫完全量 OpenAPI 文档,唯一和写相关的操作是 CreateBlob,没有 CreateCommit / CreateTree / UpdateRef / PutContent。
结论 :CNB 上写仓库只有一条路 —— git push over HTTPS (用户名 + 令牌当密码)。
解法 :把"写文件"这一步交给 CNB 流水线做(流水线里有 git 环境),云函数只负责"触发流水线"。
坑 2:api.cnb.cool 没有 CORS 头
现象 :浏览器直接 fetch('https://api.cnb.cool/...') 必被拦。
实测 :OPTIONS 预检、带 Origin 的 200 响应、甚至把 Origin 换成 https://cnb.cool 自己 —— 全都没有 Access-Control-Allow-Origin 。
结论 :浏览器不能直连 CNB API。
解法 :必须有一个同源的 服务端代理。EdgeOne 云函数正好挂在同一个域名下(/api/*),天然同源,零跨域。
坑 3:CNB 只认单一的 Accept 头(406)
现象 :自检接口一直返回 cnb.ok: false, status: 406,但用 curl 直接调 CNB 是好的。
根因 :云函数里写成了
1 Accept : 'application/vnd.cnb.api+json, application/json'
CNB 的内容协商不接受逗号分隔的合并值 。实测:
Accept 值
结果
application/vnd.cnb.api+json, application/json
❌ 406
application/vnd.cnb.api+json
✅ 200
application/json
✅ 200
不带 Accept(默认 */*)
❌ 406
解法 :只给一个值。
1 Accept : 'application/vnd.cnb.api+json'
坑 4:EdgeOne CLI 会把密钥写进本地 .env
这是一次真实的安全事故,务必重视。
经过 :用 edgeone pages env set 配环境变量时,CLI 顺手把 4 个变量写进了本地 .env。因为 .gitignore 当时没有 排除 .env,结果这个文件被推送脚本一起提交进了仓库 —— 后台密码和 CNB 令牌明文进过仓库历史 。
影响 :即使之后把文件删掉,旧提交对象可能仍存在于服务端。
处理 :
.gitignore 补上 .env、.env.*、.edgeone/
重建历史 + 强推,让仓库只剩一个干净提交
轮换所有泄露过的密钥 (换 CNB 令牌 + 换后台密码)
确认仓库是私密 的
教训 :
用任何 CLI 配密钥后,第一件事是检查它有没有在本地留下明文文件
.gitignore 要在放密钥之前 就配好,不能事后补
坑 5:isomorphic-git 的"快进"不会检出文件
背景 :本机没装 git,用纯 JS 的 isomorphic-git 做推送工具。
现象 :线上后台发了一篇文章后(远端比本地新),本地再同步时,那篇文章被当成"已删除"提交上去了。
根因 :
1 await git.merge ({ fs, dir, ours, theirs, fastForwardOnly : true });
merge({fastForwardOnly:true}) 只更新分支指针,不会把新文件检出到工作区 。所以工作区里没有那个文件,statusMatrix 就认为它是"被删除了"。
正确写法 :
1 2 await git.writeRef ({ fs, dir, ref : `refs/heads/${BRANCH} ` , value : remoteSha, force : true });await git.checkout ({ fs, dir, ref : BRANCH , force : true });
坑 6:函数目录名是 cloud-functions
EdgeOne 早期文档写的是 node-functions,后来改名为 cloud-functions 。写错目录名,函数不会生效(表现为 /api/ping 返回静态站的 404 页面)。
另外:手动构建时,要把函数文件和 package.json 一起放进部署产物目录 ,CLI 才能识别。
坑 7:api_trigger 要挂在分支名下
.cnb.yml 里事件不是顶层键,而是分支名 → 事件 的两层结构:
1 2 3 main: push: [... ] api_trigger: [... ]
写成顶层 api_trigger: 不会触发。
另外 POST /{repo}/-/build/start 的 event 字段只能是 api_trigger(或它的前缀),没法用它触发 push 流水线 。
附:沙箱/网络相关(仅影响本地调试)
某些受限网络环境下访问不了 *.edgeone.dev(curl 直连超时),但 api.cnb.cool 和 EdgeOne 的部署 API 是通的
验证线上内容可以换个通道(比如用能出网的抓取工具),测 POST 类接口可以本地 import 云函数模块直接调用处理器
7. 安全清单
搭完自查一遍:
[ ] CNB 仓库设为私密
[ ] .gitignore 里有 .env、.env.*、.edgeone/
[ ] .cnb.yml 里的 EdgeOne Token 只在这一个文件里,且仓库私密
[ ] EdgeOne 环境变量里配的是 ADMIN_PASSWORD / CNB_TOKEN,不要 把这些值写进任何前端文件
[ ] edgeone.json 里 /admin/* 有 noindex
[ ] CNB 令牌权限最小化 :只给 repo-code 和 repo-cnb-trigger,不勾其他
[ ] 用 CLI 配完密钥后,检查本地有没有生成 .env 之类的明文文件
[ ] 后台密码别用弱密码;怀疑泄露就换(换密码不用重新部署)
[ ] 定期轮换 CNB 访问令牌
关于后台安全性的说明 :后台密码校验发生在服务端云函数里,密码不会下发到浏览器;CNB 令牌只存在 EdgeOne 环境变量中,前端代码里没有任何密钥。因此即使有人看到你的 /admin/ 页面,没有密码也做不了任何事。
附录:文件清单
项目根目录
文件
作用
.cnb.yml
流水线定义(WebIDE / push 构建 / api_trigger 发布)
.gitignore
排除依赖、产物、密钥文件
build.sh
组装部署产物 dist/
edgeone.json
站点配置(响应头、路由重写)
TUTORIAL.md
本文的 Markdown 全文
.ide/Dockerfile
WebIDE 的容器环境(Node 20 + code-server)
Blog/
Hexo 工程(源码 + 主题 + 文章)
admin/index.html
线上后台页面(单文件)
cloud-functions/
云函数(_cnb.js + api/{publish,posts,ping}.js)
functions/package.json
函数运行时声明(type: module)
scripts/build-and-deploy.sh
渲染 + 部署
scripts/publish-post.sh
写文章 + git push
Blog/ 里的关键文件
文件
作用
_config.yml
站点配置(标题、作者、url、theme)
_config.fluid.yml
主题配置(样式、导航、评论等,注释很全)
source/_posts/*.md
文章
themes/fluid/
主题本体
scaffolds/post.md
hexo new 时的模板(定义 front-matter 结构)
三个核心文件的关系
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 管理员点发布 │ ├─ admin/index.html ──POST──▶ cloud-functions/api/publish.js │ │ │ ▼ │ CNB OpenAPI build/start (api_trigger) │ │ │ ▼ │ scripts/publish-post.sh → 写 md + git push │ │ └──────────────────────────────────────┤ ▼ scripts/build-and-deploy.sh → hexo generate → build.sh → dist/ → edgeone pages deploy
成本与限制
项目
情况
费用
0(CNB 免费额度 + EdgeOne Pages 免费额度)
服务器
不需要
备案
不需要(用海外访问区)
国内访问速度
走海外节点,首屏约 1~3 秒,偶有波动
想要国内秒开
需要域名备案 + 用 EdgeOne 国内版选「国内可访问」
单篇正文上限
6 万字
发布延迟
约 1 分钟
本文记录的每一步都经过实际运行验证。文中所有密钥均为占位符,照做时请替换成你自己的。