<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet type="text/xsl" href="/feed.xsl"?>

<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>RavelloH's Blog</title>
        <link>https://ravelloh.com</link>
        <description>做点开发，写点开源。</description>
        <lastBuildDate>Mon, 27 Apr 2026 14:23:26 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>NeutralPress</generator>
        <language>zh-CN</language>
        <image>
            <title>RavelloH's Blog</title>
            <url>https://ravelloh.com/icon/512x</url>
            <link>https://ravelloh.com</link>
        </image>
        <copyright>All rights reserved 2026, RavelloH</copyright>
        <atom:link href="https://ravelloh.com/feed.xml" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Cap.js：基于工作量证明的自部署验证码]]></title>
            <link>https://ravelloh.com/posts/cap-js-pow-self-hosted-captcha</link>
            <guid isPermaLink="false">https://ravelloh.com/posts/cap-js-pow-self-hosted-captcha</guid>
            <pubDate>Mon, 27 Apr 2026 14:23:26 GMT</pubDate>
            <description><![CDATA[Cap.js 的自部署 PoW 验证码集成实践指南。文章介绍了工作量证明验证码与传统人机验证的区别，说明其通过提高请求计算成本来限制批量请求。随后结合 NeutralPress 项目，讲解了 Cap.js 后端存储、Challenge 创建、答案验证、Token 校验、Server Action 接入和前端 Hook 封装等实现方式，并总结了它在隐私、可自部署、无打扰体验上的优势，以及无法识别人类、设备性能差异大等局限。]]></description>
            <content:encoded><![CDATA[<p class="feed-lead">前往 <a href="https://ravelloh.com/posts/cap-js-pow-self-hosted-captcha">https://ravelloh.com/posts/cap-js-pow-self-hosted-captcha</a> 查看以获得最佳体验。</p>
<h2 id="前言-1">前言</h2>
<p><a href="https://github.com/tiagozip/cap">https://github.com/tiagozip/cap</a></p>
<p>Cap.js 是一种基于工作量证明 (PoW) 的验证码系统。相较于常见的验证码系统（如 Cloudflare Turnstile ，reCAPTCHA，hCAPTCHA），其特色是可自建，无隐私问题，无打扰，体积小。</p>
<p>我的博客的登录、注册等接口就使用了 Cap.js，你可以来 <a href="/signin">看看效果</a>。</p>
<p>其他的验证码大概分为两类（当然两者可能混合使用），一类是要求用户解决某些困难问题的，例如数字运算、选自行车、人行道等。另一类则是收集用户浏览器的信息，来判断浏览器是否属于正常用户。</p>
<p>而工作量证明类验证码则与他们不同，可以说目标就不一样。其他的验证码主要是为人机验证，防止自动化程序批量请求，所以需要从中辨别出真人。工作量证明类验证码则主要是为了速率限制，通过提高请求成本，来防止大量请求。</p>
<p>其原理与挖矿类似，即给客户端一个需大量计算、但服务器端可轻松验证的问题，例如：服务端发一个种子 + 配置的挑战，客户端在本地用 SHA-256 不断哈希，找到使哈希以目标前缀开头的 nonce，提交给服务端。而服务器端验证时，只需要验证用户答案是否满足条件即可。具体的交互流程大概是两次请求，即：</p>
<ol>
<li>客户端向服务器获取问题</li>
<li>服务器给出问题，并将问题存储</li>
<li>客户端计算哈希，将结果发送到服务器</li>
<li>服务器验证答案，将Token发送到客户端，并将Token存储</li>
</ol>
<p>随后，客户端即可用 Token 进行提交请求，服务器端验证Token是否已存储，若已存储则此次质询通过。根据设置，可以选择删除这个已验证过的Token（一次性），或者保留固定时间（有效时期内不再验证）。</p>
<p>从上述流程不难发现，PoW验证码完全不需要用户操作，它将本来给用户的验证流程交给用户的设备来完成。但 PoW 仍具有局限性，如：</p>
<ul>
<li>无法进行人机验证。完全不验证对方是否为真人，只要问题能解决即可。如果对方使用高性能计算集群来快速解决验证码，则仍可以对接口进行高速率请求</li>
<li>问题难度固定，各个设备通过时间存在极大差异。为了限制高性能设备的请求速率，难度不能过低，但难度较高同时也会导致低性能设备需要花费较长时间。（你可以前往 <a href="https://capjs.js.org/guide/benchmark.html">官方BenchMark</a> 来对比不同设备解决问题的时间）</li>
</ul>
<p>其中，对于无法识别机器请求的问题，我通过 Server Action 已经解决了，后面再写个文章讲讲。</p>
<p>但对于不同用户设备算力存在差异的问题，确实没有什么太好的办法，我的方案是主动在进入页面时就开始计算验证码，让用户把等待的时间用来填表单。但是，对于太老的设备，计算时间可能仍超出用户预期。</p>
<h2 id="关于-capjs-2">关于 Cap.js</h2>
<p>Cap.js 则是我用着不错的 PoW，比较的轻量，支持较大程度的自定义。想要将其添加到你现有的项目中，你需要分别创建后端和前端部分：</p>
<ol>
<li><code>@cap.js/server</code>：负责创建挑战、兑换答案、校验 token</li>
<li><code>@cap.js/widget</code>：负责在浏览器端本地计算挑战</li>
</ol>
<p>安装：</p>
<pre><code class="language-bash">pnpm add @cap.js/server @cap.js/widget
</code></pre>
<p>我这个项目里用的是 Redis 来存挑战和 token，你也可以使用内存（Serverless 下不可用）或者数据库等。整体结构是：</p>
<ol>
<li>服务端初始化一个 <code>Cap</code> 实例</li>
<li>把 challenge 和 token 的读写逻辑接到存储端</li>
<li>暴露三个操作：<code>createChallenge</code>、<code>verifyChallenge</code>、<code>verifyToken</code></li>
<li>前端加载 widget，在页面打开时就先开始解题</li>
<li>提交登录、注册、评论这些请求时，把 token 带上</li>
<li>实现业务逻辑的时候，先验证请求中的 token 是否有效</li>
</ol>
<h2 id="实现-3">实现</h2>
<h3 id="后端-4">后端</h3>
<p>后端需完成三个操作： <code>createChallenge</code> 、<code>verifyChallenge</code> 、<code>verifyToken</code>，分别用于创建验证码问题、验证验证码答案并分发token、验证token。三步操作都需要数据存取操作。例如：</p>
<pre><code class="language-ts">// src/lib/server/captcha.ts
// https://github.com/RavelloH/NeutralPress/blob/main/apps/web/src/lib/server/captcha.ts
import "server-only";

import Cap from "@cap.js/server";

import { generateCacheKey } from "@/lib/server/cache";
import redis, { ensureRedisConnection } from "@/lib/server/redis";

export const cap = new Cap({
  storage: {
    challenges: {
      store: async (token, challengeData) => {
        // 以下为你的存储端写入逻辑
        await ensureRedisConnection();
        const ttl = Math.floor((challengeData.expires - Date.now()) / 1000);
        if (ttl > 0) {
          await redis.setex(
            generateCacheKey("captcha", "challenge", token),
            ttl,
            JSON.stringify(challengeData),
          );
        }
      },
      read: async (token) => {
        // 以下为你的存储端读取逻辑
        await ensureRedisConnection();
        const data = await redis.get(
          generateCacheKey("captcha", "challenge", token),
        );
        if (!data) return null;

        const challengeData = JSON.parse(data);
        if (challengeData.expires &#x3C;= Date.now()) {
          await redis.del(generateCacheKey("captcha", "challenge", token));
          return null;
        }

        return { challenge: challengeData, expires: challengeData.expires };
      },
      delete: async (token) => {
        // 以下为你的存储端删除逻辑
        await redis.del(generateCacheKey("captcha", "challenge", token));
      },
      deleteExpired: async () => {
        // 以下为你的存储端删除过期项的逻辑
        // 不过我用 Redis 的 ttl 实现的自动清理，这里就不用写了
      },
    },
    tokens: {
      store: async (tokenKey, expires) => {
        // 以下为你的存储端写入逻辑
        await ensureRedisConnection();
        const ttl = Math.floor((expires - Date.now()) / 1000);
        if (ttl > 0) {
          await redis.setex(
            generateCacheKey("captcha", "token", tokenKey),
            ttl,
            expires.toString(),
          );
        }
      },
      get: async (tokenKey) => {
        // 以下为你的存储端读取逻辑
        await ensureRedisConnection();
        const data = await redis.get(
          generateCacheKey("captcha", "token", tokenKey),
        );
        if (!data) return null;

        const expires = parseInt(data, 10);
        if (expires &#x3C;= Date.now()) {
          await redis.del(generateCacheKey("captcha", "token", tokenKey));
          return null;
        }

        return expires;
      },
      delete: async (tokenKey) => {
        // 以下为你的存储端删除逻辑
        await redis.del(generateCacheKey("captcha", "token", tokenKey));
      },
      deleteExpired: async () => {
        // 以下为你的存储端删除过期项的逻辑
        // 不过我用 Redis 的 ttl 实现的自动清理，这里就不用写了
      },
    },
  },
});
</code></pre>
<p>其中，<code>challenges</code>存还没被解出的题，<code>tokens</code>存已经解题成功、可以拿去提交业务请求的令牌。</p>
<p>存储方式随意，可以用内存，或者 Redis，或者数据库，甚至 HTTP 数据库。但在 Serverless 下，内存不可用，所以我用的是 Redis ，这样清理也容易多了，带个 TTL 就行，都不用单独写了。</p>
<h4 id="方法-5">方法</h4>
<p>初始化完 <code>cap</code> 之后，后端需要三个操作方法：</p>
<ol>
<li><code>createChallenge</code>：生成题目</li>
<li><code>verifyChallenge</code>：验证答案并发 token</li>
<li><code>verifyToken</code>：提交表单时检查 token</li>
</ol>
<pre><code class="language-ts">// src/actions/captcha.ts
// https://github.com/RavelloH/NeutralPress/blob/main/apps/web/src/actions/captcha.ts
"use server";

import { cap } from "@/lib/server/captcha";

export async function createChallenge() {
  const data = await cap.createChallenge({
    challengeCount: 50,
    challengeSize: 32,
    challengeDifficulty: 5, // 5 似乎耗时有点长，我博客里用的就是5，你可以到 BenchMark 里自己试试
    expiresMs: 600000,
  });

  return {
    success: true,
    data,
  };
}

export async function verifyChallenge({
  token,
  solutions,
}: {
  token: string;
  solutions: number[];
}) {
  const data = await cap.redeemChallenge({ token, solutions });

  return {
    success: true,
    data,
  };
}
</code></pre>
<pre><code class="language-ts">// src/lib/server/captcha.ts
// https://github.com/RavelloH/NeutralPress/blob/main/apps/web/src/lib/server/captcha.ts
export async function verifyToken(token: string) {
  try {
    const isValid = await cap.validateToken(token, {
      keepToken: false, // 令牌是否可以重复使用
    });
    return isValid;
  } catch (error) {
    return { success: false };
  }
}
</code></pre>
<p>Challenge 参数大多都在 <a href="https://capjs.js.org/guide/benchmark.html">官方BenchMark</a> 页面里面有，你可以自己试试。<code>keepToken: false</code>决定令牌是否可以被重复使用，不过似乎可重复使用的令牌也起不到放置批量调用的作用了。</p>
<h4 id="用-server-action-替代-api-6">用 Server Action 替代 API</h4>
<blockquote>
<p>仅为拓展用途，Server Action 目前用的还不是太广泛，如果你的项目没用到过就不用看这段</p>
</blockquote>
<p>官方的通信方式是直接使用 fetch 来通信， 但在 NeutralPress 里，我基本都在用 Server Action，官方有 <code>window.CAP_CUSTOM_FETCH</code> 方法来自定义 fetcher：</p>
<pre><code class="language-ts">// src/hooks/use-captcha.ts
// https://github.com/RavelloH/NeutralPress/blob/main/apps/web/src/hooks/use-captcha.ts
useEffect(() => {
  if (typeof window === "undefined") return;

  window.CAP_CUSTOM_FETCH = async function (url, options) {
    const result =
      url === "/challenge"
        ? await createChallenge()
        : url === "/redeem" &#x26;&#x26;
            options?.body &#x26;&#x26;
            typeof options.body === "string"
          ? await verifyChallenge(JSON.parse(options.body))
          : null;

    if (result &#x26;&#x26; "success" in result &#x26;&#x26; result.success &#x26;&#x26; result.data) {
      return new Response(JSON.stringify(result.data));
    }

    return new Response(
      JSON.stringify({ error: "Failed to create challenge" }),
      { status: 500 },
    );
  };

  return () => {
    delete window.CAP_CUSTOM_FETCH;
  };
}, []);
</code></pre>
<p>从这套实现可以看出，Cap.js widget 仍然在请求 <code>/challenge</code> 和 <code>/redeem</code>，但真正的网络调用已经被转发给 <code>createChallenge()</code> 和 <code>verifyChallenge()</code> 这两个 Server Action 了。</p>
<h3 id="前端-7">前端</h3>
<p>前端核心逻辑在 <code>useCaptcha</code> 这个 hook 里。它会动态导入 <code>@cap.js/widget</code>，然后创建实例、监听进度、监听成功事件：</p>
<pre><code class="language-ts">// src/hooks/use-captcha.ts
// https://github.com/RavelloH/NeutralPress/blob/main/apps/web/src/hooks/use-captcha.ts
import { useCallback, useEffect, useRef } from "react";

let CapClass: unknown = null;

async function loadCap() {
  if (typeof window !== "undefined" &#x26;&#x26; !CapClass) {
    const widgetModule = await import("@cap.js/widget");
    CapClass = widgetModule.default || widgetModule.Cap;
  }
  return CapClass;
}

export function useCaptcha(callbacks?: {
  onProgress?: (progress: number) => void;
  onSolve?: (token: string) => void;
  onError?: (error: unknown) => void;
}) {
  const capRef = useRef&#x3C;unknown>(null);

  const initializeCaptcha = useCallback(async () => {
    const CapClass = await loadCap();
    const CapConstructor = CapClass as new () => {
      addEventListener: (
        type: string,
        listener: (event: {
          detail: { progress?: number; token?: string };
        }) => void,
      ) => void;
      solve: () => Promise&#x3C;{ success: boolean; token: string }>;
      reset: () => void;
    };

    const capInstance = new CapConstructor();
    capRef.current = capInstance;

    capInstance.addEventListener("progress", (event) => {
      callbacks?.onProgress?.(event.detail.progress || 0);
    });

    capInstance.addEventListener("solve", (event) => {
      callbacks?.onSolve?.(event.detail.token || "");
    });
  }, [callbacks]);

  const solve = useCallback(async () => {
    if (!capRef.current) {
      await initializeCaptcha();
    }

    return await (capRef.current as {
      solve: () => Promise&#x3C;{ success: boolean; token: string }>;
    }).solve();
  }, [initializeCaptcha]);

  return { solve };
}
</code></pre>
<p>写成 Hook 之后就可以方便的在其他组件里面调用了：</p>
<pre><code class="language-tsx">// src/components/ui/CaptchaButton.tsx
// https://github.com/RavelloH/NeutralPress/blob/main/apps/web/src/components/ui/CaptchaButton.tsx
export function CaptchaButton(props: CaptchaButtonProps) {
  const { broadcast } = useBroadcastSender&#x3C;object>();

  const { solve, reset } = useCaptcha({
    onSolve: (token) => {
      broadcast({ type: "captcha-solved", token });
    },
    onProgress: (progress) => {
      setInternalLoading(progress);
    },
    onError: (error) => {
      broadcast({ type: "captcha-error", error });
    },
  });

  useEffect(() => {
    solve();
  }, [solve]);

  useBroadcast((message: { type: string }) => {
    if (message?.type === "captcha-reset") {
      reset();
      solve();
    }
  });

  return Button({ ...props, loading });
}
</code></pre>
<p>你也可以这样把它集成到各种表单提交方式中。为了优化用户体验，最好一挂载就开始验证码，而不是用户点击提交时才开始算，这样能大幅减少用户被阻塞的时间：</p>
<pre><code class="language-ts">useEffect(() => {
  solve();
}, [solve]);
</code></pre>
<p>同时，<code>CaptchaButton</code> 还会把进度状态显示成按钮 loading，并在算完后通过广播把 token 发给表单组件。详情看 <a href="https://github.com/RavelloH/NeutralPress/blob/main/apps/web/src/components/ui/CaptchaButton.tsx">NeutralPress/apps/web/src/components/ui/CaptchaButton.tsx at main · RavelloH/NeutralPress</a> 。</p>
<h4 id="在表单里使用-token-8">在表单里使用 token</h4>
<p>有了 <code>CaptchaButton</code> 之后，业务表单接入起来就很简单了。例如注册页里：</p>
<pre><code class="language-tsx">const [token, setToken] = useState("");

useBroadcast((message: { type: string; token: string }) => {
  if (message?.type === "captcha-solved") {
    setToken(message.token);
  }
});

const register = async () => {
  if (!token) {
    showMessage("安全验证失败，请刷新页面重试");
    return;
  }

  const result = await registerAction({
    username,
    email,
    password,
    captcha_token: token,
  });
};
</code></pre>
<p>前端拿到验证成功后传回的 token 后，将其与业务参数一起提交即可。注意检查验证是否成功：</p>
<pre><code class="language-ts">if (!(await verifyToken(captcha_token)).success)
  return response.unauthorized({
    message: "安全验证失败，请刷新页面重试",
  });
</code></pre>
<h3 id="在-neutralpress-中的应用-9">在 NeutralPress 中的应用</h3>
<p>NeutralPress 里本来就大量使用 Server Action，登录、注册、邮箱验证、评论、友链申请这些动作，都是提交一个表单 -> 进入 Server Action -> 返回结果，Cap.js 刚好也适合放在这个链路里。</p>
<p>此外，我本来就已经给这些接口做了 <code>limitControl</code> 速率限制，所以现在的防护有三层：</p>
<ol>
<li>Server Action 的调用有难度（无法简单的使用 http 来请求）</li>
<li>纯请求频率限制</li>
<li>每次关键请求都要先付出一段本地计算成本</li>
</ol>
<p>大概是我想到的不依靠三方防火墙的最有效的办法了。</p>
<h2 id="总结-10">总结</h2>
<h3 id="优点-11">优点</h3>
<ol>
<li>可自部署，不依赖第三方验证码平台</li>
<li>对隐私友好，不需要把大量浏览器指纹信息交给外部服务</li>
<li>不打扰用户，0 交互</li>
<li>很适合接在 Server Action 前面，做统一的请求闸门</li>
<li>对批量脚本确实能显著增加成本</li>
</ol>
<h3 id="缺点-12">缺点</h3>
<ol>
<li>不能识别真人，只能提高自动化请求成本</li>
<li>不同设备的计算耗时差异非常大</li>
<li>仍然需要服务端存储 challenge 和 token</li>
<li>高性能设备或分布式算力仍然可以绕过它（大概没人拿这种高性能集群来打吧？）</li>
<li>对特别老的设备来说，体验可能不太好（）<br>
<img src="/p/ECwvIoqYDSr3" alt="A918F0B1831E035338448EAA68177E1B.jpg"></li>
</ol>
<p>但作为开源自部署验证码来说，已经是相当好的选择了，给到夯。</p>
<p>—— 完。</p>]]></content:encoded>
            <author>RavelloH</author>
            <enclosure url="https://ravelloh.com/p/PV8WNrnyuV8B" length="0" type="image//p/PV8WNrnyuV8B"/>
            <contentPreview><![CDATA[前言 https://github.com/tiagozip/cap Cap.js 是一种基于工作量证明 (PoW) 的验证码系统。相较于常见的验证码系统（如 Cloudflare Turnstile ，reCAPTCHA，hCAPTCHA），其特色是可自建，无隐私问题，无打扰，体积小。 我的博客的登录、注册等接口就使用了 Cap.js，你可以来 看看效果 。 其他的验证码大概分为两类（当然两者可能混合使用），一类是要求用户解决某些困难...]]></contentPreview>
        </item>
        <item>
            <title><![CDATA[使用物化路径与 DFS 排序来实现树状评论]]></title>
            <link>https://ravelloh.com/posts/implement-tree-comments-materialized-path-dfs</link>
            <guid isPermaLink="false">https://ravelloh.com/posts/implement-tree-comments-materialized-path-dfs</guid>
            <pubDate>Fri, 03 Apr 2026 10:08:21 GMT</pubDate>
            <description><![CDATA[一篇探讨如何使用物化路径与 DFS 排序高效实现树状评论系统的技术文章。文章首先分析了传统平铺式与有限层级评论在上下文追溯时的痛点，随后详细展示了如何通过 Prisma 结合邻接表、物化路径（path）和 DFS 排序键（sortKey）来优化数据库结构。该方案通过后端预先编码树信息并返回扁平数组，配合前端基于标识字段直接进行缩进和折叠渲染，完美解决了无限层级评论在递归查询、分页加载及展示上的性能难题。]]></description>
            <content:encoded><![CDATA[<p class="feed-lead">前往 <a href="https://ravelloh.com/posts/implement-tree-comments-materialized-path-dfs">https://ravelloh.com/posts/implement-tree-comments-materialized-path-dfs</a> 查看以获得最佳体验。</p>
<h2 id="前言-1">前言</h2>
<p>树状评论看起来只是简单的无限层级的评论回复评论，但在实际场景中很少见。能说的上来的，恐怕只有 Reddit 了。</p>
<p>其余的一般可以分为两种：</p>
<ol>
<li>没有分层的：例如各类论坛系统（Discoures、Discuz、百度贴吧），一般只使用“@xxx: 回复内容”这样的标识来表示回复关系。</li>
<li>分少数几层的：例如哔哩哔哩、知乎、微信公众号，一层主要评论（评论正文的）+一层该评论的回复串，其中该评论的回复串一般是不继续分层的，也使用“@xxx: 回复内容”这样的标识来表示回复关系。</li>
</ol>
<p>这两种方式都有比较明显的问题，就是找上下文比较的繁琐。部分 App 会考虑内置上下文查找的功能，例如 Discoures 的查看上文/下文、哔哩哔哩的“查看回复”，但无论怎么后天通过功能来弥补，其还是不能直观的看出“那个帖子回复了哪个帖子”，而是需要依赖上文的功能。考虑以下场景：</p>
<ul>
<li>A：123</li>
<li>B：456</li>
<li>A：回复B：789</li>
<li>C：回复A：000</li>
</ul>
<p>问：此时 C 回复的是 A 的 123 呢，还是 789 呢？</p>
<p>我反正是在哔哩哔哩里面碰到过很多次这个问题了，而且哔哩哔哩只能查看评论（下文），上文是找不到的。</p>
<p>当然，上面的问题也有其他解决方法，非常简单粗暴，直接引用整个上文，例如 Github Issue。但这样导致随着评论层级增加，每个评论的长度也增加了，不仅对数据库不友好，对鼠标滚轮也不太友好。</p>
<p>树状评论则能完美的解决这个问题。我去年写了一个论坛系统，就是使用的树状评论，你可以轻松看到一整个评论串：左上是父评论，右下是子评论，同一缩进的上下评论均为同级。</p>
<p><img src="/p/7BAQZpM7Pz77" alt="image.png"></p>
<p>当然其也是存在缺点的，最明显的是由于有缩进，导致其在深度较大的时候，内容被挤到右侧，左侧大量留空；此外评论看不出所有评论的时间关系，只能看到同级评论的时间顺序。前者可以靠自适应间距来解决，后者只能当作特色了。</p>
<p>此外，为了方便阅读，一般来说你也可以加一些花活来作为辅助功能。例如我上面的论坛系统：<a href="https://xeoos.net/zh-CN/post/2409/german-forest-spirits--digital-paths">德国森林精灵与数字路径 | XEO OS</a> ，支持高亮评论、聚焦评论、hover高亮整个层级、折叠/隐藏，这样阅读体验会更好一些。</p>
<p><img src="/p/lTkLu0P6qSa0" alt="image.png"></p>
<p><img src="/p/wRfnLDmaXPZb" alt="image.png"></p>
<p>这个博客系统也使用了类似的方案，不过考虑到评论不会太多，我把聚焦功能去掉了。本文接下来主要介绍博客系统的这款。</p>
<p><img src="/p/bJXgs7hN4EuC" alt="image.png"></p>
<hr>
<p>如果真的想把它写得比较舒服，通常还要额外解决几个问题：</p>
<ol>
<li>评论需要有明确的层级关系</li>
<li>父评论后面应该紧跟自己的子评论，而不是简单按时间打散</li>
<li>顶级评论和子评论最好都支持分页或局部展开</li>
<li>前端需要方便判断一条评论是谁的父评论、谁的祖先评论</li>
</ol>
<p>在这种情况下，如果只给评论表加一个 <code>parentId</code>，虽然也能勉强做出来，但后面在查询、排序和渲染时会比较麻烦。因此，使用深度优先搜索 + 物化路径来优化性能是个不错的方案。</p>
<h2 id="实现-2">实现</h2>
<p>在 NeutralPress 这个项目里，我用的是邻接表 + 物化路径 + DFS 排序键的组合，在评论创建时就把 DFS 顺序编码进数据库字段，后面查询和渲染的性能就很高了。（实际上，prisma 对无限递归查询的支持不太好，大概只能这样才比较实用。）</p>
<p>也就是：</p>
<ol>
<li><code>parentId</code> 表示这条评论直接回复谁</li>
<li><code>depth</code> 表示当前层级，前端拿来算缩进</li>
<li><code>path</code> 表示从根评论到当前评论的完整祖先链</li>
<li><code>sortKey</code> 表示这条评论在整棵树中的 DFS 顺序</li>
<li><code>replyCount</code> 记录直接子评论数量，方便前端判断是否需要展开回复</li>
</ol>
<h3 id="评论表结构-3">评论表结构</h3>
<p>项目里评论表的核心字段大概是这样的：</p>
<pre><code class="language-prisma">model Comment {
  id        String   @id @default(uuid())
  content   String

  parentId  String?
  parent    Comment?  @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)
  replies   Comment[] @relation("CommentReplies")

  depth      Int    @default(0)
  path       String @default("") @db.VarChar(2000)
  sortKey    String @default("") @db.VarChar(500)
  replyCount Int    @default(0)

  @@index([parentId])
  @@index([postId, status, sortKey])
  @@index([pageId, status, sortKey])
  @@index([path])
}
</code></pre>
<p>这里最关键的其实不是 <code>parentId</code>，而是后面这些辅助字段。只靠 <code>parentId</code>，你只能知道这个评论的上一级评论，却没法高效查询：</p>
<ol>
<li>这条评论的所有后代是谁（用于展开）</li>
<li>这棵子树应该排在整篇评论流的什么位置（前端排序）</li>
<li>某条评论折叠后，哪些后代应该一起隐藏</li>
<li>某条评论下方是否还有更多没展开的后代</li>
</ol>
<p><code>path</code>、<code>depth</code> 和 <code>sortKey</code> 用于标识这些附加字段。</p>
<h3 id="创建评论时补全树信息-4">创建评论时补全树信息</h3>
<p>在创建评论时，先查询父评论，再计算自己的 <code>depth</code>，最后回填 <code>path</code> 和 <code>sortKey</code>：</p>
<pre><code class="language-ts">let depth = 0;
let path = "";
let parentSortKey = "";

if (parentId) {
  const parentComment = await prisma.comment.findUnique({
    where: { id: parentId },
    select: { depth: true, path: true, sortKey: true },
  });

  depth = parentComment.depth + 1;
  parentSortKey = parentComment.sortKey;
}

const record = await prisma.comment.create({
  data: {
    content,
    parentId: parentId || null,
    depth,
    path: "",
    sortKey: "pending",
    replyCount: 0,
  },
  select: { id: true },
});

path = parentId
  ? `${parentComment.path}/${record.id}`
  : record.id;

const finalSortKey = parentId
  ? `${parentSortKey}.${record.id}`
  : record.id;

await prisma.comment.update({
  where: { id: record.id },
  data: { path, sortKey: finalSortKey },
});
</code></pre>
<p>顶级评论的 <code>path</code> 就是自己的 id，例如：</p>
<pre><code class="language-text">a
</code></pre>
<p>如果 <code>a</code> 下面有个子评论 <code>b</code>，那么它的 <code>path</code> 就会变成：</p>
<pre><code class="language-text">a/b
</code></pre>
<p>再往下一层 <code>c</code>：</p>
<pre><code class="language-text">a/b/c
</code></pre>
<p>于是 <code>depth</code> 也就分别是 <code>0</code>、<code>1</code>、<code>2</code>。如果你想找 a 的所有评论串，只需要：</p>
<pre><code class="language-ts">const comment = { path: "a", postId: "test" };

const descendantCount = await prisma.comment.findMany({
  where: {
    postId: comment.postId,
    path: { startsWith: comment.path + "/" },
    deletedAt: null,
  },
});
</code></pre>
<p>此外，项目里还会在子评论创建成功后给父评论的 <code>replyCount + 1</code>，用空间换时间，防止还需要额外判断是否存在子评论。</p>
<p>不过，这里的<code>sortKey</code> 段值直接用了评论 id，能保证的是“树顺序稳定”，不是同级严格按发布时间排序。如果你希望同级也严格按时间升序，可以把每一段换成零填充时间戳、Snowflake，或者每个父评论下的自增序号。</p>
<h3 id="后端返回-5">后端返回</h3>
<p>如果一篇文章真的有几千条评论，一次性查整棵树、递归组装后再返回，会导致数据库传输的数据太多。我这里加了两层优化（现在的评论系统就是这种）：</p>
<ol>
<li>首屏只分页拿顶级评论</li>
<li>每个顶级评论先预加载少量子评论，更深的按需展开</li>
</ol>
<p>顶级评论的查询大概是这样的：</p>
<pre><code class="language-ts">const rootWhere: Prisma.CommentWhereInput = {
  postId: target.id,
  deletedAt: null,
  parentId: null,
  ...(cursor ? { sortKey: { gt: cursor } } : {}),
};

const rootComments = await prisma.comment.findMany({
  where: rootWhere,
  orderBy: { sortKey: "asc" },
  take: pageSize + 1,
  select: commentSelect,
});
</code></pre>
<p>也就是说，首屏实际上只看主评论，子评论不加入。但如果完全不带子评论，也就看不出树状效果了。所以需要额外做一层预加载：</p>
<p>每个主评论最多先带 5 条一级子评论，每个一级子评论再最多带 5 条二级子评论。</p>
<p>这样首屏一打开就已经有树状结构的感觉，也不会导致每一页加载的评论数太多。</p>
<p>而当用户点击“展开回复”时，则调用 <code>getDirectChildren</code>，只加载某个父评论的直接子评论：</p>
<pre><code class="language-ts">const where: Prisma.CommentWhereInput = {
  postId: target.id,
  deletedAt: null,
  parentId,
  depth: parentDepth + 1,
  ...(cursor ? { sortKey: { gt: cursor } } : {}),
};

const children = await prisma.comment.findMany({
  where,
  orderBy: { sortKey: "asc" },
  take: pageSize + 1,
  select: commentSelect,
});
</code></pre>
<p>这里用了两个条件：</p>
<ol>
<li><code>parentId = 当前评论 id</code></li>
<li><code>depth = 父层级 + 1</code></li>
</ol>
<p><code>parentId</code> 用来锁定直属评论，<code>depth</code> 用来再保险，避免脏数据把别的层级混进来。</p>
<p>至于“这条评论下面到底还有多少后代没展开”，可以直接利用 <code>path</code> 的前缀匹配来统计：</p>
<pre><code class="language-ts">const descendantCount = await prisma.comment.count({
  where: {
    postId: comment.postId,
    path: { startsWith: comment.path + "/" },
    deletedAt: null,
  },
});
</code></pre>
<p>这就是物化路径的好处，想查整棵子树，不用递归或 <code>WITH RECURSIVE</code>，直接前缀匹配即可。</p>
<h3 id="前端渲染-6">前端渲染</h3>
<p>前端这边我没有真的把评论重新组装成嵌套 <code>children</code> 树，而是直接用后端返回的扁平数组，然后按 <code>sortKey</code> 排序、按 <code>depth</code> 缩进、按 <code>path</code> 判断可见性。</p>
<p>一个简化后的写法如下：</p>
<pre><code class="language-tsx">"use client";

import { useMemo, useState } from "react";

type CommentItem = {
  id: string;
  parentId: string | null;
  content: string;
  depth: number;
  path: string;
  sortKey: string;
  replyCount: number;
  author: {
    displayName: string;
  };
};

function isHiddenByCollapse(
  comment: CommentItem,
  collapsedIds: Set&#x3C;string>,
  commentsMap: Map&#x3C;string, CommentItem>,
) {
  for (const collapsedId of collapsedIds) {
    const collapsed = commentsMap.get(collapsedId);
    if (collapsed &#x26;&#x26; comment.path.startsWith(`${collapsed.path}/`)) {
      return true;
    }
  }
  return false;
}

export default function TreeComments({
  initialComments,
}: {
  initialComments: CommentItem[];
}) {
  const [collapsedIds, setCollapsedIds] = useState&#x3C;Set&#x3C;string>>(new Set());

  const comments = useMemo(
    () =>
      [...initialComments].sort((a, b) =>
        a.sortKey.localeCompare(b.sortKey),
      ),
    [initialComments],
  );

  const commentsMap = useMemo(
    () => new Map(comments.map((comment) => [comment.id, comment])),
    [comments],
  );

  const visibleComments = useMemo(() => {
    return comments.filter((comment) => {
      return !isHiddenByCollapse(comment, collapsedIds, commentsMap);
    });
  }, [comments, collapsedIds, commentsMap]);

  const toggleCollapse = (commentId: string) => {
    setCollapsedIds((prev) => {
      const next = new Set(prev);
      if (next.has(commentId)) {
        next.delete(commentId);
      } else {
        next.add(commentId);
      }
      return next;
    });
  };

  return (
    &#x3C;div>
      {visibleComments.map((comment) => {
        const hasChildren = comment.replyCount > 0;
        const isCollapsed = collapsedIds.has(comment.id);

        return (
          &#x3C;article
            key={comment.id}
            style={{ paddingLeft: `${comment.depth * 24}px` }}
          >
            &#x3C;div style={{ display: "flex", gap: 8, alignItems: "center" }}>
              &#x3C;strong>{comment.author.displayName}&#x3C;/strong>
              {hasChildren ? (
                &#x3C;button onClick={() => toggleCollapse(comment.id)}>
                  {isCollapsed ? "展开回复" : "折叠回复"}
                &#x3C;/button>
              ) : null}
            &#x3C;/div>
            &#x3C;p>{comment.content}&#x3C;/p>
          &#x3C;/article>
        );
      })}
    &#x3C;/div>
  );
}
</code></pre>
<p>这个思路和项目里的真实实现是一致的，只是把登录、点赞、分页加载、评论预览这些和文章主题无关的东西都砍掉了。你可以在 <a href="https://github.com/RavelloH/NeutralPress/blob/main/apps/web/src/components/client/features/posts/CommentsSection.tsx">NeutralPress/apps/web/src/components/client/features/posts/CommentsSection.tsx at main · RavelloH/NeutralPress</a> 看完整的实现。</p>
<p>核心逻辑是：</p>
<ol>
<li>后端返回扁平列表，但已经按 <code>sortKey</code> 排好了 DFS 顺序</li>
<li>前端直接 <code>map</code> 渲染，不需要再递归建树</li>
<li>缩进靠 <code>depth</code>，折叠靠 <code>path</code> 前缀判断</li>
</ol>
<p>在实际项目里，我还额外维护了一个 <code>childPaginationMap</code>，用来区分“这条评论是折叠了”还是“这条评论的子评论其实还没加载出来”，这样局部展开和分页加载都更自然。</p>
<h2 id="原理-7">原理</h2>
<h3 id="树状评论排序-8">树状评论排序</h3>
<p>假设现在有这样一棵评论树：</p>
<pre><code class="language-text">A
| B
| | C
| D
E
</code></pre>
<p>如果把它们的 <code>sortKey</code> 设计成：</p>
<pre><code class="language-text">A
A.B
A.B.C
A.D
E
</code></pre>
<p>那么只要按字符串升序排序，结果天然就是一次深度优先遍历：</p>
<pre><code class="language-text">A
A.B
A.B.C
A.D
E
</code></pre>
<p>也就是说，数据库的压力非常小，只是在按一个普通字符串字段排序而已；真正让这个顺序看起来像树的，是前端根据 <code>depth</code> 做的缩进。</p>
<p>整套方案的重点并不是 DFS 算法本身，而是把 DFS 的结果提前编码进了 <code>sortKey</code> 里，也就是物化路径。</p>
<h3 id="path-和-sortkey-的区别-9"><code>path</code> 和 <code>sortKey</code> 的区别</h3>
<p>这两个字段看起来有点像，但职责其实完全不同：</p>
<ol>
<li><code>sortKey</code> 负责排序和游标分页</li>
<li><code>path</code> 负责祖先链判断和子树查询</li>
<li><code>depth</code> 负责缩进和直接层级判断</li>
</ol>
<p>只用 <code>path</code> 也不是完全不行，但会有两个问题：</p>
<ol>
<li>不好做稳定分页，因为“某条评论之后是谁”这件事本质上还是排序问题</li>
<li>前端做折叠时，容易把“祖先关系判断”和“显示顺序”搅在一起</li>
</ol>
<p>分成三个字段之后就很清晰了：</p>
<ol>
<li>一条评论是不是另一条评论的后代：看 <code>path.startsWith(parent.path + "/")</code></li>
<li>整篇评论流应该怎么排：看 <code>sortKey</code></li>
<li>该往右缩进多少：看 <code>depth</code></li>
</ol>
<h3 id="前端不使用递归来建树而使用扁平数组-10">前端不使用递归来建树而使用扁平数组</h3>
<p>做评论树，一般都是把数据组装成：</p>
<pre><code class="language-ts">type TreeComment = Comment &#x26; {
  children: TreeComment[];
};
</code></pre>
<p>这样当然能 render，我在 XEOOS 里面就是这样做的，但我发现，一旦要做局部展开、只加载直接子评论、根评论无限滚动，递归就会重复太多次，且随着同屏评论的增加而性能下降。</p>
<p>这个项目里前端保留的是扁平数组，</p>
<ol>
<li>首屏加载 10 条顶级评论</li>
<li>某个父评论展开后，把新拿到的子评论按 <code>sortKey</code> 合并进原数组</li>
<li>渲染时直接遍历一遍</li>
<li>哪些该显示，靠 <code>path</code> 和展开状态判断</li>
</ol>
<p>这样做有几个非常实际的好处：</p>
<ol>
<li>增量合并简单，<code>mergeBySortKey</code> 一次搞定</li>
<li>分页简单，根评论和子评论都可以走同一套 cursor 思路</li>
<li>折叠简单，只要判断当前评论是不是某个折叠节点的后代</li>
<li>高亮祖先路径也简单，可以用<code>comment.path.split("/")</code> 直接拿祖先链</li>
</ol>
<p>所以从渲染树的角度看它像树；但从状态管理的角度看，它其实一直是个有顺序的扁平列表。</p>
<h2 id="总结-11">总结</h2>
<p>优点：</p>
<ol>
<li>父评论后面天然跟着自己的子评论，渲染方便</li>
<li>折叠、展开、高亮祖先、局部加载都很好做</li>
<li>不需要数据库递归查询，查询性能高</li>
<li>前后端都只是在处理排序好的列表，复杂度低</li>
</ol>
<p>缺点：</p>
<ol>
<li><code>path</code> 和 <code>sortKey</code> 都是冗余字段，浪费一点空间</li>
<li>如果要移动一整棵子树，后代所有节点的 <code>path</code> / <code>sortKey</code> 需要一起改</li>
<li>当前实现若直接用 UUID 片段做 <code>sortKey</code>，同级顺序不是严格时间顺序</li>
<li>深度特别大时，移动端缩进空间还是会被吃掉，但这个属于树状评论的通病。</li>
</ol>
<p>不过对于博客评论这种读多写少、完全不移动树的场景这套方案是足够了。实际上，项目的媒体管理系统也是用的这套类似的方案来实现虚拟文件夹，这里就用到了移动树，有时间我再写一写。</p>
<p>—— 完。</p>]]></content:encoded>
            <author>RavelloH</author>
            <enclosure url="https://ravelloh.com/p/7BAQZpM7Pz77" length="0" type="image//p/7BAQZpM7Pz77"/>
            <contentPreview><![CDATA[前言 树状评论看起来只是简单的无限层级的评论回复评论，但在实际场景中很少见。能说的上来的，恐怕只有 Reddit 了。 其余的一般可以分为两种： 没有分层的：例如各类论坛系统（Discoures、Discuz、百度贴吧），一般只使用“@xxx: 回复内容”这样的标识来表示回复关系。 分少数几层的：例如哔哩哔哩、知乎、微信公众号，一层主要评论（评论正文的）+一层该评论的回复串，其中该评论的回复串一般是不继续分层的，也使用“@xxx: 回复...]]></contentPreview>
        </item>
        <item>
            <title><![CDATA[使用 Cloudinary + CF Worker 实现图片自动优化]]></title>
            <link>https://ravelloh.com/posts/auto-image-optimization-cloudinary-cloudflare-worker</link>
            <guid isPermaLink="false">https://ravelloh.com/posts/auto-image-optimization-cloudinary-cloudflare-worker</guid>
            <pubDate>Sat, 21 Feb 2026 15:28:00 GMT</pubDate>
            <description><![CDATA[利用 Cloudinary 与 Cloudflare Worker 结合，为前端站点（如 Next.js）提供低成本、高性能图片自动优化与缓存的完整解决方案。 文章详细说明了如何通过编写 Cloudflare Worker 脚本接管图片加载请求，利用边缘缓存大幅降低 Cloudinary 的带宽消耗。此外，该方案还内置了防盗链、恶意参数校验以及基于 HTTP Accept 头的图片格式（如 AVIF/WebP）按需智能分发功能，有效突破了常规免费图片优化服务（如 Vercel/Cloudflare 默认配额）的瓶颈。]]></description>
            <content:encoded><![CDATA[<p class="feed-lead">前往 <a href="https://ravelloh.com/posts/auto-image-optimization-cloudinary-cloudflare-worker">https://ravelloh.com/posts/auto-image-optimization-cloudinary-cloudflare-worker</a> 查看以获得最佳体验。</p>
<h2 id="前言-1">前言</h2>
<p>NeutralPress 自带图片优化，使用 Next.js 内置的图片优化来自带裁剪并压缩图片，在每个设备上都能给出适配其实际显示大小的图片。</p>
<p>这的确在一定程度上，让图片体积更小、加载更快，然而，图片优化极其消耗性能，所以各家给的限额都很少。强如 Vercel 和 Cloudflare，也都仅仅只提供每月 5000 次转换。如果短期之内上传大量图片，则很容易导致你的 Billing 不太好看。</p>
<p><img src="/p/iEOnD5oG1Qpf" alt="图片"></p>
<p>不过，<a href="https://cloudinary.com/">Cloudinary</a> 则提供了很大方的额度：免费版（Free Plan）每月提供 25 个积分，每个积分可以用来提供 1000 次转换，或者 1 GB 存储，或者 1 GB 带宽。</p>
<p>其中消耗最快的一般是带宽，所以这里我们引入 Cloudflare 来对每个请求尽可能的缓存。理想情况，除了第一次请求、后续请求完全由 Cloudflare CDN 来处理。此外，Cloudflare 也能顺便用来解决 Cloudinary 默认域名访问异常、容易被恶意请求刷优化次数的问题。</p>
<p>NeutralPress 在上传图片的时候会将其转为 avif 并压缩，在我个人这里的话，截图一般能压到 30 KB 左右，普通图片大概 300 KB。按平均一张照片 200 KB 算的话，单张照片的成本是：</p>
<ul>
<li>转换费： 1 次转换 = 0.001 积分（1/1000）。</li>
<li>带宽费： 200KB ≈ 0.00019GB = 0.00019 积分（传给 Cloudflare）。</li>
<li>存储费： 200KB ≈ 0.00019GB = 0.00019 积分（存在 Cloudinary）。</li>
<li>单张总计： 0.00138 积分</li>
</ul>
<p>也就是说，理论上每月可以处理<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mfrac><mrow><mn>25</mn><mtext> 积分</mtext></mrow><mrow><mn>0.00138</mn><mtext> 积分/张</mtext></mrow></mfrac><mo>≈</mo><mn>18</mn><mo separator="true">,</mo><mn>115</mn></mrow><annotation encoding="application/x-tex">\frac{25 \text{ 积分}}{0.00138 \text{ 积分/张}} \approx 18,115</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1.3923em;vertical-align:-0.52em;"></span><span class="mord"><span class="mopen nulldelimiter"></span><span class="mfrac"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.8723em;"><span style="top:-2.655em;"><span class="pstrut" style="height:3em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mtight">0.00138</span><span class="mord text mtight"><span class="mord mtight"> </span><span class="mord cjk_fallback mtight">积分</span><span class="mord mtight">/</span><span class="mord cjk_fallback mtight">张</span></span></span></span></span><span style="top:-3.23em;"><span class="pstrut" style="height:3em;"></span><span class="frac-line" style="border-bottom-width:0.04em;"></span></span><span style="top:-3.394em;"><span class="pstrut" style="height:3em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mtight">25</span><span class="mord text mtight"><span class="mord mtight"> </span><span class="mord cjk_fallback mtight">积分</span></span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.52em;"><span></span></span></span></span></span><span class="mclose nulldelimiter"></span></span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">≈</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.8389em;vertical-align:-0.1944em;"></span><span class="mord">18</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord">115</span></span></span></span>张图片。虽然 Next.js 的图片优化一般会生成很多个不同尺寸的图片，但在这个额度下，应该也没有任何额度焦虑了。</p>
<h2 id="原理-2">原理</h2>
<p>下面以 NeutralPress 举例，实际上所有使用 next/image 组件的都可以用。或者，如果不是 Next.js ，自己写一个根据当前图片实际显示宽度获取对应尺寸的照片的 Image 组件也可以。</p>
<pre><code class="language-ts">'use client'
 
import Image from 'next/image'
 
const imageLoader = ({ src, width, quality }) => {
  return `https://example.com/${src}?w=${width}&#x26;q=${quality || 75}`
}
 
export default function Page() {
  return (
    &#x3C;Image
      loader={imageLoader}
      src="me.png"
      alt="Picture of the author"
      width={500}
      height={500}
    />
  )
}
</code></pre>
<p>默认情况下，你可以认为 Next.js 内置的 Loader 就是 <code>/_next/image?url={url}&#x26;w={width}&#x26;q={quality}</code>，因此只需要修改它，改成先请求  Cloudflare Worker => Worker 再请求 Cloudinary => Cloudinary 从你的图片源中获取图片并优化 即可。</p>
<p>其中，Worker 负责转发请求的同时，还需要验证请求参数，防止有意外的断点大小，造成额外的 Cloudinary 调用。</p>
<h2 id="步骤-3">步骤</h2>
<p>首先需要一个 <a href="https://cloudinary.com/">Cloudinary</a> 账号，然后点击左下角 Settings - Security，在<code>Allowed fetch domains</code> 中添加你的域名。</p>
<p><img src="/p/gw7XkVXB3zZU" alt="image.png"></p>
<p>然后，前往 <a href="https://dash.cloudflare.com/">dash.cloudflare.com</a> ，新建一个 Worker，选择”从 Hello World“开始即可。</p>
<p><img src="/p/oIqGAZ4gZGqT" alt="image.png"></p>
<p>然后点击编辑代码：</p>
<p><img src="/p/ZkNTuihOjfD8" alt="image.png"></p>
<p>把 hello word 修改成下列内容：</p>
<pre><code class="language-js">export default {
  async fetch(request, env, ctx) {
    const urlObj = new URL(request.url);
    const params = urlObj.searchParams;

    // ==========================================
    // 1. 配置区域 (Configuration)
    // ==========================================

    // Cloudinary Cloud Name
    const cloudName = 'your_cloud_id';
    // 当输入是相对路径时，拼接的域名
    const defaultOrigin = 'https://ravelloh.com';
    // 可选尺寸参数
    const allowedWidths = new Set([32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2560]);
    // 可选质量参数，（75的时候，交由 Cloudinary 自动处理）
    const allowedQualities = new Set([75]);
    // 允许代理的域名
    const allowedDomains = new Set(['ravelloh.com']);
    // 防盗链白名单（留空则关闭防盗链）
    const allowedReferers = new Set([
      'ravelloh.com',
      'localhost'
    ]);
    // 是否允许空 Referer (建议 true，否则无法直接在浏览器/部分App打开图片)
    const allowEmptyReferer = true;

    // ==========================================
    // 2. 基础请求处理 (CORS &#x26; Methods)
    // ==========================================

    // 处理 OPTIONS 预检请求
    if (request.method === 'OPTIONS') {
      return new Response(null, {
        status: 204,
        headers: {
          'Access-Control-Allow-Origin': '*',
          'Access-Control-Allow-Methods': 'GET,HEAD,OPTIONS',
          'Access-Control-Allow-Headers': 'Content-Type',
          'Access-Control-Max-Age': '86400',
        }
      });
    }

    // 只允许 GET 和 HEAD
    if (request.method !== 'GET' &#x26;&#x26; request.method !== 'HEAD') {
      return new Response('Method Not Allowed', { status: 405 });
    }

    // ==========================================
    // 3. 防盗链检查 (Hotlink Protection)
    // ==========================================
    const referer = request.headers.get('Referer');
    if (referer) {
      try {
        const refererUrl = new URL(referer);
        const hostname = refererUrl.hostname;

        // 检查 Referer 是否在白名单中 (允许子域名)
        const isAllowed = Array.from(allowedReferers).some(allowed =>
          hostname === allowed || hostname.endsWith(`.${allowed}`)
        );

        if (!isAllowed) {
          // 放行搜索引擎爬虫
          const ua = (request.headers.get('User-Agent') || '').toLowerCase();
          const isBot = ua.includes('googlebot') || ua.includes('bingbot') || ua.includes('twitterbot');

          if (!isBot) {
            return new Response('Hotlinking Not Allowed', { status: 403 });
          }
        }
      } catch (e) {
        // Referer 格式异常，视为不通过
        console.log(`[Block] Invalid Referer: ${referer}`);
        return new Response('Invalid Referer', { status: 403 });
      }
    } else {
      // Referer 为空时的处理
      if (!allowEmptyReferer) {
        return new Response('Hotlinking Not Allowed (Empty Referer)', { status: 403 });
      }
    }

    // ==========================================
    // 4. 健康检查 &#x26; 参数验证
    // ==========================================

    let imageUrl = params.get('url');
    // 如果没有 url 参数，返回服务状态 JSON
    if (!imageUrl) {
      return new Response(JSON.stringify({
        status: "active",
        service: "Image Optimization Proxy",
        region: request.cf?.colo || "unknown",
        time: new Date().toISOString()
      }, null, 2), {
        status: 200,
        headers: {
          'Content-Type': 'application/json; charset=utf-8',
          'Access-Control-Allow-Origin': '*',
          'Cache-Control': 'no-store'
        }
      });
    }

    // URL 格式化与校验
    try {
      if (!imageUrl.startsWith('http')) {
        if (imageUrl.startsWith('//')) {
          imageUrl = 'https:' + imageUrl;
        } else {
          imageUrl = new URL(imageUrl, defaultOrigin).toString();
        }
      }
      const targetUrlObj = new URL(imageUrl);
      // 限制只能代理白名单域名的图片
      if (!allowedDomains.has(targetUrlObj.hostname)) {
        return new Response('Forbidden: Domain not allowed', { status: 403 });
      }
    } catch (e) {
      return new Response('Invalid URL', { status: 400 });
    }

    const width = parseInt(params.get('w'), 10);
    const quality = parseInt(params.get('q'), 10);

    if (!width || !quality) {
      return new Response('Missing required parameters: w, q', { status: 400 });
    }
    if (!allowedWidths.has(width)) return new Response('Invalid width.', { status: 400 });
    if (!allowedQualities.has(quality)) return new Response('Invalid quality.', { status: 400 });

    // ==========================================
    // 5. 格式归一化 (Format Normalization)
    // ==========================================

    // 将复杂的 Accept 头坍缩为简单的 key，提高缓存命中率
    const acceptHeader = request.headers.get('Accept') || '';
    let format = 'auto';
    if (acceptHeader.includes('image/avif')) {
      format = 'avif';
    } else if (acceptHeader.includes('image/webp')) {
      format = 'webp';
    }
    // 其他情况 format = 'auto' (通常回退到 jpeg/png)

    // ==========================================
    // 6. 缓存键构建 (Cache Key Construction)
    // ==========================================

    // 使用固定的 URL 结构作为 Cache Key，确保参数顺序一致
    const cacheKeyUrl = new URL('http://cache-key');
    cacheKeyUrl.searchParams.set('url', imageUrl);
    cacheKeyUrl.searchParams.set('w', width.toString());
    cacheKeyUrl.searchParams.set('q', quality.toString());
    cacheKeyUrl.searchParams.set('f', format);

    // 强制使用 GET 方法的 Request 对象作为 Key
    const cacheKeyReq = new Request(cacheKeyUrl.toString(), { method: 'GET' });
    const cache = caches.default;

    // ==========================================
    // 7. 缓存查询与回源逻辑
    // ==========================================

    let response = await cache.match(cacheKeyReq);

    if (!response) {
      // --- Cache Miss: 回源 Cloudinary ---
      console.log(`[Cache Miss] ${imageUrl}`);

      const qualityParam = (quality === 75) ? 'q_auto' : `q_${quality}`;
      const encodedImageUrl = encodeURIComponent(imageUrl);
      const cloudinaryUrl =
        `https://res.cloudinary.com/${cloudName}/image/fetch/` +
        `f_${format},c_limit,w_${width},${qualityParam}/` +
        `${encodedImageUrl}`;

      try {
        const originResponse = await fetch(cloudinaryUrl, {
          headers: {
            'User-Agent': 'Cloudflare Worker Image Proxy'
          }
        });

        if (!originResponse.ok) {
          // 返回源站错误信息，方便调试
          return new Response(`Upstream Error: ${originResponse.status}`, { status: 502 });
        }

        // --- 构建优化的响应头 ---
        const newHeaders = new Headers(originResponse.headers);

        // 允许跨域
        newHeaders.set('Access-Control-Allow-Origin', '*');

        // 强缓存配置 (1年 + immutable)
        newHeaders.set('Cache-Control', 'public, max-age=31536000, s-maxage=31536000, immutable');
        newHeaders.delete('CDN-Cache-Control');
        newHeaders.delete('Cloudflare-CDN-Cache-Control');
        newHeaders.delete('Pragma'); 

        // 标记实际返回的格式
        newHeaders.set('X-Image-Format', format);

        // 删除 Vary 头，防止缓存碎片化
        newHeaders.delete('Vary');

        // 清理不必要的头
        newHeaders.delete('Content-Disposition');
        newHeaders.delete('Set-Cookie');

        // --- Stream 处理 ---
        // 1. 创建返回给用户的 Response
        response = new Response(originResponse.body, {
          status: originResponse.status,
          statusText: originResponse.statusText,
          headers: newHeaders
        });

        // 2. 克隆 Response 用于写入缓存
        const responseToCache = response.clone();

        // 3. 异步写入缓存
        ctx.waitUntil(cache.put(cacheKeyReq, responseToCache));

      } catch (error) {
        return new Response('Image Fetch Error', { status: 502 });
      }
    } else {
      // --- Cache Hit ---
      console.log(`[Cache Hit] ${imageUrl}`);
      const newHeaders = new Headers(response.headers);
      newHeaders.set('X-Cache-Status', 'HIT');

      // 重建 Response 以便修改 headers
      response = new Response(response.body, {
        status: response.status,
        statusText: response.statusText,
        headers: newHeaders
      });
    }

    // ==========================================
    // 8. HEAD 请求处理
    // ==========================================
    // 如果是 HEAD 请求，只返回 Headers，不返回 Body
    if (request.method === 'HEAD') {
      return new Response(null, {
        status: response.status,
        statusText: response.statusText,
        headers: response.headers
      });
    }

    return response;
  },
};
</code></pre>
<p>这个是我写的比较完善的一版，目前的图片分发就是用的这个。这能做到：</p>
<ul>
<li>防盗链</li>
<li>限制参数（w/q），防止恶意构建的请求</li>
<li>限制代理 url 模式，防止被代理其他域名</li>
<li>内容协商（自动判断应该发放 avif / webp / 其他）</li>
<li>边缘缓存，使用 Cloudflare Cache 来进行缓存</li>
<li>缓存键清理，即便 Vary 头不同、参数顺序不同，仍能使用同一份缓存</li>
</ul>
<p>这还能绕过两个付费功能：Cloudflare 自带的缓存清理是收费的；Cloudinary 的免费版的 <code>f_auto</code> 并不会自动返回 avif 格式的图片。因此，这里在 Worker 里我手动处理了。</p>
<h2 id="配置-4">配置</h2>
<h3 id="cloudinary-cloud-name-5">Cloudinary Cloud Name</h3>
<p><img src="/p/91qMNzbZqKX1" alt="image.png"></p>
<p>就是主页左上角的这个</p>
<h3 id="defaultorigin-6">defaultOrigin</h3>
<p>有时你传入的url网址是相对路径的，比如<code>/p/xxxxxxxxxxxx</code>，这个字段在传入相对路径时，将其拼接为完整url。填你的站点地址即可。</p>
<h3 id="allowedwidths-7">allowedWidths</h3>
<p>w参数允许的值，一般是 <code>next.config.js</code>里面 <code>image</code> 的 <code>imageSizes</code>+ <code>deviceSizes</code>。</p>
<h3 id="allowedqualities-8">allowedQualities</h3>
<p>next/image 里是默认75，此时会让 Cloudinary 自行决定。</p>
<h3 id="alloweddomains-9">allowedDomains</h3>
<p>url 如果是个完整路径，其允许的域名列表。</p>
<h3 id="allowedreferers-10">allowedReferers</h3>
<p>允许的 referers 来源，用于防盗链</p>
<h3 id="allowemptyreferer-11">allowEmptyReferer</h3>
<p>是否允许空 referers 请求，这不仅会拦截从其他网站发来的请求，还会拦截直接打开和下载等操作。不建议开启，因为实际没什么防护效果，referer 改一下就行。</p>
<h2 id="效果-12">效果</h2>
<p><img src="/p/qbO7CRq7QAX3" alt="image.png"></p>
<p>不出意外的话，你的 Bandwidth 和 Storage 应该差别不大。这就说明 Cloudflare Worker 正确的起到了缓存的作用。</p>]]></content:encoded>
            <author>RavelloH</author>
            <enclosure url="https://ravelloh.com/p/NSzL6oKlA7MC" length="0" type="image//p/NSzL6oKlA7MC"/>
            <contentPreview><![CDATA[前言 NeutralPress 自带图片优化，使用 Next.js 内置的图片优化来自带裁剪并压缩图片，在每个设备上都能给出适配其实际显示大小的图片。 这的确在一定程度上，让图片体积更小、加载更快，然而，图片优化极其消耗性能，所以各家给的限额都很少。强如 Vercel 和 Cloudflare，也都仅仅只提供每月 5000 次转换。如果短期之内上传大量图片，则很容易导致你的 Billing 不太好看。 不过， Cloudinary 则提...]]></contentPreview>
        </item>
        <item>
            <title><![CDATA[Minecraft Meteor 使用指南]]></title>
            <link>https://ravelloh.com/posts/minecraft-meteor-guide</link>
            <guid isPermaLink="false">https://ravelloh.com/posts/minecraft-meteor-guide</guid>
            <pubDate>Mon, 11 Aug 2025 17:51:38 GMT</pubDate>
            <description><![CDATA[Minecraft 辅助模组 Meteor Client 的详细中文使用指南。文章涵盖了从 Meteor 的下载安装（含旧版本及 ViaFabricPlus 兼容方案）、第三方汉化获取，到 GUI 界面配置的完整流程。重点对战斗、玩家、移动、渲染、世界及杂项六大类功能模块进行了深度解析与逐项说明，旨在帮助玩家在单人或特定服务器场景下掌握该工具的使用。]]></description>
            <content:encoded><![CDATA[<p class="feed-lead">前往 <a href="https://ravelloh.com/posts/minecraft-meteor-guide">https://ravelloh.com/posts/minecraft-meteor-guide</a> 查看以获得最佳体验。</p>
<h2 id="前言-1">前言</h2>
<p>可能是目前最详细的中文 Meteor 文档。</p>
<blockquote>
<p>什么是 Meteor ?</p>
</blockquote>
<p><img src="/p/ko11MHV4X0hn" alt="图片"></p>
<p>Meteor，中文译名彗星端，是一款 Minecraft 外挂辅助模组。其运行在客户端，修改部分客户端行为，让你不管是看起来还是用起来都像是“开了"一样。尽管其不应该被运行在任何服务器中，其对单人生存等场景仍有着十分炸裂的辅助提升效果。但因其不支持中文，这里我专门写篇文章来详细说明下 Meteor 如何使用。</p>
<p>不过我在此再次重申，<strong>其不应该被运行在任何服务器中</strong>。</p>
<p>叠甲：我不玩PVP，没在除自己以外的任何服务器上使用此工具，这篇文章主要是因为某生电服服主允许并提倡玩家使用此类工具，所以给群友写个教程。</p>
<p>以下内容请善用右侧目录功能来快速查询。</p>
<h2 id="安装-2">安装</h2>
<blockquote>
<p>Meteor只能运行在Fabric上。</p>
</blockquote>
<p>你可以在官网(<a href="https://meteorclient.com/">Meteor Client</a>)下载到 Meteor 的最新版本。但官网也只会提供最新版本，这意味着你只能在 Minecraftr 的最新正式版上使用此模组。</p>
<blockquote>
<p>由于此项目无官方汉化支持，所以你也可以直接下载汉化包，不过汉化包只支持1.20以上的几个版本，详见章节<a href="#%E6%B1%89%E5%8C%96">#汉化</a>。</p>
</blockquote>
<p>要想将其连接至旧版本的世界，有两个办法：</p>
<ol>
<li>安装最新版本的 Minecraft 与 Meteor，并同时安装 ViaFabricPlus。</li>
</ol>
<ul>
<li>ViaFabricPlus 是 Fabric 平台上 ViaVersion 的更深层次实现，简单来说它可以让你的客户端加入几乎所有版本的 Minecraft 服务器，不需要服务器做什么。下载地址如下。</li>
<li><a href="https://modrinth.com/mod/viafabricplus/">ViaFabricPlus - Minecraft Mod</a></li>
<li><a href="https://www.curseforge.com/minecraft/mc-mods/viafabricplus">ViaFabricPlus - Minecraft Mods - CurseForge</a></li>
<li><a href="https://github.com/ViaVersion/ViaFabricPlus/releases">Releases · ViaVersion/ViaFabricPlus</a></li>
</ul>
<ol start="2">
<li>安装 Minecraft 与对应版本的 Meteor。</li>
</ol>
<ul>
<li>值得注意的是，这个方法并不被官方推荐，因为这可能导致你使用的旧版 Meteor 有当时未修复的漏洞，或者缺失某些新功能。因此，官方并没有提供对应旧版本的 Meteor jar 包。因此，此方法只能使用第三方提供的旧版本 Meteor 备份或者手动从Github上构建，而前者有可能存在jar包被修改的风险。</li>
<li>使用第三方 Meteor 备份:
<ul>
<li><a href="https://github.com/ManInMyVan/meteor-archive">ManInMyVan/meteor-archive: A Meteor Client archive.</a></li>
</ul>
</li>
<li>从Github手动编译:
<ul>
<li>需要安装 Git 。如果你不知道 Git 是什么，请不要使用这个方法，直接使用上面的第三方备份。</li>
<li>cmd 运行以下命令:</li>
</ul>
</li>
</ul>
<pre><code class="language-shell">git clone https://github.com/MeteorDevelopment/meteor-client
cd meteor-client
git checkout v0.5.9 # 此处v0.5.9改成你想要的版本，Meteor与Minecraft的版本对应关系也可以从上面的第三方备份中看到
gradlew build
</code></pre>
<p>将你得到的 Meteor Jar 包放入 mods 文件夹中即可，注意需要安装 Fabric 。</p>
<p>如果你正在以方法2使用旧版本的 Meteor ，那么进入世界的时候大概会弹出弹框提示你更新，无视即可。</p>
<h2 id="汉化-3">汉化</h2>
<p>由于此项目没有官方支持的中文，所以汉化也需要找第三方。我找到的不错的一个项目是<a href="https://github.com/dingzhen-vape/MeteorCN">dingzhen-vape/MeteorCN: Meteor的汉化捏</a>，不过其仅支持了1.20.2以上的少数几个版本。</p>
<p>汉化的方式是直接下载其提供的第三方Jar包，不过注意：经过修改的第三方包存在安全风险。</p>
<h2 id="使用-4">使用</h2>
<p>进入游戏，按下右Shift键，即可看到 Meteor 的菜单。</p>
<blockquote>
<p>注意，汉化版的菜单和原版的顺序不一样。</p>
</blockquote>
<p><img src="/p/RnmWM6D7oEYQ" alt="image.png"></p>
<p><img src="/p/DTUcJcVdb7cW" alt="image.png"></p>
<p>菜单分为两部分，最上面的顶栏和下面的功能区。你可以从上面的顶栏来选择功能区显示什么。<br>
顶栏包括 ModuIes Config GUI HUD Friends Macros Profiles 七个区域，下面先说一下设置相关内容：</p>
<h3 id="config-5">Config</h3>
<p><img src="/p/BMxWYj4ahSTD" alt="image.png"></p>
<p>其中，建议关了自定义字体，这样就能正常显示中文了。（不关的话，方块/生物的文字都不显示）</p>
<h3 id="gui-6">GUI</h3>
<p><img src="/p/w4FNftSGGNWe" alt="image.png"></p>
<p>控制屏幕上显示的内容的大小/颜色等，调整缩放来设置整个菜单的大小。</p>
<h3 id="hud-7">HUD</h3>
<p>在屏幕上显示以下信息：</p>
<p><img src="/p/O2RG0hIXpigM" alt="image.png"></p>
<p>需要勾选下方的Active。</p>
<h3 id="friends-8">Friends</h3>
<p>填写玩家名，之后就不会攻击该玩家了。</p>
<p><img src="/p/HJmPYejAWLZe" alt="image.png"></p>
<h3 id="macros-9">Macros</h3>
<p>消息宏。比如，下方的设置可以让你在按下H键的时候就公屏说"Help!"</p>
<p><img src="/p/Uw04kxqw5lJW" alt="image.png"></p>
<h3 id="profiles-10">Profiles</h3>
<p>将当前所有的设置保存成一个档案，可以根据情况随时切其他的档案。</p>
<p><img src="/p/SfXoPP3xHt35" alt="image.png"></p>
<h3 id="modules-11">Modules</h3>
<p>Modules 中包含了 Meteor 的核心功能，从上到下都说一遍：</p>
<h4 id="combat战斗类-12">Combat(战斗类)</h4>
<h5 id="anchor-aura-13">Anchor Aura</h5>
<ul>
<li>自动在敌人附近放置并引爆重生锚。</li>
<li>在下界 PvP 中常用于快速造成范围伤害。</li>
</ul>
<p><img src="/p/NzE7WFj7i6qo" alt="image.png"></p>
<h5 id="anti-anvil-14">Anti Anvil</h5>
<ul>
<li>反铁砧，防止其他玩家拿铁砧砸你</li>
</ul>
<p><img src="/p/jOWpoEUKlvfx" alt="image.png"></p>
<h5 id="anti-bed-15">Anti Bed</h5>
<ul>
<li>反床，在周围放置线，防止其他玩家在地狱/末地用床炸你</li>
</ul>
<p><img src="/p/jOWpoEUKlvfx" alt="image.png"></p>
<h5 id="arrow-dodge-16">Arrow Dodge</h5>
<ul>
<li>自动躲射来的箭，也可以改成全部投射物。</li>
<li>建议一定要开忽略自己，不然自己射箭的时候也会往周围闪。</li>
</ul>
<p><img src="/p/OMyCNumHS2ae" alt="image.png"></p>
<h5 id="auto-anvil-17">Auto Anvil</h5>
<ul>
<li>自动在你头上放置一个铁砧，防止其他玩家进入你的洞</li>
</ul>
<h5 id="auto-armor-18">Auto Armor</h5>
<ul>
<li>自动穿戴最佳防具。</li>
</ul>
<p><img src="/p/jb5phldKBh4P" alt="image.png"></p>
<h5 id="auto-city-19">Auto City</h5>
<ul>
<li>自动挖掉敌人身边的黑曜石方块，为放置水晶创造空间。</li>
<li>在 Crystal PvP 中常用于破坏敌人的 Surround 防御。</li>
</ul>
<p><img src="/p/iQ4cfeZ4wFNV" alt="image.png"></p>
<h5 id="auto-exp-20">Auto Exp</h5>
<ul>
<li>自动使用经验瓶修复装备或提升等级。</li>
<li>在装备耐久降低时持续向脚下扔经验瓶。</li>
</ul>
<p><img src="/p/FgacVug86KZn" alt="image.png"></p>
<h5 id="auto-log-21">Auto Log</h5>
<ul>
<li>在生命值过低或检测到致命威胁时自动退出游戏。</li>
<li>用于避免被秒杀或装备掉落。</li>
</ul>
<p><img src="/p/N0xlvDLVvRem" alt="image.png"></p>
<h5 id="auto-totem-22">Auto Totem</h5>
<ul>
<li>自动将不死图腾切换到副手。</li>
<li>在生命值过低或受到高伤害威胁时触发切换。</li>
</ul>
<p><img src="/p/aydyEz4xkmss" alt="image.png"></p>
<h5 id="auto-trap-23">Auto Trap</h5>
<ul>
<li>自动在敌人周围放置方块将其困住。</li>
<li>在近战中常与水晶攻击结合，防止敌人逃跑。</li>
</ul>
<p><img src="/p/OsSo3tk5jr37" alt="image.png"></p>
<h5 id="auto-weapon-24">Auto Weapon</h5>
<ul>
<li>自动切换到最适合的武器。</li>
</ul>
<p><img src="/p/T0LB6WvBT3oD" alt="image.png"></p>
<h5 id="auto-web-25">Auto Web</h5>
<ul>
<li>自动在敌人位置放置蛛网以限制行动。</li>
<li>在近战中可减缓敌人移动速度。</li>
</ul>
<p><img src="/p/XBRjoUl2fb3l" alt="image.png"></p>
<h5 id="bed-aura-26">Bed Aura</h5>
<ul>
<li>自动在敌人附近放置并引爆床。</li>
<li>在下界和末地 PvP 中利用床爆炸造成高伤害。</li>
</ul>
<p><img src="/p/SvzlBoibSlLI" alt="image.png"></p>
<h5 id="bow-aimbot-27">Bow Aimbot</h5>
<ul>
<li>自动瞄准敌人进行弓箭射击。</li>
<li>可自动计算箭矢下坠和飞行时间。</li>
</ul>
<p><img src="/p/K6btE9rWESoJ" alt="image.png"></p>
<h5 id="bow-spam-28">Bow Spam</h5>
<ul>
<li>快速、连续地发射弓箭。</li>
<li>自动重复拉弓与松弦动作，实现持续输出。</li>
</ul>
<p><img src="/p/IBtmd5aWwuAa" alt="image.png"></p>
<h5 id="burrow-29">Burrow</h5>
<ul>
<li>在脚下瞬间放置一个方块（通常是黑曜石、铁砧），把自己埋进去。</li>
<li>用于在激烈战斗中快速防御，防止被水晶或其他攻击秒杀。</li>
</ul>
<p><img src="/p/x5Nv0neyvrQ4" alt="image.png"></p>
<h5 id="criticals-30">Criticals</h5>
<ul>
<li>在 Minecraft 里，玩家跳跃攻击会造成暴击（多 50% 伤害）。</li>
<li>这个功能会模拟跳跃或直接修改数据包，让你每次攻击都触发暴击，即便你实际上没有跳。</li>
</ul>
<p><img src="/p/dGNpSeZvpGdD" alt="image.png"></p>
<h5 id="crystal-aura-31">Crystal Aura</h5>
<ul>
<li>自动在敌人附近放置末地水晶并立即引爆。</li>
<li>在水晶 PvP 中，这是最核心的秒杀手段，可以设置计算最佳放置位置和时机，让水晶爆炸最大化伤害。</li>
</ul>
<p><img src="/p/vNgo8XmFDj9P" alt="image.png"></p>
<h5 id="hitboxes-32">Hitboxes</h5>
<ul>
<li>增大其他玩家（或实体）的碰撞检测范围（碰撞箱），使你的攻击更容易命中。</li>
</ul>
<p><img src="/p/3TtCUAulO5Ba" alt="image.png"></p>
<h5 id="holefiller-33">HoleFiller</h5>
<ul>
<li>自动填充附近的 1x1 洞（通常是黑曜石、石头），防止敌人躲进去进行防御或水晶反击。</li>
<li>主要用于压制 Crystal PvP 中的“洞内战术”。</li>
</ul>
<p><img src="/p/kJXWkgSAoa0a" alt="image.png"></p>
<h5 id="killauro-34">KillAuro</h5>
<ul>
<li>自动攻击附近敌人（可结合范围、优先级、背后攻击等逻辑）。</li>
</ul>
<p><img src="/p/8RAHlQetqPQj" alt="image.png"></p>
<h5 id="offhand-35">Offhand</h5>
<ul>
<li>自动切换副手物品。</li>
</ul>
<p><img src="/p/nVN2vQrkOOuS" alt="image.png"></p>
<h5 id="quiver-36">Quiver</h5>
<ul>
<li>给自己射药水箭。</li>
</ul>
<p><img src="/p/RjSjchJFVRki" alt="image.png"></p>
<h5 id="self-anvil-37">Self Anvil</h5>
<ul>
<li>在自己头顶快速放置铁砧，让铁砧砸下来对自己造成击退或伤害。</li>
<li>我也不知道有什么用。</li>
</ul>
<p><img src="/p/M7RPUSgbQnZM" alt="image.png"></p>
<h5 id="selftrap-38">SelfTrap</h5>
<ul>
<li>自动在自己脚边放置方块，把自己封在方块中防御敌人攻击（类似 Burrow，但通常是用方块把头部封起来）。</li>
</ul>
<p><img src="/p/rLm8FClTjUU3" alt="image.png"></p>
<h5 id="selfweb-39">SelfWeb</h5>
<ul>
<li>在自己位置放置蛛网，让自己无法移动，从而减少爆炸击退（Crystal PvP 中可以防止被炸飞）、或延缓掉落。</li>
</ul>
<p><img src="/p/jPCEWh8ry7l3" alt="image.png"></p>
<h5 id="surround-40">Surround</h5>
<ul>
<li>在自己脚周围一圈快速放置方块（通常是黑曜石），形成 1x1 的防御墙，防止敌人贴近放水晶。</li>
<li>在水晶战开局阶段非常常见。</li>
</ul>
<p><img src="/p/pMynJvHuenZz" alt="image.png"></p>
<h4 id="player玩家类-41">Player(玩家类)</h4>
<h5 id="airplace-42">AirPlace</h5>
<ul>
<li>在空中放置方块。</li>
<li>用于在没有支撑方块的情况下快速搭建或防御。</li>
</ul>
<p><img src="/p/JUMCaQ7ceGW3" alt="image.png"></p>
<h5 id="antiafk-43">AntiAfk</h5>
<ul>
<li>防止玩家被服务器判定为挂机。</li>
<li>自动执行小幅移动或随机动作以保持活跃状态。</li>
</ul>
<p><img src="/p/8K2yJ41YGu3P" alt="image.png"></p>
<h5 id="anti-hunger-44">Anti Hunger</h5>
<ul>
<li>减缓或阻止饥饿值下降。</li>
<li>通过限制奔跑或跳跃数据包来减少饥饿消耗。</li>
</ul>
<p><img src="/p/D1FG0w6rXBRN" alt="image.png"></p>
<h5 id="auto-clicker-45">Auto Clicker</h5>
<ul>
<li>自动进行鼠标点击操作。</li>
<li>可用于快速攻击、建造或破坏方块。</li>
</ul>
<p><img src="/p/n5wgGH15I8fc" alt="image.png"></p>
<h5 id="auto-eat-46">Auto Eat</h5>
<ul>
<li>在饥饿值过低时自动进食。</li>
<li>自动选择最佳食物并进行食用动作。</li>
</ul>
<p><img src="/p/ZvxzH8om1YQ3" alt="image.png"></p>
<h5 id="auto-fish-47">Auto Fish</h5>
<ul>
<li>自动进行钓鱼操作。</li>
<li>检测到鱼上钩时立即收杆并抛竿。</li>
</ul>
<p><img src="/p/JoDPOm9KPDVd" alt="image.png"></p>
<h5 id="auto-gap-48">Auto Gap</h5>
<ul>
<li>自动食用金苹果。</li>
<li>在生命值或吸收效果低于设定值时触发。</li>
</ul>
<p><img src="/p/cGcIJgnWnZIr" alt="image.png"></p>
<h5 id="auto-mend-49">Auto Mend</h5>
<ul>
<li>自动使用经验瓶修复装备。</li>
<li>检测到装备耐久度不足时持续扔经验瓶。</li>
</ul>
<p><img src="/p/o1VzZNr6GM1B" alt="image.png"></p>
<h5 id="auto-replenish-50">Auto Replenish</h5>
<ul>
<li>自动补充热键栏物品。</li>
<li>当快捷栏物品耗尽时从背包补充。</li>
</ul>
<p><img src="/p/P4rtprLPM4gL" alt="image.png"></p>
<h5 id="autorespawn-51">AutoRespawn</h5>
<ul>
<li>玩家死亡后自动立即重生。</li>
<li>跳过手动点击重生按钮的过程。</li>
</ul>
<p><img src="/p/ueEi5m03RvgK" alt="image.png"></p>
<h5 id="auto-tool-52">Auto Tool</h5>
<ul>
<li>自动切换到当前操作所需的最佳工具。</li>
<li>在挖掘不同方块时自动选择镐、铲、斧等。</li>
</ul>
<p><img src="/p/6ePFCKa4PH4X" alt="image.png"></p>
<h5 id="break-delay-53">Break Delay</h5>
<ul>
<li>调整方块破坏间隔。</li>
<li>可减少或消除破坏冷却时间。</li>
</ul>
<p><img src="/p/DkcWixsrD25d" alt="image.png"></p>
<h5 id="chest-swap-54">Chest Swap</h5>
<ul>
<li>自动切换鞘翅和胸甲</li>
</ul>
<p><img src="/p/pEqOMbOGO0nh" alt="image.png"></p>
<h5 id="exp-thrower-55">Exp Thrower</h5>
<ul>
<li>自动向地面丢掷经验瓶。</li>
<li>用于快速修复装备或升级。</li>
</ul>
<p><img src="/p/1Oedibd3gqfd" alt="image.png"></p>
<h5 id="fake-player-56">Fake Player</h5>
<ul>
<li>生成一个假玩家实体。</li>
<li>可用于测试攻击、防御或假装在线。</li>
</ul>
<p><img src="/p/vRN7f0sqTVYS" alt="image.png"></p>
<h5 id="fast-use-57">Fast Use</h5>
<ul>
<li>缩短使用物品的冷却时间。</li>
<li>快速放置方块、吃食物或使用药水。</li>
</ul>
<p><img src="/p/oFMVPufl5NtS" alt="image.png"></p>
<h5 id="ghost-hand-58">Ghost Hand</h5>
<ul>
<li>在没有视线的情况下与方块交互。</li>
<li>可穿过方块打开箱子。</li>
</ul>
<p><img src="/p/2MSaNJ9RYdd8" alt="image.png"></p>
<h5 id="instant-rebreak-59">Instant Rebreak</h5>
<ul>
<li>方块被中断破坏后立即再次开始破坏。</li>
<li>在敌人妨碍挖掘时保持高效率。</li>
</ul>
<p><img src="/p/sxMxv3oLBW8H" alt="image.png"></p>
<h5 id="liguid-interact-60">Liguid Interact</h5>
<ul>
<li>与液体方块进行交互。</li>
<li>在水或岩浆上放置方块或使用物品。</li>
</ul>
<p><img src="/p/99EJYbOdsRcB" alt="image.png"></p>
<h5 id="middle-click-extra-61">Middle Click Extra</h5>
<ul>
<li>中键点击触发额外功能。</li>
<li>可绑定为快速放置方块、攻击或切换物品。</li>
</ul>
<p><img src="/p/agNjYEve82jg" alt="image.png"></p>
<h5 id="multitask-62">Multitask</h5>
<ul>
<li>允许同时进行多个操作。</li>
<li>在挖掘方块的同时吃东西或使用其他物品。</li>
</ul>
<p><img src="/p/4Zmrq3qoaxBa" alt="image.png"></p>
<h5 id="name-protect-63">Name Protect</h5>
<ul>
<li>隐藏或替换玩家昵称。</li>
<li>在录屏或直播时保护隐私。</li>
</ul>
<p><img src="/p/tPRhGv1Tq1cm" alt="image.png"></p>
<h5 id="no-interact-64">No Interact</h5>
<ul>
<li>阻止与特定方块或实体交互。</li>
<li>防止误点击箱子、工作台等。</li>
</ul>
<p><img src="/p/OY3zNR65eVtL" alt="image.png"></p>
<h5 id="no-mining-trace-65">No Mining Trace</h5>
<ul>
<li>防止方块破坏痕迹被发现。</li>
<li>绕过某些反作弊对挖掘路径的检测。</li>
</ul>
<p><img src="/p/rSjcIDEYYOY7" alt="image.png"></p>
<h5 id="no-rotate-portals-66">No Rotate Portals</h5>
<ul>
<li>禁止通过传送门时视角被强制旋转。</li>
<li>保持原有视角方向不变。</li>
</ul>
<p><img src="/p/o48Mzoza7ToR" alt="image.png"></p>
<h5 id="potion-saver-67">Potion Saver</h5>
<ul>
<li>延长药水效果持续时间。</li>
<li>在效果接近结束时才使用新药水。</li>
</ul>
<p><img src="/p/PksAjUiXaBL5" alt="image.png"></p>
<h5 id="potionspoof-68">PotionSpoof</h5>
<ul>
<li>伪装或修改药水效果。</li>
<li>让服务器认为玩家拥有不同的药水状态。</li>
</ul>
<p><img src="/p/4QBL3ayPWx8v" alt="image.png"></p>
<h5 id="reach-69">Reach</h5>
<ul>
<li>增加手长。</li>
</ul>
<p><img src="/p/C0RPzMzFTc0e" alt="image.png"></p>
<h5 id="rotation-70">Rotation</h5>
<ul>
<li>锁定角度。</li>
</ul>
<p><img src="/p/DhZ1frKZPoMJ" alt="image.png"></p>
<h5 id="speedmine-71">SpeedMine</h5>
<ul>
<li>提高方块破坏速度。</li>
<li>缩短破坏时间，实现快速挖掘。</li>
</ul>
<p><img src="/p/yI2NoLXRikSb" alt="image.png"></p>
<h4 id="movement移动类-72">Movement(移动类)</h4>
<h5 id="air-jump-73">Air Jump</h5>
<ul>
<li>在空中进行跳跃。</li>
<li>用于多段跳跃或在空中保持高度。</li>
</ul>
<p><img src="/p/rrJUdbErGSLn" alt="image.png"></p>
<h5 id="anchor-74">Anchor</h5>
<ul>
<li>将玩家位置固定在特定坐标。</li>
<li>防止在边缘或高处被击退掉落。</li>
</ul>
<p><img src="/p/HX1gMexZi8oT" alt="image.png"></p>
<h5 id="anti-void-75">Anti Void</h5>
<ul>
<li>防止掉入虚空。</li>
<li>检测到下方为空时自动传送回安全位置或进行反向移动。</li>
</ul>
<p><img src="/p/ZjoA3QWfhE0E" alt="image.png"></p>
<h5 id="auto-jump-76">Auto Jump</h5>
<ul>
<li>自动跳跃。</li>
<li>在行走到方块边缘或前进时自动进行跳跃。</li>
</ul>
<p><img src="/p/yCj9cF4Sv8Bu" alt="image.png"></p>
<h5 id="auto-walk-77">Auto Walk</h5>
<ul>
<li>自动持续向前行走。</li>
<li>可配合挂机或长距离移动使用。</li>
</ul>
<p><img src="/p/XolSx8SI5peB" alt="image.png"></p>
<h5 id="auto-wasp-78">Auto Wasp</h5>
<ul>
<li>自动躲避攻击或投射物。</li>
<li>在检测到威胁时快速改变位置。</li>
</ul>
<p><img src="/p/GkVFZ8ohN4c9" alt="image.png"></p>
<h5 id="blink-79">Blink</h5>
<ul>
<li>暂存移动数据包并一次性发送。</li>
<li>在客户端移动但服务器位置保持不变，释放时瞬移到新位置。</li>
</ul>
<p><img src="/p/BTx5Gwf51hkW" alt="image.png"></p>
<h5 id="boat-fly-80">Boat Fly</h5>
<ul>
<li>使用船在空中飞行。</li>
<li>控制船只脱离地面或水面进行移动。</li>
</ul>
<p><img src="/p/MAMHF4lW4EJU" alt="image.png"></p>
<h5 id="click-tp-81">Click Tp</h5>
<ul>
<li>通过点击位置实现瞬间传送。</li>
<li>在点击方块或地面时将玩家移动到该位置。</li>
</ul>
<p><img src="/p/ZG6sxL3SqOby" alt="image.png"></p>
<h5 id="elytra-boost-82">Elytra Boost</h5>
<ul>
<li>为鞘翅飞行提供额外加速。</li>
<li>使用烟花或其他方式提升飞行速度。</li>
</ul>
<p><img src="/p/hbFKcq4J6ira" alt="image.png"></p>
<h5 id="elytra-fly-83">Elytra Fly</h5>
<ul>
<li>使用鞘翅在空中持续飞行。</li>
<li>控制飞行方向与速度，不依赖重力下落。</li>
</ul>
<p><img src="/p/lmIdiJBZrVRn" alt="image.png"></p>
<h5 id="entity-control-84">Entity Control</h5>
<ul>
<li>控制被骑乘的实体方向与移动。</li>
<li>用于操控猪、马或其他坐骑。</li>
</ul>
<p><img src="/p/y1OOQodwfHhw" alt="image.png"></p>
<h5 id="entity-speed-85">Entity Speed</h5>
<ul>
<li>提高骑乘实体的移动速度。</li>
<li>加速马、船或矿车等的行进速度。</li>
</ul>
<p><img src="/p/ISd0CE1EKoc5" alt="image.png"></p>
<h5 id="fast-climb-86">Fast Climb</h5>
<ul>
<li>提升攀爬梯子或藤蔓的速度。</li>
<li>缩短上升所需时间。</li>
</ul>
<p><img src="/p/n4NWEgpVDebd" alt="image.png"></p>
<h5 id="flight-87">Flight</h5>
<ul>
<li>在生存模式下自由飞行。</li>
<li>不受重力限制，可随意升降和移动。</li>
</ul>
<p><img src="/p/L2lgm5YAkhbi" alt="image.png"></p>
<h5 id="gui-move-88">Gui Move</h5>
<ul>
<li>在打开界面时允许移动。</li>
<li>打开箱子或菜单时仍可进行走动和跳跃。</li>
</ul>
<p><img src="/p/hebOagTnfnng" alt="image.png"></p>
<h5 id="high-jump-89">High Jump</h5>
<ul>
<li>跳得比正常更高。</li>
<li>用于跨越高障碍或快速到达高处。</li>
</ul>
<p><img src="/p/jxwUjcdV1Gpc" alt="image.png"></p>
<h5 id="jesus-90">Jesus</h5>
<ul>
<li>在水面或熔岩上行走。</li>
<li>不会下沉，可像在地面一样移动。</li>
</ul>
<p><img src="/p/FJogVmzeVKCu" alt="image.png"></p>
<h5 id="long-jump-91">Long Jump</h5>
<ul>
<li>一次性跳跃更远的距离。</li>
<li>在 PvP 或跑酷中跨越宽阔空隙。</li>
</ul>
<p><img src="/p/kvK0zq3TnDCC" alt="image.png"></p>
<h5 id="no-fall-92">No Fall</h5>
<ul>
<li>阻止坠落伤害。</li>
<li>在高处落下时取消或修改坠落数据。</li>
</ul>
<p><img src="/p/1TwIhdQEehPn" alt="image.png"></p>
<h5 id="no-slow-93">No Slow</h5>
<ul>
<li>防止在特定状态下减速。</li>
<li>在使用物品、处于蛛网或灵魂沙上时保持正常速度。</li>
</ul>
<p><img src="/p/Az4Strs9fQYs" alt="image.png"></p>
<h5 id="parkour-94">Parkour</h5>
<ul>
<li>自动进行跑酷动作。</li>
<li>检测到边缘时自动跳跃以跨越空隙。</li>
</ul>
<p><img src="/p/JtjmfVQfoeRp" alt="image.png"></p>
<h5 id="reverse-step-95">Reverse Step</h5>
<ul>
<li>更快地下落到低处。</li>
<li>在边缘自动加速下降。</li>
</ul>
<p><img src="/p/wIoVgRNKH6NB" alt="image.png"></p>
<h5 id="safe-walk-96">Safe Walk</h5>
<ul>
<li>防止从方块边缘掉落。</li>
<li>类似潜行状态，接近边缘时自动停下。</li>
</ul>
<p><img src="/p/euTBOBOj1rv7" alt="image.png"></p>
<h5 id="scaffold-97">Scaffold</h5>
<ul>
<li>自动在脚下或前方放置方块。</li>
<li>在行走中快速搭桥或垂直上升。</li>
</ul>
<p><img src="/p/qH38zzzZukwQ" alt="image.png"></p>
<h5 id="stippy-98">Stippy</h5>
<ul>
<li>改变方块的摩擦水平。</li>
</ul>
<p><img src="/p/vcywqxF0LG8G" alt="image.png"></p>
<h5 id="sneak-99">Sneak</h5>
<ul>
<li>自动潜行。</li>
<li>可持续保持潜行状态以避免掉落或降低被发现概率。</li>
</ul>
<p><img src="/p/4CBqtNOlF6le" alt="image.png"></p>
<h5 id="speed-100">Speed</h5>
<ul>
<li>提高玩家的移动速度。</li>
<li>在地面或空中加快行走与奔跑速度。</li>
</ul>
<p><img src="/p/LsFHE3lBw0ap" alt="image.png"></p>
<h5 id="spider-101">Spider</h5>
<ul>
<li>沿墙壁垂直攀爬。</li>
<li>像蜘蛛一样无障碍爬上方块。</li>
</ul>
<p><img src="/p/fle3F4wFvH8a" alt="image.png"></p>
<h5 id="sprint-102">Sprint</h5>
<ul>
<li>自动冲刺。</li>
<li>保持最高移动速度而无需手动按键。</li>
</ul>
<p><img src="/p/wLBEy2cLgwX3" alt="image.png"></p>
<h5 id="step-103">Step</h5>
<ul>
<li>自动跨越更高的方块。</li>
<li>无需跳跃即可上 1 格以上的台阶。</li>
</ul>
<p><img src="/p/jsmpGW56mpUI" alt="image.png"></p>
<h5 id="trident-boost-104">Trident Boost</h5>
<ul>
<li>使用三叉戟提供额外推进力。</li>
<li>在水中或雨天利用激流附魔快速加速。</li>
</ul>
<p><img src="/p/TSAKYKLz4GHh" alt="image.png"></p>
<h5 id="velocity-105">Velocity</h5>
<ul>
<li>修改或减少击退效果。</li>
<li>在受到攻击时降低被击退的距离或方向。</li>
</ul>
<p><img src="/p/uAiGJuKztEox" alt="image.png"></p>
<h4 id="render渲染类-106">Render(渲染类)</h4>
<h5 id="better-tab-107">Better Tab</h5>
<ul>
<li>优化玩家列表界面显示。</li>
<li>显示更多玩家信息或自定义样式。</li>
</ul>
<p><img src="/p/MyXmHrXnpNaQ" alt="image.png"></p>
<h5 id="better-tooltips-108">Better Tooltips</h5>
<ul>
<li>改进物品提示信息显示。</li>
<li>在悬停物品时显示额外数据或更清晰的界面。</li>
</ul>
<p><img src="/p/5UFoXgKATdHs" alt="image.png"></p>
<h5 id="block-esp-109">Block Esp</h5>
<ul>
<li>高亮显示特定方块。</li>
<li>用于标记矿石、箱子或自定义方块。</li>
</ul>
<p><img src="/p/Kox6camTJF2o" alt="image.png"></p>
<h5 id="block-selection-110">Block Selection</h5>
<ul>
<li>自定义方块选中框样式。</li>
<li>改变选中方块的高亮边框颜色或形状。</li>
</ul>
<p><img src="/p/GS0o1rgLP9pM" alt="image.png"></p>
<h5 id="blur-111">Blur</h5>
<ul>
<li>在打开界面时添加背景模糊效果。</li>
<li>让界面与游戏背景分离更清晰。</li>
</ul>
<p><img src="/p/QuaEJlflDdRy" alt="image.png"></p>
<h5 id="boss-stack-112">Boss Stack</h5>
<ul>
<li>合并相同 Boss 血条显示。</li>
<li>多个相同 Boss 时只显示一个血条。</li>
</ul>
<p><img src="/p/hwogquSJaxEJ" alt="image.png"></p>
<h5 id="breadcrumbs-113">Breadcrumbs</h5>
<ul>
<li>在地面留下移动轨迹。</li>
<li>用于标记自己走过的路径。</li>
</ul>
<p><img src="/p/az8ZL8EimLFN" alt="image.png"></p>
<h5 id="break-indicators-114">Break Indicators</h5>
<ul>
<li>显示方块破坏进度。</li>
<li>在破坏方块时提供可视化进度条。</li>
</ul>
<p><img src="/p/WBeBZLxykgbT" alt="image.png"></p>
<h5 id="camera-tweaks-115">Camera Tweaks</h5>
<ul>
<li>调整摄像机控制方式。</li>
<li>允许平滑旋转、缩放或自定义视角。</li>
</ul>
<p><img src="/p/ZyU7BoZxhzHJ" alt="image.png"></p>
<h5 id="chams-116">Chams</h5>
<ul>
<li>用特殊材质高亮实体。</li>
<li>通过墙壁或障碍物仍可看到实体。</li>
</ul>
<p><img src="/p/vBCWah7pSmUJ" alt="image.png"></p>
<h5 id="city-esp-117">City Esp</h5>
<ul>
<li>可能与水晶战有关。</li>
<li>不玩PVP，不清楚。欢迎补充。</li>
</ul>
<p><img src="/p/NaYCpJ2CRVlE" alt="image.png"></p>
<h5 id="entity-owner-118">Entity Owner</h5>
<ul>
<li>显示实体的拥有者信息。</li>
<li>用于区分坐骑、宠物等归属。</li>
</ul>
<p><img src="/p/E9ZRsyNdbF5g" alt="image.png"></p>
<h5 id="esp-119">Esp</h5>
<ul>
<li>高亮显示实体。</li>
<li>通过墙体可见玩家、怪物或其他目标。</li>
</ul>
<p><img src="/p/sdJvsVjSn0fk" alt="image.png"></p>
<h5 id="free-look-120">Free Look</h5>
<ul>
<li>独立移动视角而不改变玩家朝向。</li>
<li>用于观察周围环境而不影响行走方向。</li>
</ul>
<p><img src="/p/Yu7wYOSzC8a0" alt="image.png"></p>
<h5 id="freecam-121">Freecam</h5>
<ul>
<li>分离视角与玩家位置。</li>
<li>允许在不移动角色的情况下自由飞行查看环境。</li>
</ul>
<p><img src="/p/TuU1FqrigvGC" alt="image.png"></p>
<h5 id="fullbright-122">Fullbright</h5>
<ul>
<li>提升亮度到最高。</li>
<li>在黑暗环境中保持可见度。</li>
</ul>
<p><img src="/p/ase5IdhFd7Xp" alt="image.png"></p>
<h5 id="hand-view-123">Hand View</h5>
<ul>
<li>调整第一人称手部模型位置。</li>
<li>自定义手部的显示大小或角度。</li>
</ul>
<p><img src="/p/8PCb38OPCvO6" alt="image.png"></p>
<h5 id="hole-esp-124">Hole Esp</h5>
<ul>
<li>高亮显示坑洞位置。</li>
<li>在 PvP 中标记可利用的防御或陷阱点。</li>
</ul>
<p><img src="/p/5Nub5wcN8XFI" alt="image.png"></p>
<h5 id="item-highlight-125">Item Highlight</h5>
<ul>
<li>高亮显示掉落物品。</li>
<li>更容易在地面找到特定物品。</li>
</ul>
<p><img src="/p/6GgZUDF8sb8c" alt="image.png"></p>
<h5 id="item-physics-126">Item Physics</h5>
<ul>
<li>改变掉落物的物理效果。</li>
<li>使物品在地面倾斜或滚动。</li>
</ul>
<p><img src="/p/FgTM1PPzvopx" alt="image.png"></p>
<h5 id="light-overlay-127">Light Overlay</h5>
<ul>
<li>显示方块的光照等级。</li>
<li>用于检查怪物生成点。</li>
</ul>
<p><img src="/p/f8vXmnybTMKK" alt="image.png"></p>
<h5 id="logout-spots-128">Logout Spots</h5>
<ul>
<li>标记玩家下线的位置。</li>
<li>在地图上显示玩家退出时的坐标。</li>
</ul>
<p><img src="/p/jHSvdPhRB78m" alt="image.png"></p>
<h5 id="marker-129">Marker</h5>
<ul>
<li>在世界中放置标记点/图形。</li>
</ul>
<p><img src="/p/KC80jXNGkOwm" alt="image.png"></p>
<h5 id="nametags-130">Nametags</h5>
<ul>
<li>修改或增强玩家名称标签显示。</li>
<li>可显示更远距离或附加信息。</li>
</ul>
<p><img src="/p/Bj7muvruQNYr" alt="image.png"></p>
<h5 id="no-render-131">No Render</h5>
<ul>
<li>阻止渲染特定元素。</li>
<li>隐藏火焰、粒子效果或实体模型。</li>
</ul>
<p><img src="/p/LgArht9ICdz7" alt="image.png"></p>
<h5 id="pop-chams-132">Pop Chams</h5>
<ul>
<li>在图腾触发时高亮显示玩家。</li>
<li>在 PvP 中标记敌人触发图腾的瞬间。</li>
</ul>
<p><img src="/p/pnznGKoXAXtR" alt="image.png"></p>
<h5 id="storage-esp-133">Storage Esp</h5>
<ul>
<li>高亮显示储物方块。</li>
<li>显示箱子、潜影盒、末影箱位置。</li>
</ul>
<p><img src="/p/207fFfjBfob8" alt="image.png"></p>
<h5 id="time-changer-134">Time Changer</h5>
<ul>
<li>修改客户端时间。</li>
<li>自定义世界的昼夜显示效果。</li>
</ul>
<p><img src="/p/LnktwYHDOh4n" alt="image.png"></p>
<h5 id="tracers-135">Tracers</h5>
<ul>
<li>从玩家到目标画出指引线。</li>
<li>用于快速找到实体位置。</li>
</ul>
<p><img src="/p/7MtJhMwwvqMJ" alt="image.png"></p>
<h5 id="trail-136">Trail</h5>
<ul>
<li>在玩家移动时绘制尾迹。</li>
<li>显示彩色轨迹线条。</li>
</ul>
<p><img src="/p/6v1XL6LiIKed" alt="image.png"></p>
<h5 id="trojectories-137">Trojectories</h5>
<ul>
<li>显示投掷物的轨迹预测。</li>
<li>用于弓箭、雪球、三叉戟等。</li>
</ul>
<p><img src="/p/itdZtOXX3XwN" alt="image.png"></p>
<h5 id="tunnel-esp-138">Tunnel Esp</h5>
<ul>
<li>高亮显示地下通道。</li>
<li>用于定位矿道或隧道结构。</li>
</ul>
<p><img src="/p/URHlLe5lfb0t" alt="image.png"></p>
<h5 id="void-esp-139">Void Esp</h5>
<ul>
<li>高亮显示虚空边界。</li>
<li>在建造或探索时避免跌落。</li>
</ul>
<p><img src="/p/GmHyX7mTf6EQ" alt="image.png"></p>
<h5 id="wall-hack-140">Wall Hack</h5>
<ul>
<li>穿透墙体查看实体与方块。</li>
<li>用于发现隐藏的玩家或资源。</li>
</ul>
<p><img src="/p/PGZ4aezs5YD2" alt="image.png"></p>
<h5 id="waypoints-141">Waypoints</h5>
<ul>
<li>设置并显示地图标记。</li>
<li>显示目标的距离与方向。</li>
</ul>
<p><img src="/p/QhXm3VdKWYXR" alt="image.png"></p>
<h5 id="xray-142">Xray</h5>
<ul>
<li>过滤掉不必要的方块以显示资源。</li>
<li>用于快速寻找矿石、地牢或其他方块。</li>
</ul>
<p><img src="/p/Jqmr4YAdlEHe" alt="image.png"></p>
<h5 id="zoom-143">Zoom</h5>
<ul>
<li>放大视野范围中心。</li>
<li>便于远距离观察目标。</li>
</ul>
<p><img src="/p/EJtvNXnG69YK" alt="image.png"></p>
<h4 id="world世界类-144">World(世界类)</h4>
<h5 id="ambience-145">Ambience</h5>
<ul>
<li>修改世界环境氛围。</li>
<li>调整颜色、光照或天气效果。</li>
</ul>
<p><img src="/p/qPsRE3v4DlCv" alt="image.png"></p>
<h5 id="auto-breed-146">Auto Breed</h5>
<ul>
<li>自动为动物繁殖。</li>
<li>在检测到食物时自动喂养配对。</li>
</ul>
<p><img src="/p/kaWIAZPBuYCG" alt="image.png"></p>
<h5 id="auto-brewer-147">Auto Brewer</h5>
<ul>
<li>自动酿造药水。</li>
<li>按顺序放置材料并完成酿造过程。</li>
</ul>
<p><img src="/p/X4o3KVlpAS0f" alt="image.png"></p>
<h5 id="auto-mount-148">Auto Mount</h5>
<ul>
<li>自动骑乘坐骑或载具。</li>
<li>接近目标时自动骑上。</li>
</ul>
<p><img src="/p/PoKGgqlR8w0K" alt="image.png"></p>
<h5 id="auto-nametag-149">Auto Nametag</h5>
<ul>
<li>自动为实体命名。</li>
<li>在持有命名牌时靠近目标自动使用。</li>
</ul>
<p><img src="/p/cPMFYmncNC3Z" alt="image.png"></p>
<h5 id="auto-shearer-150">Auto Shearer</h5>
<ul>
<li>自动为羊剪毛。</li>
<li>接近羊时自动使用剪刀。</li>
</ul>
<p><img src="/p/AMXdN5klhZlz" alt="image.png"></p>
<h5 id="auto-sign-151">Auto Sign</h5>
<ul>
<li>自动放置并填写告示牌。</li>
<li>可预设文字内容快速批量放置。</li>
</ul>
<p><img src="/p/WeT7zmaVxDHN" alt="image.png"></p>
<h5 id="auto-smelter-152">Auto Smelter</h5>
<ul>
<li>自动完成熔炉冶炼任务。</li>
<li>自动补充燃料与原料。</li>
</ul>
<p><img src="/p/Z3j8ejOavxxZ" alt="image.png"></p>
<h5 id="build-height-153">Build Height</h5>
<ul>
<li>允许你在建筑限制处与物体互动。</li>
</ul>
<p><img src="/p/Qo8NB5nQk8re" alt="image.png"></p>
<h5 id="collisions-154">Collisions</h5>
<ul>
<li>显示方块碰撞箱。</li>
<li>检测不可见的边界与判定范围。</li>
</ul>
<p><img src="/p/tNRnSxVU9xFa" alt="image.png"></p>
<h5 id="echest-farmer-155">Echest Farmer</h5>
<ul>
<li>自动寻找并破坏末影箱。</li>
<li>收集掉落的黑曜石。</li>
</ul>
<p><img src="/p/PCfl5MrMvqkf" alt="image.png"></p>
<h5 id="enderman-look-156">Enderman Look</h5>
<ul>
<li>自动看向或者躲避末影人。</li>
</ul>
<p><img src="/p/6d90UiJtQOBF" alt="image.png"></p>
<h5 id="flamethrower-157">Flamethrower</h5>
<ul>
<li>使用火焰持续攻击目标。</li>
<li>持续点燃敌人或环境。</li>
</ul>
<p><img src="/p/vRX73jWEPgro" alt="image.png"></p>
<h5 id="highway-builder-158">Highway Builder</h5>
<ul>
<li>自动建造高速通道。</li>
<li>在地狱或主世界长距离铺路。</li>
</ul>
<p><img src="/p/SszO586KObdm" alt="image.png"></p>
<h5 id="liquid-filler-159">Liquid Filler</h5>
<ul>
<li>自动填充液体。</li>
<li>在空洞中填入水或熔岩。</li>
</ul>
<p><img src="/p/JmHWU2zavE8q" alt="image.png"></p>
<h5 id="mount-bypass-160">Mount Bypass</h5>
<ul>
<li>绕过坐骑上不能放箱子的限制。</li>
</ul>
<p><img src="/p/umhSsFwokBtQ" alt="image.png"></p>
<h5 id="no-ghost-blocks-161">No Ghost Blocks</h5>
<ul>
<li>移除客户端与服务器不同步的幽灵方块。</li>
<li>通过刷新方块状态修复异常。</li>
</ul>
<p><img src="/p/S5sXEf5MS6eH" alt="image.png"></p>
<h5 id="nuker-162">Nuker</h5>
<ul>
<li>快速大范围破坏方块。</li>
<li>一次性清理大面积区域。</li>
</ul>
<p><img src="/p/70P3bBt2qTVP" alt="image.png"></p>
<h5 id="packet-mine-163">Packet Mine</h5>
<ul>
<li>使用数据包进行方块破坏。</li>
<li>可在不连续破坏的情况下直接完成挖掘。</li>
</ul>
<p><img src="/p/EZasksmyewoA" alt="image.png"></p>
<h5 id="spawn-proofer-164">Spawn Proofer</h5>
<ul>
<li>自动放置方块或光源防止怪物生成。</li>
<li>在建筑范围内进行刷怪点封锁。</li>
</ul>
<p><img src="/p/EYgM9naX5gQK" alt="image.png"></p>
<h5 id="stashfinder-165">StashFinder</h5>
<ul>
<li>自动寻找隐藏储物点。</li>
<li>探测并标记箱子、潜影盒等位置。</li>
</ul>
<p><img src="/p/cNz65gBjKQdP" alt="image.png"></p>
<h5 id="timer-166">Timer</h5>
<ul>
<li>修改客户端游戏速度。</li>
<li>加快或减慢游戏内动作。</li>
</ul>
<p><img src="/p/3mzFh9S0j6d6" alt="image.png"></p>
<h5 id="vein-miner-167">Vein Miner</h5>
<ul>
<li>一次性挖掘整片矿脉。</li>
<li>持续破坏相邻同类方块。</li>
</ul>
<p><img src="/p/RsTndkroYO4r" alt="image.png"></p>
<h4 id="misc杂项-168">Misc(杂项)</h4>
<h5 id="anti-packet-kick-169">Anti Packet Kick</h5>
<ul>
<li>阻止因异常数据包被服务器踢出。</li>
<li>自动过滤或修正数据包内容。</li>
</ul>
<p><img src="/p/XJdsNJ9KjAJq" alt="image.png"></p>
<h5 id="auto-reconnect-170">Auto Reconnect</h5>
<ul>
<li>断线后自动重连服务器。</li>
<li>可设置延迟或重连次数。</li>
</ul>
<p><img src="/p/UNjzDI3uCLB0" alt="image.png"></p>
<h5 id="better-beacons-171">Better Beacons</h5>
<ul>
<li>允许切换效果。</li>
</ul>
<p><img src="/p/kcU4ZF7Uhmpn" alt="image.png"></p>
<h5 id="better-chat-172">Better Chat</h5>
<ul>
<li>改进聊天界面显示。</li>
<li>支持自定义颜色、格式或过滤信息。</li>
</ul>
<p><img src="/p/JzJQnD2GBc1Z" alt="image.png"></p>
<h5 id="book-bot-173">Book Bot</h5>
<ul>
<li>自动填写并生成书籍内容。</li>
<li>可批量生成预设文本书。</li>
</ul>
<p><img src="/p/vuxe34e8J3sO" alt="image.png"></p>
<h5 id="discord-presence-174">Discord Presence</h5>
<ul>
<li>在 Discord 状态中显示游戏信息。</li>
<li>展示当前服务器、坐标等数据。</li>
</ul>
<p><img src="/p/kVzHNgUExvHX" alt="image.png"></p>
<h5 id="inventory-tweaks-175">Inventory Tweaks</h5>
<ul>
<li>自动整理背包物品。</li>
<li>按类别或顺序排列物品。</li>
</ul>
<p><img src="/p/dwb2vaDqLCbj" alt="image.png"></p>
<h5 id="message-aura-176">Message Aura</h5>
<ul>
<li>自动在接近玩家时发送消息。</li>
<li>用于问候、警告或嘲讽。</li>
</ul>
<p><img src="/p/8tTyyy1bl7xL" alt="image.png"></p>
<h5 id="notebot-notifier-177">Notebot Notifier</h5>
<ul>
<li>自动演奏音符盒。</li>
<li>需要设置歌曲，但我不会。</li>
</ul>
<p><img src="/p/JeA1W10KfioW" alt="image.png"></p>
<h5 id="offhand-crash-178">Offhand Crash</h5>
<ul>
<li>利用副手物品造成客户端或服务器崩溃。</li>
<li>通过异常数据触发错误。</li>
</ul>
<p><img src="/p/StqbQpZkv9aC" alt="image.png"></p>
<h5 id="packet-canceller-179">Packet Canceller</h5>
<ul>
<li>阻止特定数据包的发送或接收。</li>
<li>用于避免触发检测或修改交互。</li>
</ul>
<p><img src="/p/ggOCMZ671VPg" alt="image.png"></p>
<h5 id="server-spoof-180">Server Spoof</h5>
<ul>
<li>伪装客户端为其他服务器状态。</li>
<li>显示与实际不同的服务器信息。</li>
</ul>
<p><img src="/p/nmMe3QDyjBNl" alt="image.png"></p>
<h5 id="sound-blocker-181">Sound Blocker</h5>
<ul>
<li>阻止特定声音播放。</li>
<li>静音环境中的特定音效。</li>
</ul>
<p><img src="/p/YnehayIBjHYl" alt="image.png"></p>
<h5 id="spam-182">Spam</h5>
<ul>
<li>自动在聊天中发送消息。</li>
<li>可设置重复内容或定时发送。</li>
</ul>
<p><img src="/p/Sk0pjuqMAnSw" alt="image.png"></p>
<h5 id="swarm-183">Swarm</h5>
<ul>
<li>远程控制多个 Meteor 客户端。</li>
</ul>
<p><img src="/p/rpcPFlPVg9pF" alt="image.png"></p>
<p>—— 完。</p>]]></content:encoded>
            <author>RavelloH</author>
            <enclosure url="https://ravelloh.com/p/ko11MHV4X0hn" length="0" type="image//p/ko11MHV4X0hn"/>
            <contentPreview><![CDATA[前言 可能是目前最详细的中文 Meteor 文档。 什么是 Meteor ? Meteor，中文译名彗星端，是一款 Minecraft 外挂辅助模组。其运行在客户端，修改部分客户端行为，让你不管是看起来还是用起来都像是“开了"一样。尽管其不应该被运行在任何服务器中，其对单人生存等场景仍有着十分炸裂的辅助提升效果。但因其不支持中文，这里我专门写篇文章来详细说明下 Meteor 如何使用。 不过我在此再次重申， 其不应该被运行在任何服务器中...]]></contentPreview>
        </item>
        <item>
            <title><![CDATA[使用 Meilisearch 实现全站搜索]]></title>
            <link>https://ravelloh.com/posts/using-meilisearch-to-achieve-sitewide-search</link>
            <guid isPermaLink="false">https://ravelloh.com/posts/using-meilisearch-to-achieve-sitewide-search</guid>
            <pubDate>Wed, 25 Jun 2025 14:05:07 GMT</pubDate>
            <description><![CDATA[Meilisearch 是一个支持中文分词、模糊搜索与拼写纠错的开源轻量级全文搜索引擎。文章对比了其与 Elasticsearch、Typesense 等竞品的优劣，详细介绍了通过二进制文件和 Docker 进行部署的方法，并完整演示了从获取 API Token、配置索引设置、写入数据到执行搜索的具体开发流程。]]></description>
            <content:encoded><![CDATA[<p class="feed-lead">前往 <a href="https://ravelloh.com/posts/using-meilisearch-to-achieve-sitewide-search">https://ravelloh.com/posts/using-meilisearch-to-achieve-sitewide-search</a> 查看以获得最佳体验。</p>
<h2 id="简介-1">简介</h2>
<p><a href="https://github.com/meilisearch/meilisearch">Meilisearch</a> 是一个开源的轻量级全文搜索引擎，支持中文分词、模糊搜索、拼写纠错等。</p>
<p>与之相似的竞品有：Elasticsearch、Typesense、Algolia等。其中，Elasticsearch 是老牌搜索服务，但是运行起来太笨重了，除非数据量很大，否则其余轻量级的实现要更好。</p>
<p>后两者 Typesense、Algolia 和本文的主角 Meilisearch 都是更轻量级别的存在，但是 Algolia 不开源，只能使用他们的云服务，而我不太喜欢被平台给局限住，所以也放弃了。</p>
<p>Typesense 和 Meilisearch 都是开源的同时也都提供云服务。很可惜，前者的文档集存在 RAM 里，后者则主要放在磁盘里，虽然性能上确实是前者更出色，但是很明显 RAM 可比存储空间贵的多。</p>
<p>另外，Meilisearch 的社区开发环境也更好。截至本文发布，Meilisearch 有<code>52K stars, 296 watching, 2.1K forks, 201 contributors</code>，而 Typesense 只有<code>23.6K stars, 132 watching, 773 forks, 46 contributors</code>。</p>
<p><del>(另外，Meilisearch还是用我喜欢的Rust写的)</del></p>
<p>因此，本文主要讲 Meilisearch 的部署与使用。另附我找到的几个比较资料，或许能帮你选择合适自己的服务。</p>
<ul>
<li><a href="https://www.tubring.cn/articles/typesense-vs-algolia-vs-elasticsearch-vs-meilisearch">Algolia vs ElasticSearch vs Meilisearch vs Typesense 之比较 | 日思录</a></li>
<li><a href="https://meilisearch.org.cn/blog/meilisearch-vs-typesense">Meilisearch 与 Typesense - Meilisearch 搜索引擎</a></li>
</ul>
<h2 id="部署-2">部署</h2>
<p>Meilisearch 支持下载二进制和 Docker 部署两种方式。</p>
<h3 id="二进制-3">二进制</h3>
<p>另外，Meilisearch 还支持一键脚本安装：</p>
<pre><code class="language-shell">curl -L https://install.meilisearch.com | sh
</code></pre>
<p>或者手动下载二进制文件：</p>
<p><a href="https://github.com/meilisearch/meilisearch/releases">Releases · meilisearch/meilisearch</a></p>
<p>下载完后，使用以下方式启动：</p>
<pre><code class="language-shell">./meilisearch --master-key=YourMasterKey
</code></pre>
<p>其中，MasterKey 算是一个管理员密码，可以是 16 位以上的 UTF-8 字符串。获取 API 密钥需要通过此Key，访问管理后台也需要。</p>
<p>也可以自定义端口，默认是7700。</p>
<pre><code class="language-shell">./meilisearch --http-addr '0.0.0.0:7700'
</code></pre>
<h3 id="docker-4">Docker</h3>
<pre><code class="language-shell">docker run -it --rm \
  -p 7700:7700 \
  getmeili/meilisearch \
  meilisearch --master-key='YourMasterKey' --http-addr '0.0.0.0:7700'
</code></pre>
<h2 id="使用-5">使用</h2>
<p>使用也很简单，分4步，分别是查看当前的API Token、设置要搜索的数据类型、写入数据、搜索。<br>
下面会用到很多curl，windows建议用Postman，直接粘贴进去就能解析。</p>
<h3 id="查看当前api-token-6">查看当前API Token</h3>
<pre><code class="language-shell">curl -X GET 'https://Your_Meili_URL/keys' -H 'Authorization: YourMasterToken'
</code></pre>
<p>获取到的结果是：</p>
<pre><code class="language-json">{
    "results": [
        {
            "name": "Default Search API Key",
            "description": "Use it to search from the frontend",
            "key": "xxxxxxxx",
            "uid": "xxxxxxxx",
            "actions": [
                "search"
            ],
            "indexes": [
                "*"
            ],
            "expiresAt": null,
            "createdAt": "2025-06-25T11:08:45.729263258Z",
            "updatedAt": "2025-06-25T11:08:45.729263258Z"
        },
        {
            "name": "Default Admin API Key",
            "description": "Use it for anything that is not a search operation. Caution! Do not expose it on a public frontend",
            "key": "xxxxxxxx",
            "uid": "xxxxxxxx",
            "actions": [
                "*"
            ],
            "indexes": [
                "*"
            ],
            "expiresAt": null,
            "createdAt": "2025-06-25T11:08:45.726891008Z",
            "updatedAt": "2025-06-25T11:08:45.726891008Z"
        }
    ],
    "offset": 0,
    "limit": 20,
    "total": 2
}
</code></pre>
<p>简单来说会给你两个 Token，存在key字段里，上面是搜索用的，下面是管理用的。</p>
<h3 id="设置数据类型-7">设置数据类型</h3>
<p>如果你想索引这样的一个东西：</p>
<pre><code>model Post {
  id           Int      @id @default(autoincrement())
  title        String   @db.VarChar(50)
  content      String   @db.VarChar(200)
  userUid      Int?
  createdAt    DateTime @default(now())
  User         User?    @relation(fields: [userUid], references: [uid])
}
</code></pre>
<p>它包含一个主键:<code>id</code>，两个你想索引的东西:<code>title</code>,<code>content</code>。<br>
那么，你首先应该创建这个项：</p>
<p>（这里的API Token既可以用MasterKey，也能用Default Admin API Key）</p>
<pre><code class="language-shell">curl -X POST 'https://Your_Meili_URL/indexes' \
  -H 'Authorization: Bearer API_TOKEN' \
  -H 'Content-Type: application/json' \
  --data '{
    "uid": "posts",
    "primaryKey": "id"
}'
</code></pre>
<p>结果：</p>
<pre><code class="language-json">{

    "taskUid": 0,
    "indexUid": "posts",
    "status": "enqueued",
    "type": "indexCreation",
    "enqueuedAt": "2025-06-25T12:37:59.414437837Z"

}
</code></pre>
<p>然后，设置可被搜索的字段：</p>
<pre><code class="language-shell">curl -X PUT 'https://Your_Meili_URL/indexes/posts/settings/searchable-attributes' \
  -H 'Authorization: Bearer API_KEY' \
  -H 'Content-Type: application/json' \
  --data '[
    "title", "content"
  ]'
</code></pre>
<p>你也可以设置一些可以用于排序的字段：</p>
<pre><code class="language-shell">curl -X PUT 'https://Your_Meili_URL/indexes/posts/settings/sortable-attributes' \
  -H 'Authorization: Bearer API_KEY' \
  -H 'Content-Type: application/json' \
  --data '["createdAt"]'
</code></pre>
<p>类似的，设置用于过滤的字段：</p>
<pre><code class="language-shell">curl -X PUT 'https://Your_Meili_URL/indexes/posts/settings/filterable-attributes' \
  -H 'Authorization: Bearer API_KEY' \
  -H 'Content-Type: application/json' \
  --data '["userUid"]'
</code></pre>
<h3 id="写入数据-8">写入数据</h3>
<p>现在就可以写入数据了。可以用curl，或者是官方SDK：</p>
<pre><code class="language-shell">curl -X POST 'https://Your_Meili_URL/indexes/posts/documents' \
  -H 'Authorization: Bearer API_KEY' \
  -H 'Content-Type: application/json' \
  --data '[
    {
      "id": 1,
      "createdAt": "2025-06-25T00:00:00Z",
      "title": "中文标题",
      "content": "中文内容",
      "userUid": 1,
    }
  ]'
</code></pre>
<pre><code class="language-typescript">import { MeiliSearch } from 'meilisearch';
import { Post } from '@/generated/prisma';

const client = new MeiliSearch({
  host: process.env.MEILI_HOST || "",
  apiKey: process.env.MEILI_API_KEY, 
})

const index = client.index('posts')

async function addSingleDocument(post: Post) {
  await index.addDocuments([post])
}

await addSingleDocument(post)
</code></pre>
<h3 id="搜索-9">搜索</h3>
<p>搜索也是，可以用curl，或者是官方SDK：</p>





























<table><thead><tr><th>参数</th><th>说明</th></tr></thead><tbody><tr><td>q</td><td>要搜索的关键词</td></tr><tr><td>limit</td><td>返回的最大结果数</td></tr><tr><td>offset</td><td>跳过的结果数（用于分页）</td></tr><tr><td>filter</td><td>过滤条件（可选）</td></tr><tr><td>sort</td><td>排序条件（可选）</td></tr></tbody></table>
<pre><code class="language-shell">curl -X POST 'https://Your_Meili_URL/indexes/posts/search' \
  -H 'Authorization: Bearer API_KEY' \
  -H 'Content-Type: application/json' \
  --data '{
    "q": "关键词",
    "limit": 10,
    "offset": 0,
    "filter": "userUid = 1 AND title = xxx",
    "sort": ["createdAt:desc"]
}'

</code></pre>
<pre><code class="language-ts">import { MeiliSearch } from 'meilisearch';

const client = new MeiliSearch({
  host: process.env.MEILI_HOST || "",
  apiKey: process.env.MEILI_API_KEY, 
})

async function searchPosts(query) {
  const index = client.index('posts')
  const result = await index.search(query, {
    limit: 10
  })
  console.log(result.hits)
}

searchPosts('中文')

</code></pre>]]></content:encoded>
            <author>RavelloH</author>
            <enclosure url="https://ravelloh.com/p/e3b02Kvf6gAC" length="0" type="image//p/e3b02Kvf6gAC"/>
            <contentPreview><![CDATA[简介 Meilisearch 是一个开源的轻量级全文搜索引擎，支持中文分词、模糊搜索、拼写纠错等。 与之相似的竞品有：Elasticsearch、Typesense、Algolia等。其中，Elasticsearch 是老牌搜索服务，但是运行起来太笨重了，除非数据量很大，否则其余轻量级的实现要更好。 后两者 Typesense、Algolia 和本文的主角 Meilisearch 都是更轻量级别的存在，但是 Algolia 不开源，只能...]]></contentPreview>
        </item>
        <item>
            <title><![CDATA[Timepulse：现代化高颜值计时器]]></title>
            <link>https://ravelloh.com/posts/timepulse-modern-beautiful-timer</link>
            <guid isPermaLink="false">https://ravelloh.com/posts/timepulse-modern-beautiful-timer</guid>
            <pubDate>Thu, 03 Apr 2025 13:05:28 GMT</pubDate>
            <description><![CDATA[TimePulse 是一个具有现代化 UI 和交互的倒计时网页应用，致力于提供极佳的视觉体验。它采用玻璃态设计和流畅动画效果，支持多计时器管理、数据本地存储与云端同步、智能节假日识别以及 PWA 离线访问等功能，并开放了核心源码。]]></description>
            <content:encoded><![CDATA[<p class="feed-lead">前往 <a href="https://ravelloh.com/posts/timepulse-modern-beautiful-timer">https://ravelloh.com/posts/timepulse-modern-beautiful-timer</a> 查看以获得最佳体验。</p>
<h2 id="前言-1">前言</h2>
<p>最近没什么事做，也懒得再写博客的新内容了，于是打算做个新东西。</p>
<p>想来想去，似乎自己没找到什么好用的倒计时器，于是自己就写了一个。</p>
<p>这里也不写什么技术细节了，大致看下效果就好。</p>
<h2 id="预览-2">预览</h2>
<p><a href="https://timepulse.ravelloh.top/">TimePulse - 现代化倒计时(https://timepulse.ravelloh.top/)</a></p>
<p>Github: <a href="https://github.com/RavelloH/TimePulse">RavelloH/TimePulse: 致力于成为最漂亮的倒计时应用。 TimePulse 是一个具有现代化 UI 和交互的倒计时网页应用，支持多计时器管理、数据分享、数据同步、全屏展示等功能。采用玻璃态设计和流畅动画效果，提供极佳的视觉体验。</a></p>
<p><img src="/p/vZZwiVxZcgWQ" alt="image.png"></p>
<p><img src="/p/EFn6yd7FhzA2" alt="image.png"></p>
<p><img src="/p/FaZgSySt9jp7" alt="image.png"></p>
<p><img src="/p/J70O8S6td7a4" alt="image.png"></p>
<p><img src="/p/SFBkylbtalxP" alt="image.png"></p>
<p><img src="/p/O9IR0NlWLUyG" alt="image.png"></p>
<p><img src="/p/JQXv3TY82VFc" alt="image.png"></p>
<p><img src="/p/ZB9piQOGSoJo" alt="image.png"></p>
<h2 id="功能-3">功能</h2>
<h3 id="核心功能-4">核心功能</h3>
<ul>
<li>支持多个计时器的创建和管理</li>
<li>数据本地存储与云端同步</li>
<li>支持生成分享链接和二维码</li>
<li>支持全屏展示模式</li>
<li>智能识别常用节假日</li>
<li>支持PWA，可安装到主屏幕并离线使用</li>
</ul>
<h3 id="视觉和交互-5">视觉和交互</h3>
<ul>
<li>精美的玻璃态设计和流畅动效</li>
<li>自适应模糊渐变背景，随主题色变化</li>
<li>暗色/亮色主题自动切换</li>
<li>完全响应式设计，适配各种设备</li>
</ul>
<h3 id="新增特性-6">新增特性</h3>
<ul>
<li>自定义滚动条与滚动进度指示器，与主题色联动</li>
<li>PWA功能支持，可作为独立应用安装到设备</li>
<li>离线访问支持，无需网络也能使用核心功能</li>
<li>优化的渐变背景效果，解决色彩断层问题</li>
<li>自定义下拉菜单组件，完美适配明暗两种模式</li>
<li>支持键盘导航的无障碍交互体验</li>
<li>滚动触发区优化，改善移动设备触控体验</li>
</ul>
<h2 id="技术-7">技术</h2>
<p>技术方面我觉得有价值的不算多，主要也就是个时间处理和背景设计让我花了点时间。</p>
<p>目前考虑到的节日放在<code>context/TimerContext</code>中，具体来说，只有以下节日:</p>
<pre><code class="language-js">// 生成固定日期的节日列表，考虑时区调整
const generateFixedHolidays = (year) => {
  // 获取用户时区偏移量（分钟）
  const userTimezoneOffsetMinutes = new Date().getTimezoneOffset();
  // UTC+0 时区和用户时区的差异小时数（注意符号相反）
  const userTimezoneOffsetHours = -userTimezoneOffsetMinutes / 60;
  
  // 创建时区补偿函数
  const createDateWithOffset = (monthIndex, day) => {
    // 创建UTC时间
    const date = new Date(Date.UTC(year, monthIndex, day));
  
    // 格式化为ISO字符串，保留T00:00:00Z的UTC标志
    return date.toISOString();
  };
  
  return [
    // 国际节日 - 日期固定
    { name: `${year}年元旦`, date: createDateWithOffset(0, 1), color: '#1890FF' },
    { name: `${year}年情人节`, date: createDateWithOffset(1, 14), color: '#EB2F96' },
    { name: `${year}年妇女节`, date: createDateWithOffset(2, 8), color: '#C71585' },
    { name: `${year}年植树节`, date: createDateWithOffset(2, 12), color: '#52C41A' },
    { name: `${year}年愚人节`, date: createDateWithOffset(3, 1), color: '#722ED1' },
    { name: `${year}年青年节`, date: createDateWithOffset(4, 4), color: '#722ED1' },
    { name: `${year}年劳动节`, date: createDateWithOffset(4, 1), color: '#FA8C16' },
    { name: `${year}年清明节`, date: getQingmingDate(year).toISOString(), color: '#228B22' },
    { name: `${year}年儿童节`, date: createDateWithOffset(5, 1), color: '#13C2C2' },
    { name: `${year}年建党节`, date: createDateWithOffset(6, 1), color: '#FF0000' },
    { name: `${year}年建军节`, date: createDateWithOffset(7, 1), color: '#CF1322' },
    { name: `${year}年教师节`, date: createDateWithOffset(8, 10), color: '#096DD9' },
    { name: `${year}年国庆节`, date: createDateWithOffset(9, 1), color: '#FF4D4F' },
    { name: `${year}年万圣节`, date: createDateWithOffset(9, 31), color: '#FF7A45' },
    { name: `${year}年平安夜`, date: createDateWithOffset(11, 24), color: '#36CFC9' },
    { name: `${year}年圣诞节`, date: createDateWithOffset(11, 25), color: '#F759AB' },
  ];
};

// 计算动态节日日期，也考虑时区
const calculateDynamicHolidays = (year) => {
  const holidays = [];
  
  // 获取用户时区的偏移量
  const userTimezoneOffsetMinutes = new Date().getTimezoneOffset();
  const userTimezoneOffsetHours = -userTimezoneOffsetMinutes / 60;
  
  // 创建ISO格式的UTC日期字符串
  const createISODate = (date) => {
    return date.toISOString();
  };
  
  // 母亲节 - 5月第二个星期日
  const firstDayOfMay = new Date(Date.UTC(year, 4, 1));
  const motherDayDay = firstDayOfMay.getUTCDay(); // 获取星期几
  const daysUntilSecondSunday = (7 - motherDayDay) % 7 + 7; // 到第二个星期日的天数
  const motherDayDate = new Date(Date.UTC(year, 4, 1 + daysUntilSecondSunday)); 
  holidays.push({
    name: `${year}年母亲节`,
    date: createISODate(motherDayDate),
    color: '#F759AB'
  });
  
  // 父亲节 - 6月第三个星期日
  const firstDayOfJune = new Date(Date.UTC(year, 5, 1));
  const fatherDayDay = firstDayOfJune.getUTCDay(); // 获取星期几
  const daysUntilThirdSunday = (7 - fatherDayDay) % 7 + 14; // 到第三个星期日的天数
  const fatherDayDate = new Date(Date.UTC(year, 5, 1 + daysUntilThirdSunday)); 
  holidays.push({
    name: `${year}年父亲节`,
    date: createISODate(fatherDayDate),
    color: '#1890FF'
  });
  
  // 感恩节 - 11月第四个星期四
  const firstDayOfNovember = new Date(Date.UTC(year, 10, 1));
  const thanksgivingDayDay = firstDayOfNovember.getUTCDay(); // 获取星期几
  const daysToThursday = (4 + 7 - thanksgivingDayDay) % 7; // 到第一个星期四的天数
  const thanksgivingDayDate = new Date(Date.UTC(year, 10, 1 + daysToThursday + 21)); // 加21天到第四个星期四
  holidays.push({
    name: `${year}年感恩节`,
    date: createISODate(thanksgivingDayDate),
    color: '#FAAD14'
  });
  
  return holidays;
};

// 添加农历节日转换函数
const getChineseFestivals = (year) => {
  const lunarHolidays = [];
  const holidaysMapping = [
    { name: `${year}年春节`, lunarMonth: 1, lunarDay: 1, color: '#FF0000' },
    { name: `${year}年元宵节`, lunarMonth: 1, lunarDay: 15, color: '#FF6347' },
    { name: `${year}年端午节`, lunarMonth: 5, lunarDay: 5, color: '#32CD32' },
    { name: `${year}年七夕节`, lunarMonth: 7, lunarDay: 7, color: '#FF1493' },
    { name: `${year}年中元节`, lunarMonth: 7, lunarDay: 15, color: '#708090' },
    { name: `${year}年中秋节`, lunarMonth: 8, lunarDay: 15, color: '#FFA500' },
    { name: `${year}年重阳节`, lunarMonth: 9, lunarDay: 9, color: '#800080' },
    { name: `${year}年腊八节`, lunarMonth: 12, lunarDay: 8, color: '#8B4513' },
  ];
  
  holidaysMapping.forEach(holiday => {
    // 使用 solarlunar 将农历日期转换为公历日期
    const solarDate = solarlunar.lunar2solar(year, holiday.lunarMonth, holiday.lunarDay, false);
    // 构造 ISO 格式日期字符串，注意月-1
    const date = new Date(Date.UTC(solarDate.cYear, solarDate.cMonth - 1, solarDate.cDay)).toISOString();
    lunarHolidays.push({
      name: holiday.name,
      date: date,
      color: holiday.color
    });
  });
  
  return lunarHolidays;
};
</code></pre>
<p>一般来说这些已经够了，你也可以直接拿这部分成品节日日期生成器自己用。</p>
<p>另外比较有意思的是Background，文件在<code>components/Background/GradientBackground.js</code>。</p>
<p>这里其实做到了一种动态化的背景效果，实现方式也比较取巧，是直接在高斯模糊后面加上几个半透明圆，赋予其不同的位置、初速度、颜色，让他们自由组合，效果就已经不错了。</p>
<p><img src="/p/QnUruK5WUpsf" alt="image.png"></p>
<pre><code class="language-js">import { useRef, useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useTimers } from '../../context/TimerContext';
import { useTheme } from '../../context/ThemeContext';

export default function GradientBackground() {
  const { getActiveTimer, activeTimerId } = useTimers();
  const { theme } = useTheme();
  const [circles, setCircles] = useState([]);
  const [prevTimerId, setPrevTimerId] = useState(null);
  const containerRef = useRef(null);
  const activeTimer = getActiveTimer();
  const [isSafari, setIsSafari] = useState(false);
  
  // 检测Safari浏览器
  useEffect(() => {
    // 检测Safari浏览器
    const isSafariBrowser = 
      navigator.userAgent.indexOf('Safari') !== -1 &#x26;&#x26; 
      navigator.userAgent.indexOf('Chrome') === -1;
  
    // 检测iOS设备
    const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) &#x26;&#x26; !window.MSStream;
  
    setIsSafari(isSafariBrowser || isIOS);
  }, []);
  
  // 生成渐变圆圈 - 针对Safari减少圆圈数量和动画复杂度
  useEffect(() => {
    // 检查计时器是否变化
    const isNewTimer = activeTimerId !== prevTimerId;
    if (isNewTimer) {
      setPrevTimerId(activeTimerId);
    }
  
    // 基于活动计时器的颜色生成颜色
    const baseColor = activeTimer?.color || '#0ea5e9';
    const colors = generateColors(baseColor, theme === 'dark');
  
    // 针对Safari减少圆圈数量
    const circleCount = 5
  
    // 生成或更新圆圈配置
    if (circles.length === 0 || isNewTimer) {
      // 如果是新计时器或初始化，创建新圆圈
      const newCircles = Array.from({ length: circleCount }, (_, i) => ({
        id: i,
        x: Math.random() * 100 - 30 + Math.random() * 40,
        y: Math.random() * 100 - 30 + Math.random() * 40,
        size: 30 + Math.random() * 40,
        speedX: (Math.random() - 0.5) * (isSafari ? 0.01 : 0.03), // 降低Safari中的速度
        speedY: (Math.random() - 0.5) * (isSafari ? 0.01 : 0.03), // 降低Safari中的速度
        color: colors[i % colors.length],
        blur: isSafari ? 40 : (60 + Math.random() * 40), // 减少Safari中的模糊强度
        opacity: 0.3 + Math.random() * (isSafari ? 0.2 : 0.3) // 降低Safari中的透明度变化
      }));
      setCircles(newCircles);
    } else if (activeTimer) {
      // 如果计时器颜色变化，平滑过渡圆圈颜色
      setCircles(prev => prev.map((circle, i) => ({
        ...circle,
        // 使用过渡这里，而不是闪烁效果
        color: colors[i % colors.length]
      })));
    }
  }, [activeTimerId, theme, activeTimer, isSafari]);
  
  // 根据基础颜色生成一组和谐的颜色
  const generateColors = (baseColor, isDark) => {
    // 解析颜色
    let r, g, b;
  
    if (baseColor.startsWith('#')) {
      const hex = baseColor.slice(1);
      r = parseInt(hex.slice(0, 2), 16);
      g = parseInt(hex.slice(2, 4), 16);
      b = parseInt(hex.slice(4, 6), 16);
    } else {
      // 默认颜色
      r = 14;
      g = 165;
      b = 233;
    }
  
    // 生成颜色变体
    return [
      `rgba(${r}, ${g}, ${b}, 0.5)`, // 原色
      `rgba(${r * 0.8}, ${g * 1.1}, ${b * 1.2}, 0.5)`, // 变体1
      `rgba(${r * 1.2}, ${g * 0.8}, ${b * 0.9}, 0.5)`, // 变体2
      `rgba(${r * 0.9}, ${g * 0.9}, ${b * 1.3}, 0.5)`, // 变体3
      `rgba(${r * 1.1}, ${g * 1.2}, ${b * 0.8}, 0.5)`, // 变体4
      `rgba(${r * 1.3}, ${g * 0.9}, ${b * 0.9}, 0.5)`  // 变体5
    ];
  };
  
  return (
    &#x3C;div className="fixed inset-0 overflow-hidden z-0 pointer-events-none">
      &#x3C;AnimatePresence>
        {circles.map(circle => (
          &#x3C;motion.div
            key={`circle-${circle.id}-${activeTimerId || 'default'}`}
            className="moving-circle absolute"
            initial={{ 
              left: `${circle.x}vw`,
              top: `${circle.y}vh`,
              width: `${circle.size}vw`,
              height: `${circle.size}vw`,
              opacity: 0 
            }}
            animate={{
              left: [`${circle.x}vw`, `${circle.x + circle.speedX * 100}vw`],
              top: [`${circle.y}vh`, `${circle.y + circle.speedY * 100}vh`],
              backgroundColor: circle.color,
              filter: `blur(${circle.blur}px)`,
              opacity: circle.opacity
            }}
            transition={{
              left: { duration: isSafari ? 30 : 20, ease: "linear", repeat: Infinity, repeatType: "reverse" },
              top: { duration: isSafari ? 30 : 20, ease: "linear", repeat: Infinity, repeatType: "reverse" },
              // 增加Safari中的过渡持续时间，减少更新频率
              backgroundColor: { duration: isSafari ? 3.5 : 2.5, ease: "easeOut" },
              opacity: { duration: isSafari ? 1.2 : 0.8 }
            }}
            style={{
              // 添加硬件加速属性
              WebkitTransform: "translateZ(0)",
              transform: "translateZ(0)",
              willChange: "transform"
            }}
          />
        ))}
      &#x3C;/AnimatePresence>
  
      {/* 移除闪烁动画，替换为以下更平滑的过渡效果 */}
      {activeTimerId !== prevTimerId &#x26;&#x26; prevTimerId !== null &#x26;&#x26; (
        &#x3C;motion.div
          className="absolute inset-0 z-0 pointer-events-none"
          // 背景使用径向渐变，从中心向外扩散，效果更自然
          style={{ 
            background: `radial-gradient(circle at center, ${activeTimer?.color || '#0ea5e9'}05 0%, transparent 70%)` 
          }}
          initial={{ opacity: 0 }}
          animate={{ opacity: 0.5 }}
          exit={{ opacity: 0 }}
          transition={{ duration: 2.5, ease: "easeOut" }}
          onAnimationComplete={() => setPrevTimerId(activeTimerId)}
        />
      )}
    &#x3C;/div>
  );
}
</code></pre>]]></content:encoded>
            <author>RavelloH</author>
            <enclosure url="https://ravelloh.com/p/FaZgSySt9jp7" length="0" type="image//p/FaZgSySt9jp7"/>
            <contentPreview><![CDATA[前言 最近没什么事做，也懒得再写博客的新内容了，于是打算做个新东西。 想来想去，似乎自己没找到什么好用的倒计时器，于是自己就写了一个。 这里也不写什么技术细节了，大致看下效果就好。 预览 TimePulse - 现代化倒计时(https://timepulse.ravelloh.top/) Github: RavelloH/TimePulse: 致力于成为最漂亮的倒计时应用。 TimePulse 是一个具有现代化 UI 和交互的倒计时网...]]></contentPreview>
        </item>
        <item>
            <title><![CDATA[Nextjs 使用 Server Action 实现动态页面重部署]]></title>
            <link>https://ravelloh.com/posts/nextjs-server-action-dynamic-redeployment</link>
            <guid isPermaLink="false">https://ravelloh.com/posts/nextjs-server-action-dynamic-redeployment</guid>
            <pubDate>Thu, 03 Apr 2025 13:02:17 GMT</pubDate>
            <description><![CDATA[利用 Next.js 14 的 Server Action 机制实现动态页面重部署的实战记录。核心是通过在服务端调用 revalidatePath 函数来按需刷新页面缓存（如文章列表、详情页、RSS 等），使得内容发布后无需执行完整的 Redeploy 流程即可即时生效，在保持静态缓存性能的同时实现了动态站的发布体验。]]></description>
            <content:encoded><![CDATA[<p class="feed-lead">前往 <a href="https://ravelloh.com/posts/nextjs-server-action-dynamic-redeployment">https://ravelloh.com/posts/nextjs-server-action-dynamic-redeployment</a> 查看以获得最佳体验。</p>
<h2 id="前言-1">前言</h2>
<p>目前博客已经支持动态页面重部署，简单来说就是写完文章发布后，不用再去Redeploy了，直接就能在访问的时候完成构建，并且保留构建成果作为缓存。</p>
<p>从这个角度来说，现在的RTheme才是个比较理想的动态站，至少写文章变的简单多了——现在只需要编辑、保存，即可。</p>
<p>实际上算是比较简单的，只是用了Nextjs 14里面的新机制Server Action，允许你在服务器端执行代码，并直接调用它来重新验证页面缓存。</p>
<h2 id="实现-2">实现</h2>
<p>非常简单，例如:</p>
<pre><code class="language-js">// app/api/_utils/actions.ts
'use server';

import { revalidatePath } from 'next/cache';

export async function refreshPosts() {
    revalidatePath('/posts'); // 重新渲染 `/posts` 页面
    revalidatePath('/posts/[name]'); // 重新渲染所有`/posts/xxx`页面
}
</code></pre>
<pre><code class="language-js">// 文章编辑/修改api
import { NextResponse } from 'next/server';
import prisma from '@/app/api/_utils/prisma';
import { refreshPosts } from '@/app/api/_utils/actions';

export async function POST(req: Request) {
    try {
        const data = await req.json();
        const post = await prisma.post.create({
            data: {
                title: data.title,
                name: data.name,
                published: true,
                createdAt: new Date(),
            },
        });

        // 文章添加成功后，刷新博客页面
        await refreshPosts();

        return NextResponse.json({ success: true, post });
    } catch (error) {
        return NextResponse.json({ success: false, error: error.message }, { status: 500 });
    }
}

</code></pre>
<p>实际上如果你的其余文章内容与修改无关的话，你可以自己让调用Server Actions的组件自己传参来刷新特定页面而非所有博客，毕竟如果真全刷新了回到无缓存状态，那么第一次加载的速度还是非常慢的。</p>
<p>同理，只要是个<code>page.tsx</code>就都能通过Server Actions进行刷新，也包括<code>RSS</code>和<code>Sitemap</code>。</p>]]></content:encoded>
            <author>RavelloH</author>
            <enclosure url="https://ravelloh.com/p/d2b3HQvKMYE0" length="0" type="image//p/d2b3HQvKMYE0"/>
            <contentPreview><![CDATA[前言 目前博客已经支持动态页面重部署，简单来说就是写完文章发布后，不用再去Redeploy了，直接就能在访问的时候完成构建，并且保留构建成果作为缓存。 从这个角度来说，现在的RTheme才是个比较理想的动态站，至少写文章变的简单多了——现在只需要编辑、保存，即可。 实际上算是比较简单的，只是用了Nextjs 14里面的新机制Server Action，允许你在服务器端执行代码，并直接调用它来重新验证页面缓存。 实现 非常简单，例如: /...]]></contentPreview>
        </item>
        <item>
            <title><![CDATA[使用 Wireshark 进行自我网络安全审计]]></title>
            <link>https://ravelloh.com/posts/self-network-security-audit-with-wireshark</link>
            <guid isPermaLink="false">https://ravelloh.com/posts/self-network-security-audit-with-wireshark</guid>
            <pubDate>Tue, 25 Feb 2025 13:25:09 GMT</pubDate>
            <description><![CDATA[一篇使用 Wireshark 对移动设备进行网络安全与隐私泄露风险排查的实操教程。文章详细介绍了如何通过电脑热点搭建抓包环境，重点分析了 DNS 明文传输、SNI 域名泄露以及潜在的中间人攻击（MITM）风险，并针对检测出的问题提供了配置 DoT/DoH 加密及优化代理分流的建议。]]></description>
            <content:encoded><![CDATA[<p class="feed-lead">前往 <a href="https://ravelloh.com/posts/self-network-security-audit-with-wireshark">https://ravelloh.com/posts/self-network-security-audit-with-wireshark</a> 查看以获得最佳体验。</p>
<h2 id="前言-1">前言</h2>
<p>在某些公用网络环境中，你可能会面临网络安全泄露的风险。本文使用Wireshark来分析你的设备是否存在被监听的风险。</p>
<p>这里以移动设备为例，实际上其余设备均可用此方法分析。你需要有无线网卡的计算机以用于为其他设备提供网络连接。</p>
<h2 id="准备工作-2">准备工作</h2>
<p>你需要：</p>
<ul>
<li>一台计算机(带有无线网卡)</li>
<li>Wireshark</li>
<li>其余需要分析的设备</li>
</ul>
<h2 id="分析-3">分析</h2>
<h3 id="筛选目标设备-4">筛选目标设备</h3>
<ol>
<li>首先，将你需要分析的设备连接至你的电脑热点</li>
<li>在你的设备上检查其分配到的IP地址</li>
<li>在你的计算机中，使用cmd输入<code>ipconfig</code>以查看所有网卡的信息</li>
<li>找到其中有你所需分析设备的网卡。</li>
<li>在Wireshark中选择此网卡监听。</li>
</ol>
<p>例如，我的设备IP为<code>192.168.137.199</code>，通过ipconfig发现其被<code>无线局域网适配器 本地连接* 2</code>管理，那么就应该在Wireshark中选择<code>本地连接* 2</code>。</p>
<p><img src="/p/JC8paPlh7f30" alt="image.png"></p>
<p><img src="/p/Tq3S0bNmDyTo" alt="image.png"></p>
<h3 id="检查dns是否加密-5">检查DNS是否加密</h3>
<p>通常情况下，你的DNS未经加密，由明文直接使用udp协议传输，这会带来巨大的安全隐患。你可以在Wireshark中，筛选并查看这些未经加密的流量。</p>
<p>使用你的设备访问一定数量的资源后，在你的Wireshark中以以下格式筛选：</p>
<pre><code>ip.addr == [目标设备IP] &#x26;&#x26; udp.port == 53
</code></pre>
<p><img src="/p/ehhm4PiU21UY" alt="image.png"></p>
<p>如发现类似上图的数据，说明你的DNS未经加密，可能存在泄露隐私的风险。你可以使用DoT/DoH来加密。例如，在Quantumult X的DNS配置中这样设置：</p>
<pre><code>[dns]
prefer-doh3
doh-server = https://223.5.5.5/dns-query, https://223.6.6.6/dns-query,https://1.12.12.12/dns-query,https://8.8.8.8/dns-query,https://208.67.222.222/dns-query,https://208.67.220.220/dns-query,https://208.67.222.123/dns-query
no-system
</code></pre>
<h3 id="检查sni泄露-6">检查SNI泄露</h3>
<blockquote>
<p>SNI（Server Name Indication，服务器名称指示）是TLS（传输层安全协议）扩展的一部分。它的作用是在TLS握手时，客户端告诉服务器它想访问的具体域名。</p>
</blockquote>
<p>很遗憾，即使DNS加密，在未使用代理的情况下，你所访问的资源域名仍可被监测。</p>
<p>你可以在Wireshark中以以下方式筛选所有Client Hello包，查看其泄露情况。</p>
<pre><code>ip.addr == [目标设备IP] &#x26;&#x26; tls.handshake.type == 1 &#x26;&#x26; tcp
</code></pre>
<p><img src="/p/HuCa6iPq6oaD" alt="image.png"></p>
<p>如上图所示，如果你发现了任何你不想让其他人看到的域名，说明你的代理分流工作未到位，设置此域名走代理即可。</p>
<h3 id="mitm-7">MITM</h3>
<p>常用的网络监听应该就是以上两种方式，不过也不排除有些极端环境可能会MITM，虽然99%是用不上的。</p>
<p>MITM的特点是需要伪造证书，没什么很好的检查方式，不过你也可以通过查看Server端发过来的证书的Issuer来确认正式是否由知名CA签署。</p>
<pre><code>ip.dst == [目标设备IP] &#x26;&#x26; tls.handshake.certificates
</code></pre>
<p><img src="/p/fqATSkT5HP4s" alt="图片"></p>]]></content:encoded>
            <author>RavelloH</author>
            <enclosure url="https://ravelloh.com/p/HuCa6iPq6oaD" length="0" type="image//p/HuCa6iPq6oaD"/>
            <contentPreview><![CDATA[前言 在某些公用网络环境中，你可能会面临网络安全泄露的风险。本文使用Wireshark来分析你的设备是否存在被监听的风险。 这里以移动设备为例，实际上其余设备均可用此方法分析。你需要有无线网卡的计算机以用于为其他设备提供网络连接。 准备工作 你需要： 一台计算机(带有无线网卡) Wireshark 其余需要分析的设备 分析 筛选目标设备 首先，将你需要分析的设备连接至你的电脑热点 在你的设备上检查其分配到的IP地址 在你的计算机中，使用...]]></contentPreview>
        </item>
        <item>
            <title><![CDATA[React 实现外链提示]]></title>
            <link>https://ravelloh.com/posts/react-external-link-warning</link>
            <guid isPermaLink="false">https://ravelloh.com/posts/react-external-link-warning</guid>
            <pubDate>Tue, 11 Feb 2025 08:17:20 GMT</pubDate>
            <description><![CDATA[React 实现的外链预览组件，旨在优化站内外部链接的交互体验。当用户将鼠标悬停在 target="_blank" 的超链接上时，该组件会弹出一个包含目标网页截图、域名及安全提示的模糊背景悬浮窗。文章详细介绍了如何利用 Canvas 分析图片亮度以实现文字颜色的自适应调整，并提供了包含网页截图功能的完整版以及仅保留背景模糊效果的轻量化 Mini 版两种实现代码。]]></description>
            <content:encoded><![CDATA[<p class="feed-lead">前往 <a href="https://ravelloh.com/posts/react-external-link-warning">https://ravelloh.com/posts/react-external-link-warning</a> 查看以获得最佳体验。</p>
<h2 id="前言-1">前言</h2>
<p>有的时候指向外链的超链接的显示文字可能根本不能让你了解到这是来源于哪个站点的，鼠标移上去看左下角又不太优雅，于是写了个这样的预览框。</p>
<h2 id="效果-2">效果</h2>
<p><img src="/p/nul9ZpsIQug0" alt="image.png"></p>
<p><img src="/p/FunqNxAa8cmk" alt="image.png"></p>
<p>当鼠标悬停在 <code>target="_blank"</code> 的超链接上时，会出现一个带模糊背景的弹出窗口，显示网站的主域名和完整 URL，同时附带一个提醒，告知该链接为外部链接。同时会加载目标页面的截图，获取到截图后根据图片的中心色，自动选择字体颜色(黑/白)。</p>
<p>你也可以在下方这个外链上自己试试:</p>
<p><a href="https://github.com/RavelloH/RTheme/blob/main/src/components/LinkPreview.jsx">RTheme/src/components/LinkPreview.jsx at main · RavelloH/RTheme</a></p>
<h2 id="使用-3">使用</h2>
<p>在所需的页面引入下方的jsx即可，你可以修改下方<code>const link = e.target.closest('article a[target="_blank"]');</code>的定义来自定义哪些元素要有此效果。默认是<code>article</code>元素下的具有<code>target="_blank"</code>的标签。</p>
<p>另，截图功能依靠<a href="https://github.com/Lete114/WebStack-Screenshot">Lete114/WebStack-Screenshot: 📸 Website Screenshot API</a>，这里是用到我部署在Vercel上的API<code>https://screenshot.ravelloh.top</code>，你也可以自己部署一个把下面的api链接换了。</p>
<p>截图为了加快加载，默认缓存<code>2592000</code>秒(30天)。</p>
<pre><code class="language-jsx">'use client';

import { useEffect } from 'react';

const styles = {
    preview: {
        position: 'absolute',
        background: 'rgba(0, 0, 0, 0.65)',
        backdropFilter: 'blur(8px)',
        WebkitBackdropFilter: 'blur(8px)',
        backgroundSize: 'cover',
        backgroundPosition: 'center',
        backgroundRepeat: 'no-repeat',
        border: '1px solid rgba(255, 255, 255, 0.1)',
        borderRadius: '12px',
        padding: '16px',
        maxWidth: '400px',
        width: 'calc(100vw - 32px)',
        boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
        zIndex: 1000,
        fontSize: '14px',
        transition: 'opacity 0.3s ease',
        opacity: 0,
        pointerEvents: 'none',
        color: '#fff',
        textShadow: '0 1px 3px rgba(0, 0, 0, 0.9), 0 2px 6px rgba(0, 0, 0, 0.9)',
        overflow: 'hidden',
        '&#x26;.loading': {
            backgroundImage: 'none !important',
            animation: 'shimmer 2.5s infinite linear',
            backgroundSize: '400% 100%',
            background:
                'linear-gradient(90deg, rgba(255,255,255,0.06) 0%, rgba(255,255,255,0.12) 50%, rgba(255,255,255,0.06) 100%)',
        },
    },
    header: {
        display: 'flex',
        alignItems: 'center',
        gap: '8px',
        marginBottom: '12px',
        lineHeight: '20px',
        textShadow: '0 1px 3px rgba(0, 0, 0, 0.9), 0 2px 6px rgba(0, 0, 0, 0.9)',
    },
    title: {
        margin: '0',
        fontSize: '16px',
        fontWeight: 'bold',
        color: '#fff',
        lineHeight: '20px',
        display: 'flex',
        alignItems: 'center',
        textShadow: '0 1px 3px rgba(0, 0, 0, 0.9), 0 2px 6px rgba(0, 0, 0, 0.9)',
        overflow: 'hidden',
        textOverflow: 'ellipsis',
        whiteSpace: 'nowrap',
        maxWidth: '100%',
    },
    url: {
        color: 'rgba(255, 255, 255, 0.7)',
        fontSize: '13px',
        marginBottom: '12px',
        wordBreak: 'break-all',
        textShadow: '0 1px 3px rgba(0, 0, 0, 0.9), 0 2px 6px rgba(0, 0, 0, 0.9)',
        overflow: 'hidden',
        textOverflow: 'ellipsis',
        display: '-webkit-box',
        '-webkit-line-clamp': '2',
        '-webkit-box-orient': 'vertical',
        whiteSpace: 'normal',
        maxHeight: '2.6em',
    },
    warning: {
        display: 'flex',
        alignItems: 'center',
        gap: '6px',
        color: 'rgba(255, 255, 255, 0.6)',
        fontSize: '12px',
        paddingTop: '12px',
        borderTop: '1px solid rgba(255, 255, 255, 0.1)',
        textShadow: '0 1px 3px rgba(0, 0, 0, 0.9), 0 2px 6px rgba(0, 0, 0, 0.9)',
    },
};

export default function LinkPreview() {
    useEffect(() => {
        let currentPreview = null;
        let timeout = null;

        const createPreview = (link) => {
            const preview = document.createElement('div');
            preview.className = 'link-preview loading';
            const bgImageUrl = `https://screenshot.ravelloh.top/?url=${encodeURIComponent(
                link.href,
            )}&#x26;viewport=1600x800&#x26;cache=2592000`;

            Object.assign(preview.style, styles.preview);

            const url = link.href;
            const hostname = new URL(url).hostname;

            preview.innerHTML = `
                &#x3C;div style="${Object.entries(styles.header)
                    .map(([k, v]) => `${k}:${v}`)
                    .join(';')}">
                    &#x3C;h4 style="${Object.entries(styles.title)
                        .map(([k, v]) => `${k}:${v}`)
                        .join(';')}">
                        ${hostname}
                    &#x3C;/h4>
                &#x3C;/div>
                &#x3C;div style="${Object.entries(styles.url)
                    .map(([k, v]) => `${k}:${v}`)
                    .join(';')}">
                    ${url}
                &#x3C;/div>
                &#x3C;div style="${Object.entries(styles.warning)
                    .map(([k, v]) => `${k}:${v}`)
                    .join(';')}">
                    &#x3C;span class="i_mini ri-error-warning-fill">&#x3C;/span>
                    非本站站内链接，请注意外部链接的安全性
                &#x3C;/div>
            `;

            const img = new Image();
            img.crossOrigin = 'Anonymous';
            img.onload = () => {
                const canvas = document.createElement('canvas');
                const ctx = canvas.getContext('2d');
                canvas.width = img.width;
                canvas.height = img.height;
                ctx.drawImage(img, 0, 0);

                // 获取图片中心区域的亮度
                const imageData = ctx.getImageData(
                    img.width * 0.25,
                    img.height * 0.25,
                    img.width * 0.5,
                    img.height * 0.5,
                );
                const data = imageData.data;
                let brightness = 0;

                for (let i = 0; i &#x3C; data.length; i += 4) {
                    brightness += data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114;
                }
                brightness = brightness / (data.length / 4) / 255;

                const textColor = brightness > 0.6 ? '#000' : '#fff';
                const textShadow =
                    brightness > 0.6
                        ? '0 1px 3px rgba(255, 255, 255, 0.9), 0 2px 6px rgba(255, 255, 255, 0.9)'
                        : '0 1px 3px rgba(0, 0, 0, 0.9), 0 2px 6px rgba(0, 0, 0, 0.9)';

                preview.querySelectorAll('div, h4').forEach((el) => {
                    el.style.color = textColor;
                    el.style.textShadow = textShadow;
                });

                preview.classList.remove('loading');
                preview.style.backgroundImage = `url("${bgImageUrl}")`;
            };
            img.src = bgImageUrl;

            return preview;
        };

        const showPreview = (link) => {
            const rect = link.getBoundingClientRect();
            const preview = createPreview(link);
            document.body.appendChild(preview);

            const previewRect = preview.getBoundingClientRect();
            const top = rect.top - previewRect.height - 10;

            let left = rect.left + (rect.width - previewRect.width) / 2;
            const minLeft = 16;
            const maxLeft = window.innerWidth - previewRect.width - 16;
            left = Math.max(minLeft, Math.min(left, maxLeft));

            Object.assign(preview.style, {
                top: `${top}px`,
                left: `${left}px`,
            });

            requestAnimationFrame(() => {
                preview.style.opacity = '1';
            });

            currentPreview = preview;
        };

        const hidePreview = () => {
            if (currentPreview) {
                currentPreview.style.opacity = '0';
                setTimeout(() => {
                    currentPreview?.remove();
                    currentPreview = null;
                }, 300);
            }
        };

        const handleMouseEnter = (e) => {
            const link = e.target.closest('article a[target="_blank"]');
            if (link) {
                timeout = setTimeout(() => {
                    showPreview(link);
                }, 300);
            }
        };

        const handleMouseLeave = () => {
            if (timeout) {
                clearTimeout(timeout);
                timeout = null;
            }
            hidePreview();
        };

        document.addEventListener('mouseover', handleMouseEnter);
        document.addEventListener('mouseout', handleMouseLeave);

        return () => {
            document.removeEventListener('mouseover', handleMouseEnter);
            document.removeEventListener('mouseout', handleMouseLeave);
            if (currentPreview) {
                currentPreview.remove();
            }
        };
    }, []);

    return null;
}
</code></pre>
<h3 id="mini版-4">mini版</h3>
<p>如果你只需要显示个背景模糊框而不需要截图，那么用下面这个就行。</p>
<pre><code class="language-jsx">'use client';

import { useEffect } from 'react';

const styles = {
    preview: {
        position: 'absolute',
        background: 'rgba(0, 0, 0, 0.65)',
        backdropFilter: 'blur(8px)',
        WebkitBackdropFilter: 'blur(8px)',
        border: '1px solid rgba(255, 255, 255, 0.1)',
        borderRadius: '12px',
        padding: '16px',
        maxWidth: '400px',
        width: 'calc(100vw - 32px)',
        boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
        zIndex: 1000,
        fontSize: '14px',
        transition: 'opacity 0.3s ease',
        opacity: 0,
        pointerEvents: 'none',
        color: '#fff',
    },
    header: {
        display: 'flex',
        alignItems: 'center',
        gap: '8px',
        marginBottom: '12px',
        lineHeight: '20px',
    },
    title: {
        margin: '0',
        fontSize: '16px',
        fontWeight: 'bold',
        color: '#fff',
        lineHeight: '20px',
        display: 'flex',
        alignItems: 'center',
    },
    url: {
        color: 'rgba(255, 255, 255, 0.7)',
        fontSize: '13px',
        marginBottom: '12px',
        wordBreak: 'break-all',
    },
    warning: {
        display: 'flex',
        alignItems: 'center',
        gap: '6px',
        color: 'rgba(255, 255, 255, 0.6)',
        fontSize: '12px',
        paddingTop: '12px',
        borderTop: '1px solid rgba(255, 255, 255, 0.1)',
    },
};

export default function LinkPreview() {
    useEffect(() => {
        let currentPreview = null;
        let timeout = null;

        const createPreview = (link) => {
            const preview = document.createElement('div');
            preview.className = 'link-preview';
            Object.assign(preview.style, styles.preview);

            const url = link.href;
            const hostname = new URL(url).hostname;

            preview.innerHTML = `
                &#x3C;div style="${Object.entries(styles.header)
                    .map(([k, v]) => `${k}:${v}`)
                    .join(';')}">
                    &#x3C;h4 style="${Object.entries(styles.title)
                        .map(([k, v]) => `${k}:${v}`)
                        .join(';')}">
                        ${hostname}
                    &#x3C;/h4>
                &#x3C;/div>
                &#x3C;div style="${Object.entries(styles.url)
                    .map(([k, v]) => `${k}:${v}`)
                    .join(';')}">
                    ${url}
                &#x3C;/div>
                &#x3C;div style="${Object.entries(styles.warning)
                    .map(([k, v]) => `${k}:${v}`)
                    .join(';')}">
                    &#x3C;span class="i_mini ri-error-warning-fill">&#x3C;/span>
                    非本站站内链接，请注意外部链接的安全性
                &#x3C;/div>
            `;

            return preview;
        };

        const showPreview = (link) => {
            const rect = link.getBoundingClientRect();
            const preview = createPreview(link);
            document.body.appendChild(preview);

            const previewRect = preview.getBoundingClientRect();
            const top = rect.top - previewRect.height - 10;

            // 修改left计算逻辑，确保不会超出屏幕
            let left = rect.left + (rect.width - previewRect.width) / 2;
            const minLeft = 16; // 左边距
            const maxLeft = window.innerWidth - previewRect.width - 16; // 右边距
            left = Math.max(minLeft, Math.min(left, maxLeft)); // 限制在可视区域内

            Object.assign(preview.style, {
                top: `${top}px`,
                left: `${left}px`,
            });

            requestAnimationFrame(() => {
                preview.style.opacity = '1';
            });

            currentPreview = preview;
        };

        const hidePreview = () => {
            if (currentPreview) {
                currentPreview.style.opacity = '0';
                setTimeout(() => {
                    currentPreview?.remove();
                    currentPreview = null;
                }, 300);
            }
        };

        const handleMouseEnter = (e) => {
            const link = e.target.closest('article a[target="_blank"]');
            if (link) {
                timeout = setTimeout(() => {
                    showPreview(link);
                }, 300);
            }
        };

        const handleMouseLeave = () => {
            if (timeout) {
                clearTimeout(timeout);
                timeout = null;
            }
            hidePreview();
        };

        document.addEventListener('mouseover', handleMouseEnter);
        document.addEventListener('mouseout', handleMouseLeave);

        return () => {
            document.removeEventListener('mouseover', handleMouseEnter);
            document.removeEventListener('mouseout', handleMouseLeave);
            if (currentPreview) {
                currentPreview.remove();
            }
        };
    }, []);

    return null;
}
</code></pre>
<p>注意，我这里的图标是用的<code>&#x3C;span class="i_mini ri-error-warning-fill">&#x3C;/span></code>来直接显示的，上面每图标库的话不会有图标，你可以换成相应的svg:</p>
<pre><code class="language-html">&#x3C;svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">&#x3C;path fill="currentColor" d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10s-4.477 10-10 10m-1-7v2h2v-2zm0-8v6h2V7z">&#x3C;/path>&#x3C;/svg>
</code></pre>
<p>或者在 <a href="https://icones.js.org/collection/ri?s=error-warning&#x26;icon=ri:error-warning-fill">Icônes</a> 找更多格式。</p>
<h2 id="原理-5">原理</h2>
<blockquote>
<p>功能上没什么可说的，只是创建个临时元素而已，下面用AI生成一份说明。</p>
</blockquote>
<p>该组件在 <code>useEffect</code> 中为 <code>article</code> 内的 <code>target="_blank"</code> 链接添加鼠标悬停事件。</p>
<ol>
<li>组件在挂载时使用 useEffect 设置了全局的 mouseover 和 mouseout 事件监听器。</li>
</ol>
<ul>
<li>当鼠标移到页面中的 article 内部的、target 为 "_blank" 的链接上时，会延时 300 毫秒后调用 showPreview 函数显示预览。</li>
<li>当鼠标移出时，清除定时器，并通过 hidePreview 函数隐藏预览。</li>
</ul>
<ol start="2">
<li>showPreview 函数：</li>
</ol>
<ul>
<li>通过 createPreview 创建一个预览元素，该元素是一个 div，并应用了一系列内联样式（从 styles 对象中转换而来）。</li>
<li>预览内容包括：显示链接的 hostname、完整 url 以及一个警告信息，提示用户这是外部链接。</li>
</ul>
<ol start="3">
<li>createPreview 函数：</li>
</ol>
<ul>
<li>创建预览元素，并设置 loading 状态，同时根据链接构造一个远程截图 API 的 URL 作为背景图片地址。</li>
<li>创建一个 Image 对象来加载背景图片，加载完成后在 canvas 上绘制图片，并采集中间区域的亮度信息。</li>
<li>根据图片亮度调整预览中各个部分的文字颜色和文字阴影，确保无论背景明暗都能清晰显示信息。</li>
<li>最后移除 loading 状态，并把背景图片赋值给预览元素。</li>
</ul>
<ol start="4">
<li>预览元素的位置计算：</li>
</ol>
<ul>
<li>根据触发预览的链接元素的边界信息，动态计算预览框的位置，保证不会超出屏幕左右边界。</li>
</ul>
<ol start="5">
<li>预览的显示和隐藏：</li>
</ol>
<ul>
<li>显示时通过 requestAnimationFrame 让预览元素渐显（opacity 从 0 变成 1）。</li>
<li>隐藏时先设置 opacity 为 0，然后在 300 毫秒后将预览元素从 DOM 中移除。</li>
</ul>
<ol start="6">
<li>清理工作：</li>
</ol>
<ul>
<li>在组件卸载时，移除所有事件监听器，并清理残留的预览元素，避免内存泄漏。</li>
</ul>]]></content:encoded>
            <author>RavelloH</author>
            <enclosure url="https://ravelloh.com/p/FunqNxAa8cmk" length="0" type="image//p/FunqNxAa8cmk"/>
            <contentPreview><![CDATA[前言 有的时候指向外链的超链接的显示文字可能根本不能让你了解到这是来源于哪个站点的，鼠标移上去看左下角又不太优雅，于是写了个这样的预览框。 效果 当鼠标悬停在 target="_blank" 的超链接上时，会出现一个带模糊背景的弹出窗口，显示网站的主域名和完整 URL，同时附带一个提醒，告知该链接为外部链接。同时会加载目标页面的截图，获取到截图后根据图片的中心色，自动选择字体颜色(黑/白)。 你也可以在下方这个外链上自己试试: RThe...]]></contentPreview>
        </item>
        <item>
            <title><![CDATA[记一次 Bing 登顶]]></title>
            <link>https://ravelloh.com/posts/bing-ranking-top-experience</link>
            <guid isPermaLink="false">https://ravelloh.com/posts/bing-ranking-top-experience</guid>
            <pubDate>Tue, 11 Feb 2025 07:17:02 GMT</pubDate>
            <description><![CDATA[对个人博客文章意外在 Bing 搜索结果中登顶的观察记录。作者通过分析流量统计发现来自 Bing 的访问激增，排查后确认为关于 Paper 端插件的文章获得了 Bing 搜索的置顶推荐，这一结果打破了作者对 .top 域名权重较低的固有印象]]></description>
            <content:encoded><![CDATA[<p class="feed-lead">前往 <a href="https://ravelloh.com/posts/bing-ranking-top-experience">https://ravelloh.com/posts/bing-ranking-top-experience</a> 查看以获得最佳体验。</p>
<p>最近在看访问统计的时候发现来自bing的流量增长了很多，看了一下发现是关于Paper端插件的文章的。</p>
<p><img src="/p/yAjxKyqfL9ud" alt="image.png"></p>
<p>于是开无痕去搜索了一下，发现已经被bing置顶了:</p>
<p><img src="/p/oC2MzNWZF3JZ" alt="image.png"></p>
<p>这个倒是确实挺令我意外的，毕竟top域名的权重应该挺低的才对。</p>]]></content:encoded>
            <author>RavelloH</author>
            <enclosure url="https://ravelloh.com/p/oC2MzNWZF3JZ" length="0" type="image//p/oC2MzNWZF3JZ"/>
            <contentPreview><![CDATA[最近在看访问统计的时候发现来自bing的流量增长了很多，看了一下发现是关于Paper端插件的文章的。 于是开无痕去搜索了一下，发现已经被bing置顶了: 这个倒是确实挺令我意外的，毕竟top域名的权重应该挺低的才对。]]></contentPreview>
        </item>
    </channel>
</rss>