hexo全站NPM化之通过npmmirror加速你的博客访问

由于NPM开启白名单模式,此篇文章可能以过期,仅供参考

在这短短的一个月内,我的博客被攻击了6次,我的源站也被打黑洞了6次,所以我就放弃了使用cdn,然后将全部的服务搬到了CloudFlare上,但是由于国内环境问题,cf的网站可能访问的有点慢,经过优化之后,我选择将博客的资源通过NPMMIRROR来下发(对不起了淘宝),下面就是sw的两种写法,来实现从npmmirror来获取数据,一种是依赖插件空梦写的swpp,令一种就是CyanFalse提供的基础代码,Q78KG修改后的不依赖swpp的sw写法。

创建Service workers

基于SWPP

安装swpp

npm install hexo-swpp swpp-backends

具体教程请移步山岳库博
这里是空梦的Swpp Backends 官方文档地址,有需要的可以去看看。



假如你的主题没有内置swpp的话,你可以使用以下的sw规则,也可以使用本文的另一个方案,以下是一个默认的sw规则文件,你需要将其放置在主题根目录下
创建[themes]\sw-rules.js
/**
 * @see https://kmar.top/posts/b70ec88f/
 */

module.exports.config = {
  /**
   * 与 ServiceWorker 有关的配置项
   * 若想禁止插件自动生成 sw,此项填 false 即可
   * @type ?Object|boolean
   */
  serviceWorker: {
    cacheName: "ByerCache"
  },
  register: {
    onerror: undefined
  },
  dom: {
    onsuccess: () => {
      caches.match('https://id.v3/').then(function(response) {
        if (response) {
          // 如果找到了匹配的缓存响应
          response.json().then(function(data) {
            anzhiyuPopupManager && anzhiyuPopupManager.enqueuePopup('通知📢', `已刷新缓存,更新为${data.global + "." + data.local}版本最新内容`, null, 5000);
          });
        } else {
          console.info('未找到匹配的缓存响应');
        }
      }).catch(function(error) {
        console.error('缓存匹配出错:', error);
      });
    },
  },
  json: {
    merge: ['page', 'archives', 'categories', 'tags']
  },
  external: {
    stable: [
      /^https:\/\/npm\.elemecdn\.com\/[^/@]+\@[^/@]+\/[^/]+\/[^/]+$/,
      /^https:\/\/cdn\.cbd\.int\/[^/@]+\@[^/@]+\/[^/]+\/[^/]+$/,
      /^https:\/\/cdn\.jsdelivr\.net\/npm\/[^/@]+\@[^/@]+\/[^/]+\/[^/]+$/,
    ],
    replacer: srcUrl => {
      if (srcUrl.startsWith('https://npm.elemecdn.com')) {
        const url = new URL(srcUrl)
        return [
            srcUrl,
            `https://cdn.cbd.int` + url.pathname,
            `https://cdn.jsdelivr.net/npm` + url.pathname,
            `https://cdn1.tianli0.top/npm` + url.pathname,
            `https://fastly.jsdelivr.net/npm` + url.pathname
        ]
      } else {
        return srcUrl
      }
    },
  }
};

/** 跳过处理番剧封面 */
module.exports.skipRequest = request => request.url.startsWith('https://i0.hdslb.com');

/**
 * 缓存列表
 * @param clean 清理全站时是否删除其缓存
 * @param match {function(URL)} 匹配规则
 */
module.exports.cacheRules = {
  simple: {
    clean: true,
    search: false,
    match: (url, $eject) => {
      const allowedHost = $eject.domain;
      const allowedPaths = ["/404.html", "/css/index.css"];
      return url.host === allowedHost && allowedPaths.includes(url.pathname);
    },
  },
  cdn: {
    clean: true,
    match: url =>
      [
        "cdn.cbd.int",
        "lf26-cdn-tos.bytecdntp.com",
        "lf6-cdn-tos.bytecdntp.com",
        "lf3-cdn-tos.bytecdntp.com",
        "lf9-cdn-tos.bytecdntp.com",
        "cdn.staticfile.org",
        "npm.elemecdn.com",
      ].includes(url.host) && url.pathname.match(/\.(js|css|woff2|woff|ttf|cur)$/),
  },
};

/**
 * 获取一个 URL 对应的备用 URL 列表,访问顺序按列表顺序,所有 URL 访问时参数一致
 * @param srcUrl {string} 原始 URL
 * @return {{list: string[], timeout: number}} 返回 null 或不返回表示对该 URL 不启用该功能。timeout 为超时时间(ms),list 为 URL 列表,列表不包含原始 URL 表示去除原始访问
 */
module.exports.getSpareUrls = srcUrl => {
  if (srcUrl.startsWith("https://npm.elemecdn.com")) {
    return {
      timeout: 3000,
      list: [srcUrl, `https://cdn.cbd.int/${new URL(srcUrl).pathname}`],
    };
  }
};

/**
 * 获取要插入到 sw 中的变量或常量
 * @param hexo hexo 对象
 * @param rules 合并后的 sw-rules 对象
 * @return {Object} 要插入的键值对
 */
module.exports.ejectValues = (hexo, rules) => {
  return {
    domain: {
      prefix: "const",
      value: new URL(hexo.config.url).host,
    },
  };
};

空梦的swpp已经提供了现成的Request 篡改,我们直接使用就好,打开主题目录下的sw-rules.js,在最后添加如下内容
module.exports.modifyRequest = (request, $eject) => {
  const url = request.url
  const endings = ['jpg','png','js','css','woff2','woff','ttf','cur','webp','jpeg','gif','mp4','svg','ico','json'];
  const denyendings = ['html','htm','update.json','cacheList.json','sw.js','sw-dom.js'];
  if(url.startsWith('https://${link}/') && endings.some(ending => url.endsWith('.' + ending)) && !denyendings.some(denyending => url.endsWith(denyending))){
    const source = url.replace('https://${link}', '');
    return new Request('https://registry.npmmirror.com/${packagename}/latest/files'+source, {...request, mode: 'cors'});
  }
}

(因为我写的代码遇到html文件就会直接下载,我又不知道怎么调整,干脆直接频闭掉html文件)
将上方的${link}${packagename}换成自己的域名npm包名即可,此时当我们把NPM包发布以后就可以正常的使用了,假如你发布成功,你可以把代码推送到你的服务器或者是github上来进行构建,此时访问你就会发现自己的除了html的文件均通过npmmirror进行下发了。

基于自建sw规则

以下代码来自CyanFalseQ78KG,我只是在其代码的基础上进行了一些微调和书写教程。
在主题根目录创建文件sw.js,swReg.js
创建[themes]/source/sw.js,内容如下:

const CACHE_NAME = 'ICDNCache'; //修改为自己的CacheName
let cachelist = [];
self.addEventListener('install', async function (installEvent) {
    self.skipWaiting();
    installEvent.waitUntil(
        caches.open(CACHE_NAME)
            .then(function (cache) {
                console.log('Opened cache');
                return cache.addAll(cachelist);
            })
    );
});
self.addEventListener('fetch', async event => {
    try {
        event.respondWith(handle(event.request))
    } catch (msg) {
        event.respondWith(handleerr(event.request, msg))
    }
});
const handleerr = async (req, msg) => {
    return new Response(`<h1>CDN分流器遇到了致命错误</h1>
    <b>${msg}</b>`, { headers: { "content-type": "text/html; charset=utf-8" } })
}
const lfetch = async (urls, url) => {
    let controller = new AbortController();
    const PauseProgress = async (res) => {
        return new Response(await (res).arrayBuffer(), { status: res.status, headers: res.headers });
    };
    if (!Promise.any) {
        Promise.any = function (promises) {
            return new Promise((resolve, reject) => {
                promises = Array.isArray(promises) ? promises : []
                let len = promises.length
                let errs = []
                if (len === 0) return reject(new AggregateError('All promises were rejected'))
                promises.forEach((promise) => {
                    promise.then(value => {
                        resolve(value)
                    }, err => {
                        len--
                        errs.push(err)
                        if (len === 0) {
                            reject(new AggregateError(errs))
                        }
                    })
                })
            })
        }
    }
    return Promise.any(urls.map(urls => {
        return new Promise((resolve, reject) => {
            fetch(urls, {
                signal: controller.signal
            })
                .then(PauseProgress)
                .then(res => {
                    if (res.status == 200) {
                        controller.abort();
                        resolve(res)
                    } else {
                        reject(res)
                    }
                })
        })
    }))
}
const fullpath = (path) => {
    path = path.split('?')[0].split('#')[0]
    if (path.match(/\/$/)) {
        path += 'index'
    }
    if (!path.match(/\.[a-zA-Z]+$/)) {
        path += '.html'
    }
    return path
}
const generate_blog_urls = (packagename, blogversion, path) => {
    const npmmirror = [
        // `https://unpkg.zhimg.com/${packagename}@${blogversion}`,
        // `https://npm.elemecdn.com/${packagename}@${blogversion}`,
        // `https://cdn1.tianli0.top/npm/${packagename}@${blogversion}`,
        // `https://cdn.afdelivr.top/npm/${packagename}@${blogversion}`,
        //`https://ariasakablog.s3.ladydaily.com`,
        `https://registry.npmmirror.com/${packagename}/${blogversion}/files`
    ]
    for (var i in npmmirror) {
        npmmirror[i] += path
    }
    return npmmirror
}
const mirror = [
    // `https://registry.npmmirror.com/ariasakablog/latest`,
    // `https://registry.npmjs.org/ariasakablog/latest`,
    // `https://mirrors.cloud.tencent.com/npm/ariasakablog/latest`,
    `https://registry.npmmirror.com/karunari/latest`
]
const get_newest_version = async (mirror) => {
return lfetch(mirror, mirror[0])
    .then(res => res.json())
    .then(res.version)
}
self.db = { //全局定义db,只要read和write,看不懂可以略过
    read: (key, config) => {
        if (!config) { config = { type: "text" } }
        return new Promise((resolve, reject) => {
            caches.open(CACHE_NAME).then(cache => {
                cache.match(new Request(`https://LOCALCACHE/${encodeURIComponent(key)}`)).then(function (res) {
                    if (!res) resolve(null)
                    res.text().then(text => resolve(text))
                }).catch(() => {
                    resolve(null)
                })
            })
        })
    },
    write: (key, value) => {
        return new Promise((resolve, reject) => {
            caches.open(CACHE_NAME).then(function (cache) {
                cache.put(new Request(`https://LOCALCACHE/${encodeURIComponent(key)}`), new Response(value));
                resolve()
            }).catch(() => {
                reject()
            })
        })
    }
}

const set_newest_version = async (mirror) => { //改为最新版本写入数据库
    return lfetch(mirror, mirror[0])
        .then(res => res.json()) //JSON Parse
        .then(async res => {
            await db.write('blog_version', res.version) //写入
            return;
        })
}

setInterval(async() => {
    await set_newest_version(mirror) //定时更新,一分钟一次
}, 60*1000);

setTimeout(async() => {
    await set_newest_version(mirror)//打开五秒后更新,避免堵塞
},5000)
function getFileType(fileName) {
    suffix=fileName.split('.')[fileName.split('.').length-1]
    if(suffix=="html"||suffix=="htm") {
        return 'text/html';
    }
    if(suffix=="js") {
        return 'text/javascript';
    }
    if(suffix=="css") {
        return 'text/css';
    }
    if(suffix=="jpg"||suffix=="jpeg") {
        return 'image/jpeg';
    }
    if(suffix=="ico") {
        return 'image/x-icon';
    }
    if(suffix=="png") {
        return 'image/png';
    }
    return 'text/plain';
  }
const handle = async(req)=>{
    const urlStr = req.url
    const urlObj = new URL(urlStr);
    const urlPath = urlObj.pathname;
    const domain = urlObj.hostname;
    //从这里开始
    lxs=[]
    if(domain === "byer.top"){//这里写你需要拦截的域名
        var l=lfetch(generate_blog_urls('karunari',await db.read('blog_version') || 'latest',fullpath(urlPath))) //将`karunari`改为自己的npm包名
        return l
        .then(res=>res.arrayBuffer())
        .then(buffer=>new Response(buffer,{headers:{"Content-Type":`${getFileType(fullpath(urlPath).split("/")[fullpath(urlPath).split("/").length-1].split("\\")[fullpath(urlPath).split("/")[fullpath(urlPath).split("/").length-1].split("\\").length-1])};charset=utf-8`}}));//重新定义header
    }
    else{
        return fetch(req);
    }
}

你需要修改如下内容:
sw.js的第1行的CacheName
sw.js的第95行的NPM包名
sw.js的第173行的拦截域名
sw.js的第174行的NPM包名


创建[themes]/source/swReg.js,内容如下(可以从内容上就看出来是CyanFalse的代码):
(async () => {//使用匿名函数确保body已载入
    /*
    ChenBlogHelper_Set 存储在LocalStorage中,用于指示sw安装状态
    0 或不存在 未安装
    1 已打断
    2 已安装
    3 已激活,并且已缓存必要的文件(此处未写出,无需理会)
    */
    const $ = document.querySelector.bind(document);//语法糖
    if ('serviceWorker' in navigator) { //如果支持sw
        if (Number(window.localStorage.getItem('ChenBlogHelper_Set')) < 1) {
            window.localStorage.setItem('ChenBlogHelper_Set', 1)
            window.stop()
            document.innerHTML=""
        }
        navigator.serviceWorker.register(`/sw.js?time=${Math.ceil(Math.random()*10000000000000000000)}`)//随机数,强制更新
            .then(async () => {
                if (Number(window.localStorage.getItem('ChenBlogHelper_Set')) < 2) {
                    setTimeout(() => {
                        window.localStorage.setItem('ChenBlogHelper_Set', 2)
                        //window.location.search = `?time=${ranN(1, 88888888888888888888)}` //已弃用,在等待500ms安装成功后直接刷新没有问题
                        window.location.reload()//刷新,以载入sw
                    }, 500)//安装后等待500ms使其激活
                }
            })
            .catch(err => console.error(`ChenBlogHelperError:${err}`))
    }
    
})()

添加完之后,从理论上来说,当你把代码推送到服务器上,并且将public推送到你的npm仓库,你的sw就已经安装在了浏览器上,可以通过F12进行查看资源是否成功通过npmmirror进行下发。
做完之后可能有的人404界面会失效,那我我们可以做一个伪静态,具体教程可以看我的这篇文章,原理是一样的

自动发布NPM包

假如你已经手动发送过包给npm了,那你可以将你的npm包的package.json放入source文件夹中

获取Npm Token

npm官网->头像->Access Tokens->Generate New Token,勾选Automation选项,Token只会显示这一次,之后如果忘记了就只能重新生成重新配置了。



在github的[Blog]仓库设置项里添加一个名为NPM_TOKEN的secrets,把获取的Npm的Access token输入进去。

在博客根目录下新建.github/workflows/Hexo Blog CI.yml,按照需要修改其中的内容
内容如下:

name: Hexo Blog CI

on:
  push:
    branches:
      - main
  watch:
    types: [started]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
    - name: Checkout Repository master branch
      uses: actions/checkout@main

    - name: Setup Node.js latest
      uses: actions/setup-node@main
      with:
        node-version: "latest"

    - name: Setup Hexo Dependencies
      run: |
        npm i hexo-cli -g
        npm i yarn -g
        yarn

    - name: Setup Deploy Private Key
      env:
        HEXO_DEPLOY_PRIVATE_KEY: ${{ secrets.HEXO_DEPLOY_PRIVATE_KEY }}
      run: |
        mkdir -p ~/.ssh/
        echo "$HEXO_DEPLOY_PRIVATE_KEY" > ~/.ssh/id_rsa
        chmod 600 ~/.ssh/id_rsa
        ssh-keyscan github.com >> ~/.ssh/known_hosts

    - name: Setup Git Infomation
      run: |
        git config --global user.name 'Hoshino-Yumetsuki'
        git config --global user.email 'hoshino-yumetsuki@outlook.com'

    - name: Deploy Hexo
      run: |
        hexo clean
        hexo bangumi -u
        hexo generate
        hexo deploy

    - name: NPM Publish Pre Set
      run: |
        node ./prescripts/pkgpublish.mjs

    - uses: JS-DevTools/npm-publish@v3
      with:
        token: ${{ secrets.NPM_TOKEN }}
        package: ./public/package.json

在博客根目录下创建prescripts/pkgpublish.mjs文件,修改其中部分内容
内容如下:
import { writeFile } from 'fs';
const pkgfile = {
    "name": "karunari",
    "version": "0.0.0-"+new Date().getTime()
}
writeFile('./public/package.json', JSON.stringify(pkgfile), function (err) {
    if (err) {
        console.log(err);
    }
    console.log("Package.json file is created successfully.");
})

此时你的博客就可以自动向npm发送构建好的包了,你可以测试一下自己的网站是否能正常的访问了