Share

外观
风格

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

2026年2月9日 · 专栏

封面图

我有 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], `![${imageName}](${url})`);
      }
    } catch {}
  }

  // Markdown 图片: ![alt](path)
  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, `![${alt}](${url})`);
      }
    } 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: ["测试", "分享"]
---

这是正文内容。

![截图](./screenshot.png)

发布:

share ~/Documents/我的笔记.md

进阶功能

自动封面生成

需要配置 AI 图片生成 API(如 Gemini):

export GEMINI_API_KEY="你的密钥"

封面生成逻辑:

  1. 检测文章分类是否为"专栏"
  2. 根据标题、摘要、标签构建 prompt
  3. 调用 AI 生成简笔双色漫画风封面
  4. 保存到 attachments/{slug}-cover-v1.png

敏感内容脱敏

发布前自动识别并遮蔽:

  • Bearer xxxBearer [REDACTED]
  • sk-xxxsk-[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 多篇文章。最满意的是那种"写完就能分享"的顺滑感——不用再打开后台、不用手动上传图片、不用担心密钥泄露。

有问题欢迎交流。