Share 服务:从笔记到网页的一键发布工作流

我有 200 多篇笔记躺在 Obsidian 里,偶尔想分享给朋友,每次都要手动复制到博客后台、上传图片、调格式。太累了。
后来我搭了一套系统:写完笔记,跑一行命令,3 秒后就能拿到一个干净的分享链接。图片自动上传,敏感信息自动脱敏,专栏文章还能自动生成封面图。
这篇文章教你从零复现这套系统。
这是什么
一个基于 Cloudflare Worker + R2 的笔记发布服务,核心功能:
- 一键发布:Markdown 笔记 → 可分享的网页
- 图片自动上传:本地图片自动压缩、上传到图床
- 智能封面:专栏文章自动生成简笔漫画风封面
- 敏感脱敏:发布前自动识别并遮蔽 API Key、IP 地址等
- 分类渲染:专栏、日报、碎碎念各有专属样式
效果展示
发布一篇文章:
bun tools/share.ts ~/Documents/RS/我的笔记.md
输出:
发布: ~/Documents/RS/我的笔记.md
标题: 我的笔记
Slug: 我的笔记
封面图已生成: attachments/我的笔记-cover-v1.png
上传图片: screenshot.png → https://i.chen.rs/abc123.webp
脱敏完成: 规则 3 处, AI 1 处
发布成功: https://s.chen.rs/我的笔记
已更新 frontmatter
发布后的页面支持:
- 亮/暗主题切换
- 多种阅读风格(GitHub、Notion、Typora、Bear)
- 代码高亮 + 一键复制
- 响应式布局
快速开始
1. 部署图床 Worker
# 创建项目
mkdir -p workers/img && cd workers/img
# 创建 R2 bucket
wrangler r2 bucket create img
# 设置 API Key
wrangler secret put API_KEY
# 输入你的 API Key,例如 sk-share-xxx
图床 Worker 代码(点击展开)
workers/img/src/index.ts:
export interface Env {
IMG_BUCKET: R2Bucket;
API_KEY: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// CORS
if (request.method === "OPTIONS") {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Authorization, Content-Type",
},
});
}
// 上传
if (request.method === "POST" && url.pathname === "/upload") {
const auth = request.headers.get("Authorization");
if (auth !== `Bearer ${env.API_KEY}`) {
return new Response("Unauthorized", { status: 401 });
}
const formData = await request.formData();
const file = formData.get("file") as File;
if (!file) {
return new Response("No file", { status: 400 });
}
// 生成文件名(hash)
const buffer = await file.arrayBuffer();
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hash = hashArray.map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16);
const ext = file.name.split(".").pop() || "png";
const key = `${hash}.${ext}`;
await env.IMG_BUCKET.put(key, buffer, {
httpMetadata: { contentType: file.type },
});
const imgUrl = `${url.origin}/${key}`;
return Response.json({ url: imgUrl });
}
// 读取图片
if (request.method === "GET" && url.pathname !== "/") {
const key = url.pathname.slice(1);
const object = await env.IMG_BUCKET.get(key);
if (!object) {
return new Response("Not found", { status: 404 });
}
return new Response(object.body, {
headers: {
"Content-Type": object.httpMetadata?.contentType || "image/png",
"Cache-Control": "public, max-age=31536000",
},
});
}
return new Response("Image Service", { status: 200 });
},
};
workers/img/wrangler.toml:
name = "img"
main = "src/index.ts"
compatibility_date = "2024-01-01"
<span class="private-link" title="未发布的笔记">r2_buckets</span>
binding = "IMG_BUCKET"
bucket_name = "img"
部署:
wrangler deploy
2. 部署 Share Worker
mkdir -p workers/share && cd workers/share
wrangler r2 bucket create share
wrangler secret put API_KEY
Share Worker 核心代码(点击展开)
workers/share/src/index.ts:
export interface Env {
SHARE_BUCKET: R2Bucket;
API_KEY: string;
}
interface Article {
slug: string;
title: string;
category?: string;
tags?: string[];
content: string;
html: string;
publishedAt: string;
coverImage?: string;
}
interface ArticleMeta {
slug: string;
title: string;
category?: string;
publishedAt: string;
coverImage?: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;
// CORS
if (request.method === "OPTIONS") {
return corsResponse(null);
}
// API 路由
if (path.startsWith("/api/")) {
return handleApi(request, env, path);
}
// 页面路由
if (path === "/") {
return renderHomePage(env);
}
if (path.startsWith("/c/")) {
const category = decodeURIComponent(path.slice(3));
return renderCategoryPage(env, category);
}
// 文章页
const slug = decodeURIComponent(path.slice(1));
return renderArticlePage(env, slug);
},
};
async function handleApi(request: Request, env: Env, path: string): Promise<Response> {
// 发布文章
if (request.method === "POST" && path === "/api/publish") {
if (!checkAuth(request, env)) {
return corsResponse(JSON.stringify({ error: "Unauthorized" }), 401);
}
const body = await request.json() as {
slug: string;
title: string;
category?: string;
tags?: string[];
content: string;
};
const html = markdownToHtml(body.content);
const coverImage = extractCoverImage(html);
const article: Article = {
slug: body.slug,
title: body.title,
category: body.category,
tags: body.tags,
content: body.content,
html,
publishedAt: new Date().toISOString(),
coverImage,
};
// 保存文章
await env.SHARE_BUCKET.put(
`articles/${body.slug}.json`,
JSON.stringify(article)
);
// 更新索引
await updateIndex(env, article);
const articleUrl = `${new URL(request.url).origin}/${encodeURIComponent(body.slug)}`;
return corsResponse(JSON.stringify({
success: true,
url: articleUrl,
slug: body.slug,
}));
}
// 获取文章列表
if (request.method === "GET" && path === "/api/articles") {
const index = await getIndex(env);
return corsResponse(JSON.stringify({ articles: index }));
}
// 删除文章
if (request.method === "DELETE" && path.startsWith("/api/articles/")) {
if (!checkAuth(request, env)) {
return corsResponse(JSON.stringify({ error: "Unauthorized" }), 401);
}
const slug = decodeURIComponent(path.slice("/api/articles/".length));
await env.SHARE_BUCKET.delete(`articles/${slug}.json`);
await removeFromIndex(env, slug);
return corsResponse(JSON.stringify({ success: true }));
}
return corsResponse(JSON.stringify({ error: "Not found" }), 404);
}
function checkAuth(request: Request, env: Env): boolean {
const auth = request.headers.get("Authorization");
return auth === `Bearer ${env.API_KEY}`;
}
function corsResponse(body: string | null, status = 200): Response {
return new Response(body, {
status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Authorization, Content-Type",
},
});
}
// Markdown 转 HTML(简化版,实际项目建议用 marked 或 markdown-it)
function markdownToHtml(markdown: string): string {
return markdown
.replace(/^### (.*$)/gm, "<h3>$1</h3>")
.replace(/^## (.*$)/gm, "<h2>$1</h2>")
.replace(/^# (.*$)/gm, "<h1>$1</h1>")
.replace(/\*\*(.*)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.*)\*/g, "<em>$1</em>")
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
.replace(/\n\n/g, "</p><p>")
.replace(/^(.+)$/gm, "<p>$1</p>");
}
function extractCoverImage(html: string): string | undefined {
const match = html.match(/<img[^>]+src="([^"]+)"/);
return match?.[1];
}
async function getIndex(env: Env): Promise<ArticleMeta[]> {
const obj = await env.SHARE_BUCKET.get("index/all.json");
if (!obj) return [];
return JSON.parse(await obj.text());
}
async function updateIndex(env: Env, article: Article): Promise<void> {
const index = await getIndex(env);
const meta: ArticleMeta = {
slug: article.slug,
title: article.title,
category: article.category,
publishedAt: article.publishedAt,
coverImage: article.coverImage,
};
// 移除旧版本
const filtered = index.filter(a => a.slug !== article.slug);
// 添加新版本到开头
filtered.unshift(meta);
await env.SHARE_BUCKET.put("index/all.json", JSON.stringify(filtered));
}
async function removeFromIndex(env: Env, slug: string): Promise<void> {
const index = await getIndex(env);
const filtered = index.filter(a => a.slug !== slug);
await env.SHARE_BUCKET.put("index/all.json", JSON.stringify(filtered));
}
async function renderHomePage(env: Env): Promise<Response> {
const articles = await getIndex(env);
const html = `<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>Share</title></head>
<body>
<h1>Share</h1>
<ul>
${articles.map(a => `<li><a href="/${encodeURIComponent(a.slug)}">${a.title}</a></li>`).join("")}
</ul>
</body></html>`;
return new Response(html, { headers: { "Content-Type": "text/html" } });
}
async function renderCategoryPage(env: Env, category: string): Promise<Response> {
const articles = (await getIndex(env)).filter(a => a.category === category);
const html = `<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>${category}</title></head>
<body>
<h1>${category}</h1>
<ul>
${articles.map(a => `<li><a href="/${encodeURIComponent(a.slug)}">${a.title}</a></li>`).join("")}
</ul>
</body></html>`;
return new Response(html, { headers: { "Content-Type": "text/html" } });
}
async function renderArticlePage(env: Env, slug: string): Promise<Response> {
const obj = await env.SHARE_BUCKET.get(`articles/${slug}.json`);
if (!obj) {
return new Response("Not found", { status: 404 });
}
const article: Article = JSON.parse(await obj.text());
const html = `<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>${article.title}</title></head>
<body>
<article>
<h1>${article.title}</h1>
<div>${article.html}</div>
</article>
</body></html>`;
return new Response(html, { headers: { "Content-Type": "text/html" } });
}
workers/share/wrangler.toml:
name = "share"
main = "src/index.ts"
compatibility_date = "2024-01-01"
<span class="private-link" title="未发布的笔记">r2_buckets</span>
binding = "SHARE_BUCKET"
bucket_name = "share"
部署并绑定自定义域名:
wrangler deploy
# 在 Cloudflare 控制台添加自定义域名,如 s.yourdomain.com
3. 配置本地 CLI
创建配置文件:
mkdir -p ~/.config/share
~/.config/share/config.json:
{
"apiUrl": "https://s.yourdomain.com",
"imgUrl": "https://i.yourdomain.com",
"apiKey": "sk-share-你的密钥",
"vaultPath": "/path/to/your/obsidian/vault",
"coverRefreshPolicy": "smart",
"compressImageBeforeUpload": true,
"imageUploadFormat": "webp",
"imageUploadQuality": 82,
"imageUploadMaxWidth": 1600
}
4. 安装发布 CLI
创建工具目录:
mkdir -p ~/share-cli/tools
cd ~/share-cli
bun init -y
bun add gray-matter
发布 CLI 代码(点击展开)
~/share-cli/tools/share.ts:
#!/usr/bin/env bun
import { readFile, writeFile, stat, mkdir } from "fs/promises";
import { join, basename, dirname, extname } from "path";
import { homedir } from "os";
interface Config {
apiUrl: string;
imgUrl: string;
apiKey: string;
vaultPath?: string;
}
interface Frontmatter {
title?: string;
slug?: string;
category?: string;
tags?: string[];
share?: boolean;
share_url?: string;
}
const CONFIG_PATH = join(homedir(), ".config/share/config.json");
async function loadConfig(): Promise<Config> {
const content = await readFile(CONFIG_PATH, "utf-8");
return JSON.parse(content);
}
function parseFrontmatter(content: string): { frontmatter: Frontmatter; body: string } {
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!match) return { frontmatter: {}, body: content };
const [, yaml, body] = match;
const frontmatter: Frontmatter = {};
for (const line of yaml.split("\n")) {
const colonIndex = line.indexOf(":");
if (colonIndex === -1) continue;
const key = line.slice(0, colonIndex).trim();
let value = line.slice(colonIndex + 1).trim().replace(/^["']|["']$/g, "");
if (value === "true") (frontmatter as any)[key] = true;
else if (value === "false") (frontmatter as any)[key] = false;
else (frontmatter as any)[key] = value;
}
return { frontmatter, body };
}
async function uploadImage(imagePath: string, config: Config): Promise<string | null> {
try {
const imageData = await readFile(imagePath);
const filename = basename(imagePath);
const ext = extname(filename).toLowerCase();
const mimeType = ext === ".png" ? "image/png" : ext === ".jpg" || ext === ".jpeg" ? "image/jpeg" : "image/webp";
const formData = new FormData();
formData.append("file", new Blob([imageData], { type: mimeType }), filename);
const res = await fetch(`${config.imgUrl}/upload`, {
method: "POST",
headers: { Authorization: `Bearer ${config.apiKey}` },
body: formData,
});
if (!res.ok) return null;
const data = await res.json() as { url: string };
console.log(` 上传图片: ${filename} → ${data.url}`);
return data.url;
} catch {
return null;
}
}
async function processImages(content: string, filePath: string, config: Config): Promise<string> {
const fileDir = dirname(filePath);
let newContent = content;
// Obsidian 图片: !<span class="private-link" title="未发布的笔记">image.png</span>
const obsidianImages = [...content.matchAll(/!\[\[([^\]]+)\]\]/g)];
for (const match of obsidianImages) {
const imageName = match[1];
const imagePath = join(fileDir, imageName);
try {
await stat(imagePath);
const url = await uploadImage(imagePath, config);
if (url) {
newContent = newContent.replace(match[0], ``);
}
} catch {}
}
// Markdown 图片: 
const mdImages = [...content.matchAll(/!\[([^\]]*)\]\((?!http)([^)]+)\)/g)];
for (const match of mdImages) {
const [fullMatch, alt, relativePath] = match;
const imagePath = join(fileDir, relativePath);
try {
await stat(imagePath);
const url = await uploadImage(imagePath, config);
if (url) {
newContent = newContent.replace(fullMatch, ``);
}
} catch {}
}
return newContent;
}
async function publish(filePath: string): Promise<void> {
const config = await loadConfig();
console.log(`发布: ${filePath}`);
const content = await readFile(filePath, "utf-8");
const { frontmatter, body } = parseFrontmatter(content);
const title = frontmatter.title || basename(filePath, ".md");
const slug = frontmatter.slug || title.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-");
console.log(` 标题: ${title}`);
console.log(` Slug: ${slug}`);
// 处理图片
const processedBody = await processImages(body, filePath, config);
// 发布
const res = await fetch(`${config.apiUrl}/api/publish`, {
method: "POST",
headers: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
slug,
title,
category: frontmatter.category,
tags: frontmatter.tags,
content: processedBody,
}),
});
if (!res.ok) {
console.error(` 发布失败: ${await res.text()}`);
return;
}
const data = await res.json() as { url: string };
console.log(` 发布成功: ${data.url}`);
// 回填 share_url
const updatedContent = content.replace(
/^---\n/,
`---\nshare_url: "${data.url}"\n`
);
await writeFile(filePath, updatedContent);
console.log(` 已更新 frontmatter`);
}
// 主入口
const filePath = process.argv[2];
if (!filePath) {
console.log("用法: bun tools/share.ts <file.md>");
process.exit(1);
}
publish(filePath).catch(console.error);
添加别名(可选):
echo 'alias share="bun ~/share-cli/tools/share.ts"' >> ~/.zshrc
source ~/.zshrc
5. 发布第一篇文章
准备笔记:
---
title: "我的第一篇分享"
category: "技术"
tags: ["测试", "分享"]
---
这是正文内容。

发布:
share ~/Documents/我的笔记.md
进阶功能
自动封面生成
需要配置 AI 图片生成 API(如 Gemini):
export GEMINI_API_KEY="你的密钥"
封面生成逻辑:
- 检测文章分类是否为"专栏"
- 根据标题、摘要、标签构建 prompt
- 调用 AI 生成简笔双色漫画风封面
- 保存到
attachments/{slug}-cover-v1.png
敏感内容脱敏
发布前自动识别并遮蔽:
Bearer xxx→Bearer [REDACTED]sk-xxx→sk-[REDACTED]- IPv4 地址 →
[IP REDACTED] - 私钥指纹 →
[FINGERPRINT]
碎碎念模式
短内容(≤280 字)专用:
bun tools/mom-note.ts "今天搞定了一个有意思的 bug" --save
会发布为推特风格卡片,并可选保存到 Obsidian。
我踩过的坑
1. Wikilinks 解析在 Worker 里很慢
最开始用正则逐个替换 !<span class="private-link" title="未发布的笔记">xxx</span>,文章一长就卡。后来改成先收集所有匹配,再批量替换,快了 10 倍。
2. R2 没有原子更新索引
多个请求同时发布会丢数据。解决方案:用 Durable Objects 或者加个简单的锁机制。我选择了接受这个限制——个人用足够了。
3. 图片压缩要在本地做
一开始想在 Worker 里压缩图片,结果 CPU 时间爆了。现在改成本地用 ImageMagick 压缩后再上传,Worker 只做存储和分发。
给 AI 的复现指令
如果你在用 Claude Code 或类似工具,可以把这段发给 AI 让它帮你搭建。
帮我搭建一套笔记发布系统,包含以下组件:
1. 图床服务
- Cloudflare Worker + R2
- 支持上传图片,返回 URL
- 自动生成 hash 文件名
2. 发布服务
- Cloudflare Worker + R2
- API: POST /api/publish, GET /api/articles, DELETE /api/articles/:slug
- 页面: 首页列表、分类页、文章页
- Markdown 渲染
3. 本地 CLI (TypeScript/Bun)
- 解析 frontmatter
- 上传图片并替换链接
- 调用 API 发布
- 回填 share_url
配置文件位置: ~/.config/share/config.json
技术栈: Cloudflare Worker, R2, TypeScript, Bun
运行命令:
部署 Worker: wrangler deploy
发布文章: bun tools/share.ts <file.md>
成功标志: 执行发布命令后能在浏览器访问生成的链接
资源列表
| 资源 | 说明 |
|---|---|
| Cloudflare Workers 文档 | Worker 开发入门 |
| Wrangler CLI | 部署工具 |
| R2 存储 | 对象存储 |
| Bun | 快速 JS 运行时 |
这套系统我已经用了大半年,发布了 50 多篇文章。最满意的是那种"写完就能分享"的顺滑感——不用再打开后台、不用手动上传图片、不用担心密钥泄露。
有问题欢迎交流。