一键解决语雀图片防盗链:WeChat Markdown Edito + Serverless + Github 实用方案

681 阅读5分钟

大家好,我是徐徐。今天跟大家分享一个语雀 Markdown 文档图片防盗链解决方案。

前言

最近一个月我发公众号文章遇到一个问题,那就是从语雀导出的 Markdown 文档在三方网站美化后,然后再拷贝到公众号的时候,图片都上传失败了,无法显示。所以每次我都要把图片全部重新下载下来,然后再一张张插入到拷贝过后的文章当中,图片少的话还好,如果图片非常多就会非常麻烦,耗费时间。

方案构想

后面就一直在想能不能把这个过程用程序的方式来解决掉,然后我就在网上搜寻了各种方案,其中有一个用 Python 脚本转换的方案,于是我就想到了用 Node 来实现,但是这个还是有一个不方便的地方,不能直接在一个平台上一次性解决所有问题,需要一步步搞定,也还是有点麻烦。后面经过多方面的调研,我决定打一个组合拳,下面就来看看我是如何解决的。

方案一

开源微信 Markdown 编辑器 + Node.js 服务 + 自己服务器的图床

这个方案我是不太想用的,不想搭建 Node 服务了,运维太麻烦,服务器配置太低,图片压力,所以直接 PASS 了。

方案二

开源微信 markdown 编辑器 + Serveless 云函数 + Github 图床

这个方案是在和同事聊天的时候突发想到的,需要找一个像微信云开发的服务,可以直接通过调用各种 API 实现服务逻辑。后面就找到了腾讯云的 cloudbase 云开发平台,然后就注册使用了起来,简直就是神器,可以云调用服务,云调用数据库等各种后端服务的操作。

云函数服务搞定了,现在需要找一个开源的微信 markdown 编辑器。最后找到了 WeChat Markdown Editor 这款在线的编辑器,也非常强大好用。

Github 图床主要是为了把语雀的图片转为 github 的图片地址,最终解决语雀图片防盗链的问题。

方案实现

其实在开始构建在线的平台之前,我自己写了一个 Node.js 程序来实现替换图片的方案,但是需要每次都运行命令行,也挺麻烦的。但是不管是在服务端还是本地操作,其核心逻辑其实都是一样的,只是在本地运行输入和输出是文件,在线调用的话输入和输出是字符串。下面就是核心方案实现,其实非常简单。

根据流程图,我们需要完成四个核心步骤,下面来看看这几个步骤。

获得文档中的语雀图片链接

这个主要就是进行正则匹配去获得 markdown 里面的所有图片然后形成一个数组。

const yuqueImageRegex = /https:\/\/cdn\.nlark\.com\/yuque\/.*?\.(png|jpg|jpeg|gif|webp)/gi;
const yuqueUrls = Array.from(markdownContent.matchAll(yuqueImageRegex)).map(
  (match) => match[0],
);

下载语雀图片

发起一个 responseType 为 arraybuffer 的请求,然后将图片下载下来。

async function downloadYuqueImage(yuqueImageUrl) {
  try {
    const response = await axios.get(yuqueImageUrl, {
      responseType: "arraybuffer",
      headers: {
        "User-Agent":
          "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
        Referer: "https://www.yuque.com/",
      },
    });
    return response.data;
  } catch (error) {
    console.error("Download failed:", error);
    throw error;
  }
}

这里需要注意一下,我们要在头上加上 User-Agent 和 Referer,完全模拟语雀图片的下载。

上传图片到 GitHub

GitHub 有开放的 API ,可以通过调用接口的方式上传文件,文件需要转成 base64格式上传。

async function uploadToGithub(imageBuffer, fileName, token, owner, repo) {
  try {
    const content = Buffer.from(imageBuffer).toString("base64");
    const response = await axios({
      method: "PUT",
      url: `https://api.github.com/repos/${owner}/${repo}/contents/${fileName}`,
      headers: {
        Accept: "application/vnd.github+json",
        Authorization: `Bearer ${token}`,
        "X-GitHub-Api-Version": "2022-11-28",
      },
      data: {
        message: "my commit message",
        committer: {
          name: "",
          email: "",
        },
        content,
      },
    });
    return response.data;
  } catch (error) {
    console.error("Upload failed:", error);
    throw error;
  }
}

这里的token 和 repo 就是你自己 GitHub 的一个项目,如 Xutaotaotao/cloud_img。

token 的获取可以参考这个文档:

docs.github.com/en/authenti…

需要注意权限问题,如果不勾选对应的权限会导致无法上传GitHub,即 repo 和 write:packages。

替换语雀图片

我们上传图片之后会获得相应的图片链接,然后只需要替换 Markdown里面的图片链接就可以。

processedContent = processedContent.replace(yuqueUrl, newImageUrl);

完整 Node.js 源码

下面给出完整的 Node.js 源码

const axios = require("axios");
const fs = require("fs").promises;

async function downloadYuqueImage(yuqueImageUrl) {
  try {
    const response = await axios.get(yuqueImageUrl, {
      responseType: "arraybuffer",
      headers: {
        "User-Agent":
          "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
        Referer: "https://www.yuque.com/",
      },
    });
    return response.data;
  } catch (error) {
    console.error("Download failed:", error);
    throw error;
  }
}

function generateFileName() {
  const timestamp = Date.now();
  const random = Math.floor(Math.random() * 1000);
  return `image_${timestamp}_${random}.png`;
}

async function uploadToGithub(imageBuffer, fileName, token, owner, repo) {
  try {
    const content = Buffer.from(imageBuffer).toString("base64");
    const response = await axios({
      method: "PUT",
      url: `https://api.github.com/repos/${owner}/${repo}/contents/${fileName}`,
      headers: {
        Accept: "application/vnd.github+json",
        Authorization: `Bearer ${token}`,
        "X-GitHub-Api-Version": "2022-11-28",
      },
      data: {
        message: "my commit message",
        committer: {
          name: "",
          email: "",
        },
        content,
      },
    });
    return response.data;
  } catch (error) {
    console.error("Upload failed:", error);
    throw error;
  }
}

async function uploadYuqueImageToGithub(yuqueImageUrl, token, repo) {
  try {
    // 解析 repo
    const [owner, repository] = repo.split("/");

    // 下载图片
    const imageBuffer = await downloadYuqueImage(yuqueImageUrl);

    // 生成文件名
    const fileName = generateFileName();

    // 上传到 Github
    const githubUrl = await uploadToGithub(
      imageBuffer,
      fileName,
      token,
      owner,
      repository,
    );

    return githubUrl;
  } catch (error) {
    console.error("Process failed:", error);
    throw error;
  }
}

async function processMarkdown({ markdownContent, token, repo }) {
  const yuqueImageRegex = /https:\/\/cdn\.nlark\.com\/yuque\/.*?\.png/g;

  const yuqueUrls = Array.from(markdownContent.matchAll(yuqueImageRegex)).map(
    (match) => match[0],
  );

  let processedContent = markdownContent;

  for (const yuqueUrl of yuqueUrls) {
    try {
      const githubResponse = await uploadYuqueImageToGithub(
        yuqueUrl,
        token,
        repo,
      );

      const newImageUrl = githubResponse.content.download_url;
      console.log("New Image URL:", newImageUrl);
      processedContent = processedContent.replace(yuqueUrl, newImageUrl);

      // 等待一段时间,避免 GitHub API 限制
      await new Promise((resolve) => setTimeout(resolve, 500));
    } catch (error) {
      console.error(`Error processing image ${yuqueUrl}:`, error);
    }
  }

  return processedContent;
}

async function main() {
  try {
    // 直接从文件读取 Markdown 内容
    const markdownContent = await fs.readFile("input.md", "utf8");
    const processedMarkdown = await processMarkdown({
      markdownContent,
      token: "",
      repo: "",
    });
    // 将处理后的内容写入新文件
    await fs.writeFile("output.md", processedMarkdown, "utf8");

    console.log("Markdown processing completed!");
  } catch (error) {
    console.error("Error:", error);
  }
}

main();

有了这段代码你就可以自己本地运行 Node 程序然后替换图片啦,也可以把这段代码部署到云函数里面,这样在web 端就可以调用啦。

Web 端实现

其实这个地方最主要的就是把图片下载和转换的逻辑放在了云函数上,如下所示。cloudbaseApp.callFunction 就是腾讯云的 cloudbase 的云函数调用方法。

async function processMarkdown(params: ProcessMarkdownParams) {
  const { markdownContent, token, repo } = params
  if (!markdownContent) {
    return
  }
  const yuqueImageRegex = /https:\/\/cdn\.nlark\.com\/yuque\/.*?\.png/g
  const yuqueUrls = Array.from(markdownContent.matchAll(yuqueImageRegex)).map(match => match[0])

  totalImageCount.value = yuqueUrls.length
  isProcessing.value = true
  currentImageIndex.value = 0

  let processedContent = markdownContent
  let hasError = false
  for (const yuqueUrl of yuqueUrls) {
    try {
      const response = await cloudbaseApp.callFunction({
        name: `uploadYuqueImageToGithub`,
        data: {
          yuqueImageUrl: yuqueUrl,
          token,
          repo,
        },
      })
      processedContent = processedContent.replace(yuqueUrl, response.result)
      currentImageIndex.value++
    }
    catch (error) {
      console.error(`Error processing image ${yuqueUrl}:`, error)
      hasError = true
    }
  }
  isProcessing.value = false
  if (hasError) {
    showToastMessage(`图片处理完成,但有${totalImageCount.value - currentImageIndex.value}张图片处理失败`, `warning`)
  }
  else {
    showToastMessage(`成功处理${totalImageCount.value}张图片`, `success`)
  }
  return processedContent
}

相当方便和快捷,可以实时编码,发布,灰度,如果是强依赖 node 环境的,可以用这个方案去做一些小应用。 具体可参考:

tcb.cloud.tencent.com/dev

最终效果展示

结语

这个解决方案就我自己而言现在用着还不错,提升了不少效率,GitHub 的图片没有限制,所以图片格式化转换之后到各个平台发布都没有问题,再也不用担心防盗链的问题了。希望这个解决方案能帮助到同样遇到这个问题的朋友们,让语雀文档处理变得更加轻松愉快。如果你对这个方案有任何想法或建议,欢迎与我交流讨论。

源码

  • Node 语雀 Markdown 图片转换

github.com/Xutaotaotao…

  • 在线平台

github.com/Xutaotaotao…