我是基于Kimi moonshot-v1-8k实现的AI助手,在此博客上负责整理和概括文章
本文详细记录了shokaX主题的开发过程,重点介绍了AI总结配置接口的改进,解决了长文章处理和接口兼容性问题。通过提取与预处理、缓存管理、长文处理等步骤,提高了摘要生成的效率和准确性。具体包括读取Markdown原文、替换变量、解析成纯文本、利用本地JSON文件存储摘要和文本哈希、避免重复调用接口、对超过一定字符数的内容采用分块策略生成摘要等。此外,还讨论了自定义页面标题、随机主题图片、优化搜索栏和fancybox效果等方法,并通过修改配置文件和脚本实现页面标题自定义,更新首页和文章图片源,调整随机文章和评论显示数量等。还增加了文章加密插件,确保文章能够成功加密。这些开发记录和技巧有助于提升网站功能和用户体验,为读者提供了一套完整的shokaX主题开发指南。
编辑记录
2025-01-15 第一次编辑
- 整理笔记
2025-02-27 第二次编辑
- 优化:搜索栏、字体、fancybox
2025-02-28 第三次编辑
- 优化:代码块和 tab 冲突问题
2025-03-01 第四次编辑
- 增加文章加密插件
2025-03-02 第五次编辑
- 增加 AI 总结的功能
2025-03-04 第六次编辑
- 优化 AI 总结
# 安装过程
主要参考官方的文档,安装通过如下命令:
hexo init #初始化 hexo | |
cd your_blog #转到博客目录 | |
npm install shokax-cli --location=global #全局安装 | |
SXC install shokaX #安装相关包和依赖 |
接下来安装文档进行简单的初始配置。
对于 Hexo>=7.0.0
,需要停用停用默认代码高亮,
syntax_highlighter: false | |
highlight: | |
enable: false | |
line_number: true | |
auto_detect: false | |
tab_replace: '' | |
wrap: true | |
hljs: false | |
prismjs: | |
enable: false | |
preprocess: true | |
line_number: true | |
tab_replace: '' |
注意在 _config.yml
中, theme: shokaX
,这里 X 要与 _config.shokaX.yml
中的一致即大写,否则 hexo deploy
部署后有问题主题渲染不出来。
# 自动部署
采用 Github Pages
进行自动部署,本个博客连接到之前的 Github 已有的存放博客代码的仓库中,作为新的分支 shoka,与之前的博客共同管理。
cd /path/to/原博客源码 | |
git checkout main | |
git branch shoka | |
git checkout shoka | |
git commit --allow-empty -m "Initialize empty shoka branch" | |
git push origin shoka --force | |
git checkout main | |
cd /path/to/新博客源码 | |
git init | |
git remote add origin <远程仓库URL> | |
git checkout shoka | |
git add . | |
git commit -m "Initial commit for shoka blog" | |
git push --set-upstream origin shoka | |
git push origin shoka --force |
自动部署,同时管理双博客的部署任务:
- 如果为 push 触发,根据 push 的是哪个分支,设置哪个标志位为
true
,另一个则为false
。 - 如果为 schedule 触发,则标志位都设置为
true
,顺序进行部署。 - 如果为手动触发,则可以选择部署哪一个或者同时部署。
每个分支的部署按照同一套流程顺序执行,根据标志位判断是否执行。
name: 自动任务 | |
on: | |
push: | |
branches: | |
- master | |
- shoka | |
release: | |
types: | |
- published | |
workflow_dispatch: | |
inputs: | |
branch: | |
description: "选择分支" | |
required: true | |
default: "shoka" | |
options: | |
- master | |
- shoka | |
- all | |
schedule: | |
- cron: "0 16 * * *" | |
jobs: | |
deploy: | |
runs-on: ubuntu-latest | |
steps: | |
- name: 确定分支和标志位 | |
id: setup_flags | |
run: | | |
if [[ "$" == "workflow_dispatch" ]]; then | |
SELECTED_BRANCH="$" | |
elif [[ "$" == "schedule" ]]; then | |
SELECTED_BRANCH="all" | |
else | |
SELECTED_BRANCH="$" | |
fi | |
if [[ "$SELECTED_BRANCH" == "all" || "$SELECTED_BRANCH" == "master" ]]; then | |
FLAG_MASTER=true | |
else | |
FLAG_MASTER=false | |
fi | |
if [[ "$SELECTED_BRANCH" == "all" || "$SELECTED_BRANCH" == "shoka" ]]; then | |
FLAG_SHOKA=true | |
else | |
FLAG_SHOKA=false | |
fi | |
echo "SELECTED_BRANCH=$SELECTED_BRANCH" >> $GITHUB_ENV | |
echo "FLAG_MASTER=$FLAG_MASTER" >> $GITHUB_ENV | |
echo "FLAG_SHOKA=$FLAG_SHOKA" >> $GITHUB_ENV | |
- name: 安装 Node | |
uses: actions/setup-node@v1 | |
with: | |
node-version: "20.x" | |
- name: 设置时区 | |
run: echo "TZ='Asia/Shanghai'" >> $GITHUB_ENV | |
- name: 配置 Git 用户信息 | |
run: | | |
git config --global user.name "$" | |
git config --global user.email "$" | |
# 执行 Master 分支任务 | |
- name: 克隆 Master 分支 | |
if: env.FLAG_MASTER == 'true' | |
uses: actions/checkout@v2 | |
with: | |
ref: master | |
- name: 清除旧缓存(Master) | |
if: env.FLAG_MASTER == 'true' | |
run: | | |
echo "Clearing old cache for Master branch..." | |
rm -rf node_modules | |
- name: 缓存 Hexo(Master) | |
if: env.FLAG_MASTER == 'true' | |
uses: actions/cache@v1 | |
id: cache_master | |
with: | |
path: node_modules | |
key: $<!--swig6-->-master-$<!--swig7--> | |
- name: 安装依赖(Master) | |
if: env.FLAG_MASTER == 'true' | |
run: npm install --save | |
- name: 执行 Master 分支任务 | |
if: env.FLAG_MASTER == 'true' | |
run: | | |
echo "Running tasks for Master branch..." | |
node fix_kramed.js | |
- name: 安装 Hexo(Master) | |
if: env.FLAG_MASTER == 'true' | |
run: | | |
export TZ='Asia/Shanghai' | |
npm install hexo-cli -g | |
- name: 生成静态文件(Master) | |
if: env.FLAG_MASTER == 'true' | |
run: | | |
export TZ='Asia/Shanghai' | |
hexo clean | |
hexo generate | |
gulp | |
- name: 设置 SSH 密钥(Master) | |
if: env.FLAG_MASTER == 'true' | |
uses: webfactory/ssh-agent@v0.5.3 | |
with: | |
ssh-private-key: $<!--swig8--> | |
- name: 部署(Master) | |
if: env.FLAG_MASTER == 'true' | |
run: hexo deploy | |
# 执行 Shoka 分支任务 | |
- name: 克隆 Shoka 分支 | |
if: env.FLAG_SHOKA == 'true' | |
uses: actions/checkout@v2 | |
with: | |
ref: shoka | |
- name: 清除旧缓存(Shoka) | |
if: env.FLAG_SHOKA == 'true' | |
run: | | |
echo "Clearing old cache for Shoka branch..." | |
rm -rf node_modules | |
- name: 缓存 Hexo(Shoka) | |
if: env.FLAG_SHOKA == 'true' | |
uses: actions/cache@v1 | |
id: cache_shoka | |
with: | |
path: node_modules | |
key: $<!--swig9-->-shoka-$<!--swig10--> | |
- name: 安装依赖(Shoka) | |
if: env.FLAG_SHOKA == 'true' | |
run: npm install --save | |
- name: 执行 Shoka 分支任务 | |
if: env.FLAG_SHOKA == 'true' | |
run: | | |
echo "Running tasks for Shoka branch..." | |
node updateYaml.js | |
echo "Contents of _images.yml:" | |
cat ./themes/shokaX/_images.yml | |
echo "Contents of _images_index.yml:" | |
cat ./themes/shokaX/_images_index.yml | |
ls -l source/_posts/*/cover.jpg || echo "No cover images found." | |
- name: 安装 Hexo(Shoka) | |
if: env.FLAG_SHOKA == 'true' | |
run: | | |
export TZ='Asia/Shanghai' | |
npm install hexo-cli -g | |
- name: 生成静态文件(Shoka) | |
if: env.FLAG_SHOKA == 'true' | |
run: | | |
export TZ='Asia/Shanghai' | |
hexo clean | |
hexo generate | |
hexo algolia | |
- name: 设置 SSH 密钥(Shoka) | |
if: env.FLAG_SHOKA == 'true' | |
uses: webfactory/ssh-agent@v0.5.3 | |
with: | |
ssh-private-key: $<!--swig11--> | |
- name: 部署(Shoka) | |
if: env.FLAG_SHOKA == 'true' | |
run: hexo deploy |
# 统计网站运行时间
创建文件: themes\shokaX\source\js\create-time.js
:
function createtime() { | |
const createTime = new Date("2025/01/02 00:00:00"); // 替换为您的站点创建时间 | |
const now = new Date(); | |
const diff = now - createTime; | |
const days = Math.floor(diff / (1000 * 60 * 60 * 24)); | |
const hours = Math.floor((diff / (1000 * 60 * 60)) % 24); | |
const minutes = Math.floor((diff / (1000 * 60)) % 60); | |
const seconds = Math.floor((diff / 1000) % 60); | |
document.getElementById("time").innerHTML = | |
`此站已存活 ${days} 天 ${hours} 小时 ${minutes} 分 ${seconds} 秒`; | |
} | |
setInterval(createtime, 1000); |
创建文件: themes\shokaX\layout\_partials\create-time.pug
:
div(style="width: 100%; text-align: center;") | |
span(id="time") 此站已存活 0 天 0 小时 0 分 0 秒 | |
script(src="/miyano/js/create-time.js" defer) |
这里由于我的博客的 Github Pages
部署在仓库 miyano
中,所以默认的地址一般有多个 /miyano
。需要加上否则找不到文件路径。
最后在 themes\shokaX\layout\_partials\footer.pug
中第一行新增代码 include create-time.pug
即可引入站点存活时间。
# 实现相册功能
# 相册主页面配置
- 创建
source\_data\gallery.yml
用于存放相册数据,方便配置。格式为:
gallery_groups: | |
- name: KON | |
descr: KON一生推,毕业不是终点,今后也都是朋友。 | |
url: /miyano/gallery/kon/ | |
img: "https://cloudflare-imgbed-9os.pages.dev/file/kon_16.webp" | |
photos: | |
- "https://cloudflare-imgbed-9os.pages.dev/file/kon_16.webp" | |
- name: Summer Pockets | |
descr: 还记得那个夏天吗?那份炫目,别忘了口袋里的夏天。 | |
url: /miyano/gallery/sp/ | |
img: "https://cloudflare-imgbed-9os.pages.dev/file/sp_3.webp" | |
photos: | |
- "https://cloudflare-imgbed-9os.pages.dev/file/sp_4.webp" |
注意这里配置了 id
为 gallery_groups
。
- 创建
themes\shokaX\layout\gallery.pug
生成一个相册页面,展示多个相册组。
extends ./_partials/layout.pug | |
block content | |
div#gallery-container | |
//- h1.page-title 相册 | |
div.gallery-group-main | |
each group in page.posts | |
a(href=group.url target="_blank") | |
figure.gallery-group | |
img.gallery-group-img(src=group.img alt=group.name) | |
figcaption | |
div.gallery-group-name= group.name | |
p= group.descr | |
include _partials/pagination.pug |
使用 each
循环遍历 page.posts
中的每个相册组,并为每个相册组生成一个链接和相应的内容; a(href=group.url target="_blank")
:为每个相册组生成一个链接,链接地址为 group.url
,并在新标签页中打开;定义一个 figcaption
元素,用于包含相册组的标题和描述;包含 _partials/pagination.pug
文件,用于添加分页功能。
- 创建
themes\shokaX\scripts\inject-gallery.js
用于在生成静态网站时注入相册数据。将相册组数据注入到当前页面的本地变量locals.page.gallery_groups
中,从而实现了在相册页面中动态加载相册数据的功能。
const path = require('path'); | |
const fs = require('fs'); | |
const yaml = require('js-yaml'); | |
hexo.extend.filter.register('template_locals', function (locals) { | |
if (locals.page.layout === 'gallery') { | |
const galleryDataPath = path.join(hexo.source_dir, '_data/gallery.yml'); | |
try { | |
const data = yaml.load(fs.readFileSync(galleryDataPath, 'utf8')); | |
if (data && data.gallery_groups) { | |
locals.page.gallery_groups = data.gallery_groups; | |
} else { | |
locals.page.gallery_groups = []; | |
} | |
} catch (e) { | |
console.error('Error loading gallery.yml:', e); | |
locals.page.gallery_groups = []; | |
} | |
} | |
}); |
需要在 gallery
的 index.md
配置好 layout: gallery
- 创建
themes\shokaX\source\css\_common\components\tags\gallery.styl
用于设置相册页面的排列样式,效果是实现网格布局,每行最多显示三个相册,每个相册占据相等的宽度,鼠标悬停时有过渡效果显示相册描述。并对移动端单独适配,对于移动端一行只显示一个相册。该文件在themes\shokaX\source\css\_common\components\components.styl
中引入:@import 'tags/gallery';
/* 限制 Gallery 样式作用域 */ | |
.gallery-group-main | |
display grid | |
grid-template-columns repeat(3, 1fr) // 每行最多三个相册 | |
gap 20px // 调整相册间距,确保布局更美观 | |
justify-items center // 确保相册内容在单元格内居中 | |
padding 20px | |
/* 移动端适配 */ | |
@media (max-width: 768px) // 当屏幕宽度小于 768px 时 | |
grid-template-columns 1fr // 每行一个相册,按列排列 | |
gap 15px // 调整相册间距,适应窄屏布局 | |
padding 10px | |
/* 针对 Gallery 内的 Figure */ | |
.gallery-group-main figure.gallery-group | |
position relative | |
width 100% // 使相册占满单元格宽度 | |
max-width 300px // 设置最大宽度,避免相册过大 | |
aspect-ratio 1 / 1 // 确保相册为正方形 | |
border-radius 8px | |
background #000000 | |
overflow hidden | |
transition transform 0.3s, box-shadow 0.3s | |
box-shadow 0 4px 6px rgba(0, 0, 0, 0.1) | |
cursor pointer | |
&:hover | |
transform translateY(-5px) | |
box-shadow 0 8px 12px rgba(0, 0, 0, 0.2) | |
img | |
opacity 0.4 | |
figcaption | |
opacity 1 | |
/* Gallery 内的 Image */ | |
.gallery-group-main img.gallery-group-img | |
width 100% | |
height 100% | |
object-fit cover | |
transition opacity 0.3s ease | |
/* Gallery 的 Caption 容器 */ | |
.gallery-group-main figcaption | |
position absolute | |
bottom 0 | |
left 0 | |
width 100% | |
padding 15px | |
background rgba(0, 0, 0, 0.6) | |
color #fff | |
opacity 0 | |
transition opacity 0.3s ease-in-out | |
.gallery-group-name | |
margin 0 | |
font-size 1.2em | |
line-height 1.4 | |
font-weight bold | |
text-shadow 0 2px 4px rgba(0, 0, 0, 0.5) | |
.gallery-group-descr | |
margin 10px 0 0 | |
font-size 0.9em | |
line-height 1.4 | |
text-shadow 0 2px 4px rgba(0, 0, 0, 0.5) |
- 调整
themes\shokaX\source\css\_common\outline\sidebar\sidebar.styl
对相册页面的侧边栏进行调整:取消侧边栏,位置由主页面占据。
大约在原文件 40-44 新增:
// 新增:gallery 页面隐藏 sidebar | |
body.gallery #sidebar { | |
display: none; | |
} |
在原文件 74-89 新增:
// 新增:gallery 页面 pjax 全宽 | |
body.gallery .pjax { | |
width: 100%; | |
max-width: 100%; | |
flex: 1; | |
} | |
// 如果需要支持 flex 布局的全宽调整 | |
.inner { | |
//gallery 页面全宽覆盖逻辑 | |
body.gallery & > .pjax { | |
flex: 1; | |
max-width: 100%; | |
} | |
} |
- 第 4 点需要指明相册页面的 body 的类
class
为gallery
才能生效,且为了防止 PJAX 导致的其他页面同步配置该效果,需要在themes\shokaX\source\js\_app\pjax\siteInit.ts
中进行配置。
import domInit from './domInit'; | |
import { pjaxReload, siteRefresh } from './refresh'; | |
import { cloudflareInit } from '../components/cloudflare'; | |
import { BODY, CONFIG, pjax, setPjax, setSiteSearch, siteSearch } from '../globals/globalVars'; | |
import { autoDarkmode, themeColorListener } from '../globals/themeColor'; | |
import { resizeHandle, scrollHandle, visibilityListener } from '../globals/handles'; | |
import { pagePosition } from '../globals/tools'; | |
import Pjax from 'theme-shokax-pjax'; | |
import { initVue } from '../library/vue'; | |
import { $dom } from '../library/dom'; | |
import { createChild } from '../library/proto'; | |
import { transition } from '../library/anime'; | |
import initializeGallery from '../library/gallery'; // 替换为实际路径 | |
const updateGalleryPageLayout = () => { | |
const body = document.body; | |
const footer = document.getElementById("footer"); | |
const main = document.querySelector("main"); | |
const headerAlternate = document.querySelector("#header .artboard"); | |
const headerTitle = document.querySelector("#header .title"); | |
const HeaderMsgConfigPath = window.location.pathname.replace(CONFIG.root, ""); // 获取配置了 Header 参数的页面路径 | |
const HeaderMsgConfig = CONFIG.pages[HeaderMsgConfigPath+'index.html']; // 从页面元数据中获取当前页面配置 | |
//console.log ("当前页面配置:", HeaderMsgConfig); | |
const DefaultHeaderMsgConfig = CONFIG.headerMsg | |
//console.log ("默认页面配置:", DefaultHeaderMsgConfig); | |
if (window.location.pathname.includes('/gallery')) { | |
body.classList.add("gallery"); | |
// 隐藏 footer | |
if (footer) footer.style.display = "none"; | |
// 调整 main 的 margin-bottom | |
if (main) main.style.marginBottom = "0px"; | |
// 如果页面配置了自定义字段,则动态更新 header | |
if (HeaderMsgConfig) { | |
const { alternate, headerTitle: pageTitle } = HeaderMsgConfig; | |
if (headerAlternate && alternate) headerAlternate.textContent = alternate; | |
if (headerTitle && pageTitle) headerTitle.textContent = pageTitle; | |
} | |
initializeGallery(); | |
} else { | |
body.classList.remove("gallery"); | |
// 恢复 footer 的显示 | |
if (footer) footer.style.display = ""; | |
// 恢复 main 的 margin-bottom | |
if (main) main.style.marginBottom = ""; | |
// 恢复默认 header 内容 | |
if (DefaultHeaderMsgConfig) { | |
const { alternate, headerTitle: pageTitle } = DefaultHeaderMsgConfig; | |
if (headerAlternate && alternate) headerAlternate.textContent = alternate; | |
if (headerTitle && pageTitle) headerTitle.textContent = pageTitle; | |
} | |
} | |
}; | |
const updateAnimePageLayout = () => { | |
const comment = document.getElementById("comments"); | |
const main = document.querySelector("main"); | |
const footer = document.getElementById("footer"); | |
const ffooter = document.querySelector('footer'); | |
if (window.location.pathname.includes('/anime')) { | |
// 隐藏 footer | |
if (comment) comment.style.display = "none"; | |
if (footer) footer.style.display = "none"; | |
if (ffooter) ffooter.style.display = "none"; | |
// 调整 main 的 margin-bottom | |
if (main) main.style.marginBottom = "0px"; | |
} else { | |
if (!window.location.pathname.includes('/gallery')) { | |
// 恢复 footer 的显示 | |
if (comment) comment.style.display = ""; | |
if (footer) footer.style.display = ""; | |
if (ffooter) ffooter.style.display = ""; | |
// 恢复 main 的 margin-bottom | |
if (main) main.style.marginBottom = ""; | |
} | |
} | |
}; | |
const siteInit = async () => { | |
initVue(); | |
domInit(); | |
setPjax(new Pjax({ | |
selectors: [ | |
'head title', | |
'.languages', | |
'.twikoo', | |
'.pjax', | |
'.leancloud-recent-comment', | |
'script[data-config]', | |
], | |
cacheBust: false, | |
})); | |
CONFIG.quicklink.ignores = LOCAL.ignores; | |
import('quicklink').then(({ listen }) => { | |
listen(CONFIG.quicklink); | |
}); | |
autoDarkmode(); | |
if (__shokax_VL__) { | |
visibilityListener(); | |
} | |
themeColorListener(); | |
if (__shokax_search__) { | |
document.querySelector('li.item.search > i').addEventListener('click', () => { | |
if (CONFIG.search === null) { | |
return; | |
} | |
if (!siteSearch) { | |
setSiteSearch(createChild(BODY, 'div', { | |
id: 'search', | |
innerHTML: '<div class="inner"><div class="header"><span class="icon"><i class="ic i-search"></i></span><div class="search-input-container"></div><span class="close-btn"><i class="ic i-times-circle"></i></span></div><div class="results"><div class="inner"><div id="search-stats"></div><div id="search-hits"></div><div id="search-pagination"></div></div></div></div>', | |
})); | |
} | |
import('../page/search').then(({ algoliaSearch }) => { | |
algoliaSearch(pjax); | |
}); | |
$dom.each('.search', (element) => { | |
element.addEventListener('click', () => { | |
document.body.style.overflow = 'hidden'; | |
transition(siteSearch, 'shrinkIn', () => { | |
(document.querySelector('.search-input') as HTMLInputElement).focus(); | |
}); | |
}); | |
}); | |
}, { once: true, capture: true }); | |
} | |
if (__shokax_fireworks__) { | |
import('mouse-firework').then((firework) => { | |
firework.default(CONFIG.fireworks); | |
}); | |
} | |
// 初始化时更新 gallery 页面布局 | |
updateGalleryPageLayout(); | |
updateAnimePageLayout(); | |
window.addEventListener('scroll', scrollHandle, { passive: true }); | |
window.addEventListener('resize', resizeHandle, { passive: true }); | |
window.addEventListener('pjax:send', pjaxReload, { passive: true }); | |
window.addEventListener('pjax:success', () => { | |
siteRefresh(); | |
updateGalleryPageLayout(); // PJAX 切换完成后更新布局 | |
updateAnimePageLayout(); | |
}, { passive: true }); | |
window.addEventListener('beforeunload', () => { | |
pagePosition(); | |
}); | |
await siteRefresh(1); | |
}; | |
cloudflareInit(); | |
window.addEventListener('DOMContentLoaded', siteInit, { passive: true }); | |
console.log('%c Theme.ShokaX v' + CONFIG.version + ' %c https://github.com/theme-shoka-x/hexo-theme-shokaX ', 'color: white; background: #e9546b; padding:5px 0;', 'padding:4px;border:1px solid #e9546b;'); |
先只关注这里的:
if (window.location.pathname.includes('/gallery')) { | |
document.body.classList.add('gallery'); | |
} else { | |
document.body.classList.remove('gallery'); | |
} |
实现了动态切换类名。然后在 siteInit
函数中初始化时调用 updateGalleryPageLayout();
,在 PJAX 切换完成后更新: 在 window.addEventListener('pjax:success', ...)
中调用 updateGalleryPageLayout();
,确保切换页面时动态更新类名。
并且同时使相册页面不显示 footer
,使 <main>...</main>
容器可以适当向下增加空间。当处于 gallery
界面时隐藏 footer
并使 main
和底部边缘为 0
,否则恢复原样。
- 添加分页按钮
创建文件:themes\shokaX\scripts\generaters\gallery.js
,用于处理分页逻辑,前面已经在themes\shokaX\layout\gallery.pug
中引入了原来的分页布局:include _partials/pagination.pug
,并且已经有themes\shokaX\source\css\_common\scaffolding\pagination.styl
文件。不用另外配置。
const pagination = require('hexo-pagination'); | |
const path = require('path'); | |
// 主相册页面生成 | |
hexo.extend.generator.register('gallery', function (locals) { | |
// 确保读取 gallery_groups 下的数据 | |
const galleryGroups = (locals.data.gallery && locals.data.gallery.gallery_groups) || []; | |
if (!galleryGroups.length) { | |
hexo.log.warn('Gallery data is empty. Check your gallery.yml file.'); | |
return []; | |
} | |
return pagination('/gallery', galleryGroups, { | |
perPage: hexo.config.per_page || 6, | |
layout: 'gallery', // 使用 gallery.pug 模板 | |
data: { | |
gallery: true, // 标记为相册页面 | |
}, | |
}); | |
}); |
以上代码用于生成分页数据。在 themes\shokaX\layout\gallery.pug
通过 page.posts
读取分页数据并渲染分页数据。
# 相册子页面配置
- 创建文件:
themes\shokaX\layout\gallery-item.pug
,用于生成一个相册组页面,展示相册组中的所有照片。
extends ./_partials/layout.pug | |
block content | |
div.gallery-container.gallery-items | |
each photo in page.photos | |
a(href=photo class="gallery-item" data-src=photo) | |
img(src=photo alt=page.group.name title=page.group.descr loading="lazy") |
themes\shokaX\scripts\generaters\gallery.js
中・增加代码传递 yml 的相册数据到gallery-item.pug
被读取。
// 子相册页面生成 | |
hexo.extend.generator.register('galleryPages', function (locals) { | |
const galleryGroups = (locals.data.gallery && locals.data.gallery.gallery_groups) || []; | |
return galleryGroups.map((group) => { | |
// 确保 photos 是字符串数组 | |
const photos = group.photos || []; // 不再需要解析对象,直接返回数组内容 | |
// 根据 URL 提取唯一标识符 | |
// const groupId = group.url.split('/').filter((part) => part).pop(); | |
return { | |
path: path.join(group.url.replace('/miyano', ''), 'index.html'), // 由于获取的 url 里已经含有 '/miyano' 导致重复,需要去掉。 | |
layout: 'gallery-item', // 使用 gallery-item.pug 模板 | |
data: { | |
group, // 传递 group 数据 | |
photos, // 直接传递字符串数组的 photos | |
//id: groupId // 传递唯一的 id | |
}, | |
}; | |
}); | |
}); |
- 实现相册集内图片的自动自适应布局以及灯箱功能,查看文档:Justified-Gallery 和 lightgallery 进行实现。
依赖的文件有:
- CSS 文件:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/justifiedGallery/dist/css/justifiedGallery.min.css"> | |
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/lightgallery/css/lightgallery.min.css"> |
- JS 文件:
<script src="https://cdn.jsdelivr.net/npm/jquery@3.6.4/dist/jquery.min.js"></script> | |
<script src="https://cdn.jsdelivr.net/npm/justifiedGallery/dist/js/jquery.justifiedGallery.min.js"></script> | |
<script src="https://cdn.jsdelivr.net/npm/lightgallery/lightgallery.min.js"></script> | |
<script src="https://cdn.jsdelivr.net/npm/lightgallery/plugins/zoom/lg-zoom.min.js"></script> | |
<script src="https://cdn.jsdelivr.net/npm/lightgallery/plugins/thumbnail/lg-thumbnail.min.js"></script> |
需要创建文件 themes\shokaX\source\js\_app\library\gallery.ts
实现,动态导入了以上的 css 和 js 文件 (注意到其实 shokaX
已经实现的 fancybox
就是利用了 justifiedGallery
,并且已在 _config.shokaX.yml
中的 vendors
中引入了)。这里模仿了 themes\shokaX\source\js\_app\page\fancybox.ts
使用的方法。
// 动态加载 CSS | |
const loadCss = (url: string): void => { | |
const link = document.createElement('link'); | |
link.rel = 'stylesheet'; | |
link.href = url; | |
document.head.appendChild(link); | |
}; | |
// 动态加载 JS | |
const loadJs = (url: string): Promise<void> => { | |
return new Promise((resolve, reject) => { | |
const script = document.createElement('script'); | |
script.src = url; | |
script.onload = () => resolve(); | |
script.onerror = () => reject(new Error(`Failed to load script: ${url}`)); | |
document.body.appendChild(script); | |
}); | |
}; | |
const destroyLightGallery = (): void => { | |
try { | |
// 获取所有 LightGallery 容器 | |
const lightGalleryContainers = document.querySelectorAll('.lg-container'); | |
// console.log('LightGallery containers found:', lightGalleryContainers); | |
if (lightGalleryContainers.length === 0) { | |
// console.log('No LightGallery instances found to destroy.'); | |
return; | |
} | |
// 遍历每个 LightGallery 容器 | |
lightGalleryContainers.forEach((container) => { | |
// console.log('Destroying LightGallery instance for container:', container); | |
// 清理与 LightGallery 容器关联的事件 | |
const containerId = container.id; | |
const backdrop = document.getElementById(`lg-backdrop-${containerId.split('-').pop()}`); | |
const outer = document.getElementById(`lg-outer-${containerId.split('-').pop()}`); | |
const content = document.getElementById(`lg-content-${containerId.split('-').pop()}`); | |
// 清理事件和相关子元素 | |
if (backdrop) backdrop.remove(); | |
if (outer) outer.remove(); | |
if (content) content.remove(); | |
// 移除容器本身 | |
container.remove(); | |
// console.log('LightGallery instance and DOM elements destroyed for:', containerId); | |
}); | |
// 确保没有遗留的全局实例 | |
if ((window as any).lgInstances) { | |
(window as any).lgInstances = (window as any).lgInstances.filter((inst: any) => { | |
const isDestroyed = !document.body.contains(inst.LGEl); | |
if (isDestroyed) console.log('Removed destroyed instance:', inst); | |
return !isDestroyed; | |
}); | |
} | |
// console.log('All LightGallery instances and DOM cleaned up.'); | |
} catch (error) { | |
console.error('Error destroying LightGallery instances:', error); | |
} | |
}; | |
// 初始化 Justified Gallery 和 LightGallery | |
const initializeGallery = async (): Promise<void> => { | |
try { | |
// 动态加载 CSS | |
loadCss('https://cdn.jsdelivr.net/npm/justifiedGallery/dist/css/justifiedGallery.min.css'); | |
loadCss('https://cdn.jsdelivr.net/npm/lightgallery/css/lightgallery-bundle.css'); | |
// 动态加载 JS | |
await loadJs('https://cdn.jsdelivr.net/npm/jquery@3.6.4/dist/jquery.min.js'); | |
await loadJs('https://cdn.jsdelivr.net/npm/justifiedGallery/dist/js/jquery.justifiedGallery.min.js'); | |
await loadJs('https://cdn.jsdelivr.net/npm/lightgallery/lightgallery.min.js'); | |
await loadJs('https://cdn.jsdelivr.net/npm/lightgallery/plugins/zoom/lg-zoom.min.js'); | |
await loadJs('https://cdn.jsdelivr.net/npm/lightgallery/plugins/thumbnail/lg-thumbnail.min.js'); | |
await loadJs('https://cdn.jsdelivr.net/npm/lightgallery/plugins/autoplay/lg-autoplay.min.js'); | |
await loadJs('https://cdn.jsdelivr.net/npm/lightgallery/plugins/rotate/lg-rotate.min.js'); | |
const $ = (window as any).jQuery; | |
//const pathSegments = window.location.pathname.split ('/').filter (segment => segment); // 提取路径段 | |
//const galleryId = pathSegments [pathSegments.length - 1]; // 获取最后一个路径段 | |
const galleryContainer = document.querySelector('.gallery-items') as HTMLElement; | |
if (!galleryContainer) { | |
console.warn('No gallery container found.'); | |
return; | |
} | |
// 销毁现有 LightGallery 实例,防止重复初始化 | |
destroyLightGallery(); | |
// 初始化 Justified Gallery | |
$(galleryContainer) | |
.justifiedGallery({ | |
rowHeight: 200, | |
margins: 5, | |
lastRow: 'justify', | |
captions: false, | |
}) | |
.on('jg.complete', function () { | |
console.log('Justified Gallery layout complete.'); | |
// 初始化 LightGallery | |
(window as any).lightGallery(galleryContainer, { | |
plugins: [ | |
(window as any).lgZoom, | |
(window as any).lgThumbnail, | |
(window as any).lgAutoplay, | |
(window as any).lgRotate, | |
], | |
speed: 200, | |
thumbnail: true, | |
zoom: true, | |
}); | |
console.log('LightGallery initialized.'); | |
}); | |
} catch (error) { | |
console.error('Error initializing gallery:', error); | |
} | |
}; | |
export default initializeGallery; |
首先,为了防止 PJAX 的缓存问题,选择使用 ts 实现,并在 siteinit.ts
中导入该模块,实现 pjax 刷新,但遇到了 lightgallery
的对象没有清除,在切换相册时会叠加出现的问题,最后通过实现 destroyLightGallery()
方法,在每次初始化容器前都先清除对象防止对象叠加影响。
关于 justifiedGallery
和 lightGallery
的实例化的参数详见文档。注意 lightgallery
增加插件 plugins 需要导入对应的脚本,这些脚本也可在文档查到。
最后该模块 initializeGallery
在 siteinit.ts
中通过 import initializeGallery from '../library/gallery';
导入即可,放在 updateGalleryPageLayout()
内当处于 gallery
页面时。
# 页面标题自定义
就是显示在大封面的每个页面的两行字,即:
const headerAlternate = document.querySelector("#header .artboard"); | |
const headerTitle = document.querySelector("#header .title"); |
实现可以在 index.md
中自定义配置。通过 siteinit.ts
中的 import { BODY, CONFIG, pjax, setPjax, setSiteSearch, siteSearch } from '../globals/globalVars';
中的 CONFIG
追踪到 themes\shokaX\scripts\generaters\script.js
,在 31-64 行中加入代码:
// 读取 gallery.yml 的数据 | |
const galleryData = hexo.locals.get('data').gallery || {}; | |
locals.pages.forEach((page) => { | |
// galleryData.gallery_groups.forEach((group) => { | |
// // 去掉 Group URL 中的 "miyano/" 前缀 | |
// const groupUrl = group.url.replace(/^\/?miyano\//, "").replace(/\/$/, ""); | |
// // 去掉 Page Path 中的 "index.html" 后缀 | |
// const pagePath = page.path.replace(/index\.html$/, "").replace(/\/$/, ""); | |
// console.log("Page Path:", pagePath); | |
// console.log("Group URL:", groupUrl); | |
// }); | |
const galleryItem = galleryData.gallery_groups.find( | |
(group) => page.path.replace(/index\.html$/, "").replace(/\/$/, "") === group.url.replace(/^\/?miyano\//, "").replace(/\/$/, "") | |
); | |
if (galleryItem) { | |
page.alternate = galleryItem.name; | |
page.headerTitle = galleryItem.descr; | |
} | |
}); | |
// 动态生成页面元数据 | |
const pages = locals.pages.reduce((acc, page) => { | |
const metadata = {}; | |
if (page.alternate) metadata.alternate = page.alternate; | |
if (page.headerTitle) metadata.headerTitle = page.headerTitle; | |
if (Object.keys(metadata).length > 0) { | |
acc[page.path] = metadata; // 仅记录有自定义参数的页面 | |
} | |
return acc; | |
}, {}); | |
//console.log ("页面数据:",pages); |
其中,对于子相册页面,通过读取 source\_data\gallery.yml
中的 name
和 descr
参数作为该页面的 alternate
和 headerTitle
的数据。其中需要获取到和当前子页面路径匹配的 yml 中对应的 url 的数据。需要对两个路径分别进行处理才能得到一致的结构获取到对应的数据。
后面一步则是获取 index.md
中有配置 alternate
和 headerTitle
参数的页面,如果有配置,则返回该页面 pages
(带有页面路径和配置的参数值的数据。)
在 66-69 添加代码获取默认的 alternate
和 title
参数用于默认的页面标题。
在 110 添加参数 pages.
headerMsg: { | |
alternate: theme.alternate, | |
title: config.title, | |
}, | |
pages, |
接着,在 siteinit.ts
中,加入下面代码:
const updateGalleryPageLayout = () => { | |
const body = document.body; | |
const footer = document.getElementById("footer"); | |
const main = document.querySelector("main"); | |
const headerAlternate = document.querySelector("#header .artboard"); | |
const headerTitle = document.querySelector("#header .title"); | |
const HeaderMsgConfigPath = window.location.pathname.replace(CONFIG.root, ""); // 获取配置了 Header 参数的页面路径 | |
const HeaderMsgConfig = CONFIG.pages[HeaderMsgConfigPath+'index.html']; // 从页面元数据中获取当前页面配置 | |
//console.log ("当前页面配置:", HeaderMsgConfig); | |
const DefaultHeaderMsgConfig = CONFIG.headerMsg | |
//console.log ("默认页面配置:", DefaultHeaderMsgConfig); | |
if (window.location.pathname.includes('/gallery')) { | |
body.classList.add("gallery"); | |
// 隐藏 footer | |
if (footer) footer.style.display = "none"; | |
// 调整 main 的 margin-bottom | |
if (main) main.style.marginBottom = "0px"; | |
// 如果页面配置了自定义字段,则动态更新 header | |
if (HeaderMsgConfig) { | |
const { alternate, headerTitle: pageTitle } = HeaderMsgConfig; | |
if (headerAlternate && alternate) headerAlternate.textContent = alternate; | |
if (headerTitle && pageTitle) headerTitle.textContent = pageTitle; | |
} | |
initializeGallery(); | |
} else { | |
body.classList.remove("gallery"); | |
// 恢复 footer 的显示 | |
if (footer) footer.style.display = ""; | |
// 恢复 main 的 margin-bottom | |
if (main) main.style.marginBottom = ""; | |
// 恢复默认 header 内容 | |
if (DefaultHeaderMsgConfig) { | |
const { alternate, headerTitle: pageTitle } = DefaultHeaderMsgConfig; | |
if (headerAlternate && alternate) headerAlternate.textContent = alternate; | |
if (headerTitle && pageTitle) headerTitle.textContent = pageTitle; | |
} | |
} | |
}; |
实现了页面的 Header
字段自定义,退出 gallery
页面时则恢复默认。
# 每天随机主题图片
themes\shokaX\_images_index.yml
为首页图片源文件,themes\shokaX\_images.yml
为首页的后备图片源和文章的图片源。创建一个themes\shokaX\images.json
用于管理各个主题的图片集,格式为:
{ | |
"kon": { | |
"home": [ | |
"https://cloudflare-imgbed-9os.pages.dev/file/kon_14.webp" | |
], | |
"articles": [ | |
"https://cloudflare-imgbed-9os.pages.dev/file/kon_28.webp" | |
] | |
}, | |
"eva": { | |
"home": [ | |
"https://cloudflare-imgbed-9os.pages.dev/file/eva_35.webp" | |
], | |
"articles": [ | |
"https://cloudflare-imgbed-9os.pages.dev/file/eva_23.webp" | |
] | |
}, | |
"sp": { | |
"home": [ | |
"https://cloudflare-imgbed-9os.pages.dev/file/sp_3.webp" | |
], | |
"articles": [ | |
"https://cloudflare-imgbed-9os.pages.dev/file/ghibli_31.webp" | |
], | |
"articles": [ | |
"https://cloudflare-imgbed-9os.pages.dev/file/ghibli_9.webp" | |
] | |
} | |
} |
分别存储了用于首页和文章的图片。
- 创建文件:
updateYaml.js
,用于根据日期选择主题或者根据_config.shokaX.yml
中配置的Mytheme
选择主题。通过读取 json 文件,将图片集写入两个 yml 文件,运行node updateYaml.js
实现更新。这一步在自动部署中定时实现。
const fs = require("fs"); | |
const path = require("path"); | |
// JSON 文件路径 | |
const jsonFilePath = path.join(__dirname, "themes/shokaX/images.json"); | |
// YAML 文件路径 | |
const indexYmlPath = path.join(__dirname, "themes/shokaX/_images_index.yml"); | |
const articleYmlPath = path.join(__dirname, "themes/shokaX/_images.yml"); | |
// 主题图片文件夹路径 | |
const coverImgDir = path.join(__dirname, "source/_data/coverimg"); | |
// 博客文章文件夹路径 | |
const postsDir = path.join(__dirname, "source/_posts"); | |
// 获取当前主题逻辑 | |
const getTheme = () => { | |
const configPath = path.join(__dirname, "_config.shokaX.yml"); | |
const config = fs.readFileSync(configPath, "utf8"); | |
const match = config.match(/MyTheme:\s*(\S+)/); | |
return match ? match[1] : "default"; | |
}; | |
// 随机从数组中选取 n 个元素 | |
const getRandomElements = (array, n) => { | |
const shuffled = array.sort(() => 0.5 - Math.random()); | |
return shuffled.slice(0, n); | |
}; | |
// 获取文件夹数量 | |
const getSubfolderCount = (dir) => { | |
return fs.readdirSync(dir).filter((item) => { | |
const itemPath = path.join(dir, item); | |
return fs.statSync(itemPath).isDirectory(); | |
}).length; | |
}; | |
// 更新 YAML 文件和生成封面图片 | |
const updateYmlFiles = async () => { | |
const theme = getTheme(); | |
const jsonData = JSON.parse(fs.readFileSync(jsonFilePath, "utf8")); | |
let selectedTheme = theme; | |
if (theme === "default") { | |
const themes = ["kon", "sp", "eva", "ghibli"]; | |
const currentDate = new Date(); | |
const beijingTime = new Date(currentDate.getTime() + (8 * 60 * 60 * 1000)); // UTC 时间转为北京时间 | |
console.log("beijingTime:",beijingTime) | |
beijingTime.setDate(beijingTime.getDate() + 1); // 在当前日期上加上 d_day, 为了与另一个网站主题同步 | |
const formattedDate = beijingTime.toISOString().slice(0, 10); // 获取修改后的日期的 YYYY-MM-DD 格式 | |
const hash = Array.from(formattedDate).reduce((sum, char) => sum + char.charCodeAt(0), 0); // 计算日期字符串的哈希值 | |
const themeIndex = hash % themes.length; // 对主题数组长度取模 | |
selectedTheme = themes[themeIndex]; | |
} | |
console.log("selectedTheme",selectedTheme) | |
const homeImages = jsonData[selectedTheme]?.home || []; | |
const articleImages = jsonData[selectedTheme]?.articles || []; | |
// 写入 YAML 文件 | |
fs.writeFileSync(indexYmlPath, homeImages.map((url) => `- ${url}`).join("\n"), "utf8"); | |
fs.writeFileSync(articleYmlPath, articleImages.map((url) => `- ${url}`).join("\n"), "utf8"); | |
console.log(`YAML files updated for theme: ${selectedTheme}`); | |
// 获取主题对应的图片文件夹 | |
const themeCoverDir = path.join(coverImgDir, selectedTheme); | |
if (!fs.existsSync(themeCoverDir)) { | |
console.error(`Theme folder not found: ${themeCoverDir}`); | |
return; | |
} | |
// 获取主题图片列表 | |
const themeImages = fs.readdirSync(themeCoverDir).filter((file) => /\.(jpg|jpeg|png|webp)$/i.test(file)); | |
if (themeImages.length === 0) { | |
console.error(`No images found in theme folder: ${themeCoverDir}`); | |
return; | |
} | |
// 获取博客文章文件夹数量 | |
const postFolders = fs.readdirSync(postsDir).filter((item) => { | |
const itemPath = path.join(postsDir, item); | |
return fs.statSync(itemPath).isDirectory(); | |
}); | |
// 随机选取图片并生成 cover.jpg | |
const selectedImages = getRandomElements(themeImages, postFolders.length); | |
postFolders.forEach((folder, index) => { | |
const postFolderPath = path.join(postsDir, folder); | |
const sourceImagePath = path.join(themeCoverDir, selectedImages[index]); | |
const outputImagePath = path.join(postFolderPath, "cover.jpg"); | |
// 复制图片为 cover.jpg | |
fs.copyFileSync(sourceImagePath, outputImagePath); | |
console.log(`cover.jpg created for ${folder}`); | |
}); | |
}; | |
updateYmlFiles(); |
注意:对于分类卡片的图片,需要先创建分类的文章文件夹,在每个分类文件夹下存入对应的文章,再加上一张 cover.jpg
,必须命名为该格式,才能渲染出卡片。要实现分类卡片的主题切换,于是新建 source\_data\coverimg
文件夹,里面再设置各个主题的子文件夹,每个主题下存入对应图片的 jpg
图片, updateYaml.js
则根据当天主题将该主题下的图片抽取重命名为 cover
后覆盖掉原来分类文件夹里的图片,作为当前主题的分类卡片图片。(注意每个主题下存入的图片数量需大于分类的类别数量。)
附:首页的分类卡片,还需要在 _config.yml
中进行配置,如:
# Category & Tag | |
default_category: uncategorized | |
category_map: | |
文献翻译: Literature-Translation | |
整活探索: Playful-Exploration | |
科研笔记: Scientific-Notes |
为了使部署后的网页的分类卡片能立即刷新,可以加入时间戳清除缓存,修改 themes\shokaX\layout\_mixin\card.pug
,第 2 行修改为:
- var timestamp = Date.now() | |
- cover = url_for(theme.statics + item.slug + '/cover.jpg') + '?v=' + timestamp |
注意:js 获取的时间 const currentDate = new Date();
为 UTC 时间,需要转为 UTC+8 的北京时间,不然更新主题会出错。
# 增改一些参数
_config.shokaX.yml
中widgets
中增加参数:
widgets: | |
# if true, will show random posts | |
random_posts: | |
enable: true | |
post_number: 5 | |
# if true, will show recent comments | |
recent_comments: | |
enable: true | |
comment_number: 5 |
用于配置随机文章和最新评论的显示数量。
于是在 themes\shokaX\scripts\generaters\script.js
第 70 增加代码:
recent_comments_count: theme.widgets.recent_comments.comment_number, |
修改 themes\shokaX\source\js\_app\components\comments.ts
,首先,为了点击最新评论后能正确跳转到该评论所在文章,需要修改第 32 行为:
const root = shokax_siteURL.replace(/^(https?:\/\/)?[^/]*(\/miyano)?/, ''); |
防止重复的 '/miyano'
。接着,修改 34-37 为:
const { comments } = await RecentComments({ | |
serverURL: CONFIG.waline.serverURL.replace(/\/+$/, ''), | |
count: CONFIG.recent_comments_count || 5 | |
}) |
应用设置的参数作为评论数量。
修改 themes\shokaX\layout\_mixin\widgets.pug
,19-22 修改为:
- var postNumber = theme.widgets.random_posts.post_number || 5 // 默认值为 5 | |
- var posts = site.posts.shuffle().limit(postNumber).toArray() // 使用配置的 post_number | |
each item in posts | |
field(item) |
- 修改站点文章阅读时间的格式
修改themes\shokaX\scripts\helpers\symbols_count_time.js
的 28-37 为:
function getFormatTime(minutes, suffix) { | |
const fHours = Math.floor(minutes / 60); | |
let fMinutes = Math.floor(minutes - fHours * 60); | |
if (fMinutes < 1) { | |
fMinutes = 1; | |
} | |
// return fHours < 1 ? fMinutes + " " + suffix : fHours + ":" + ("00" + fMinutes).slice(-2); | |
// 修改时间格式化方式为?h?min | |
return fHours < 1 ? `${fMinutes}min` : `${fHours}h${fMinutes}min`; | |
} |
显示时间的格式改为 ?h?min
。
-
增加或修改图标
按照文档操作,组建自己的图标项目,生成 css 代码,将需要的图标代码加入themes\shokaX\source\css\_iconfont.styl
文件中。 -
增加或修改字段
修改文件themes\shokaX\languages\zh-CN.yml
,例如:
favicon: | |
# show: (●´3`●)やれやれだぜ | |
show: (●´3`●)萌え萌えキュン | |
hide: (´Д`)大変だ! | |
menu: | |
gallery: 相册 | |
repository: 宝箱 | |
anime: 追番 |
# 移除头图模糊
参考链接,新建文件: themes\shokaX\scripts\removeTopBlur.js
,内容为:
hexo.extend.filter.register('theme_inject', function(injects) { | |
injects.style.push('themes/shokaX/source/views/removeTopBlur.styl'); | |
}); |
新建文件: themes\shokaX\source\views\removeTopBlur.styl
,内容为:
#nav { | |
backdrop-filter: saturate(100%) blur(0px); | |
&.show { | |
backdrop-filter: saturate(180%) blur(20px); | |
} | |
} |
# 增加头图樱花特效
参考链接,新建文件: themes\shokaX\scripts\sakura.js
,内容为:
hexo.extend.filter.register('theme_inject', function(injects) { | |
injects.head.file('sakura', 'themes/shokaX/source/views/sakura.pug', {}, {cache: true}); | |
}); |
新建文件: themes\shokaX\source\views\sakura.pug
,内容为:
script. | |
window.sakuraConfig = { | |
sakura: 30, | |
xSpeed: 0.5, | |
ySpeed: 0.5, | |
rSpeed: 0.03, | |
direction: "TopRight", | |
zIndex: -1 | |
}; | |
script(src="https://cdn.jsdelivr.net/gh/minz71/sakura-rain/sakura-rain.js" defer) |
# 设置音乐列表
在 _config.shokaX.yml
中配置:
audio: | |
- title: KON | |
list: | |
- https://music.163.com/#/playlist?id=8881745341 | |
- title: Summer Pockets | |
list: | |
- https://y.qq.com/n/ryqq/playlist/7784860213 | |
- title: EVA | |
list: | |
- https://y.qq.com/n/ryqq/playlist/1754837879 | |
- title: Ghibli | |
list: | |
- https://y.qq.com/n/ryqq/playlist/2854515338 |
# 设置追番页面
路径为: source\anime\index.html
,需要是 html
的格式。需要安装:
$ npm install hexo-bilibili-bangumi --save |
在 _config.yml
中进行配置:
# 追番插件 | |
# https://github.com/HCLonely/hexo-bilibili-bangumi | |
bangumi: # 追番设置 | |
enable: true | |
source: bili | |
path: anime/index.html | |
vmid: 397884429 | |
title: "追番列表" | |
quote: "生命不息,追番不止!" | |
show: 1 | |
lazyload: false | |
loading: | |
showMyComment: false | |
pagination: false | |
metaColor: | |
color: | |
webp: | |
progress: | |
extraOrder: | |
proxy: | |
host: "代理host" | |
port: "代理端口" | |
extra_options: | |
top_img: false | |
lazyload: | |
enable: false |
# 文章图片管理
新建文件夹 themes\shokaX\source\img\post
用于存储博客文章用到的图片,注意路径必须全英文才能转化为 webp
格式。引用格式例如:
<img loading="lazy" src="/miyano/img/post/trans/trans4/1.jpg" width="50%"> |
# 调试技巧
要想比较某个版本的仓库代码和当前最新版本的仓库代码的区别时,可以如下命令:
git checkout -b temp-branch <commit_hash> # 创建一个临时分支来操作旧版本 | |
git checkout shoka # 切回原分支 | |
git branch -D temp-branch # 如果临时分支不再需要,可以删除 | |
# 想将 main 的最新内容合并到当前分支,不引入完整的合并历史,如下操作: | |
git checkout temp-branch # 确保在 temp-branch 分支 | |
git merge --squash shoka # 合并 shoka 分支的最新内容 | |
git commit -m "Merged shoka's latest commit into temp-branch" # 提交更改 |
# 自定义字体
原先的是使用 Google Font 的字体,如果想自定义字体,需要进行配置如下:
- 确保不引入 Google Font
_config.shokaX.yml
中的配置为:
font: | |
enable: true | |
# 从 google 字体库加载,如果自定义 @font-face 请关闭 | |
loadFromGoogle: true | |
# 字体选项: | |
# `external`: 从 google 字体库加载字体. | |
# `family: 字体家族名,无需引号 | |
# `size: x.x`. 以 `em` 为单位。默认: 1 (16px) | |
# 适用于所有在 body 标签内的文字. | |
global: | |
external: true | |
family: Mulish | |
size: | |
# 大标题字体. | |
logo: | |
external: true | |
family: Fredericka the Great | |
size: 3.5 | |
# 页面标题字体. | |
title: | |
external: true | |
family: Noto Serif JP | |
size: 2.5 | |
# 标题字体. | |
headings: | |
external: true | |
family: Noto Serif SC | |
size: | |
# 文章字体. | |
posts: | |
external: false | |
family: | |
# 代码块的字体 | |
codes: | |
external: true | |
family: Inconsolata |
其中,对于 posts
设置 external: false
,保证能通过 @font-face
加载自定义字体。
- 准备字体文件
用于网页端,为了提高性能,最好为woff2
格式的字体,其他格式可通过工具进行转换。
目前我用的字体是 LXGW WenKai,备选是 smiley-sans
。
- 方法一可建立文件夹:
source\fonts
,里面存放字体文件。 - 方法二就是把文件存到服务器,例如 CloudFlare 或者阿里云 OSS 等,通过 url 链接获取字体文件。
对于小的字体文件 (<2MB),本地字体文件的速度足够;较大的文件则通过服务器效果更好,速度更快。
- 创建
fonts.styl
路径为:themes\shokaX\source\css\fonts.styl
,代码为:
@font-face { | |
font-family: 'SmileySans Oblique'; | |
src: url('https://miyano.oss-cn-wuhan-lr.aliyuncs.com/fonts/LXGWWenKai-Light.woff2') format('woff2'), | |
url('/miyano/fonts/SmileySans-Oblique.otf.woff2') format('woff2'); | |
//src: url('https://miyano.oss-cn-wuhan-lr.aliyuncs.com/fonts/SmileySans-Oblique.ttf.woff2') format('woff2'), | |
//url('https://miyano.oss-cn-wuhan-lr.aliyuncs.com/fonts/SmileySans-Oblique.otf.woff2') format('woff2'); | |
font-style: oblique; | |
font-weight: normal; | |
font-display: swap; | |
size-adjust: 120%; /* 让字体整体放大 20% */ | |
} | |
article.post .body.md { | |
font-family: 'SmileySans Oblique', sans-serif; | |
} | |
article.post .body.md h1 { | |
font-family: 'SmileySans Oblique', sans-serif; | |
} | |
article.post .body.md h2 { | |
font-family: 'SmileySans Oblique', sans-serif; | |
} | |
article.post .body.md h3 { | |
font-family: 'SmileySans Oblique', sans-serif; | |
} | |
article.post .body.md h4 { | |
font-family: 'SmileySans Oblique', sans-serif; | |
} | |
#sidebar{ | |
font-family: 'SmileySans Oblique', sans-serif; | |
} |
其中, font-family: 'SmileySans Oblique';
里的名字可以自定义,用于指代字体。
src: url('https://miyano.oss-cn-wuhan-lr.aliyuncs.com/fonts/LXGWWenKai-Light.woff2') format('woff2'),
用于获取 url 链接。
url('/miyano/fonts/SmileySans-Oblique.otf.woff2') format('woff2');
用于获取本地字体文件。
一般 src
设置两个 url 的话,后面一个用于备选。 size-adjust: 120%;
用于整体放大字体。后面的 CSS 选择器则是指定让哪些内容字体用自定义的字体。这里包括:文章内容、侧边栏、小节标题。
- 修改
app.styl
路径为:themes\shokaX\source\css\app.styl
,其实就是引入fonts.styl
。在最后加入代码:
// Font | |
@import "fonts"; |
通过以上设置,成功完成自定义字体对内容的渲染。
# 改进搜索栏
原来的搜索栏搜索时只显示匹配文章的分类和标题,没有显示匹配的内容片段。这里进行改进。即修改 themes\shokaX\source\js\_app\page\search.ts
文件,修改片段为:
hits({ | |
container: '#search-hits', | |
templates: { | |
item(data) { | |
const highlightExcerpt = (html: string, maxLength = 200) => { | |
if (!html) return ''; | |
// 查找第一个高亮标签 | |
const tagStart = html.indexOf('<mark>'); | |
if (tagStart === -1) { | |
// 无高亮时返回空字符串,不显示内容 | |
return ''; | |
} | |
// 获取高亮片段结束位置 | |
const tagEnd = html.indexOf('</mark>', tagStart) + 7; | |
const contextSize = Math.floor(maxLength / 2); | |
// 计算截取范围 | |
const start = Math.max(0, tagStart - contextSize); | |
const end = Math.min(html.length, (tagEnd > 0 ? tagEnd : tagStart) + contextSize); | |
let excerpt = html.slice(start, end); | |
const [lead, trail] = [start > 0 ? '...' : '', end < html.length ? '...' : '']; | |
// 如果截取内容中高亮标签不平衡,则进行修正 | |
const openTags = (excerpt.match(/<mark>/g) || []).length; | |
const closeTags = (excerpt.match(/<\/mark>/g) || []).length; | |
if (openTags > closeTags) excerpt += '</mark>'; | |
return lead + excerpt + trail; | |
}; | |
const cats = data.categories?.join('<i class="ic i-angle-right"></i>') || ''; | |
const title = data._highlightResult?.title?.value || data.title; | |
const rawContent = data._highlightResult?.contentStripTruncate?.value || data.contentStripTruncate || ''; | |
// 生成高亮摘录,如果无高亮,则返回空字符串 | |
const highlighted = highlightExcerpt(rawContent, 200); | |
return `<a href="${CONFIG.root + data.path}"> | |
${cats ? `<span>${cats}</span>` : ''} | |
<h3>${title}</h3> | |
${highlighted ? `<p>${highlighted}</p>` : ''} | |
</a>`; | |
}, | |
empty(data) { | |
return `<div id="hits-empty"> | |
${LOCAL.search.empty.replace(/\$\{query}/, data.query)} | |
</div>`; | |
} | |
}, | |
cssClasses: { | |
item: 'item' | |
} | |
}), |
hits({ | |
container: '#search-hits', | |
templates: { | |
item (data) { | |
const cats = data.categories | |
? '<span>' + data.categories.join('<i class="ic i-angle-right"></i>') + '</span>' | |
: '' | |
// 获取 Algolia 处理后的高亮文本(已包含 <mark> 标签) | |
const fullContent = data._highlightResult?.contentStripTruncate?.value || '' | |
// 如果没有高亮内容,truncatedContent 设为空,不显示内容部分 | |
let truncatedContent = '' | |
if (fullContent) { | |
// ** 找到第一个 <mark> 标签的位置 ** | |
const firstHighlightIndex = fullContent.indexOf('<mark>') | |
if (firstHighlightIndex !== -1) { | |
// ** 向前截取 50 字符,向后截取 150 字符 ** | |
let start = Math.max(0, firstHighlightIndex - 50) | |
let end = Math.min(fullContent.length, firstHighlightIndex + 150) | |
// ** 保留 HTML 结构(确保 <mark> 高亮不丢失)** | |
truncatedContent = (start > 0 ? '...' : '') + | |
fullContent.substring(start, end) + | |
(end < fullContent.length ? '...' : '') | |
} | |
} | |
// 如果没有高亮内容,就不显示内容部分 | |
return `<a href="${CONFIG.root + data.path}"> | |
${cats} ${(data._highlightResult.title as HitHighlightResult).value} | |
</a> | |
${truncatedContent ? `<p class="search-preview">${truncatedContent}</p>` : ''}` | |
}, | |
empty (data) { | |
return '<div id="hits-empty">' + | |
LOCAL.search.empty.replace(/\$\{query}/, data.query) + | |
'</div>' | |
} | |
}, | |
cssClasses: { | |
item: 'item' | |
} | |
}), |
hits({ | |
container: '#search-hits', | |
templates: { | |
item (data) { | |
const cats = data.categories ? '<span>' + data.categories.join('<i class="ic i-angle-right"></i>') + '</span>' : '' | |
return '<a href="' + CONFIG.root + data.path + '">' + cats + (data._highlightResult.title as HitHighlightResult).value + '</a>' | |
}, | |
empty (data) { | |
return '<div id="hits-empty">' + | |
LOCAL.search.empty.replace(/\$\{query}/, data.query) + | |
'</div>' | |
} | |
}, | |
cssClasses: { | |
item: 'item' | |
} | |
}), |
方法二的思路是:
- 找到第一个高亮的
<mark>
搜索词</mark>
- 以此为中心,向前截取 50 个字符,向后截取 50 个字符(防止内容太短)
- 保留
<mark>
高亮标签,确保搜索词可见
方法一多加了一步:自动平衡高亮标签,防止标签不闭合。
至此完成了搜索栏的改进,但有一个问题就是: Algoria
的搜索机制好像有问题,调配置参数也没解决,就是会出现搜索结果中有不含搜索词的文章,所以进行了处理:当没有高亮内容时,使显示内容只有分类和标题。 还会出现搜索词会进行部分匹配的问题,比如搜索” 磁学 “会出现含有” 学 “和含有” 磁 “的结果。这部分后续再看。
# fancybox 优化
原来的博客中,虽然有 fancybox 的相关文件,但实际上根本用不了,看了原开发者的笔记,跟着设置但也没用,点击图片后仍然没有灯箱效果。于是只能自己一步步顺着文件摸索。最终终于实现了 fancybox 的效果。
# 增加变量的设置
查找原先的 fancybox.ts
文件:发现了函数在 vendorJs('jquery', ()=>{
处就已经卡住了,于是根据该函数找到 themes\shokaX\source\js\_app\library\loadFile.ts
,发现了一个判断条件: if (LOCAL[type])
,再控制台打印这个 LOCAL
,结果中果然没有 jquery
,于是再去找 LOCAL
的定义,找到了 themes\shokaX\layout\_partials\layout.pug
,里面定义了 LOCAL
,果然缺了变量,于是补上:
var LOCAL = { | |
ispost: !{is_post()}, | |
path: `#{_permapath(page.path)}`, | |
favicon: { | |
show: `#{__('favicon.show')}`, | |
hide: `#{__('favicon.hide')}` | |
}, | |
search: { | |
placeholder: "!{__('search.placeholder')}", | |
empty: "!{__('search.empty')}", | |
stats: "!{__('search.stats')}" | |
}, | |
copy_tex: #{!!page.math}, | |
katex: #{!!page.math}, | |
mermaid: #{!!page.mermaid}, | |
audio: !{audioValue}, | |
fancybox: #{page.fancybox !== false}, | |
justifiedGallery: #{page.fancybox !== false}, //- 增加变量定义,这里保持与fancybox相同 | |
jquery: #{page.fancybox !== false}, //- 增加变量定义 | |
nocopy: #{!!page.nocopy}, | |
outime: #{page.outime !== false}, | |
template: `!{__('outime.template')}`, | |
quiz: { | |
choice: `#{__('quiz.choice')}`, | |
multiple: `#{__('quiz.multiple')}`, | |
true_false: `#{__('quiz.true_false')}`, | |
essay: `#{__('quiz.essay')}`, | |
gap_fill: `#{__('quiz.gap_fill')}`, | |
mistake: `#{__('quiz.mistake')}` | |
}, | |
ignores: [ | |
(uri) => uri.includes('#'), | |
(uri) => new RegExp(LOCAL.path + '$').test(uri), | |
!{JSON.stringify(ignores)} | |
] | |
}; |
var LOCAL = { | |
ispost: !{is_post()}, | |
path: `#{_permapath(page.path)}`, | |
favicon: { | |
show: `#{__('favicon.show')}`, | |
hide: `#{__('favicon.hide')}` | |
}, | |
search: { | |
placeholder: "!{__('search.placeholder')}", | |
empty: "!{__('search.empty')}", | |
stats: "!{__('search.stats')}" | |
}, | |
copy_tex: #{!!page.math}, | |
katex: #{!!page.math}, | |
mermaid: #{!!page.mermaid}, | |
audio: !{audioValue}, | |
fancybox: #{page.fancybox !== false}, | |
nocopy: #{!!page.nocopy}, | |
outime: #{page.outime !== false}, | |
template: `!{__('outime.template')}`, | |
quiz: { | |
choice: `#{__('quiz.choice')}`, | |
multiple: `#{__('quiz.multiple')}`, | |
true_false: `#{__('quiz.true_false')}`, | |
essay: `#{__('quiz.essay')}`, | |
gap_fill: `#{__('quiz.gap_fill')}`, | |
mistake: `#{__('quiz.mistake')}` | |
}, | |
ignores: [ | |
(uri) => uri.includes('#'), | |
(uri) => new RegExp(LOCAL.path + '$').test(uri), | |
!{JSON.stringify(ignores)} | |
] | |
}; |
这里只是进入判断的第一步,第二步则是 CONFIG['js'][type]
是否有值,再找到 themes/shokaX/source/js/_app/globals/globalVars
,里面定义了 CONFIG = shokax_CONFIG
,再找到 themes\shokaX\scripts\generaters\script.js
,里面定义了 shokax_CONFIG: JSON.stringify(siteConfig)
,进而找到了 siteConfig
并进行变量增加,修改如下:
js: { | |
jquery: (0, import_utils.getVendorLink)(hexo, theme.vendors.js.jquery), // 添加 jQuery | |
copy_tex: (0, import_utils.getVendorLink)(hexo, theme.vendors.async_js.copy_tex), | |
fancybox: (0, import_utils.getVendorLink)(hexo, theme.vendors.async_js.fancybox), | |
justifiedGallery: (0, import_utils.getVendorLink)(hexo, theme.vendors.async_js.justifiedGallery) // 添加 justifiedGallery | |
}, | |
css: { | |
katex: (0, import_utils.getVendorLink)(hexo, theme.vendors.css.katex), | |
mermaid: { | |
url: theme.css + "/mermaid.css", | |
local: true, | |
sri: "" | |
}, | |
fancybox: (0, import_utils.getVendorLink)(hexo, theme.vendors.css.fancybox), | |
justifiedGallery: (0, import_utils.getVendorLink)(hexo, theme.vendors.css.justifiedGallery) | |
}, |
js: { | |
copy_tex: (0, import_utils.getVendorLink)(hexo, theme.vendors.async_js.copy_tex), | |
fancybox: (0, import_utils.getVendorLink)(hexo, theme.vendors.async_js.fancybox) | |
}, | |
css: { | |
katex: (0, import_utils.getVendorLink)(hexo, theme.vendors.css.katex), | |
mermaid: { | |
url: theme.css + "/mermaid.css", | |
local: true, | |
sri: "" | |
}, | |
fancybox: (0, import_utils.getVendorLink)(hexo, theme.vendors.css.fancybox), | |
justifiedGallery: (0, import_utils.getVendorLink)(hexo, theme.vendors.css.justifiedGallery) | |
}, |
这其中添加的 js 的 jquery
和 justifiedGallery
要和 _config.shokaX.yml
里的 vendors
里的配置对应上。
到这一步,基本的变量已经都配置好了,但出现了问题: jquery
和 justifiedGallery
都能正常加载,但 fancybox
通过 vendorJs
加载脚本却是 failed
。无论怎么修改没找到问题。甚至改变了 cdnjs 的源,方法是:
- 在 cdnjs 找需要的 js、css 资源
- 由于该网站默认 sci 为
SHA-512
,该主题默认使用的是SHA-384
,所以需要转换。通过 SRI Hash 在线生成器可以方便地获取需要的脚本对应的 sri。 - 最终在
_config.shokaX.yml
中配置如下:
vendors: | |
cdns: | |
cdnjs: https://cdnjs.cloudflare.com/ajax/libs | |
js: | |
pace: | |
source: cdnjs | |
url: pace/1.2.4/pace.min.js | |
sri: "sha384-k6YtvFUEIuEFBdrLKJ3YAUbBki333tj1CSUisai5Cswsg9wcLNaPzsTHDswp4Az8" | |
jquery: | |
source: cdnjs | |
url: jquery/3.7.1/jquery.min.js | |
sri: "sha384-1H217gwSVyLSIfaLxHbE7dRb3v4mYCKbpQvzx0cegeju1MVsGrX5xXxAvs/HgeFs" | |
async_js: | |
fancybox: | |
source: cdnjs | |
url: fancybox/3.5.7/jquery.fancybox.min.js | |
sri: "sha384-Zm+UU4tdcfAm29vg+MTbfu//q5B/lInMbMCr4T8c9rQFyOv6PlfQYpB5wItcXWe7" | |
justifiedGallery: | |
source: cdnjs | |
url: justifiedGallery/3.8.1/js/jquery.justifiedGallery.min.js | |
sri: "sha384-TOxsBplaL96/QDWPIUg+ye3v89qSE3s22XNtJMmCeZEep3cVDmXy1zEfZvVv+y2m" | |
copy_tex: | |
source: cdnjs | |
url: KaTeX/0.16.9/contrib/copy-tex.min.js | |
sri: "sha384-ww/583aHhxWkz5DEVn6OKtNiIaLi2iBRNZXfJRiY1Ai7tnJ9UXpEsyvOITVpTl4A" | |
css: | |
katex: | |
source: cdnjs | |
url: KaTeX/0.16.9/katex.min.css | |
sri: "sha384-n8MVd4RsNIU0tAv4ct0nTaAbDJwPJzDEaqSD1odI+WdtXRGWt2kTvGFasHpSy3SV" | |
comment: | |
source: local | |
url: css/comment.css | |
sri: "" | |
fancybox: | |
source: cdnjs | |
url: fancybox/3.5.7/jquery.fancybox.min.css | |
sri: "sha384-Q8BgkilbsFGYNNiDqJm69hvDS7NCJWOodvfK/cwTyQD4VQA0qKzuPpvqNER1UC0F" | |
justifiedGallery: | |
source: cdnjs | |
url: justifiedGallery/3.8.1/css/justifiedGallery.min.css | |
sri: "sha384-V/1Ew9pYm8xpy/L9i078ZXT6HSEOrGC6KNFYLbXOdtqb3+c6brpGqVzZtEPSQOiz" |
vendors: | |
cdns: | |
cdnjs: https://s4.zstatic.net/ajax/libs | |
js: | |
pace: | |
source: cdnjs | |
url: pace/1.2.4/pace.min.js | |
sri: "sha384-k6YtvFUEIuEFBdrLKJ3YAUbBki333tj1CSUisai5Cswsg9wcLNaPzsTHDswp4Az8" | |
jquery: | |
source: cdnjs | |
url: jquery/3.5.1/jquery.min.js | |
sri: "sha384-ZvpUoO/+PpLXR1lu4jmpXWu80pZlYUAfxl5NsBMWOEPSjUn/6Z/hRTt8+pR6L4N2" | |
async_js: | |
fancybox: | |
source: cdnjs | |
url: fancybox/3.5.7/jquery.fancybox.min.js | |
sri: "sha384-Zm+UU4tdcfAm29vg+MTbfu//q5B/lInMbMCr4T8c9rQFyOv6PlfQYpB5wItcXWe7" | |
justifiedGallery: | |
source: cdnjs | |
url: justifiedGallery/3.8.1/js/jquery.justifiedGallery.min.js | |
sri: "sha384-TOxsBplaL96/QDWPIUg+ye3v89qSE3s22XNtJMmCeZEep3cVDmXy1zEfZvVv+y2m" | |
copy_tex: | |
source: cdnjs | |
url: KaTeX/0.16.9/contrib/copy-tex.min.js | |
sri: "sha384-ww/583aHhxWkz5DEVn6OKtNiIaLi2iBRNZXfJRiY1Ai7tnJ9UXpEsyvOITVpTl4A" | |
css: | |
katex: | |
source: cdnjs | |
url: KaTeX/0.16.9/katex.min.css | |
sri: "sha384-n8MVd4RsNIU0tAv4ct0nTaAbDJwPJzDEaqSD1odI+WdtXRGWt2kTvGFasHpSy3SV" | |
comment: | |
source: local | |
url: css/comment.css | |
sri: "" | |
fancybox: | |
source: cdnjs | |
url: fancybox/3.5.7/jquery.fancybox.min.css | |
sri: "sha384-Q8BgkilbsFGYNNiDqJm69hvDS7NCJWOodvfK/cwTyQD4VQA0qKzuPpvqNER1UC0F" | |
justifiedGallery: | |
source: cdnjs | |
url: justifiedGallery/3.8.1/css/justifiedGallery.min.css | |
sri: "sha384-V/1Ew9pYm8xpy/L9i078ZXT6HSEOrGC6KNFYLbXOdtqb3+c6brpGqVzZtEPSQOiz" |
但该方法没有奏效。
# 改进 fancybox.ts
放弃原来的实现方案,使用手动加载 js,代码为:
import { $dom } from '../library/dom' | |
import { vendorCss, vendorJs} from '../library/loadFile' | |
import { insertAfter } from '../library/proto' | |
import { CONFIG } from '../globals/globalVars' | |
// TODO 使用 PhotoSwipe 替换 Fancybox | |
export const postFancybox = async (p) => { | |
console.log('[postFancybox] Function called with selector:', p) | |
const images = document.querySelectorAll(p + ' .md img:not(.emoji):not(.vemoji)'); | |
if (images.length === 0) { | |
console.warn('[postFancybox] No images found for selector:', p + ' .md img'); | |
return; | |
} | |
console.log('[postFancybox] Found images in:', p + ' .md img'); | |
vendorCss('fancybox'); | |
vendorCss('justifiedGallery'); | |
// ** 手动加载 jQuery(如果尚未加载)** | |
if (typeof jQuery === 'undefined') { | |
console.log('[postFancybox] jQuery not found, loading manually...'); | |
await manualLoadScript(CONFIG['js']['jquery'].url); | |
} | |
console.log('[postFancybox] jQuery loaded:', typeof jQuery !== 'undefined' ? 'Success' : 'Failed'); | |
// ** 手动加载 justifiedGallery** | |
await manualLoadScript(CONFIG['js']['justifiedGallery'].url); | |
console.log('[postFancybox] justifiedGallery loaded:', typeof jQuery.fn.justifiedGallery !== 'undefined' ? 'Success' : 'Failed'); | |
// ** 手动加载 Fancybox** | |
await manualLoadScript(CONFIG['js']['fancybox'].url); | |
console.log('[postFancybox] fancybox loaded:', typeof jQuery.fancybox !== 'undefined' ? 'Success' : 'Failed'); | |
if (typeof jQuery.fancybox === 'undefined') { | |
console.warn('[Fix] Fancybox function not found, manually binding...'); | |
window.jQuery = jQuery; | |
window.$ = jQuery; | |
} | |
const q = jQuery.noConflict(); | |
// ** 处理 gallery** | |
console.log('[postFancybox] Processing galleries...'); | |
$dom.each(p + ' p.gallery', (element) => { | |
console.log('[postFancybox] Found gallery element:', element); | |
const box = document.createElement('div'); | |
box.className = 'gallery'; | |
box.setAttribute('data-height', String(element.getAttribute('data-height') || 220)); | |
box.innerHTML = element.innerHTML.replace(/<br>/g, ''); | |
element.parentNode.insertBefore(box, element); | |
element.remove(); | |
}); | |
// ** 处理文章中的图片 ** | |
console.log('[postFancybox] Processing images...'); | |
$dom.each(p + ' .md img:not(.emoji):not(.vemoji)', (element) => { | |
console.log('[postFancybox] Processing image:', element); | |
const $image = q(element); | |
const imageLink = $image.attr('data-src') || $image.attr('src'); | |
// 如果图片还没有被 & lt;a > 包裹,则进行包裹 | |
if (!$image.parent().is('a.fancybox')) { | |
$image.wrap(`<a class="fancybox" href="${imageLink}" itemscope itemtype="https://schema.org/ImageObject" itemprop="url"></a>`); | |
} | |
// 重新选取包裹图片的 <a> 标签,并统一设置属性 | |
const $anchor = $image.parent('a.fancybox'); | |
$anchor.attr({ | |
'data-fancybox': 'default', | |
'rel': 'default', | |
// 明确指定缩略图 URL | |
'data-thumb': imageLink | |
}); | |
let captionClass = 'image-info'; | |
if (!$image.is('a img')) { | |
$image.data('safe-src', imageLink); | |
if (!$image.is('.gallery img')) { | |
// 此处已统一设置,不重复调用 | |
// $anchor.attr('data-fancybox', 'default').attr('rel', 'default'); | |
} else { | |
captionClass = 'jg-caption'; | |
} | |
} | |
const info = element.getAttribute('title') || $image.attr('alt'); | |
if (info) { | |
console.log('[postFancybox] Found caption:', info); | |
$anchor.attr('data-caption', info); | |
const para = document.createElement('span'); | |
para.textContent = info; | |
para.classList.add(captionClass); | |
insertAfter(element, para); | |
} | |
}); | |
// ** 初始化 justifiedGallery** | |
console.log('[postFancybox] Initializing justifiedGallery...'); | |
$dom.each(p + ' div.gallery', (el, i) => { | |
console.log('[postFancybox] Processing gallery:', el); | |
q(el).justifiedGallery({ | |
rowHeight: q(el).data('height') || 120, | |
rel: `gallery-${i}` | |
}).on('jg.complete', function () { | |
console.log(`[postFancybox] justifiedGallery initialized for gallery-${i}`); | |
q(this).find('a').each((k, ele) => { | |
ele.setAttribute('data-fancybox', `gallery-${i}`); | |
}); | |
}); | |
}); | |
// ** 初始化 Fancybox** | |
console.log('[postFancybox] Initializing Fancybox...'); | |
q.fancybox.defaults.hash = false; | |
q(p + ' .fancybox').fancybox({ | |
loop: true, | |
slideShow: { | |
autoStart: false, | |
speed: 2000 | |
}, | |
thumbs: { | |
autoStart: false, | |
axis: 'y' | |
}, | |
// 指定需要显示的按钮(注意:部分按钮是自定义的) | |
buttons: [ | |
'slideShow', | |
'fullScreen', | |
'download', | |
'zoom', | |
'thumbs', | |
'close' | |
], | |
helpers: { | |
overlay: { | |
locked: false | |
} | |
} | |
}); | |
console.log('[postFancybox] Fancybox initialized successfully.'); | |
}; | |
/** | |
* ** 手动加载 JS 文件 ** | |
* @param {string} url - JS 文件 URL | |
* @returns {Promise} | |
*/ | |
function manualLoadScript(url) { | |
return new Promise((resolve, reject) => { | |
if (!url) { | |
console.warn('[manualLoadScript] Empty URL provided, skipping.'); | |
return resolve(); | |
} | |
const script = document.createElement('script'); | |
script.src = url; | |
script.crossOrigin = 'anonymous'; | |
script.onload = () => { | |
console.log(`[manualLoadScript] Script loaded successfully: ${url}`); | |
resolve(); | |
}; | |
script.onerror = () => { | |
console.error(`[manualLoadScript] Failed to load script: ${url}`); | |
reject(new Error(`Failed to load ${url}`)); | |
}; | |
document.head.appendChild(script); | |
}); | |
} |
import { $dom } from '../library/dom' | |
import { vendorCss, vendorJs } from '../library/loadFile' | |
import { insertAfter } from '../library/proto' | |
// TODO 使用 PhotoSwipe 替换 Fancybox | |
export const postFancybox = (p:string) => { | |
if (document.querySelector(p + ' .md img')) { | |
vendorCss('fancybox') | |
vendorCss('justifiedGallery') | |
vendorJs('jquery', ()=>{ | |
vendorJs('justifiedGallery',()=>{ | |
vendorJs('fancybox', () => { | |
const q = jQuery.noConflict() | |
$dom.each(p + ' p.gallery', (element) => { | |
const box = document.createElement('div') | |
box.className = 'gallery' | |
box.setAttribute('data-height', String(element.getAttribute('data-height') || 220)) | |
box.innerHTML = element.innerHTML.replace(/<br>/g, '') | |
element.parentNode.insertBefore(box, element) | |
element.remove() | |
}) | |
$dom.each(p + ' .md img:not(.emoji):not(.vemoji)', (element) => { | |
const $image = q(element) | |
const imageLink = $image.attr('data-src') || $image.attr('src') // 替换 | |
const $imageWrapLink = $image.wrap('<a class="fancybox" href="' + imageLink + '" itemscope itemtype="https://schema.org/ImageObject" itemprop="url"></a>').parent('a') | |
let info; let captionClass = 'image-info' | |
if (!$image.is('a img')) { | |
$image.data('safe-src', imageLink) | |
if (!$image.is('.gallery img')) { | |
$imageWrapLink.attr('data-fancybox', 'default').attr('rel', 'default') | |
} else { | |
captionClass = 'jg-caption' | |
} | |
} | |
if ((info = element.getAttribute('title'))) { | |
$imageWrapLink.attr('data-caption', info) | |
const para = document.createElement('span') | |
const txt = document.createTextNode(info) | |
para.appendChild(txt) | |
para.addClass(captionClass) | |
insertAfter(element, para) | |
} | |
}) | |
$dom.each(p + ' div.gallery', (el, i) => { | |
// @ts-ignore | |
q(el).justifiedGallery({ | |
rowHeight: q(el).data('height') || 120, | |
rel: 'gallery-' + i | |
}).on('jg.complete', function () { | |
q(this).find('a').each((k, ele) => { | |
ele.setAttribute('data-fancybox', 'gallery-' + i) | |
}) | |
}) | |
}) | |
q.fancybox.defaults.hash = false | |
q(p + ' .fancybox').fancybox({ | |
loop: true, | |
// @ts-ignore | |
helpers: { | |
overlay: { | |
locked: false | |
} | |
} | |
}) | |
// @ts-ignore | |
}, window.jQuery) | |
}) | |
}) | |
} | |
} |
这里面的 ".md" 可以换为 "[itemprop="articleBody"]" 也是同样的效果。
对于新的代码:
- 直接通过函数
manualLoadScript
加载脚本,脚本 url 通过CONFIG['js'][type].url
获取。 - 一开始时,没有使文章的图片有共同的属性 'data-fancybox',导致 fancybox 没有把图片当成同组,导致后面的灯箱工具栏的'slideShow' 和 thumbs 无法显示,即使加在
buttons
里也没有图标,无法使用。
但当修改代码统一设置了属性后,都能正常渲染了:
// 重新选取包裹图片的 <a> 标签,并统一设置属性 | |
const $anchor = $image.parent('a.fancybox'); | |
$anchor.attr({ | |
'data-fancybox': 'default', | |
'rel': 'default', | |
// 明确指定缩略图 URL | |
'data-thumb': imageLink | |
}); |
这里面还专门设置了 'data-thumb': imageLink
用于指向图片链接,使 thumbnail
的预览图都能正常渲染,否则可能因为懒加载,有些图片能加载出来有预览图,而有些图片没有预览图,提前为预览图指向图片地址后成功渲染。
3. fancybox 工具栏配置:
// ** 初始化 Fancybox** | |
console.log('[postFancybox] Initializing Fancybox...'); | |
q.fancybox.defaults.hash = false; | |
q(p + ' .fancybox').fancybox({ | |
loop: true, | |
slideShow: { | |
autoStart: false, | |
speed: 2000 | |
}, | |
thumbs: { | |
autoStart: false, | |
axis: 'y' | |
}, | |
// 指定需要显示的按钮(注意:部分按钮是自定义的) | |
buttons: [ | |
'slideShow', | |
'fullScreen', | |
'download', | |
'zoom', | |
'thumbs', | |
'close' | |
], | |
helpers: { | |
overlay: { | |
locked: false | |
} | |
} | |
}); |
至此,图片的 fancybox 功能完整实现。只需在文章中使用:
<img loading="lazy" src="/miyano/img/post//engine_tools/krkr4.jpg" style="max-width: 100%; height: auto;">

等方式,图片就会自动被包裹标签处理,点击有灯箱效果。
# 代码块和 tab 冲突问题
原来的情况下,由于两个组件(代码块和 tab)都使用了相同的 CSS 类名 “show-btn”,导致样式冲突。tab 组件在外层生成了一个空的 <div class="show-btn"></div>
(通常用来提供 tab 自身的展开或其他操作),而代码块内部也有一个用于显示向下箭头的 <div class="show-btn">
。当两者同时存在时,tab 组件的样式可能会影响代码块内部的按钮定位,从而导致箭头偏移到代码块内部左下的位置。
解决方案是为代码块的箭头使用不同的类名(例如 code-show-btn),并对应调整 HTML 和 CSS,这样就能彻底避免与 tab 组件的 show-btn
产生冲突。
于是全局查找 show-btn
,找到如下文件并进行修改:
.code-show-btn{ // 原来为 show-btn | |
position: fixed; | |
} | |
.code-show-btn { // 原来为 show-btn | |
position: absolute; | |
cursor: pointer; | |
... | |
} |
if (code_container && code_container.find('tr').length > 15) { | |
const showBtn = code_container.querySelector('.code-show-btn') | |
code_container.style.maxHeight = '300px' | |
showBtn.removeClass('open') | |
} | |
if (code_container && code_container.find('tr').length > 15) { | |
const showBtn = code_container.querySelector('.code-show-btn') | |
code_container.style.maxHeight = '' | |
showBtn.addClass('open') | |
} | |
code_container.insertAdjacentHTML('beforeend', '<div class="code-show-btn"><i class="ic i-angle-down"></i></div>') | |
const showBtn = code_container.querySelector('.code-show-btn') | |
const angleDown = document.querySelectorAll('.code-show-btn .i-angle-down') |
简单来说就是进行替换,把 show-btn
替换为 code-show-btn
后解决冲突,问题解决,正常显示.
# 增加文章加密插件
- 安装插件
npm install --save hexo-blog-encrypt |
- 配置文件
可在_config.yml
中统一配置,但具有优先级:文章信息头 > _config.yml > 默认值。
# Security | |
encrypt: # hexo-blog-encrypt | |
abstract: 有东西被加密了, 请输入密码查看. | |
message: 确定要看吗?だめよ.除非... | |
# tags: | |
# - {name: tagName, password: 密码 A} | |
# - {name: tagName, password: 密码 B} | |
theme: xray | |
wrong_pass_message: 抱歉, 这个密码看着不太对, 请再试试. | |
wrong_hash_message: 抱歉, 这个文章不能被校验, 不过您还是能看看解密后的内容. |
其中的 theme
可以修改加密效果的主题,可选有:default、blink、shrink、flip、up、surge、wave、xray 等。
可在文章 Front-Matter
进行配置:
--- | |
title: Hello World | |
tags: | |
- 作为日记加密 | |
date: 2016-03-30 21:12:21 | |
password: mikemessi | |
abstract: 有东西被加密了, 请输入密码查看. | |
message: 您好, 这里需要密码. | |
wrong_pass_message: 抱歉, 这个密码看着不太对, 请再试试. | |
wrong_hash_message: 抱歉, 这个文章不能被校验, 不过您还是能看看解密后的内容. | |
--- | |
或者直接: | |
--- | |
title: Hello World | |
date: 2016-03-30 21:18:02 | |
password: hello | |
--- |
- 修改目录显示问题
需要对加密的文章的目录问题进行修正,即加密时看不到目录,解密后才能看到。修改文件themes\shokaX\layout\_mixin\sidebar.pug
div(class="inner") | |
div(class="panels") | |
div(class="inner") | |
div(class="contents panel pjax" data-title=__('sidebar.toc')) | |
if display_toc | |
// 根据是否加密来决定 TOC 的显示与内容 | |
div#toc-div.toc-article(style=(page.encrypt ? "display:none" : "")) | |
if page.encrypt | |
// 解密后的页面使用原始内容生成目录 | |
!= toc(page.origin, {list_number: true}) | |
else | |
// 普通文章使用页面内容生成目录 | |
!= toc(page.content, {list_number: true}) |
div(class="inner") | |
div(class="panels") | |
div(class="inner") | |
div(class="contents panel pjax" data-title=__('sidebar.toc')) | |
if display_toc | |
!= toc(page.content) |
这样,文章就能成功加密了。
# 增加 AI 总结功能
在主题里,虽然已经有 AI 总结的配置接口,但是有些不足:例如缺少对长文章的处理,对于较长的文章,若直接输入 ai 可能宕机;另外就是接口目前只兼容 OPENAI,可以进行扩展。
于是目前改进的方案就是:增加对长文章的处理。(接口方面需要再说,目前采用的是 Kimi 的接口,可以兼容。)
整体架构:
- 提取与预处理:读取 Markdown 原文、替换变量、解析成纯文本。
- 缓存管理:利用本地 JSON 文件(summary.json)存储摘要和对应的文本哈希,避免重复调用接口。
- 长文处理:对于超过一定字符数的内容,采用分块策略,分别生成摘要后综合成最终摘要。
- 文本差异检测:对比缓存中的原始文本与当前文本,判断是否有大段增删变化,从而决定是否需要更新摘要。
- API 调用:通过同步调用 curl 命令与 AI 模型接口进行交互,生成摘要并重试保证稳定性。
- Hexo Helper 注册:将生成摘要的逻辑封装成 Hexo Helper 供模板调用。
# 提取与预处理文本
- 读取 Markdown 原文
- 变量替换:函数
replacePlaceholders
采用正则表达式将文本中的 ${变量} 替换为文章数据中对应的值。 - Markdown 转 HTML:利用 markdown-it 将 Markdown 渲染为 HTML,再借助 cheerio 加载 HTML 进行 DOM 操作。
- 提取正文:遍历 HTML 中的段落、列表、标题(h1–h6)等元素,过滤掉代码块(
<pre>
标签),提取纯文本内容。标题部分会附带 Markdown 格式的井号(#)前缀来保留层级信息。
# 缓存管理
- 加载缓存:启动时尝试加载
summary.json
,若不存在则创建新的数据库;缓存数据以文章路径和指定的 dbPath 作为键,存储生成的摘要、原始文本及其哈希值。 - 文本哈希计算:使用 MD5 对归一化文本(去除多余空白、转小写)进行哈希计算,以便检测文章是否发生变化,从而判断是否需要重新生成摘要。
# 长文章处理
对于内容较长(超过 4000 字符)的文章,代码采用智能分块处理:
- 章节划分:使用正则表达式依据 Markdown 标题(#)进行章节划分,确保各分块基于 “精准章节”。
- 动态合并:按照设定的最大分块大小(4000 字符),动态合并相邻章节。
- 场景 1:如果单个章节超过限制,则调用 splitOversizedSection 函数,按段落再次细分(每个子块不少于 2000 字)。
- 场景 2:普通章节直接累积,直到接近上限,再生成一个分块。
- 旧方案对比:注释中展示的旧方案是先按 \n 与 # 分割后再进行简单合并,没有考虑章节内过长的情况,逻辑相对简单;而当前方案通过 “精准章节划分” 与 “动态分块合并” 相结合,更加灵活且能处理超大章节。
# AI 摘要生成策略
摘要生成逻辑:函数 generateSummaryForContent 根据文章长度判断:
- 短文直接生成:如果文章内容在 4000 字以内,直接拼接提示词与内容调用 generateSummarySync 生成摘要。
- 长文分块生成:若文章过长,则先将文章分块。对每个块使用 CHUNK_PROMPT(要求生成 180 字以内摘要)调用接口生成小摘要,最后将所有小摘要合并后通过 FINAL_PROMPT(要求 300 字以内的最终摘要)生成综合摘要。
# API 调用与重试机制
- 调用方式:利用 Node.js 内置的 child_process.spawnSync 同步调用 curl 命令,构造 JSON 请求体(包含模型、消息、温度参数等)提交给配置好的 AI 接口。
- 错误处理:在 API 调用时设置多次重试(最多 5 次),每次失败后根据配置的参数采用不同的初始等待时间,并进行指数退避(逐步增加等待时间)。
数据清洗:在发送请求前,对文本中的反斜杠和引号进行转义,保证 JSON 请求体格式正确。
# 摘要生成
- 无缓存记录 或 缓存记录中未保存原始文本时,直接调用
generateSummaryForContent
对全文进行摘要生成。 - 已经有缓存记录,且内容没有变化时,直接使用已有的缓存。
- 已经有缓存记录,但内容发生变化时:
- 文本差异比较:使用
getParagraphDiffs
(基于 Diff.diffWords)比较缓存中的原文与当前内容,分别提取新增和删除的文本。利用isContinuous
函数遍历段落,累计连续段落的长度,判断累计值是否超过阈值,以判断内容是否发生了大幅度变化。(注:即使新增内容总字数超过阈值,但如果分散在多个短段落(<50 字)中,仍会返回 false)
并且使用双重判断条件:同时检查总字数阈值和段落连续性,避免误判分散修改,如:"if (deletionDelta>= threshold && isContinuous (diff.removed, threshold))" - 连续删除超过阈值:构造提示,让 AI 根据删除的内容调整原摘要,确保摘要反映减少的内容。
- 连续新增超过阈值:构造提示,让 AI 补充新增内容到原摘要中。
- 变化不显著:直接返回缓存中的摘要。
- 最后对缓存内容进行更新,写入
summary.json
。
# Hexo Helper 注册
- get_summary:注册了一个 Hexo Helper,接收当前文章对象,调用
getContent
获取正文,再调用postMessage
完成摘要生成。返回的摘要会在博客模板中显示。 - get_introduce:另外注册了一个 Helper 用于返回配置中定义的摘要介绍信息。
# 代码修改
修改 themes\shokaX\scripts\helpers\summary_ai.js
代码如下:
const fs = require("node:fs"); | |
const crypto = require("crypto"); | |
const { spawnSync } = require("child_process"); | |
const Diff = require('diff'); | |
const dbFile = "summary.json"; | |
let globalSummaryCache = {}; | |
// ** 加载 summary.json** | |
if (fs.existsSync(dbFile)) { | |
globalSummaryCache = JSON.parse(fs.readFileSync(dbFile, { encoding: "utf-8" })); | |
} else { | |
hexo.log.warn("[Init] 未找到 summary.json,将创建新的数据库"); | |
} | |
// ** 提示词工程配置 ** | |
const CHUNK_PROMPT = "请为以下文章内容生成一个简明且全面的摘要,要求涵盖文章主要观点,禁用Markdown,字数严格控制在180字以内:\n"; | |
const FINAL_PROMPT = "请综合以下各部分摘要,生成一份完整、逻辑连贯的文章概述。要求在保留各部分关键信息的基础上形成统一整体,禁用Markdown,且严格控制字数在300字以内:\n"; | |
// ** 计算文本哈希(改进:归一化文本以忽略格式细微差异)** | |
function computeHash(text) { | |
const normalizedText = text.replace(/\s+/g, " ").trim().toLowerCase(); | |
const hash = crypto.createHash("md5").update(normalizedText).digest("hex"); | |
hexo.log.debug(`[Hash] 计算归一化后哈希: ${hash}`); | |
return hash; | |
} | |
// ** 替换 Markdown 变量 ** | |
function replacePlaceholders(text, data) { | |
return text.replace(/\$\{(\w+)\}/g, (match, key) => data[key] || ""); | |
} | |
/** | |
* 直接处理 raw Markdown 文本,提取实际内容: | |
* 1. 移除代码块和行内代码(保留代码内容时去除反引号) | |
* 2. 将 Markdown 图片(  )替换为 alt 文本, | |
* 同时处理 HTML 格式图片(<img ...>),提取 alt 或 title 属性 | |
* 3. 将链接 [text](url) 替换为纯文本 text | |
* 4. 去除加粗、斜体等标记,但保留行首的 # 号(用于章节划分) | |
* 5. 将单独一行的水平分割线(如 ---、***、___)替换为占位符 | |
* 6. 处理引用行(移除行首的 > 号) | |
* 7. 对每一行做 trim 处理,保留段落间换行(不再完全去除行内空格,以便后续 diff 调用专门处理空白字符) | |
*/ | |
function processContent(markdownContent) { | |
hexo.log.debug("[Markdown] 开始解析正文(直接处理 raw Markdown)..."); | |
let content = markdownContent; | |
// 1. 处理代码块,使用状态机确保成对匹配,防止代码块内有 ``` 造成误匹配 | |
let inCodeBlock = false; | |
let lines = content.split("\n"); | |
let processedLines = []; | |
for (let line of lines) { | |
if (/^```/.test(line.trim())) { | |
inCodeBlock = !inCodeBlock; // 切换代码块状态 | |
continue; // 跳过代码块内容 | |
} | |
if (!inCodeBlock) { | |
processedLines.push(line); | |
} | |
} | |
content = processedLines.join("\n"); | |
// 2. 处理行内代码,将 `code` 替换为 code 本身 | |
content = content.replace(/`([^`]+)`/g, '$1'); | |
// 3. 处理 Markdown 图片:  替换为 alt 文本 | |
content = content.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1'); | |
// 4. 处理 HTML 图片标签:提取 alt 属性,如无 alt 则使用 title 属性 | |
content = content.replace(/<img\s+[^>]*>/gi, function(match) { | |
let altMatch = match.match(/alt\s*=\s*"(.*?)"/i); | |
if (altMatch && altMatch[1]) return altMatch[1]; | |
let titleMatch = match.match(/title\s*=\s*"(.*?)"/i); | |
if (titleMatch && titleMatch[1]) return titleMatch[1]; | |
return ''; | |
}); | |
// 5. 处理链接: [text](url) 替换为 text | |
content = content.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1'); | |
// 6. 去除加粗和斜体标记,但保留内容(注意不要去除行首的 '#') | |
content = content.replace(/(\*\*|__)(.*?)\1/g, '$2'); | |
content = content.replace(/(\*|_)(.*?)\1/g, '$2'); | |
// 7. 处理水平分割线:单独一行的 ---、*** 或 ___ 替换为占位符(这里用 "..." 表示) | |
content = content.replace(/^(?:-{3,}|[*_]{3,})\s*$/gm, '...'); | |
// 8. 处理引用:去除行首的 > 符号及后面的空格 | |
content = content.replace(/^>\s?/gm, ''); | |
// 9. 对每一行做 trim 处理,保留行内空格(这样行首的 '#' 可被保留),并保留换行符用于段落分隔 | |
content = content.split('\n').map(line => line.trim()).join('\n'); | |
// 10. 如果需要将多余的连续空行压缩为最多两个(可选) | |
content = content.replace(/\n{3,}/g, '\n\n'); | |
hexo.log.debug(`[Markdown] 解析完成,正文长度: ${content.length} 字符`); | |
return content; | |
} | |
// ** 获取文章正文 ** | |
function getContent(post) { | |
let raw = post?.raw ?? post?._content ?? post.content; | |
raw = replacePlaceholders(raw, post); | |
return processContent(raw); | |
} | |
// ** 计算文本差异,返回连续的差异块 ** | |
function getDiff(oldText, newText) { | |
// 如果其中有一个为空,则返回空差异 | |
if (!oldText || !newText) { | |
return { oldBlock: "", newBlock: "" }; | |
} | |
let prefix = 0; | |
const minLength = Math.min(oldText.length, newText.length); | |
while (prefix < minLength && oldText[prefix] === newText[prefix]) { | |
prefix++; | |
} | |
let oldSuffix = oldText.length - 1; | |
let newSuffix = newText.length - 1; | |
while (oldSuffix >= prefix && newSuffix >= prefix && oldText[oldSuffix] === newText[newSuffix]) { | |
oldSuffix--; | |
newSuffix--; | |
} | |
const oldBlock = oldText.substring(prefix, oldSuffix + 1); | |
const newBlock = newText.substring(prefix, newSuffix + 1); | |
return { oldBlock, newBlock }; | |
} | |
function getParagraphDiffs(oldText, newText) { | |
const paragraphs = { | |
added: [], | |
removed: [] | |
}; | |
// 按段落分割(假设段落由两个换行符分隔) | |
const diff = Diff.diffWords(oldText, newText); | |
diff.forEach((part) => { | |
if (part.added) { | |
paragraphs.added.push(part.value); | |
} else if (part.removed) { | |
paragraphs.removed.push(part.value); | |
} | |
}); | |
// 定义处理函数:保留换行符,去除其他空白字符 | |
const processText = (text) => { | |
// [^\S\n] 匹配所有空白字符,但排除换行符 | |
return text.replace(/[^\S\n]+/g, ''); | |
}; | |
return { | |
added: processText(paragraphs.added.join('')), | |
removed: processText(paragraphs.removed.join('')) | |
}; | |
} | |
function isContinuous(text, threshold) { | |
// 按段落分割(兼容多种换行符) | |
const paragraphs = text.split(/(?:\n\s*){2,}/).filter(p => p.trim()); | |
// 统计连续段落总长度 | |
let totalLength = 0; | |
for (const p of paragraphs) { | |
const cleanParagraph = p.replace(/\s/g, ''); | |
totalLength += cleanParagraph.length; | |
// 发现连续段落总长度超过阈值 | |
if (totalLength >= threshold) return true; | |
// 如果遇到短段落(<50 字),重置计数器(视为不连续) | |
if (cleanParagraph.length < 50) { | |
totalLength = 0; | |
} | |
} | |
return false; | |
} | |
function processLongContent(content) { | |
hexo.log.info("[Summary] 启动智能分块处理流程"); | |
const MAX_CHUNK_SIZE = 4000; | |
const SECTION_REGEX = /\n(?=#{1,6}\s)/g; | |
let sections = content.split(SECTION_REGEX).map(s => s.trim()); | |
// 识别非标题段落 | |
const refinedSections = []; | |
let tempParagraph = ""; | |
for (const section of sections) { | |
const firstLine = section.split('\n')[0]; | |
if (/^#{1,6}\s/.test(firstLine)) { | |
if (tempParagraph) { | |
refinedSections.push(...splitOversizedSection(tempParagraph, MAX_CHUNK_SIZE)); | |
tempParagraph = ""; | |
} | |
refinedSections.push(section); | |
} else { | |
tempParagraph += tempParagraph ? `\n${section}` : section; | |
} | |
} | |
if (tempParagraph) { | |
refinedSections.push(...splitOversizedSection(tempParagraph, MAX_CHUNK_SIZE)); | |
} | |
// 分块合并 | |
const chunks = []; | |
let currentChunk = ""; | |
for (const section of refinedSections) { | |
if (section.length > MAX_CHUNK_SIZE) { | |
if (currentChunk) chunks.push(currentChunk); | |
currentChunk = ""; | |
chunks.push(...splitOversizedSection(section, MAX_CHUNK_SIZE)); | |
continue; | |
} | |
const projectedLength = currentChunk.length + section.length + 1; | |
if (projectedLength <= MAX_CHUNK_SIZE) { | |
currentChunk += currentChunk ? `\n${section}` : section; | |
} else { | |
chunks.push(currentChunk); | |
currentChunk = section; | |
} | |
} | |
if (currentChunk) chunks.push(currentChunk); | |
hexo.log.info(`[Summary] 最终分块数: ${chunks.length}`); | |
return chunks; | |
} | |
// 超长章节分割策略 | |
function splitOversizedSection(section, maxSize) { | |
const MIN_SPLIT_SIZE = 2000; // 最小分割单元 | |
const subChunks = []; | |
let buffer = ""; | |
// 按段落进行分割 | |
const paragraphs = section.split('\n').filter(p => p.trim()); | |
for (const para of paragraphs) { | |
const projectedLength = buffer.length + para.length + 1; | |
if (projectedLength > maxSize && buffer.length > MIN_SPLIT_SIZE) { | |
subChunks.push(buffer); | |
buffer = para; | |
} else { | |
buffer += buffer ? `\n${para}` : para; | |
} | |
} | |
if (buffer) subChunks.push(buffer); | |
hexo.log.debug(`[Split] 将 ${section.length} 字符章节分割为 ${subChunks.length} 个子块`); | |
return subChunks; | |
} | |
// ** 生成文章摘要(提取公共逻辑)** | |
function generateSummaryForContent(content, startMessage) { | |
if (content.length <= 4000) { | |
("[Summary] 文章长度 < 4000 字符,直接生成摘要"); | |
return generateSummarySync(`${startMessage}\n${content}`, hexo.theme.config.summary); | |
} else { | |
hexo.log.info("[Summary] 文章过长,进行分段处理"); | |
const chunks = processLongContent(content); | |
// let sections = content.split(/\n(?=#)/).filter((s) => s.trim().length > 0); | |
// let chunks = []; | |
// let currentChunk = ""; | |
// for (let section of sections) { | |
// if (currentChunk.length + section.length <= 4000) { | |
// currentChunk += (currentChunk ? "\n" : "") + section; | |
// } else { | |
// chunks.push(currentChunk); | |
// currentChunk = section; | |
// } | |
// } | |
// if (currentChunk) chunks.push(currentChunk); | |
//hexo.log.info (`[Summary] 分成 ${chunks.length} 个部分进行处理`); | |
let chunkSummaries = []; | |
for (let i = 0; i < chunks.length; i++) { | |
hexo.log.info(`[Summary] 处理第 ${i + 1}/${chunks.length} 段`); | |
chunkSummaries.push(generateSummarySync(`${CHUNK_PROMPT}${chunks[i]}`, hexo.theme.config.summary)); | |
} | |
hexo.log.info("[Summary] 综合所有小摘要,生成最终摘要"); | |
return generateSummarySync(`${FINAL_PROMPT}${chunkSummaries.join("\n")}`, hexo.theme.config.summary); | |
} | |
} | |
// ** 同步 API 调用 ** | |
function generateSummarySync(messageContent, config) { | |
hexo.log.info("[API] 发送摘要请求,文本长度: " + messageContent.length); | |
const requestBody = JSON.stringify({ | |
model: "moonshot-v1-8k", | |
messages: [{ | |
role: "user", | |
content: messageContent | |
.replace(/\\/g, "\\\\") | |
.replace(/\"/g, "\\\"") | |
}], | |
temperature: 0.7 | |
}); | |
try { | |
JSON.parse(requestBody); | |
} catch (e) { | |
hexo.log.error("[API] 无效的 JSON 请求体: " + e.message); | |
hexo.log.debug("[DEBUG] 问题请求体内容: " + requestBody); | |
return "摘要生成失败(请求格式错误)"; | |
} | |
const curlBaseCommand = [ | |
"curl", | |
"-X", "POST", | |
"-H", `Authorization: Bearer ${config.openai.apikey}`, | |
"-H", "Content-Type: application/json", | |
"--data-binary", requestBody, | |
]; | |
if (config.proxy && config.proxy.enable) { | |
curlBaseCommand.push("--proxy", config.proxy.address); | |
} | |
curlBaseCommand.push(`${config.openai.remote}/v1/chat/completions`); | |
const baseWaitTime = config.pricing === "trial" ? 10000 : 500; | |
let attempt = 0; | |
const maxRetries = 5; | |
let waitTime = baseWaitTime; | |
while (attempt < maxRetries) { | |
hexo.log.info(`[API] 尝试 ${attempt + 1} 次请求,等待 ${waitTime}ms...`); | |
if (attempt > 0) { | |
const startTime = Date.now(); | |
while (Date.now() - startTime < waitTime) {} | |
} | |
let response; | |
try { | |
response = spawnSync( | |
"curl", | |
[ | |
"-X", "POST", | |
"-H", `Authorization: Bearer ${config.openai.apikey}`, | |
"-H", "Content-Type: application/json", | |
"--data-binary", "@-", | |
`${config.openai.remote}/v1/chat/completions` | |
], | |
{ | |
input: requestBody, | |
encoding: "utf-8" | |
} | |
); | |
} catch (err) { | |
hexo.log.error("[API] curl 执行异常: " + err.message); | |
return "摘要生成失败(curl 执行异常)"; | |
} | |
if (response.error) { | |
hexo.log.error(`[API] curl 命令执行失败: ${response.error.message}`); | |
} | |
try { | |
if (!response.stdout || response.stdout.trim() === "") { | |
throw new Error("空响应"); | |
} | |
let jsonResponse = JSON.parse(response.stdout); | |
if (!jsonResponse.choices || jsonResponse.choices.length === 0) { | |
hexo.log.error("[API] API 响应无 choices 数据"); | |
throw new Error("API 响应无 choices 数据"); | |
} | |
let summary = jsonResponse.choices[0].message.content; | |
hexo.log.info(`[API] 摘要生成成功,长度: ${summary.length}`); | |
return summary; | |
} catch (error) { | |
hexo.log.error(`[API] 摘要解析失败: ${error.message}`); | |
} | |
attempt++; | |
waitTime = Math.max(waitTime * 1.5, 10000); | |
} | |
hexo.log.error(`[API] 经过 ${maxRetries} 次尝试后仍然失败`); | |
return "摘要生成失败"; | |
} | |
// ** 摘要处理(同步)** | |
function postMessage(path, content, dbPath, startMessage) { | |
if (!hexo.theme.config.summary.enable) { | |
hexo.log.warn("[Summary] 摘要功能未启用"); | |
return ""; | |
} | |
if (!globalSummaryCache[path]) { | |
globalSummaryCache[path] = {}; | |
} | |
const newHash = computeHash(content); | |
const cached = globalSummaryCache[path][dbPath]; | |
let summaryResult = ""; | |
const threshold = 500; // 阈值:500 字 | |
if (cached && cached.hash === newHash) { | |
hexo.log.info("[Cache] 命中缓存,直接返回摘要"); | |
return cached.summary; | |
} | |
// 如果没有缓存或缓存中未保存原始文本,则直接重新生成全文摘要 | |
if (!cached || !cached.content) { | |
hexo.log.info("[Summary] 首次生成摘要或缓存中未保存原文本,生成全文摘要..."); | |
summaryResult = generateSummaryForContent(content, startMessage); | |
} else { | |
// 进行文本差异比较 | |
// const diff = getDiff(cached.content, content); | |
// const deletionDelta = diff.oldBlock.length - diff.newBlock.length; | |
// const additionDelta = diff.newBlock.length - diff.oldBlock.length; | |
const diff = getParagraphDiffs(cached.content, content); | |
const deletionDelta = diff.removed.length; | |
const additionDelta = diff.added.length; | |
hexo.log.info(`[Summary] 文本差异: 删除 ${deletionDelta} 字,新增 ${additionDelta} 字`); | |
if (deletionDelta >= threshold && isContinuous(diff.removed, threshold)) { | |
hexo.log.info(`[Summary] 检测到连续删除超过 ${threshold} 字: 删除了 ${deletionDelta} 字`); | |
const prompt = `请在保留以下原摘要核心观点的基础上,根据下面被删除的文本内容,适当调整摘要,使之反映文章内容减少后的实际情况。请注意摘要中原有的重要信息应尽可能保留,同时体现因内容删减而变化的要点。请严格控制字数在300字以内,禁用Markdown格式,输出最终的文章概述。\n原摘要:\n${cached.summary}\n删除的内容:\n${diff.removed}`; | |
hexo.log.info(`[Summary] 删除内容:${diff.removed}`); | |
summaryResult = generateSummarySync(prompt, hexo.theme.config.summary); | |
} else if (additionDelta >= threshold && isContinuous(diff.added, threshold)) { | |
hexo.log.info(`[Summary] 检测到连续新增超过 ${threshold} 字: 新增了 ${additionDelta} 字`); | |
const prompt = `请在保持以下原摘要主要观点不变的前提下,根据下面新增的文本内容,适当补充摘要,确保摘要同时涵盖原有内容和新增信息。请注意不要让新增部分抢占过多篇幅,最终输出的文章概述需符合逻辑连贯、重点突出,且严格控制在300字以内,禁用Markdown格式,输出最终的文章概述。。\n原摘要:\n${cached.summary}\n新增的内容:\n${diff.added}`; | |
hexo .log.info(`[Summary] 新增内容:${diff.added}`); | |
summaryResult = generateSummarySync(prompt, hexo.theme.config.summary); | |
} else { | |
hexo.log.info("[Summary] 文本差异未达到阈值,返回原全文摘要"); | |
summaryResult = cached.summary; | |
} | |
} | |
hexo.log.info("[Summary] 摘要生成完成,更新缓存"); | |
globalSummaryCache[path][dbPath] = { summary: summaryResult, hash: newHash, content: content }; | |
fs.writeFileSync(dbFile, JSON.stringify(globalSummaryCache, null, 2)); | |
hexo.log.info("[Summary] 缓存已更新"); | |
return summaryResult; | |
} | |
// ** 注册 Hexo Helper** | |
hexo.extend.helper.register("get_summary", (post) => { | |
return postMessage(post.path, getContent(post), "summary", "请为下述文章提供一份300字以内的简明摘要,要求中文回答且尽可能简洁:"); | |
}); | |
hexo.extend.helper.register("get_introduce", () => { | |
return hexo.theme.config.summary.introduce; | |
}); |
const fs = require("node:fs"); | |
const crypto = require("crypto"); | |
const cheerio = require("cheerio"); | |
const MarkdownIt = require("markdown-it"); | |
const { spawnSync } = require("child_process"); | |
const Diff = require('diff'); | |
const dbFile = "summary.json"; | |
let globalSummaryCache = {}; | |
// ** 加载 summary.json** | |
if (fs.existsSync(dbFile)) { | |
globalSummaryCache = JSON.parse(fs.readFileSync(dbFile, { encoding: "utf-8" })); | |
} else { | |
hexo.log.warn("[Init] 未找到 summary.json,将创建新的数据库"); | |
} | |
// ** 提示词工程配置 ** | |
const CHUNK_PROMPT = "请为以下文章内容生成摘要,要求字数严格控制180字以内,禁用Markdown:\n"; | |
const FINAL_PROMPT = "请综合以下摘要生成最终摘要,要求字数严格控制300字以内,禁用Markdown:\n"; | |
// ** 计算文本哈希(改进:归一化文本以忽略格式细微差异)** | |
function computeHash(text) { | |
const normalizedText = text.replace(/\s+/g, " ").trim().toLowerCase(); | |
const hash = crypto.createHash("md5").update(normalizedText).digest("hex"); | |
hexo.log.debug(`[Hash] 计算归一化后哈希: ${hash}`); | |
return hash; | |
} | |
// ** 替换 Markdown 变量 ** | |
function replacePlaceholders(text, data) { | |
return text.replace(/\$\{(\w+)\}/g, (match, key) => data[key] || ""); | |
} | |
// ** 解析 Markdown,提取正文 ** | |
function processContent(markdownContent) { | |
hexo.log.debug("[Markdown] 开始解析正文..."); | |
const md = new MarkdownIt(); | |
const html = md.render(markdownContent); | |
const $ = cheerio.load(html); | |
$("pre").remove(); | |
let texts = []; | |
$("li, p, h1, h2, h3, h4, h5, h6").each(function () { | |
const tag = $(this)[0].tagName.toLowerCase(); | |
const text = $(this).text().trim(); | |
if (!text) return; | |
texts.push(tag.startsWith("h") ? `\n${"#".repeat(parseInt(tag[1]))} ${text}\n` : text); | |
}); | |
const result = texts.join("\n"); | |
hexo.log.debug(`[Markdown] 解析完成,正文长度: ${result.length} 字符`); | |
return result; | |
} | |
// ** 获取文章正文 ** | |
function getContent(post) { | |
let raw = post?.raw ?? post?._content ?? post.content; | |
raw = replacePlaceholders(raw, post); | |
return processContent(raw); | |
} | |
// ** 计算文本差异,返回连续的差异块 ** | |
function getDiff(oldText, newText) { | |
// 如果其中有一个为空,则返回空差异 | |
if (!oldText || !newText) { | |
return { oldBlock: "", newBlock: "" }; | |
} | |
let prefix = 0; | |
const minLength = Math.min(oldText.length, newText.length); | |
while (prefix < minLength && oldText[prefix] === newText[prefix]) { | |
prefix++; | |
} | |
let oldSuffix = oldText.length - 1; | |
let newSuffix = newText.length - 1; | |
while (oldSuffix >= prefix && newSuffix >= prefix && oldText[oldSuffix] === newText[newSuffix]) { | |
oldSuffix--; | |
newSuffix--; | |
} | |
const oldBlock = oldText.substring(prefix, oldSuffix + 1); | |
const newBlock = newText.substring(prefix, newSuffix + 1); | |
return { oldBlock, newBlock }; | |
} | |
function getParagraphDiffs(oldText, newText) { | |
const paragraphs = { | |
added: [], | |
removed: [] | |
}; | |
// 按段落分割(假设段落由两个换行符分隔) | |
const diff = Diff.diffWords(oldText, newText); | |
diff.forEach((part) => { | |
if (part.added) { | |
paragraphs.added.push(part.value); | |
} else if (part.removed) { | |
paragraphs.removed.push(part.value); | |
} | |
}); | |
// 定义处理函数:保留换行符,去除其他空白字符 | |
const processText = (text) => { | |
// [^\S\n] 匹配所有空白字符,但排除换行符 | |
return text.replace(/[^\S\n]+/g, ''); | |
}; | |
return { | |
added: paragraphs.added.join(''), | |
removed: paragraphs.removed.join('') | |
}; | |
} | |
function isContinuous(text, threshold) { | |
// 按段落分割(兼容多种换行符) | |
const paragraphs = text.split(/(?:\n\s*){2,}/).filter(p => p.trim()); | |
// 统计连续段落总长度 | |
let totalLength = 0; | |
for (const p of paragraphs) { | |
const cleanParagraph = p.replace(/\s/g, ''); | |
totalLength += cleanParagraph.length; | |
// 发现连续段落总长度超过阈值 | |
if (totalLength >= threshold) return true; | |
// 如果遇到短段落(<50 字),重置计数器(视为不连续) | |
if (cleanParagraph.length < 50) { | |
totalLength = 0; | |
} | |
} | |
return false; | |
} | |
function processLongContent(content) { | |
hexo.log.info("[Summary] 启动智能分块处理流程"); | |
// 第一阶段:精准章节划分 | |
const SECTION_REGEX = /\n(?=#{1,6}\s)/g; | |
let sections = content.split(SECTION_REGEX) | |
.map(s => s.trim()) | |
.filter(s => { | |
// 有效性验证:确保是合法标题段落 | |
const firstLine = s.split('\n')[0]; | |
return /^#{1,6}\s/.test(firstLine); | |
}); | |
// 第二阶段:动态分块合并 | |
const MAX_CHUNK_SIZE = 4000; | |
const chunks = []; | |
let currentChunk = ""; | |
for (const section of sections) { | |
// 场景 1:章节本身超过限制 | |
if (section.length > MAX_CHUNK_SIZE) { | |
if (currentChunk) chunks.push(currentChunk); | |
currentChunk = ""; | |
// 第三阶段:长章节再分割 | |
const subChunks = splitOversizedSection(section, MAX_CHUNK_SIZE); | |
chunks.push(...subChunks); | |
continue; | |
} | |
// 场景 2:正常合并流程 | |
const projectedLength = currentChunk.length + section.length + 1; // +1 for \n | |
if (projectedLength <= MAX_CHUNK_SIZE) { | |
currentChunk += currentChunk ? `\n${section}` : section; | |
} else { | |
chunks.push(currentChunk); | |
currentChunk = section; | |
} | |
} | |
if (currentChunk) chunks.push(currentChunk); | |
hexo.log.info(`[Summary] 最终分块数: ${chunks.length}`); | |
return chunks; | |
} | |
// 超长章节分割策略 | |
function splitOversizedSection(section, maxSize) { | |
const MIN_SPLIT_SIZE = 2000; // 最小分割单元 | |
const subChunks = []; | |
let buffer = ""; | |
// 按段落进行分割 | |
const paragraphs = section.split('\n').filter(p => p.trim()); | |
for (const para of paragraphs) { | |
const projectedLength = buffer.length + para.length + 1; | |
if (projectedLength > maxSize && buffer.length > MIN_SPLIT_SIZE) { | |
subChunks.push(buffer); | |
buffer = para; | |
} else { | |
buffer += buffer ? `\n${para}` : para; | |
} | |
} | |
if (buffer) subChunks.push(buffer); | |
hexo.log.debug(`[Split] 将 ${section.length} 字符章节分割为 ${subChunks.length} 个子块`); | |
return subChunks; | |
} | |
// ** 生成文章摘要(提取公共逻辑)** | |
function generateSummaryForContent(content, startMessage) { | |
if (content.length <= 4000) { | |
("[Summary] 文章长度 < 4000 字符,直接生成摘要"); | |
return generateSummarySync(`${startMessage}\n${content}`, hexo.theme.config.summary); | |
} else { | |
hexo.log.info("[Summary] 文章过长,进行分段处理"); | |
const chunks = processLongContent(content); | |
// let sections = content.split(/\n(?=#)/).filter((s) => s.trim().length > 0); | |
// let chunks = []; | |
// let currentChunk = ""; | |
// for (let section of sections) { | |
// if (currentChunk.length + section.length <= 4000) { | |
// currentChunk += (currentChunk ? "\n" : "") + section; | |
// } else { | |
// chunks.push(currentChunk); | |
// currentChunk = section; | |
// } | |
// } | |
// if (currentChunk) chunks.push(currentChunk); | |
//hexo.log.info (`[Summary] 分成 ${chunks.length} 个部分进行处理`); | |
let chunkSummaries = []; | |
for (let i = 0; i < chunks.length; i++) { | |
hexo.log.info(`[Summary] 处理第 ${i + 1}/${chunks.length} 段`); | |
chunkSummaries.push(generateSummarySync(`${CHUNK_PROMPT}${chunks[i]}`, hexo.theme.config.summary)); | |
} | |
hexo.log.info("[Summary] 综合所有小摘要,生成最终摘要"); | |
return generateSummarySync(`${FINAL_PROMPT}${chunkSummaries.join("\n")}`, hexo.theme.config.summary); | |
} | |
} | |
// ** 同步 API 调用 ** | |
function generateSummarySync(messageContent, config) { | |
hexo.log.info("[API] 发送摘要请求,文本长度: " + messageContent.length); | |
const requestBody = JSON.stringify({ | |
model: "moonshot-v1-8k", | |
messages: [{ | |
role: "user", | |
content: messageContent | |
.replace(/\\/g, "\\\\") | |
.replace(/\"/g, "\\\"") | |
}], | |
temperature: 0.7 | |
}); | |
try { | |
JSON.parse(requestBody); | |
} catch (e) { | |
hexo.log.error("[API] 无效的 JSON 请求体: " + e.message); | |
hexo.log.debug("[DEBUG] 问题请求体内容: " + requestBody); | |
return "摘要生成失败(请求格式错误)"; | |
} | |
const curlBaseCommand = [ | |
"curl", | |
"-X", "POST", | |
"-H", `Authorization: Bearer ${config.openai.apikey}`, | |
"-H", "Content-Type: application/json", | |
"--data-binary", requestBody, | |
]; | |
if (config.proxy && config.proxy.enable) { | |
curlBaseCommand.push("--proxy", config.proxy.address); | |
} | |
curlBaseCommand.push(`${config.openai.remote}/v1/chat/completions`); | |
const baseWaitTime = config.pricing === "trial" ? 10000 : 500; | |
let attempt = 0; | |
const maxRetries = 5; | |
let waitTime = baseWaitTime; | |
while (attempt < maxRetries) { | |
hexo.log.info(`[API] 尝试 ${attempt + 1} 次请求,等待 ${waitTime}ms...`); | |
if (attempt > 0) { | |
const startTime = Date.now(); | |
while (Date.now() - startTime < waitTime) {} | |
} | |
let response; | |
try { | |
response = spawnSync( | |
"curl", | |
[ | |
"-X", "POST", | |
"-H", `Authorization: Bearer ${config.openai.apikey}`, | |
"-H", "Content-Type: application/json", | |
"--data-binary", "@-", | |
`${config.openai.remote}/v1/chat/completions` | |
], | |
{ | |
input: requestBody, | |
encoding: "utf-8" | |
} | |
); | |
} catch (err) { | |
hexo.log.error("[API] curl 执行异常: " + err.message); | |
return "摘要生成失败(curl 执行异常)"; | |
} | |
if (response.error) { | |
hexo.log.error(`[API] curl 命令执行失败: ${response.error.message}`); | |
} | |
try { | |
if (!response.stdout || response.stdout.trim() === "") { | |
throw new Error("空响应"); | |
} | |
let jsonResponse = JSON.parse(response.stdout); | |
if (!jsonResponse.choices || jsonResponse.choices.length === 0) { | |
hexo.log.error("[API] API 响应无 choices 数据"); | |
throw new Error("API 响应无 choices 数据"); | |
} | |
let summary = jsonResponse.choices[0].message.content; | |
hexo.log.info(`[API] 摘要生成成功,长度: ${summary.length}`); | |
return summary; | |
} catch (error) { | |
hexo.log.error(`[API] 摘要解析失败: ${error.message}`); | |
} | |
attempt++; | |
waitTime = Math.max(waitTime * 1.5, 10000); | |
} | |
hexo.log.error(`[API] 经过 ${maxRetries} 次尝试后仍然失败`); | |
return "摘要生成失败"; | |
} | |
// ** 摘要处理(同步)** | |
function postMessage(path, content, dbPath, startMessage) { | |
if (!hexo.theme.config.summary.enable) { | |
hexo.log.warn("[Summary] 摘要功能未启用"); | |
return ""; | |
} | |
if (!globalSummaryCache[path]) { | |
globalSummaryCache[path] = {}; | |
} | |
const newHash = computeHash(content); | |
const cached = globalSummaryCache[path][dbPath]; | |
let summaryResult = ""; | |
const threshold = 500; // 阈值:500 字 | |
if (cached && cached.hash === newHash) { | |
hexo.log.info("[Cache] 命中缓存,直接返回摘要"); | |
return cached.summary; | |
} | |
// 如果没有缓存或缓存中未保存原始文本,则直接重新生成全文摘要 | |
if (!cached || !cached.content) { | |
hexo.log.info("[Summary] 首次生成摘要或缓存中未保存原文本,生成全文摘要..."); | |
summaryResult = generateSummaryForContent(content, startMessage); | |
} else { | |
// 进行文本差异比较 | |
// const diff = getDiff(cached.content, content); | |
// const deletionDelta = diff.oldBlock.length - diff.newBlock.length; | |
// const additionDelta = diff.newBlock.length - diff.oldBlock.length; | |
const diff = getParagraphDiffs(cached.content, content); | |
const deletionDelta = diff.removed.length; | |
const additionDelta = diff.added.length; | |
if (deletionDelta >= threshold && isContinuous(diff.removed, threshold)) { | |
hexo.log.info(`[Summary] 检测到连续删除超过 ${threshold} 字: 删除了 ${deletionDelta} 字`); | |
const prompt = `请根据以下删除的文本内容调整原摘要,使摘要反映内容减少的情况,要求中文回答且尽可能简洁,字数请严格控制在300字以内,最终输出本文的概述:\n原摘要:\n${cached.summary}\n删除的内容:\n${diff.removed}`; | |
summaryResult = generateSummarySync(prompt, hexo.theme.config.summary); | |
} else if (additionDelta >= threshold && isContinuous(diff.added, threshold)) { | |
hexo.log.info(`[Summary] 检测到连续新增超过 ${threshold} 字: 新增了 ${additionDelta} 字`); | |
hexo.log.info(`[Summary] 新增内容:\n${diff.added}`); | |
const prompt = `请根据以下新增的文本内容调整原摘要,使摘要补充新增内容,要求中文回答且尽可能简洁,字数请严格控制在300字以内,最终输出本文的概述:\n原摘要:\n${cached.summary}\n新增的内容:\n${diff.added}`; | |
summaryResult = generateSummarySync(prompt, hexo.theme.config.summary); | |
} else { | |
hexo.log.info("[Summary] 文本差异未达到阈值,返回原全文摘要"); | |
summaryResult = cached.summary; | |
} | |
} | |
hexo.log.info("[Summary] 摘要生成完成,更新缓存"); | |
globalSummaryCache[path][dbPath] = { summary: summaryResult, hash: newHash, content: content }; | |
fs.writeFileSync(dbFile, JSON.stringify(globalSummaryCache, null, 2)); | |
hexo.log.info("[Summary] 缓存已更新"); | |
return summaryResult; | |
} | |
// ** 注册 Hexo Helper** | |
hexo.extend.helper.register("get_summary", (post) => { | |
return postMessage(post.path, getContent(post), "summary", "请为下述文章提供一份300字以内的简明摘要,要求中文回答且尽可能简洁:"); | |
}); | |
hexo.extend.helper.register("get_introduce", () => { | |
return hexo.theme.config.summary.introduce; | |
}); |
var __create = Object.create; | |
var __defProp = Object.defineProperty; | |
var __getOwnPropDesc = Object.getOwnPropertyDescriptor; | |
var __getOwnPropNames = Object.getOwnPropertyNames; | |
var __getProtoOf = Object.getPrototypeOf; | |
var __hasOwnProp = Object.prototype.hasOwnProperty; | |
var __copyProps = (to, from, except, desc) => { | |
if (from && typeof from === "object" || typeof from === "function") { | |
for (let key of __getOwnPropNames(from)) | |
if (!__hasOwnProp.call(to, key) && key !== except) | |
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); | |
} | |
return to; | |
}; | |
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( | |
// If the importer is in node compatibility mode or this is not an ESM | |
// file that has been converted to a CommonJS file using a Babel- | |
// compatible transform (i.e. "__esModule" has not been set), then set | |
// "default" to the CommonJS "module.exports" for node compatibility. | |
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, | |
mod | |
)); | |
var import_node_fs = __toESM(require("node:fs")); | |
function getContent(post) { | |
return post?.raw ?? post?._content ?? post.content; | |
} | |
let db; | |
function postMessage(path, content, dbPath, startMessage) { | |
if (import_node_fs.default.existsSync("summary.json")) { | |
db = JSON.parse(import_node_fs.default.readFileSync("summary.json", { encoding: "utf-8" })); | |
} else { | |
db = {}; | |
} | |
const config = hexo.theme.config.summary; | |
if (config.enable) { | |
if (typeof db?.[path] !== "undefined" && typeof db?.[path]?.[dbPath] !== "undefined") { | |
return db[path][dbPath]; | |
} else { | |
if (typeof db?.[path] === "undefined") { | |
db[path] = {}; | |
} else { | |
db[path][dbPath] = ""; | |
} | |
} | |
if (config.mode === "openai") { | |
const request = () => { | |
fetch(`${config.openai.remote}/v1/chat/completions`, { | |
method: "POST", | |
headers: requestHeaders, | |
body: JSON.stringify(requestBody) | |
}).then((response) => { | |
if (!response.ok) { | |
throw Error("ERROR: Failed to get summary from Openai API"); | |
} | |
response.json().then((data) => { | |
const summary = data.choices[0].message.content; | |
try { | |
db[path][dbPath] = summary; | |
} catch (e) { | |
db ??= {}; | |
db[path] ??= {}; | |
db[path][dbPath] ??= ""; | |
db[path][dbPath] = summary; | |
} | |
import_node_fs.default.writeFileSync("summary.json", JSON.stringify(db)); | |
if (import_node_fs.default.existsSync("requested.lock")) { | |
import_node_fs.default.unlinkSync("requested.lock"); | |
} | |
return summary; | |
}); | |
}); | |
}; | |
const checkTime = (waitTime) => { | |
if (import_node_fs.default.existsSync("request.lock")) { | |
if (import_node_fs.default.existsSync("requested.lock")) { | |
setTimeout(checkTime, 1e3 * waitTime); | |
return; | |
} | |
import_node_fs.default.writeFileSync("requested.lock", ""); | |
setTimeout(request, 1e3 * 2.5 * waitTime); | |
import_node_fs.default.unlinkSync("request.lock"); | |
} else { | |
import_node_fs.default.writeFileSync("request.lock", ""); | |
request(); | |
} | |
}; | |
const requestHeaders = { | |
"Content-Type": "application/json", | |
Authorization: `Bearer ${config.openai.apikey}` | |
}; | |
const requestBody = { | |
model: "gpt-3.5-turbo", | |
messages: [{ role: "user", content: `${startMessage} ${content}` }], | |
temperature: 0.7 | |
}; | |
if (config.pricing === "trial") { | |
hexo.log.info("Requesting OpenAI API... (3 RPM mode)"); | |
hexo.log.info("It may take 20 minutes or more (depending on the number of articles, each one takes 25 seconds)"); | |
checkTime(10); | |
} else { | |
hexo.log.info("Requesting OpenAI API... (60 RPM mode)"); | |
checkTime(0.5); | |
} | |
} else { | |
} | |
} | |
} | |
hexo.extend.helper.register("get_summary", (post) => { | |
return postMessage(post.path, getContent(post), "summary", "\u8BF7\u4E3A\u4E0B\u8FF0\u6587\u7AE0\u63D0\u4F9B\u4E00\u4EFD200\u5B57\u4EE5\u5185\u7684\u6982\u62EC\uFF0C\u4F7F\u7528\u4E2D\u6587\u56DE\u7B54\u4E14\u5C3D\u53EF\u80FD\u7B80\u6D01: "); | |
}); | |
hexo.extend.helper.register("get_introduce", () => { | |
return hexo.theme.config.summary.introduce; | |
}); |
对于 themes\shokaX\layout\_partials\post\post.pug
的代码,只是稍微修改,让 "自我介绍" 先显示。
div(class="tab active" data-id="summary" data-title="自我介绍") | |
p | |
!= get_introduce() | |
div(class="tab" data-id="summary" data-title="文章概括") | |
p | |
!= get_summary(page) |
div(class="tab" data-id="summary" data-title="自我介绍") | |
p | |
!= get_introduce() | |
div(class="tab active" data-id="summary" data-title="文章概括") | |
p | |
!= get_summary(page) |
new1 和 new2 代码的不同在于,new1 不再将 Markdown 处理为 html 再提取文本,而是直接对 Markdown 原内容进行各种处理,防止了提取 html 文本会各种意想不到的标签问题。并且优化 processLongContent
函数,防止文章没有使用 #
没有分章节时的问题,现在可以处理没有章节的纯段落文章。