Vercel 部署的 Twikoo 对接moepic Chevereto V4 图床

coolwolf
32 2
 
## 环境说明

- 博客框架:Hexo Fluid 主题
- 评论系统:Twikoo 1.6.44(Vercel 部署)
- 图床:[MoePic](https://loveloli.me/) 提供的 Chevereto V4 图床

## 核心思路

Twikoo 1.6.44 的前端图片上传逻辑完全由后端控制。Twikoo 后端内置支持的图床有限,不支持 Chevereto。

因此方案分两步:

1. 在 Vercel 上创建一个代理接口 `/api/upload`,负责接收前端图片并转发到 Chevereto V4
2. 在前端直接 hook Twikoo 的 Vue 组件方法 `onSelectImage`,拦截图片上传,改为调用自己的代理接口

---

## 第一步:创建 Vercel 代理接口

在你的 Twikoo Vercel 项目根目录创建 `api/upload.js`

![在github创建js](https://s1.loveloli.me/hi168-27201-2582wulz/2026/02/23/image.png)

![编辑js文件](https://s1.loveloli.me/hi168-27201-2582wulz/2026/02/23/imagebc3e0fa3f4a6bdc9.png)

<details>
<summary>点击展开代码</summary>

```javascript
const axios = require('axios');
const FormData = require('form-data');
const Busboy = require('busboy');

module.exports = async (req, res) => {
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');

  if (req.method === 'OPTIONS') return res.status(200).end();
  if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' });

  const CHEVERETO_URL = process.env.CHEVERETO_URL;
  const CHEVERETO_KEY = process.env.CHEVERETO_API_KEY;

  return new Promise((resolve) => {
    const busboy = Busboy({ headers: req.headers });
    let fileData = null;

    busboy.on('file', (fieldname, file, info) => {
      const { filename, mimeType } = info;
      const chunks = [];
      file.on('data', (chunk) => chunks.push(chunk));
      file.on('end', () => {
        fileData = {
          buffer: Buffer.concat(chunks),
          filename: filename || 'image.jpg',
          mimeType: mimeType || 'image/jpeg',
        };
      });
    });

    busboy.on('finish', async () => {
      if (!fileData) {
        res.status(400).json({ error: '没有收到文件' });
        return resolve();
      }

      try {
        // Chevereto V4 使用 multipart 直接传二进制
        const form = new FormData();
        form.append('source', fileData.buffer, {
          filename: fileData.filename,
          contentType: fileData.mimeType,
          knownLength: fileData.buffer.length,
        });
        form.append('format', 'json');

        const response = await axios.post(
          `${CHEVERETO_URL}/api/1/upload`,
          form,
          {
            headers: {
              ...form.getHeaders(),
              'X-API-Key': CHEVERETO_KEY, // V4 用 Header 认证
            },
            timeout: 20000,
            maxContentLength: Infinity,
            maxBodyLength: Infinity,
          }
        );

        const imageUrl = response.data?.image?.url;
        if (!imageUrl) {
          res.status(500).json({ error: '未返回图片URL', detail: response.data });
          return resolve();
        }

        res.status(200).json({ url: imageUrl });
        resolve();
      } catch (err) {
        if (err.response) {
          res.status(500).json({
            error: err.message,
            chevereto_status: err.response.status,
            chevereto_detail: err.response.data,
          });
        } else {
          res.status(500).json({ error: err.message });
        }
        resolve();
      }
    });

    busboy.on('error', (err) => {
      res.status(500).json({ error: 'Busboy错误: ' + err.message });
      resolve();
    });

    req.pipe(busboy);
  });
};
```
</details>

### 添加依赖

确认 `package.json` 中包含以下依赖:

```json
{
  "dependencies": {
    "twikoo-vercel": "latest",
    "axios": "^1.6.0",
    "busboy": "^1.6.0",
    "form-data": "^4.0.0"
  }
}
```

### 配置 Vercel 环境变量

在 Vercel → 你的项目 → Settings → Environment Variables 中添加:

| 变量名 | 值 |
|---|---|
| `CHEVERETO_URL` | `https://你的图床域名`(不带末尾斜杠) |
| `CHEVERETO_API_KEY` | 你的 Chevereto V4 API Key |

> ⚠️ **注意**:不要设置 `IMAGE_CDN``IMAGE_CDN_URL``IMAGE_CDN_TOKEN` 这三个环境变量,否则 Twikoo 会启用内置图床逻辑并报错。

### 获取 Chevereto V4 API Key

登录 Chevereto 后台 → Dashboard → Settings → API,确认 API 已启用,复制 API V1 Key。

---

## 第二步:Hook 前端 Vue 组件

### 为什么不能用普通 DOM 事件拦截

Twikoo 的上传按钮是 Vue 组件渲染的,使用 `on:{change:e.onSelectImage}` 绑定事件。即使用 `cloneNode` 替换元素,Vue 仍会重新绑定事件。必须直接覆盖 Vue 实例上的 `onSelectImage` 方法。

### 创建注入脚本

在 Hexo 博客根目录的 `scripts/` 文件夹下创建 `inject-twikoo.js`

<details>
<summary>点击展开代码</summary>

```javascript
hexo.extend.injector.register('body_end', `
<script>
(function() {
  var UPLOAD_API = 'https://你的twikoo.vercel.app/api/upload';

  function hookVueComponent() {
    var input = document.querySelector('input.tk-input-image');
    if (!input) {
      setTimeout(hookVueComponent, 500);
      return;
    }

    // 向上遍历父元素,找到有 onSelectImage 方法的 Vue 实例
    var el = input;
    while (el) {
      var vue = el.__vue__;
      if (vue && typeof vue.onSelectImage === 'function') {
        console.log('找到 Vue 实例,准备 hook onSelectImage');

        vue.onSelectImage = async function(e) {
          var file = e.target.files[0];
          if (!file) return;

          try {
            var fd = new FormData();
            fd.append('file', file);
            var res = await fetch(UPLOAD_API, { method: 'POST', body: fd });
            var data = await res.json();
            if (!data.url) throw new Error(data.error || '未返回URL');

            // 将图片 Markdown 插入评论框
            var textarea = document.querySelector('.tk-input .el-textarea__inner')
                        || document.querySelector('.tk-input textarea');
            if (textarea) {
              var pos = textarea.selectionStart !== undefined
                ? textarea.selectionStart
                : textarea.value.length;
              var imgMd = '![](' + data.url + ')';
              var val = textarea.value;
              textarea.value = val.slice(0, pos) + imgMd + val.slice(pos);
              textarea.dispatchEvent(new Event('input', { bubbles: true }));
            }
            console.log('图片上传成功:', data.url);
          } catch(err) {
            console.error('上传失败:', err);
            alert('图片上传失败: ' + err.message);
          }

          e.target.value = '';
        };

        console.log('Twikoo Vue onSelectImage 已接管');
        return;
      }
      el = el.parentElement;
    }

    setTimeout(hookVueComponent, 500);
  }

  // 等待评论区 DOM 出现后再 hook
  var observer = new MutationObserver(function() {
    if (document.querySelector('input.tk-input-image')) {
      observer.disconnect();
      setTimeout(hookVueComponent, 300);
    }
  });
  observer.observe(document.body, { childList: true, subtree: true });
})();
</script>
`, 'default');
```
</details>

**注意**:将 `https://你的twikoo.vercel.app/api/upload` 替换为你的 Vercel 域名。

## 第三步:部署

这就不用我废话了吧,祝你成功!


## 我踩的坑

**坑1:Chevereto V4 认证方式变了**
V3 用表单字段 `key` 认证,V4 改为 HTTP Header `X-API-Key`,混用会返回 400。

**坑2:base64 传输会损坏**
`URLSearchParams` 传 base64 Data URL 时,`+``/` 字符被 URL 编码破坏,导致 Chevereto 报 `Invalid base64 string`。应直接用 multipart 传二进制。

**坑3:Twikoo 内置图床环境变量干扰**
设置了 `IMAGE_CDN=lskypro` 等变量后,Twikoo 后端会用内置逻辑处理上传,完全绕过自定义代理,且会把 `IMAGE_CDN_URL` 的值当成 Bearer Token 发出去。。

**坑4:cloneNode 无法移除 Vue 事件**
Twikoo 使用 Vue 绑定 `change` 事件,`cloneNode` 替换元素后 Vue 会重新渲染并重新绑定,必须直接覆盖 Vue 实例的 `onSelectImage` 方法。
 
这家伙太懒了,什么也没留下。
最新回复 ( 2 )
  • 2
    0
    建议下次发帖前注意注意代码块标签关没关,另外,萌社区不支持MarkDown
  • 3
    0
    滚来滚去……~(~o ̄▽ ̄)~o 。。。滚来滚去……o~(_△_o~) ~。。。
  • 游客
    4

    您需要登录后才可以回帖

    登录 注册

发新帖