部署 Memos 网络上已经有很多教程了,本文就不在此赘述了,直接说正题。

利用 Cloudflare Worker 制作 API 接口

Memos 的 API 有个问题,那就是几乎所有 API 都需要传入 token,但是 Memos 并没有精细化的 token 管理,例如限定 token 的权限等,所以直接在 js 中写入 token 是极度不安全的,通过 token,其他人可以随意修改你的 Memos,无论内容还是账户还是资源。因此我们需要一个只向外提供部分 API 的包装。

这里我选择的是赛博大善人 CloudflareWorker 作为 API 的包装,免费 Worker 足以应对博客的小流量了。我在此提供一个 Worker 实现,只向外暴露了获取公开 Memos 与按 uid 获取 Memo 的接口。该 Worker 部署时需要在 Worker 的设置中添加一个机密,键为 TOKEN,值为 Memos 设置页面的 token 字符串。

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
const API_BASE_URL = "https://your.memos.domain";
const BANNED_MEMO_KEY_LIST = ["keys", "you", "don't", "want", "to", "expose"];
const BANNED_RESOURCE_KEY_LIST = ["keys", "you", "don't", "want", "to", "expose"];
const EXTERNAL_LINK = {
  base: "https://bucket-name.cloudflare-id.r2.cloudflarestorage.com/",
  replace: "https://your.bucket.domain/",
};

export default {
  async fetch(request, env, ctx) {
    function formatMemo(memo) {
      BANNED_MEMO_KEY_LIST.map((key) => delete memo[key]);
      if (memo.resources) memo.resources = memo.resources.map(formatResource);
      return memo;
    }

    function formatResource(resource) {
      BANNED_RESOURCE_KEY_LIST.map((key) => delete resource[key]);
      if (resource.externalLink)
        resource.externalLink = resource.externalLink
          .split("?")[0]
          .replace(EXTERNAL_LINK.base, EXTERNAL_LINK.replace);
      return resource;
    }

    async function getApi(url) {
      const response = await fetch(url, {
        method: "GET",
        headers: {
          Authorization: `Bearer ${env.TOKEN}`,
          "Content-Type": "application/json",
        },
      });
      if (!response.ok) {
        console.error(`Error fetching data: ${response.statusText}`);
        return null;
      }
      return response.json();
    }

    async function getMemos(searchParams) {
      const num = searchParams.get("pageSize") || 15;
      const pageToken = searchParams.get("pageToken");
      const url = `${API_BASE_URL}/api/v1/memos?pageSize=${num}&${
        pageToken ? `pageToken=${pageToken}&` : ""
      }filter=visibilities==['PUBLIC']`;
      return getApi(url);
    }

    async function getMemosByUid(uid) {
      const url = `${API_BASE_URL}/api/v1/memos:by-uid/${uid}`;
      return getApi(url);
    }

    let responseData = {};
    let status = 200;
    if (request.method !== "GET") {
      status = 501;
      responseData = {
        code: 12,
        message: "Method Not Allowed",
        details: [],
      };
    }

    const apiRoute = request.url.split("?")[0].split("/").slice(4);
    const urlParams = new URLSearchParams(request.url.split("?")[1] || "");
    switch (apiRoute[0]) {
      case "":
        responseData = await getMemos(urlParams);
        if (responseData) responseData.memos = responseData.memos.map((m) => formatMemo(m));
        break;
      case "by-uid":
        responseData = await getMemosByUid(apiRoute[1]);
        if (responseData) responseData = formatMemo(responseData);
        break;
      default:
        if (!isNaN(parseInt(apiRoute[0]))) {
          status = 400;
          responseData = {
            code: 5,
            message: "get memo by id is not supported",
            details: [],
          };
        } else {
          status = 404;
          responseData = {
            code: 5,
            message: "interface not found",
            details: [],
          };
        }
    }
    const jsonResponse = new Response(JSON.stringify(responseData), {
      status: status,
      headers: {
        "Content-Type": "application/json",
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Methods": "GET",
        "Access-Control-Allow-Headers": "Content-Type, Authorization",
      },
    });
    return jsonResponse;
  },
};

在 Hexo 中接入 Memos

这里我主要针对的是我正在使用的 Solitude 主题进行,主要参考的是这篇文章:

根据 0.22 的 API 变化,做了一些简单的修改,同时修了一下主题版本变化导致的 Bug,修改如下:

solitude/_config.yml
1
2
3
4
5
6
7
says:
  enable: false
  mode: local # local | memos
  api: # 填部署的 Cloudflare api 地址
  home_mini: false
  page: /essay/
  style: 1
solitude/scripts/event/merge_config.js
1
2
3
4
5
6
7
8
says: {
  enable: false,
  mode: "local", 
  api: null, 
  home_mini: false,
  style: 1,
  strip: 30
},
solitude/scripts/filter/checkThemeConfig.js
1
2
3
4
5
6
7
8
9
10
hexo.extend.filter.register('before_post_render', () => {
  const data = hexo.locals.get('data');
  const logger = hexo.log;
  const theme = hexo.theme.config;
  if (theme.says.enable && !data.essay) { 
  if (theme.says.enable && theme.says.mode === 'local' && !data.essay) { 
    logger.error('\n 启用即刻短文的情况下,请新建 essay.yaml。');
    logger.error('\n If says is enabled, essay data must be supplied! \n Please create essay.yaml.');
    process.exit(-1);
  }
solitude/layout/includes/widgets/home/bbTimeList.pug
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
.bbTimeList.container#bbTimeList(class=is_home_first_page() ? '' : 'more-page')
    i.bber-logo.solitude.fa-solid.fa-newspaper(onclick=`pjax.loadUrl('${url_for(theme.says.page)}')`)
    .swiper-container.swiper-no-swiping.swiper-container-initialized.swiper-container-vertical.swiper-container-pointer-events#bbtalk(tabindex="-1" onclick=`pjax.loadUrl('${url_for(theme.says.page)}')`)
        .swiper-wrapper#bber-talk
            if theme.says.mode === 'local'
                each item, i in site.data.essay.essay_list.slice(0, 10)
                    .li-style.swiper-slide
                        | #{item.content}
                        if item.image
                            i.solitude.fa-solid.fa-image
                        else if item.aplayer
                            i.solitude.fa-solid.fa-disc
                        else if item.video || item.bilibili
                            i.solitude.fa-solid.fa-video
                        else if item.link
                            i.solitude.fa-solid.fa-link
            else
                .li-style.bber-loading(style="text-align: center") Loading...
                script.
                    (async function () {
                        const response = await fetch('!{url_for(theme.says.api)}');
                        const data = await response.json();
                        const list = data.memos.slice(0, 10).map(item => {
                            let type = item.image ? '【图片】' : item.aplayer ? '【音乐】' : item.video ? '【视频】' : '';
                            return `<div class="li-style swiper-slide">${item.content + type}</div>`
                        });
                        document.querySelector('#bber-talk').innerHTML = list.join(' ');
                    })()
    i.bber-gotobb.solitude.fa-solid.fa-circle-chevron-right(title=_p('home.bbtime.text') onclick=`pjax.loadUrl('${url_for(theme.says.page)}')`)
solitude/layout/includes/page/says.pug
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
include ../widgets/page/banner
if theme.says.enable
    #bber
        section.timeline.page-1
            if theme.says.mode === 'local'
                ul.list.waterfall
                    // 原封不动的代码太多,省略了
                #bber-tips
                    if theme.says.strip === -1
                        = _p('essay.tip0')
                    else
                        = _p('essay.tip1').replace('{count}', theme.says.strip)
            else
                // 下面全是新加的,我就不标记了
                ul.list.waterfall
                script.
                    (async function () {
                        const strip = !{ theme.says.strip };
                        let url = `!{url_for(theme.says.api)}?pageSize=${strip === -1 ? 65535 : strip}`;
                        const baseUrl = url.substring(0, url.indexOf("/", url.indexOf("//") + 2));
                        const response = await fetch(url);
                        const data = await response.json();
                        let items = [],
                            topitem = [],
                            essayTips = "";

                        await Promise.all(
                            data.memos.map(async (item) => {
                                const formatdata = await essayFormat(item, baseUrl);
                                if (!formatdata) return null;
                                if (item.content.includes("#top")) {
                                    topitem.push(formatdata);
                                } else {
                                    items.push(formatdata);
                                }
                                return formatdata;
                            })
                        );
                        essayTips =
                            strip === -1 || strip >= items.length
                                ? `<div id="bber-tips">- 已展开所有短文 -</div>`
                                : ((items = items.slice(0, strip)),
                                    `<div id="bber-tips">- 只展示最近 ${strip} 条短文 -</div>`);
                        document.getElementsByClassName("list")[0].innerHTML = topitem
                            .concat(items)
                            .filter((item) => item !== null)
                            .join("");
                        document.querySelector("#bber").insertAdjacentHTML("beforeend", essayTips);
                        (function wait() {
                            setTimeout(() => {
                                if (sco && utils) {
                                    sco.changeTimeFormat(document.querySelectorAll('time'));
                                    utils.lightbox(document.querySelectorAll('img'))
                                }
                                else
                                    wait()
                            }, 500);
                        })()
                    })()
                case theme.says.style
                    when 1
                        script.
                            async function essayFormat(item, baseUrl) {
                                const contentRegex = /#(.*?)\s|\n/g,
                                    imageRegex = /\!\[(.*?)\]\((.*?)\)/g,
                                    playerRegex = /{\s*player\s*(.*)\s*}/g,
                                    linkRegex = /(?<!\!)\[(.*?)\]\((.*?)\)/g,
                                    topRegex = /#top/g,
                                    fromRegex = /(?<![\w\/])(?<!\{)\{([^{}\s]+)\}(?!\})(?![\w\/])/g;
                                let time = item.displayTime
                                    content = item.content,
                                    image = "",
                                    img = content.match(imageRegex);
                                (aplayer = content.match(/{\s*music\s*(.*?)\s*(.*?)\s*}/g)),
                                    (video = content.match(playerRegex)),
                                    (link = content.match(linkRegex)),
                                    (type = ""),
                                    (from = content.match(fromRegex));

                                if (item.resources.length) {
                                    if (!img) img = [];
                                    item.resources.forEach((e) => {
                                        if (e.externalLink) img.push(`![${e.filename}](${e.externalLink})`);
                                        else img.push(`![${e.filename}](${baseUrl}/o/r/${e.uid})`);
                                    });
                                }
                                if (img)
                                    image += img.map(
                                        (e) => `<img src="${e.replace(imageRegex, "$2")}" alt="${e.replace(imageRegex, "$1")}" />`
                                    ).join("");
                                aplayer = aplayer
                                    ? `<div class="bber-music"><meting-js server="${
                                        aplayer[0].match(/\{\s*music\s*(.*?)\s*\d+\s*\}/)[1]
                                    }" type="song" id="${
                                        aplayer[0].match(/\d+/)[0]
                                    }" mutex="true" preload="none" theme="var(--efu-main)" data-lrctype="0"></meting-js></div>`
                                    : "";
                                video = video
                                    ? `<div class="bber-video"><video src="${
                                        video[0].replace(playerRegex, "$1").trim()
                                    }" controls="controls" style="object-fit: cover;"></video></div>`
                                    : content.match(/{\s*bilibili\s*(.*?)\s*}/g);
                                video = Array.isArray(video)
                                    ? `<div class="bber-video"><iframe src="//player.bilibili.com/player.html?bvid=${
                                        video[0].match(/(BV\w+)/)[1]
                                    }${
                                        video[0].match(/{\s*bilibili\s*(.*?)\s*true\s*}/g) ? "&autoplay=1" : "&autoplay=0"
                                    }" scrolling="no" bozrder="0" frameborder="no" framespacing="0" allowfullscreen="true"></iframe></div>`
                                    : "";
                                link = link
                                    ? ((type = link[0].replace(linkRegex, "$2")),
                                    `<a class="bber-content-link" href='${
                                        type.startsWith("/") ? type : type.startsWith("http") ? type : "https://" + type
                                    }' title="${
                                        link[0].replace(linkRegex, "$1") ? link[0].replace(linkRegex, "$1") : "跳转到短文指引的链接"
                                    }" target="_blank"><i class="solitude fa-solid fa-link"></i>链接</a>`)
                                    : "";
                                from = from
                                    ? `<div class="bber-info-from"><span>${from[0].replace(fromRegex, "$1")}</span></div>`
                                    : "";
                                content = content
                                    .replace(contentRegex, "")
                                    .replace(imageRegex, "")
                                    .replace(/\{(.*?)\}/g, "")
                                    .replace(linkRegex, "")
                                    .trim();
                                return `<li class="item">
                                    <div id="bber-content">
                                        ${content ? `<p class="bber-content">${content}</p>` : ""}
                                        ${image ? `<div class="bber-content-img">${image}</div>` : ""}
                                    </div>
                                    ${aplayer}
                                    ${video}
                                    <hr>
                                    <div class="bber-bottom">
                                        <div class="bber-info">
                                            <div class="bber-info-time">
                                                <i class="solitude fa-solid fa-calendar-day"></i>
                                                <time class="datatime" datetime="${time}"></time>
                                            </div>
                                            ${link}
                                            ${from}
                                            ${
                                                item.content.includes("#top")
                                                ? `<div class="bber-info-top"><i class="solitude fa-solid fa-thumbtack"></i>置顶</div>`
                                                : ""
                                            }
                                        </div>
                                        ${
                                            content
                                            ? `<a class="bber-reply" onclick="sco.toTalk('${content}')"><i class="solitude fa-solid fa-comment" style="font-size: 1rem;"></i></a>`
                                            : ""
                                        }
                                    </div>
                                </li>`;
                            }
                    when 2
                        script.
                            async function essayFormat(item, baseUrl) {
                                const contentRegex = /#(.*?)\s|\n/g,
                                    imageRegex = /\!\[(.*?)\]\((.*?)\)/g,
                                    playerRegex = /{\s*player\s*(.*)\s*}/g,
                                    linkRegex = /(?<!\!)\[(.*?)\]\((.*?)\)/g,
                                    topRegex = /#top/g,
                                    fromRegex = /(?<![\w\/])(?<!\{)\{([^{}\s]+)\}(?!\})(?![\w\/])/g;
                                let time = item.displayTime,
                                    content = item.content,
                                    image = "",
                                    img = content.match(imageRegex);
                                (aplayer = content.match(/{\s*music\s*(.*?)\s*(.*?)\s*}/g)),
                                    (video = content.match(playerRegex)),
                                    (link = content.match(linkRegex)),
                                    (type = ""),
                                    (from = content.match(fromRegex));
                                if (item.resources.length) {
                                    if (!img) img = [];
                                    item.resources.forEach((e) => {
                                        if (e.externalLink) img.push(`![${e.filename}](${e.externalLink})`);
                                        else img.push(`![${e.filename}](${baseUrl}/o/r/${e.uid})`);
                                    });
                                }
                                if (img)
                                    image += img
                                    .map(
                                        (e) => `<img src="${e.replace(imageRegex, "$2")}" alt="${e.replace(imageRegex, "$1")}" />`
                                    )
                                    .join("");
                                aplayer = aplayer
                                    ? `<div class="bber-music"><meting-js server="${
                                        aplayer[0].match(/\{\s*music\s*(.*?)\s*\d+\s*\}/)[1]
                                    }" type="song" id="${
                                        aplayer[0].match(/\d+/)[0]
                                    }" mutex="true" preload="none" theme="var(--efu-main)" data-lrctype="0"></meting-js></div>`
                                    : "";
                                video = video
                                    ? `<div class="bber-video"><video src="${video[0]
                                        .replace(playerRegex, "$1")
                                        .trim()}" controls="controls" style="object-fit: cover;"></video></div>`
                                    : content.match(/{\s*bilibili\s*(.*?)\s*}/g);
                                video = Array.isArray(video)
                                    ? `<div class="bber-video"><iframe src="//player.bilibili.com/player.html?bvid=${
                                        video[0].match(/(BV\w+)/)[1]
                                    }${
                                        video[0].match(/{\s*bilibili\s*(.*?)\s*true\s*}/g) ? "&autoplay=1" : "&autoplay=0"
                                    }" scrolling="no" bozrder="0" frameborder="no" framespacing="0" allowfullscreen="true"></iframe></div>`
                                    : "";
                                link = link
                                    ? ((type = link[0].replace(linkRegex, "$2")),
                                    `<a class="bber-content-link" href='${
                                        type.startsWith("/") ? type : type.startsWith("http") ? type : "https://" + type
                                    }' target="_blank">${
                                        link[0].replace(linkRegex, "$1")
                                        ? "@" + link[0].replace(linkRegex, "$1")
                                        : "跳转到短文指引的链接"
                                    }</a>`)
                                    : "";
                                from = from
                                    ? `<div class="bber-info-from"><i class="solitude fa-solid fa-hashtag"></i>${
                                    from[0].replace(fromRegex, "$1")
                                    }</div>`
                                    : "";
                                content = content
                                    .replace(contentRegex, "")
                                    .replace(imageRegex, "")
                                    .replace(/\{(.*?)\}/g, "")
                                    .replace(linkRegex, "")
                                    .trim();
                                return `<li class="item">
                                    <div class="meta">
                                        <img class="no-lightbox nolazyload avatar" src="!{theme.aside.card.author.img}">
                                        <div class="info">
                                            <span class="bber_nick">#{config.author}</span>
                                            <time class="datetime bber_date" datetime="${time}"></time>
                                        </div>
                                        ${
                                        content
                                            ? `<a class="bber-reply" onclick="sco.toTalk('${content}')"><i class="solitude fa-solid fa-comment" style="font-size: 1rem;"></i></a>`
                                            : ""
                                        }
                                    </div>
                                    <div id="bber-content">
                                        ${content ? `<p class="bber-content"><span>${content}</span>${link}</p>` : ""}
                                        ${image ? `<div class="bber-content-img">${image}</div>` : ""}
                                    </div>
                                    ${aplayer}
                                    ${video}
                                    <div class="bber-bottom">
                                        <div class="bber-info">
                                            ${
                                            item.content.includes("#top")
                                                ? `<div class="bber-info-top"><i class="solitude fa-solid fa-thumbtack"></i>置顶</div>`
                                                : ""
                                            }
                                            ${from}
                                        </div>
                                    </div>
                                </li>`;
                            }

主要的东西就是 solitude/layout/includes/page/says.pug 里的内嵌脚本,因为有两种风格所以只能写在 pug 里,非 Solitude 主题基本只需要改一下最后生成的 HTML 片段即可。