把博客重新换成Hugo后,我又把GotoSocial安装了起来,就是想发布自己有一些碎语之类的东西,在memos和GotoSocial中纠结了一会,最终选择了GotoSocial,主要原因还是看到memeos的作者随意改动API,为了以后不再有不必要的麻烦,还是选择一个更稳定的最好。整体完成后的效果可以参考:

我的微博

总体思路

由于 GoToSocial 默认开启了严格的 CORS 限制,获取内容的时候需要token,由于Hugo是静态博客,直接把token写在前端非常不安全,所以我们需要一个中转层。 Cloudflare Workers 可以完美胜任代理转发的功能,作用是:

  • 从 Hugo 前端发请求 → Cloudflare Worker
  • Worker 向 https://你的域名/api/v1/accounts/…/statuses 请求 GoToSocial 数据
  • Worker 再把 JSON 数据返回给 Hugo 前端
  • Hugo 构建时渲染

获取 GoToSocial 的 toot API 接口

目标概述

我们要:

  1. 向 https://你的域名/api/v1/apps 注册一个应用;
  2. 通过浏览器授权;
  3. 用授权码换取 access_token;
  4. 把这个 token 放入 Cloudflare Worker 的环境变量;
  5. 然后 Hugo 博客就能安全地从 Worker 获取嘟文数据。

详细步骤

注册一个新应用

在命令行执行以下命令(用 curl,别忘了改你的 app 名称):

1
2
3
4
5
6
7
8
curl -X POST \
  -H "Content-Type: application/json" \
  -d '{
        "client_name": "hugo-gts-proxy",
        "redirect_uris": "urn:ietf:wg:oauth:2.0:oob",
        "scopes": "read"
      }' \
  'https://你的域名/api/v1/apps'

成功后会返回类似:

1
2
3
4
5
6
7
{
  "id": "01J1CYJ4QRNFZD6WHQMZV7248G",
  "name": "hugo-gts-proxy",
  "redirect_uri": "urn:ietf:wg:oauth:2.0:oob",
  "client_id": "xxxxxxxxxxxxxxxx",
  "client_secret": "yyyyyyyyyyyyyyyy"
}

👉 复制并保存 client_id 与 client_secret。

获取授权码

打开浏览器访问以下链接(注意替换 YOUR_CLIENT_ID):

1
https://你的域名/oauth/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=read

系统会要求你登录并授权。 点击「允许」,你会看到页面上出现:

1
2
Here's your out-of-band token:
YOUR_AUTHORIZATION_CODE

👉 复制这个 YOUR_AUTHORIZATION_CODE。

用授权码换取访问令牌

然后在命令行执行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
curl -X POST \
  -H "Content-Type: application/json" \
  -d '{
        "redirect_uri": "urn:ietf:wg:oauth:2.0:oob",
        "client_id": "YOUR_CLIENT_ID",
        "client_secret": "YOUR_CLIENT_SECRET",
        "grant_type": "authorization_code",
        "code": "YOUR_AUTHORIZATION_CODE"
      }' \
  'https://你的域名/oauth/token'

会返回类似:

1
2
3
4
5
6
{
  "access_token": "YOUR_ACCESS_TOKEN",
  "created_at": 1729436650,
  "scope": "read",
  "token_type": "Bearer"
}

🎉 这就是我们需要的 access_token!

验证 token 是否可用

试试看:

1
2
curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  'https://你的域名/api/v1/accounts/verify_credentials'

如果返回你的用户资料( username, id, url 等),说明 token 有效 。

Cloudflare Worker的设置

接下来打开 Cloudflare → Workers → 你的 Worker → 「Settings」→「Variables」→「Add Variable」

1
2
Name: GTS_TOKEN
Value: YOUR_ACCESS_TOKEN

保存。 Worker 代码示例如下 👇:

点击展开查看完整的配置代码(大约 160 行)
  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
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const headers = {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Methods": "GET, OPTIONS",
      "Access-Control-Allow-Headers": "*",
    };

    if (request.method === "OPTIONS") {
      return new Response(null, { headers });
    }

    const target = `https://你的域名${url.pathname}${url.search}`;
    const resp = await fetch(target, {
      headers: {
        "Authorization": "Bearer " + env.GTS_TOKEN,
        "User-Agent": "GTS-Proxy-Worker",
      },
    });

    if (!resp.ok) {
      return new Response(await resp.text(), {
        status: resp.status,
        headers: { ...headers, "Content-Type": "application/json" },
      });
    }

    const data = await resp.json();
细步骤
    // 并行拉取每条嘟文的回复
    const statuses = await Promise.all(data.map(async (status) => {
      let replies = [];
      try {
        const ctx = await fetch(`https://你的域名/api/v1/statuses/${status.id}/context`, {
          headers: { "Authorization": "Bearer " + env.GTS_TOKEN },
        });
        if (ctx.ok) {
          const contextData = await ctx.json();
          replies = contextData?.descendants?.map(r => ({
            id: r.id,
            content: r.content,
            account: {<details>
<summary>点击展开查看完整的配置代码大约 100 </summary>
              username: r.account?.username,
              display_name: r.account?.display_name,
              avatar完全正确 : r.account?.avatar,
            }
          })) || [];
        }
      } catch (err) {
        console.log("Reply fetch failed:", err);
      }

      return {
        id: status.id,
        created_at: status.created_at,
        content: status.content,
        url: status.url,
        account: {
          username: status.account?.username,
          display_name: statuexport default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const headers = {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Methods": "GET, OPTIONS",
      "Access-Control-Allow-Headers": "*",
    };

    if (request.method === "OPTIONS") {
      return new Response(null, { headers });
    }

    const target = `https://l22.org${url.pathname}${url.search}`;
    const resp = await fetch(target, {
      headers: {
        "Authorization": "Bearer " + env.GTS_TOKEN,
        "User-Agent": "GTS-Proxy-Worker",
      },
    });

    if (!resp.ok) {
      return new Response(await resp.text(), {
        status: resp.status,
        headers: { ...headers, "Content-Type": "application/json" },
      });
    }

    const data = await resp.json();

    // 并行拉取每条嘟文的回复
    const statuses = await Promise.all(data.map(async (status) => {
      let replies = [];
      try {
        const ctx = await fetch(`https://l22.org/api/v1/statuses/${status.id}/context`, {
          headers: { "Authorization": "Bearer " + env.GTS_TOKEN },
        });
        if (ctx.ok) {
          const contextData = await ctx.json();
          replies = contextData?.descendants?.map(r => ({
            id: r.id,
            content: r.content,
            account: {
              username: r.account?.username,
              display_name: r.account?.display_name,
              avatar: r.account?.avatar,
            }
          })) || [];
        }
      } catch (err) {
        console.log("Reply fetch failed:", err);
      }

      return {
        id: status.id,
        created_at: status.created_at,
        content: status.content,
        url: status.url,
        account: {
          username: status.account?.username,
          display_name: status.account?.display_name,
          avatar: status.account?.avatar,
        },
        replies_count: status.replies_count || 0,
        reblogs_count: status.reblogs_count || 0,
        favourites_count: status.favourites_count || 0,
        media_attachments: (status.media_attachments || []).map(media => ({
          url: media.url?.startsWith("/") ? "https://域名" + media.url : media.url,
          preview_url: media.preview_url?.startsWith("/") ? "https://域名" + media.preview_url : media.preview_url,
        })),
        replies,
      };
    }));

    return new Response(JSON.stringify(statuses, null, 2), {
      headers: { ...headers, "Content-Type": "application/json" },
    });
  },
};</details>
s.account?.display_name,
          avatar: status.account?.avatar,
        },
        replies_count: status.replies_count || 0,
        reblogs_count: status.reblogs_count || 0,
        favourites_count: status.favourites_count || 0,
        media_attachments: (status.media_attachments || []).map(media => ({
          url: media.url?.startsWith("/") ? "https://你的域名" + media.url : media.url,
          preview_url: media.preview_url?.startsWith("/") ? "https://你的域名" + media.preview_url : media.preview_url,
        })),
        replies,
      };
    }));

    return new Response(JSON.stringify(statuses, null, 2), {
      headers: { ...headers, "Content-Type": "application/json" },
    });
  },
};

Hugo模板设置

在 Hugo 博客根目录下,新建:

1
2
content/toots/_index.md
layouts/_default/toots.html

📄 content/toots/_index.md

1
2
3
4
---
title: "我的嘟文"
description: "来自 GoToSocial 的最新动态"
---

🧩 layouts/_default/toots.html

点击展开查看完整的配置代码(大约 280 行)
  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
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
{{ define "main" }}
<main class="main post-single" id="toots-page">
  <header class="page-header">
    <h1>{{ .Title }}</h1>
    {{ with .Description }}
      <p class="page-description">{{ . }}</p>
    {{ end }}
  </header>

  <div id="toots-container" class="toots"></div>

  <div class="load-more-wrapper">
    <button id="load-more-btn">加载更多嘟文</button>
  </div>

  <script>
  let tootsData = [];
  let displayedCount = 0;
  const pageSize = 5; // 每次加载条数

  // ✅ 加载嘟文
  async function loadToots(initial=false) {
    if (initial) {
      // 请注意:如果您要使用 Cloudflare Worker,这里的 URL 应该是您的 Worker URL,而不是原始 Mastodon/GoToSocial 实例的 URL。
      // 假设您的 Worker 地址是 https://worker.yourdomain.com/api/v1/accounts/01M4A5Q58VD6GJH97T2TE6W25J/statuses?exclude_reblogs=true
      const url = "https://toots.iliu.org/api/v1/accounts/01M4A5Q58VD6GJH97T2TE6W25J/statuses?exclude_reblogs=true"; 
      const res = await fetch(url);
      tootsData = await res.json();
    }

    const container = document.getElementById("toots-container");
    const nextToots = tootsData.slice(displayedCount, displayedCount + pageSize);

    for (const t of nextToots) {
      const date = new Date(t.created_at).toLocaleString();
      const username = t.account?.display_name || t.account?.username || "匿名";
      const avatar = t.account?.avatar || "https://l22.org/path-to-default-avatar.png";

      // ✅ 九宫格多图布局
      let mediaHTML = "";
      if (t.media_attachments && t.media_attachments.length > 0) {
        mediaHTML = `
          <div class="toot-media-grid">
            ${t.media_attachments.map(m => `
              <div class="toot-media-item">
                <img src="${m.preview_url || m.url}" alt="${m.description || ''}" loading="lazy" onclick="showLightbox('${m.url}')">
              </div>
            `).join('')}
          </div>
        `;
      }

      // 🚨 【修复点】直接使用 Worker 预取的 t.replies 数据来构建回复内容
      let repliesHTML = "";
      if (t.replies && t.replies.length > 0) {
        repliesHTML = `
          <div class="toot-replies">
            ${t.replies.map(r => {
              const avatar = r.account?.avatar || "https://l22.org/path-to-default-avatar.png";
              const name = r.account?.display_name || r.account?.username || "匿名";
              return `
                <div class="toot-reply">
                  <img src="${avatar}" class="toot-reply-avatar" alt="${name}">
                  <div class="toot-reply-body">
                    <strong>${name}</strong>:${r.content}
                  </div>
                </div>
              `;
            }).join('')}
          </div>
        `;
      } else if (t.replies_count > 0) {
        // 如果 Worker 没有返回 replies 但计数大于 0,可能是 Worker 拉取回复失败
        repliesHTML = `<div class="toot-replies"><p class='no-reply'>暂无回复(或Worker拉取失败)</p></div>`;
      }

      const tootHTML = `
        <article class="toot-card">
          <div class="toot-header">
            <img class="toot-avatar" src="${avatar}" alt="${username}">
            <span class="toot-username">${username}</span>
          </div>
          <div class="toot-date">
            <a href="${t.url}" target="_blank">${date}</a>
          </div>
          <div class="toot-content">${t.content}</div>
          ${mediaHTML}
          <div class="toot-footer">
            ❤️ ${t.favourites_count} 🔁 ${t.reblogs_count} 💬 ${t.replies_count}
          </div>
          ${repliesHTML}
        </article>
      `;

      container.insertAdjacentHTML("beforeend", tootHTML);
      
      // 🚨 【修复点】移除了原有的 loadReplies(t.id) 调用
    }

    displayedCount += pageSize;
    document.getElementById("load-more-btn").style.display = displayedCount >= tootsData.length ? "none" : "inline-block";
  }

  // 🚨 【修复点】移除了 loadReplies 函数

  // ✅ 初次加载
  document.getElementById("load-more-btn").onclick = () => loadToots();
  loadToots(true);

  // ✅ 简易图片灯箱
  function showLightbox(src) {
    let lightbox = document.getElementById("lightbox");
    if (!lightbox) {
      lightbox = document.createElement("div");
      lightbox.id = "lightbox";
      lightbox.innerHTML = `<img id="lightbox-img"><span id="lightbox-close">×</span>`;
      document.body.appendChild(lightbox);
      document.getElementById("lightbox-close").onclick = () => lightbox.classList.remove("show");
      lightbox.onclick = e => { if (e.target === lightbox) lightbox.classList.remove("show"); };
    }
    document.getElementById("lightbox-img").src = src;
    lightbox.classList.add("show");
  }
  </script>

  <style>
  /* 容器样式 */
  #toots-container {
    display: flex;
    flex-direction: column;
    gap: 1.5rem;
    margin-top: 2rem;
  }

  /* 嘟文卡片 */
  .toot-card {
    background: var(--entry);
    border-radius: var(--radius);
    padding: 1rem 1.5rem;
    box-shadow: var(--shadow);
    transition: transform .2s ease, box-shadow .2s ease;
  }
  .toot-card:hover {
    transform: translateY(-2px);
    box-shadow: var(--shadow-hover);
  }

  .toot-header {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin-bottom: 0.5rem;
  }
  .toot-avatar {
    width: 36px;
    height: 36px;
    border-radius: 50%;
    object-fit: cover;
  }
  .toot-username {
    font-weight: bold;
    color: var(--primary);
  }

  .toot-date {
    font-size: .85rem;
    color: var(--secondary);
    margin-bottom: .25rem;
  }

  .toot-content {
    font-size: 1rem;
    color: var(--primary);
    line-height: 1.6;
    overflow-wrap: break-word;
  }

  /* 九宫格多图 */
  .toot-media-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
    gap: 4px;
    margin-top: .5rem;
  }
  .toot-media-item img {
    width: 100%;
    height: 100px;
    object-fit: cover;
    border-radius: 6px;
    cursor: pointer;
    transition: transform .2s ease, opacity .3s ease;
  }
  .toot-media-item img:hover {
    transform: scale(1.05);
    opacity: .9;
  }

  /* 回复样式 */
  .toot-replies {
    margin-top: .75rem;
    border-left: 2px solid var(--border);
    padding-left: .75rem;
  }
  .toot-reply {
    display: flex;
    align-items: flex-start;
    gap: .5rem;
    margin-top: .5rem;
  }
  .toot-reply-avatar {
    width: 28px;
    height: 28px;
    border-radius: 50%;
  }
  .toot-reply-body {
    font-size: .9rem;
    line-height: 1.4;
  }
  .no-reply {
    color: var(--secondary);
    font-size: .85rem;
  }

  /* 加载更多按钮 */
  .load-more-wrapper {
    text-align: center;
    margin: 2rem 0;
  }
  #load-more-btn {
    padding: .6rem 1.5rem;
    border: none;
    border-radius: 9999px;
    background: linear-gradient(90deg, #1e90ff, #0066cc);
    color: #fff;
    font-size: 1rem;
    cursor: pointer;
    box-shadow: 0 3px 10px rgba(0,0,0,.1);
    transition: all .3s ease;
  }
  #load-more-btn:hover {
    transform: translateY(-2px);
    box-shadow: 0 5px 15px rgba(0,0,0,.2);
  }

  /* 灯箱样式 */
  #lightbox {
    position: fixed;
    inset: 0;
    background: rgba(0,0,0,0.8);
    display: none;
    justify-content: center;
    align-items: center;
    z-index: 9999;
  }
  #lightbox.show {
    display: flex;
  }
  #lightbox img {
    max-width: 90%;
    max-height: 85%;
    border-radius: 8px;
    box-shadow: 0 0 15px rgba(0,0,0,.5);
  }
  #lightbox-close {
    position: absolute;
    top: 20px;
    right: 30px;
    font-size: 2rem;
    color: #fff;
    cursor: pointer;
  }

  @media (prefers-color-scheme: dark) {
    .toot-card {
      background: #1c1c1c;
    }
  }
  </style>
</main>
{{ end }}

生成页面

运行:

1
hugo server

访问: 👉 http://localhost:1313/toots/ 即可看到自动加载的嘟文列表。 部署后访问 https://你的博客域名/toots/ 就能看到相同效果。 我是把CSS和模板写到了一起,有兴趣的可以把CSS单独弄一个文件,还会更简练。