在 Hexo 博客中接入新版 Memos
部署 Memos 网络上已经有很多教程了,本文就不在此赘述了,直接说正题。
利用 Cloudflare Worker 制作 API 接口
Memos 的 API 有个问题,那就是几乎所有 API 都需要传入 token,但是 Memos 并没有精细化的 token 管理,例如限定 token 的权限等,所以直接在 js 中写入 token 是极度不安全的,通过 token,其他人可以随意修改你的 Memos,无论内容还是账户还是资源。因此我们需要一个只向外提供部分 API 的包装。
这里我选择的是赛博大善人 Cloudflare 的 Worker 作为 API 的包装,免费 Worker 足以应对博客的小流量了。我在此提供一个 Worker 实现,只向外暴露了获取公开 Memos 与按 uid 获取 Memo 的接口。该 Worker 部署时需要在 Worker 的设置中添加一个机密,键为 TOKEN
,值为 Memos 设置页面的 token 字符串。
1 | 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 主题进行,主要参考的是这篇文章:
引用站外链接
Solitude魔改教程:动态即刻短文
亦小封
根据 0.22 的 API 变化,做了一些简单的修改,同时修了一下主题版本变化导致的 Bug,修改如下:
1 | says: enable: false mode: local # local | memos api: # 填部署的 Cloudflare api 地址 home_mini: false page: /essay/ style: 1 |
1 | says: { enable: false, mode: "local", api: null, home_mini: false, style: 1, strip: 30 }, |
1 | 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); } |
1 | .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)}')`) |
1 | 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 片段即可。
本文是原创文章,采用CC BY-NC-SA 4.0协议,完整转载请注明来自璜珀的小屋
评论 ()