Files
blog/posts/4c91a8f2/index.html
T
biss f839e19947 更新包
-- [CST 2026-08-04 Tuesday 16:05:05]
2026-08-04 16:05:05 +08:00

1132 lines
105 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html><html lang="zh-CN"> <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><meta name="description" content="这篇文章记录如何通过 Umami 的分享链接获取公开统计数据,再用 Cloudflare Worker 做一层缓存代理,最后把访客数和访问量显示到博客侧边栏的网站信息卡里。"><meta name="author" content="biss"><meta name="keywords" content="建站手札, 网站, Umami, Cloudflare Worker"><link rel="canonical" href="https://blog.biss.click/posts/4c91a8f2/"><link rel="alternate" type="application/rss+xml" title="Bi's Blog RSS" href="https://blog.biss.click/rss.xml"><meta property="og:site_name" content="Bi's Blog"><meta property="og:locale" content="zh_CN"><meta property="og:type" content="article"><meta property="og:title" content="用 Umami 分享链接给网站信息卡提供统计数据 | Bi's Blog"><meta property="og:description" content="这篇文章记录如何通过 Umami 的分享链接获取公开统计数据,再用 Cloudflare Worker 做一层缓存代理,最后把访客数和访问量显示到博客侧边栏的网站信息卡里。"><meta property="og:url" content="https://blog.biss.click/posts/4c91a8f2/"><meta property="og:image" content="https://free.picui.cn/free/2025/08/10/689845496a283.png"><meta property="og:image:alt" content="用 Umami 分享链接给网站信息卡提供统计数据"><meta name="twitter:card" content="summary_large_image"><meta name="twitter:title" content="用 Umami 分享链接给网站信息卡提供统计数据 | Bi's Blog"><meta name="twitter:description" content="这篇文章记录如何通过 Umami 的分享链接获取公开统计数据,再用 Cloudflare Worker 做一层缓存代理,最后把访客数和访问量显示到博客侧边栏的网站信息卡里。"><meta name="twitter:image" content="https://free.picui.cn/free/2025/08/10/689845496a283.png"><meta property="article:published_time" content="2026-06-19T08:58:00.000Z"><meta property="article:modified_time" content="2026-06-19T08:58:00.000Z"><meta property="article:author" content="biss"><meta property="article:tag" content="建站手札"><meta property="article:tag" content="网站"><meta property="article:tag" content="Umami"><meta property="article:tag" content="Cloudflare Worker"><script type="application/ld+json">{"@context":"https://schema.org","@type":"BlogPosting","headline":"用 Umami 分享链接给网站信息卡提供统计数据","description":"这篇文章记录如何通过 Umami 的分享链接获取公开统计数据,再用 Cloudflare Worker 做一层缓存代理,最后把访客数和访问量显示到博客侧边栏的网站信息卡里。","image":["https://free.picui.cn/free/2025/08/10/689845496a283.png"],"datePublished":"2026-06-19T08:58:00.000Z","dateModified":"2026-06-19T08:58:00.000Z","wordCount":1646,"inLanguage":"zh-CN","mainEntityOfPage":{"@type":"WebPage","@id":"https://blog.biss.click/posts/4c91a8f2/"},"author":{"@type":"Person","name":"biss","url":"https://github.com/bishshi"},"publisher":{"@type":"Person","name":"biss","image":"https://free.picui.cn/free/2025/08/10/689845496a283.png"},"keywords":"建站手札, 网站, Umami, Cloudflare Worker","articleSection":"建站手札"}</script><link rel="icon" href="/images/Bi.ico"><link rel="manifest" href="/manifest.webmanifest"><link rel="apple-touch-icon" href="/images/apple-touch-icon.png"><meta name="theme-color" content="#f7f5ef" media="(prefers-color-scheme: light)"><meta name="theme-color" content="#0b1014" media="(prefers-color-scheme: dark)"><script defer src="https://umami.biss.click/script.js" data-website-id="3d97b310-b241-4fc6-bc9c-68e317fc7d42"></script><script>window.SITE_CONFIG = {"site":{"url":"https://blog.biss.click","name":"Bi's Blog","description":"Personal blog by biss.","language":"zh-CN","domain":"blog.biss.click","slogan":"好奇心转化为代码的空间。","startTime":"2025-01-01T00:00:00+08:00","copyrightStartYear":2025,"license":"内容采用 CC BY-NC-SA 4.0 协议"},"author":{"name":"biss","email":"bishsh2006@gmail.com","github":"https://github.com/bishshi","avatar":"https://free.picui.cn/free/2025/08/10/689845496a283.png"},"comments":{"envId":"https://comment.biss.click","region":"","script":"https://cdn.jsdelivr.net/npm/twikoo@1.7.7/dist/twikoo.min.js"},"analytics":{"umami":{"script":"https://umami.biss.click/script.js","websiteId":"3d97b310-b241-4fc6-bc9c-68e317fc7d42","statsEndpoint":"https://blogumami.biss.click"}},"talks":{"apiUrl":"https://mm.biss.click/api/echo/page","pageSize":30,"homeLimit":6,"cacheKey":"talksCache","cacheTimeKey":"talksCacheTime","cacheDurationMs":1800000,"fallbackAvatar":"https://free.picui.cn/free/2025/08/10/689845496a283.png"},"search":{"apiKey":"2uxQzk7eD2GBCljdSmIiuU3ause7UK9n","server":{"host":"typesense.biss.click","port":"443","protocol":"https"},"indexName":"blogs","searchParams":{"query_by":"title,content","highlight_full_fields":"title,content","per_page":8,"num_typos":1,"typo_tokens_threshold":1,"prefix":true},"ui":{"maxRetries":10,"retryDelay":100,"animationDuration":300,"maxInitRetries":30,"placeholder":"输入关键词寻找故事..."}},"externalLinks":{"waitSeconds":10,"whitelist":["blog.biss.click","biss.click","github.com","localhost","127.0.0.1"]}};</script><script>
(() => {
const storageKey = 'site-theme';
const media = window.matchMedia('(prefers-color-scheme: dark)');
const getSystemTheme = () => (media.matches ? 'dark' : 'light');
const getSavedTheme = () => {
try {
const theme = localStorage.getItem(storageKey);
return theme === 'dark' || theme === 'light' ? theme : null;
} catch {
return null;
}
};
document.documentElement.dataset.theme = getSavedTheme() || getSystemTheme();
})();
</script><link rel="stylesheet" href="https://cdn.jsdmirror.com/npm/instantsearch.css/themes/reset-min.css"><link rel="stylesheet" href="https://cdn.jsdmirror.com/npm/@fancyapps/ui@5.0/dist/fancybox/fancybox.css"><link rel="stylesheet" href="https://cdn.jsdmirror.com/npm/@fortawesome/fontawesome-free@6.7.2/css/all.min.css"><title>用 Umami 分享链接给网站信息卡提供统计数据 | Bi&#39;s Blog</title><link rel="stylesheet" href="/_astro/BaseLayout.DvPRLM9I.css"></head> <body> <header class="site-header"> <nav class="nav"> <a class="brand" href="/" aria-label="Bi's Blog 首页"><span>biss</span><i>.log</i></a> <button class="nav-menu-toggle" type="button" aria-label="打开菜单" aria-expanded="false" data-nav-toggle> <i class="fa-solid fa-bars nav-menu-open" aria-hidden="true"></i> <i class="fa-solid fa-xmark nav-menu-close" aria-hidden="true"></i> </button> <div class="nav-drawer-backdrop" data-nav-backdrop></div> <div class="nav-menu" data-nav-menu> <div class="nav-links nav-center"> <a href="/archives/"><i class="fa-solid fa-box-archive nav-icon" aria-hidden="true"></i><span>归档</span></a><a href="/categories/"><i class="fa-solid fa-folder-open nav-icon" aria-hidden="true"></i><span>分类</span></a><a href="/tags/"><i class="fa-solid fa-tags nav-icon" aria-hidden="true"></i><span>标签</span></a><a href="/shuoshuo/"><i class="fa-regular fa-comments nav-icon" aria-hidden="true"></i><span>说说</span></a><a href="/about/"><i class="fa-solid fa-heart nav-icon" aria-hidden="true"></i><span>关于</span></a><a href="/link/"><i class="fa-solid fa-link nav-icon" aria-hidden="true"></i><span>友链</span></a> </div> <div class="nav-links nav-actions"> <button class="nav-random" type="button" onclick="randomPost()" aria-label="前往随机文章" title="前往随机文章"> <i class="fa-solid fa-shuffle nav-icon" aria-hidden="true"></i> <span>随机文章</span> </button> <button class="nav-search search-typesense-trigger" type="button" aria-label="搜索" title="搜索"> <i class="fa-solid fa-magnifying-glass nav-icon" aria-hidden="true"></i> <span>搜索</span> </button> <button class="nav-console" type="button" aria-label="打开中控台" title="中控台" data-console-open> <i class="fa-solid fa-gauge-high nav-icon" aria-hidden="true"></i> <span>中控台</span> </button> <button class="theme-toggle" type="button" aria-label="切换夜间模式" title="切换夜间模式" data-theme-toggle> <i class="fa-solid fa-sun theme-toggle-sun nav-icon" aria-hidden="true"></i> <i class="fa-solid fa-moon theme-toggle-moon nav-icon" aria-hidden="true"></i> </button> </div> </div> </nav> </header> <div class="control-console" data-console-modal aria-hidden="true"> <div class="control-console-backdrop" data-console-close></div> <section class="control-console-panel" role="dialog" aria-modal="true" aria-labelledby="control-console-title" tabindex="-1"> <header class="control-console-head"> <div> <p>CONTROL CENTER</p> <h2 id="control-console-title">中控台</h2> </div> <button class="control-console-close" type="button" aria-label="关闭中控台" title="关闭" data-console-close> <i class="fa-solid fa-xmark" aria-hidden="true"></i> </button> </header> <div class="control-console-grid"> <a class="control-site-card" href="https://biss.click" target="_blank" rel="noreferrer"> <i class="fa-solid fa-house-chimney" aria-hidden="true"></i> <span> <strong>主站入口</strong> <em>个人主页与常用入口集合</em> </span> <small>biss.click</small> </a><a class="control-site-card" href="https://mm.biss.click" target="_blank" rel="noreferrer"> <i class="fa-regular fa-comments" aria-hidden="true"></i> <span> <strong>说说动态</strong> <em>短句、碎片记录和近况更新</em> </span> <small>mm.biss.click</small> </a><a class="control-site-card" href="https://comment.biss.click" target="_blank" rel="noreferrer"> <i class="fa-solid fa-message" aria-hidden="true"></i> <span> <strong>评论系统</strong> <em>Twikoo 评论服务与访客互动</em> </span> <small>comment.biss.click</small> </a><a class="control-site-card" href="https://umami.biss.click" target="_blank" rel="noreferrer"> <i class="fa-solid fa-chart-line" aria-hidden="true"></i> <span> <strong>站点统计</strong> <em>Umami 访问统计与站点数据</em> </span> <small>umami.biss.click</small> </a> </div> <div class="control-console-info" aria-label="站点信息"> <section> <span>当前站点</span> <strong>blog.biss.click</strong> </section> <section> <span>站点作者</span> <strong>biss</strong> </section> <section> <span>运行起点</span> <strong>2025-01-01</strong> </section> </div> </section> </div> <main> <div class="reading-progress" aria-hidden="true"><span data-reading-progress></span></div> <aside class="reading-resume" data-reading-resume hidden aria-live="polite"> <div class="reading-resume-icon" aria-hidden="true"><i class="fa-solid fa-book-open"></i></div> <div> <strong>继续上次阅读?</strong> <p>你上次读到了 <span data-reading-resume-percent>--</span></p> </div> <div class="reading-resume-actions"> <button type="button" data-reading-continue>继续阅读</button> <button type="button" data-reading-restart>从头开始</button> </div> </aside> <section class="post-layout"> <article class="article post-article"> <header class="article-header"> <div class="article-header-content"> <div class="article-date-line"> <time datetime="2026-06-19T08:58:00.000Z">发布于 2026/06/19</time> </div> <h1>用 Umami 分享链接给网站信息卡提供统计数据</h1> <div class="meta-list"> <a href="/categories/site-notes/">建站手札</a> <a href="/tags/%E7%BD%91%E7%AB%99/">#网站</a><a href="/tags/Umami/">#Umami</a><a href="/tags/Cloudflare%20Worker/">#Cloudflare Worker</a> </div> </div> </header> <section class="article-summary-card" aria-label="文章导读"> <div class="article-summary-header"> <span class="article-summary-brand"> <span class="article-summary-icon" aria-hidden="true"></span>
文章导读
</span> <span class="article-summary-model">摘要由 Qwen 生成</span> </div> <p class="article-summary-content"> <span class="article-summary-text">这篇文章记录如何通过 Umami 的分享链接获取公开统计数据,再用 Cloudflare Worker 做一层缓存代理,最后把访客数和访问量显示到博客侧边栏的网站信息卡里。</span> </p> </section> <aside class="butterfly-note butterfly-note--warning article-outdated-note" aria-label="文章时效性提示" data-freshness-at="2026-06-19T08:58:00.000Z" data-has-new-version="false" hidden> <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i> <div class="butterfly-note__content"> <p>
注意:本文最后维护于 2026/06/19,距今已超过一年,部分内容可能已经过时,请结合最新资料判断。
</p> </div> </aside> <div class="prose" data-toc="[{&quot;depth&quot;:2,&quot;text&quot;:&quot;整体思路&quot;,&quot;id&quot;:&quot;整体思路&quot;},{&quot;depth&quot;:2,&quot;text&quot;:&quot;创建 Umami 分享链接&quot;,&quot;id&quot;:&quot;创建-umami-分享链接&quot;},{&quot;depth&quot;:2,&quot;text&quot;:&quot;分享链接背后的请求&quot;,&quot;id&quot;:&quot;分享链接背后的请求&quot;},{&quot;depth&quot;:2,&quot;text&quot;:&quot;编写 Cloudflare Worker&quot;,&quot;id&quot;:&quot;编写-cloudflare-worker&quot;},{&quot;depth&quot;:2,&quot;text&quot;:&quot;配置 Worker 变量&quot;,&quot;id&quot;:&quot;配置-worker-变量&quot;},{&quot;depth&quot;:2,&quot;text&quot;:&quot;部署方式&quot;,&quot;id&quot;:&quot;部署方式&quot;},{&quot;depth&quot;:2,&quot;text&quot;:&quot;测试 Worker&quot;,&quot;id&quot;:&quot;测试-worker&quot;},{&quot;depth&quot;:2,&quot;text&quot;:&quot;接入网站信息卡&quot;,&quot;id&quot;:&quot;接入网站信息卡&quot;},{&quot;depth&quot;:2,&quot;text&quot;:&quot;最后&quot;,&quot;id&quot;:&quot;最后&quot;}]"> <p>最近给博客侧边栏加了一个“网站信息”卡片,里面除了文章数量、总字数、最后更新时间,还想放上本站访客数和访问量。</p>
<p>一开始想到的是直接调用 Umami API,但现在的 Umami 后台里不一定能很直观地找到 API key。如果为了这两个数字再专门创建一个用户、保存账号密码,又有点重。后来发现 Umami 自带的“分享链接”刚好可以解决这个问题。</p>
<p>这篇文章记录一下完整做法:用 Umami 分享链接拿公开 token,再用 Cloudflare Worker 请求统计接口并缓存,最后给前端信息卡使用。</p>
<h2 id="整体思路">整体思路</h2>
<p>流程大概是这样:</p>
<ol>
<li>在 Umami 后台给站点创建分享链接。</li>
<li>Worker 请求 <code>/api/share/{shareId}</code>,拿到 <code>websiteId</code> 和分享 token。</li>
<li>Worker 带上分享 token 请求 <code>/api/websites/{websiteId}/stats</code></li>
<li>Worker 把结果整理成前端需要的 JSON,并用 Cloudflare 缓存 10 分钟。</li>
<li>博客前端请求 Worker 地址,把访客数和访问量填到信息卡。</li>
</ol>
<p>这样做的好处是不用暴露后台账号,也不用在前端直接请求 Umami。前端只会看到 Worker 返回的简洁数据。</p>
<h2 id="创建-umami-分享链接">创建 Umami 分享链接</h2>
<p>进入 Umami 后台,找到对应网站的分享设置,打开需要公开的模块。我这里只需要概览数据,所以勾选“概览”就够了。</p>
<p>保存后会得到一个类似这样的分享链接:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;" tabindex="0" data-language="txt"><code><span class="line"><span>https://umami.example.com/share/Hqx6lIBhIHBR13RY</span></span></code></pre>
<p>最后这一段 <code>Hqx6lIBhIHBR13RY</code> 就是分享 ID,后面 Worker 会用到。</p>
<h2 id="分享链接背后的请求">分享链接背后的请求</h2>
<p>分享页本身不是一个静态 HTML 页面,直接读取页面源码一般拿不到统计数字。它会先请求:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;" tabindex="0" data-language="txt"><code><span class="line"><span>/api/share/Hqx6lIBhIHBR13RY</span></span></code></pre>
<p>这个接口会返回类似这样的数据:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;" tabindex="0" data-language="json"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF"> "shareId"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">"..."</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF"> "shareType"</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF"> "websiteId"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">"3d97b310-b241-4fc6-bc9c-68e317fc7d42"</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF"> "token"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">"..."</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>然后访问统计接口时,需要带两个请求头:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;" tabindex="0" data-language="txt"><code><span class="line"><span>x-umami-share-context: 1</span></span>
<span class="line"><span>x-umami-share-token: 上一步返回的 token</span></span></code></pre>
<p>统计接口是:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;" tabindex="0" data-language="txt"><code><span class="line"><span>/api/websites/{websiteId}/stats?startAt=0&#x26;endAt=当前时间戳</span></span></code></pre>
<p>返回结果大概是:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;" tabindex="0" data-language="json"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF"> "pageviews"</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">2</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF"> "visitors"</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF"> "visits"</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF"> "bounces"</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">0</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF"> "totaltime"</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">470</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>其中:</p>
<ul>
<li><code>visitors</code> 是访客数</li>
<li><code>pageviews</code> 是页面访问量</li>
<li><code>visits</code> 是访问会话数</li>
</ul>
<p>网站信息卡里我只用 <code>visitors</code><code>pageviews</code></p>
<h2 id="编写-cloudflare-worker">编写 Cloudflare Worker</h2>
<p>新建一个 Worker,把下面这份代码放进去:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;" tabindex="0" data-language="js"><code><span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> DEFAULT_CACHE_TTL_SECONDS</span><span style="color:#F97583"> =</span><span style="color:#79B8FF"> 600</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">function</span><span style="color:#B392F0"> jsonResponse</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">data</span><span style="color:#E1E4E8">, </span><span style="color:#FFAB70">init</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> {}) {</span></span>
<span class="line"><span style="color:#F97583"> return</span><span style="color:#F97583"> new</span><span style="color:#B392F0"> Response</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">JSON</span><span style="color:#E1E4E8">.</span><span style="color:#B392F0">stringify</span><span style="color:#E1E4E8">(data), {</span></span>
<span class="line"><span style="color:#F97583"> ...</span><span style="color:#E1E4E8">init,</span></span>
<span class="line"><span style="color:#E1E4E8"> headers: {</span></span>
<span class="line"><span style="color:#9ECBFF"> 'Content-Type'</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">'application/json; charset=utf-8'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#F97583"> ...</span><span style="color:#E1E4E8">init.headers,</span></span>
<span class="line"><span style="color:#E1E4E8"> },</span></span>
<span class="line"><span style="color:#E1E4E8"> });</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">function</span><span style="color:#B392F0"> getCorsHeaders</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">request</span><span style="color:#E1E4E8">, </span><span style="color:#FFAB70">env</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> origin</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> request.headers.</span><span style="color:#B392F0">get</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'Origin'</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">||</span><span style="color:#9ECBFF"> ''</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> allowedOrigin</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> env.</span><span style="color:#79B8FF">ALLOWED_ORIGIN</span><span style="color:#F97583"> ||</span><span style="color:#9ECBFF"> 'https://blog.example.com'</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> allowOrigin</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> origin </span><span style="color:#F97583">===</span><span style="color:#E1E4E8"> allowedOrigin </span><span style="color:#F97583">?</span><span style="color:#E1E4E8"> origin </span><span style="color:#F97583">:</span><span style="color:#E1E4E8"> allowedOrigin;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583"> return</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#9ECBFF"> 'Access-Control-Allow-Origin'</span><span style="color:#E1E4E8">: allowOrigin,</span></span>
<span class="line"><span style="color:#9ECBFF"> 'Access-Control-Allow-Methods'</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">'GET, OPTIONS'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#9ECBFF"> 'Access-Control-Allow-Headers'</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">'Content-Type'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> Vary: </span><span style="color:#9ECBFF">'Origin'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> };</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">function</span><span style="color:#B392F0"> normalizeBaseUrl</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">value</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#F97583"> return</span><span style="color:#B392F0"> String</span><span style="color:#E1E4E8">(value </span><span style="color:#F97583">||</span><span style="color:#9ECBFF"> 'https://umami.example.com'</span><span style="color:#E1E4E8">).</span><span style="color:#B392F0">replace</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">/</span><span style="color:#85E89D;font-weight:bold">\/</span><span style="color:#F97583">+$</span><span style="color:#9ECBFF">/</span><span style="color:#E1E4E8">, </span><span style="color:#9ECBFF">''</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">function</span><span style="color:#B392F0"> getShareId</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">env</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (env.</span><span style="color:#79B8FF">UMAMI_SHARE_ID</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">return</span><span style="color:#E1E4E8"> env.</span><span style="color:#79B8FF">UMAMI_SHARE_ID</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">env.</span><span style="color:#79B8FF">UMAMI_SHARE_URL</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">return</span><span style="color:#9ECBFF"> ''</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583"> try</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> url</span><span style="color:#F97583"> =</span><span style="color:#F97583"> new</span><span style="color:#B392F0"> URL</span><span style="color:#E1E4E8">(env.</span><span style="color:#79B8FF">UMAMI_SHARE_URL</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> parts</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> url.pathname.</span><span style="color:#B392F0">split</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'/'</span><span style="color:#E1E4E8">).</span><span style="color:#B392F0">filter</span><span style="color:#E1E4E8">(Boolean);</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> shareIndex</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> parts.</span><span style="color:#B392F0">indexOf</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'share'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#F97583"> return</span><span style="color:#E1E4E8"> shareIndex </span><span style="color:#F97583">>=</span><span style="color:#79B8FF"> 0</span><span style="color:#F97583"> ?</span><span style="color:#E1E4E8"> parts[shareIndex </span><span style="color:#F97583">+</span><span style="color:#79B8FF"> 1</span><span style="color:#E1E4E8">] </span><span style="color:#F97583">||</span><span style="color:#9ECBFF"> ''</span><span style="color:#F97583"> :</span><span style="color:#E1E4E8"> parts.</span><span style="color:#B392F0">at</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">-</span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">||</span><span style="color:#9ECBFF"> ''</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8"> } </span><span style="color:#F97583">catch</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#F97583"> return</span><span style="color:#E1E4E8"> env.</span><span style="color:#79B8FF">UMAMI_SHARE_URL</span><span style="color:#E1E4E8">.</span><span style="color:#B392F0">split</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'/'</span><span style="color:#E1E4E8">).</span><span style="color:#B392F0">filter</span><span style="color:#E1E4E8">(Boolean).</span><span style="color:#B392F0">at</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">-</span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">||</span><span style="color:#9ECBFF"> ''</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">async</span><span style="color:#F97583"> function</span><span style="color:#B392F0"> getShareData</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">env</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> baseUrl</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> normalizeBaseUrl</span><span style="color:#E1E4E8">(env.</span><span style="color:#79B8FF">UMAMI_BASE_URL</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> shareId</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> getShareId</span><span style="color:#E1E4E8">(env);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">shareId) {</span></span>
<span class="line"><span style="color:#F97583"> throw</span><span style="color:#F97583"> new</span><span style="color:#B392F0"> Error</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'Missing UMAMI_SHARE_ID or UMAMI_SHARE_URL'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> response</span><span style="color:#F97583"> =</span><span style="color:#F97583"> await</span><span style="color:#B392F0"> fetch</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">`${</span><span style="color:#E1E4E8">baseUrl</span><span style="color:#9ECBFF">}/api/share/${</span><span style="color:#E1E4E8">shareId</span><span style="color:#9ECBFF">}`</span><span style="color:#E1E4E8">, {</span></span>
<span class="line"><span style="color:#E1E4E8"> headers: { Accept: </span><span style="color:#9ECBFF">'application/json'</span><span style="color:#E1E4E8"> },</span></span>
<span class="line"><span style="color:#E1E4E8"> });</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">response.ok) {</span></span>
<span class="line"><span style="color:#F97583"> throw</span><span style="color:#F97583"> new</span><span style="color:#B392F0"> Error</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">`Umami share lookup failed: ${</span><span style="color:#E1E4E8">response</span><span style="color:#9ECBFF">.</span><span style="color:#E1E4E8">status</span><span style="color:#9ECBFF">}`</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> data</span><span style="color:#F97583"> =</span><span style="color:#F97583"> await</span><span style="color:#E1E4E8"> response.</span><span style="color:#B392F0">json</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">data.websiteId </span><span style="color:#F97583">||</span><span style="color:#F97583"> !</span><span style="color:#E1E4E8">data.token) {</span></span>
<span class="line"><span style="color:#F97583"> throw</span><span style="color:#F97583"> new</span><span style="color:#B392F0"> Error</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'Umami share response did not include websiteId or token'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583"> return</span><span style="color:#E1E4E8"> data;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">async</span><span style="color:#F97583"> function</span><span style="color:#B392F0"> fetchUmamiStats</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">env</span><span style="color:#E1E4E8">, </span><span style="color:#FFAB70">shareData</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> baseUrl</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> normalizeBaseUrl</span><span style="color:#E1E4E8">(env.</span><span style="color:#79B8FF">UMAMI_BASE_URL</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> websiteId</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> env.</span><span style="color:#79B8FF">UMAMI_WEBSITE_ID</span><span style="color:#F97583"> ||</span><span style="color:#E1E4E8"> shareData.websiteId;</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> startAt</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> Number</span><span style="color:#E1E4E8">(env.</span><span style="color:#79B8FF">STATS_START_AT</span><span style="color:#F97583"> ||</span><span style="color:#79B8FF"> 0</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> endAt</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> Date.</span><span style="color:#B392F0">now</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> url</span><span style="color:#F97583"> =</span><span style="color:#9ECBFF"> `${</span><span style="color:#E1E4E8">baseUrl</span><span style="color:#9ECBFF">}/api/websites/${</span><span style="color:#E1E4E8">websiteId</span><span style="color:#9ECBFF">}/stats?startAt=${</span><span style="color:#E1E4E8">startAt</span><span style="color:#9ECBFF">}&#x26;endAt=${</span><span style="color:#E1E4E8">endAt</span><span style="color:#9ECBFF">}`</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583"> return</span><span style="color:#B392F0"> fetch</span><span style="color:#E1E4E8">(url, {</span></span>
<span class="line"><span style="color:#E1E4E8"> headers: {</span></span>
<span class="line"><span style="color:#E1E4E8"> Accept: </span><span style="color:#9ECBFF">'application/json'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#9ECBFF"> 'x-umami-share-context'</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">'1'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#9ECBFF"> 'x-umami-share-token'</span><span style="color:#E1E4E8">: shareData.token,</span></span>
<span class="line"><span style="color:#E1E4E8"> },</span></span>
<span class="line"><span style="color:#E1E4E8"> });</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">async</span><span style="color:#F97583"> function</span><span style="color:#B392F0"> getStats</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">env</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> shareData</span><span style="color:#F97583"> =</span><span style="color:#F97583"> await</span><span style="color:#B392F0"> getShareData</span><span style="color:#E1E4E8">(env);</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> response</span><span style="color:#F97583"> =</span><span style="color:#F97583"> await</span><span style="color:#B392F0"> fetchUmamiStats</span><span style="color:#E1E4E8">(env, shareData);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">response.ok) {</span></span>
<span class="line"><span style="color:#F97583"> throw</span><span style="color:#F97583"> new</span><span style="color:#B392F0"> Error</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">`Umami stats failed: ${</span><span style="color:#E1E4E8">response</span><span style="color:#9ECBFF">.</span><span style="color:#E1E4E8">status</span><span style="color:#9ECBFF">}`</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> stats</span><span style="color:#F97583"> =</span><span style="color:#F97583"> await</span><span style="color:#E1E4E8"> response.</span><span style="color:#B392F0">json</span><span style="color:#E1E4E8">();</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583"> return</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8"> visitors: stats.visitors </span><span style="color:#F97583">??</span><span style="color:#79B8FF"> 0</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> pageviews: stats.pageviews </span><span style="color:#F97583">??</span><span style="color:#79B8FF"> 0</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> visits: stats.visits </span><span style="color:#F97583">??</span><span style="color:#79B8FF"> 0</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> updatedAt: Date.</span><span style="color:#B392F0">now</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8"> };</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#F97583"> async</span><span style="color:#B392F0"> fetch</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">request</span><span style="color:#E1E4E8">, </span><span style="color:#FFAB70">env</span><span style="color:#E1E4E8">, </span><span style="color:#FFAB70">ctx</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> corsHeaders</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> getCorsHeaders</span><span style="color:#E1E4E8">(request, env);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (request.method </span><span style="color:#F97583">===</span><span style="color:#9ECBFF"> 'OPTIONS'</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#F97583"> return</span><span style="color:#F97583"> new</span><span style="color:#B392F0"> Response</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">null</span><span style="color:#E1E4E8">, { status: </span><span style="color:#79B8FF">204</span><span style="color:#E1E4E8">, headers: corsHeaders });</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (request.method </span><span style="color:#F97583">!==</span><span style="color:#9ECBFF"> 'GET'</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#F97583"> return</span><span style="color:#B392F0"> jsonResponse</span><span style="color:#E1E4E8">({ error: </span><span style="color:#9ECBFF">'Method not allowed'</span><span style="color:#E1E4E8"> }, { status: </span><span style="color:#79B8FF">405</span><span style="color:#E1E4E8">, headers: corsHeaders });</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> cacheTtl</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> Number</span><span style="color:#E1E4E8">(env.</span><span style="color:#79B8FF">CACHE_TTL_SECONDS</span><span style="color:#F97583"> ||</span><span style="color:#79B8FF"> DEFAULT_CACHE_TTL_SECONDS</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> cache</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> caches.default;</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> cacheKey</span><span style="color:#F97583"> =</span><span style="color:#F97583"> new</span><span style="color:#B392F0"> Request</span><span style="color:#E1E4E8">(</span></span>
<span class="line"><span style="color:#9ECBFF"> `https://site-stats-cache.local/umami-site-stats/${</span><span style="color:#B392F0">getShareId</span><span style="color:#9ECBFF">(</span><span style="color:#E1E4E8">env</span><span style="color:#9ECBFF">) </span><span style="color:#F97583">||</span><span style="color:#9ECBFF"> 'default'}`</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> );</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> cached</span><span style="color:#F97583"> =</span><span style="color:#F97583"> await</span><span style="color:#E1E4E8"> cache.</span><span style="color:#B392F0">match</span><span style="color:#E1E4E8">(cacheKey);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (cached) {</span></span>
<span class="line"><span style="color:#F97583"> return</span><span style="color:#F97583"> new</span><span style="color:#B392F0"> Response</span><span style="color:#E1E4E8">(cached.body, {</span></span>
<span class="line"><span style="color:#E1E4E8"> status: cached.status,</span></span>
<span class="line"><span style="color:#E1E4E8"> headers: {</span></span>
<span class="line"><span style="color:#F97583"> ...</span><span style="color:#E1E4E8">Object.</span><span style="color:#B392F0">fromEntries</span><span style="color:#E1E4E8">(cached.headers),</span></span>
<span class="line"><span style="color:#F97583"> ...</span><span style="color:#E1E4E8">corsHeaders,</span></span>
<span class="line"><span style="color:#9ECBFF"> 'X-Stats-Cache'</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">'HIT'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> },</span></span>
<span class="line"><span style="color:#E1E4E8"> });</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583"> try</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> stats</span><span style="color:#F97583"> =</span><span style="color:#F97583"> await</span><span style="color:#B392F0"> getStats</span><span style="color:#E1E4E8">(env);</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> response</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> jsonResponse</span><span style="color:#E1E4E8">(stats, {</span></span>
<span class="line"><span style="color:#E1E4E8"> headers: {</span></span>
<span class="line"><span style="color:#F97583"> ...</span><span style="color:#E1E4E8">corsHeaders,</span></span>
<span class="line"><span style="color:#9ECBFF"> 'Cache-Control'</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">`public, max-age=${</span><span style="color:#E1E4E8">cacheTtl</span><span style="color:#9ECBFF">}`</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#9ECBFF"> 'X-Stats-Cache'</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">'MISS'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> },</span></span>
<span class="line"><span style="color:#E1E4E8"> });</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8"> ctx.</span><span style="color:#B392F0">waitUntil</span><span style="color:#E1E4E8">(cache.</span><span style="color:#B392F0">put</span><span style="color:#E1E4E8">(cacheKey, response.</span><span style="color:#B392F0">clone</span><span style="color:#E1E4E8">()));</span></span>
<span class="line"><span style="color:#F97583"> return</span><span style="color:#E1E4E8"> response;</span></span>
<span class="line"><span style="color:#E1E4E8"> } </span><span style="color:#F97583">catch</span><span style="color:#E1E4E8"> (error) {</span></span>
<span class="line"><span style="color:#F97583"> return</span><span style="color:#B392F0"> jsonResponse</span><span style="color:#E1E4E8">(</span></span>
<span class="line"><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8"> error: </span><span style="color:#9ECBFF">'Failed to fetch stats'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> message: error </span><span style="color:#F97583">instanceof</span><span style="color:#B392F0"> Error</span><span style="color:#F97583"> ?</span><span style="color:#E1E4E8"> error.message </span><span style="color:#F97583">:</span><span style="color:#B392F0"> String</span><span style="color:#E1E4E8">(error),</span></span>
<span class="line"><span style="color:#E1E4E8"> },</span></span>
<span class="line"><span style="color:#E1E4E8"> { status: </span><span style="color:#79B8FF">502</span><span style="color:#E1E4E8">, headers: corsHeaders },</span></span>
<span class="line"><span style="color:#E1E4E8"> );</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8"> },</span></span>
<span class="line"><span style="color:#E1E4E8">};</span></span></code></pre>
<h2 id="配置-worker-变量">配置 Worker 变量</h2>
<p>在 Cloudflare Worker 的设置里添加变量:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;" tabindex="0" data-language="txt"><code><span class="line"><span>UMAMI_BASE_URL = https://umami.example.com</span></span>
<span class="line"><span>UMAMI_SHARE_ID = Hqx6lIBhIHBR13RY</span></span>
<span class="line"><span>ALLOWED_ORIGIN = https://blog.example.com</span></span>
<span class="line"><span>CACHE_TTL_SECONDS = 600</span></span></code></pre>
<p>如果你更想直接填完整分享链接,也可以不用 <code>UMAMI_SHARE_ID</code>,改用:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;" tabindex="0" data-language="txt"><code><span class="line"><span>UMAMI_SHARE_URL = https://umami.example.com/share/Hqx6lIBhIHBR13RY</span></span></code></pre>
<p><code>CACHE_TTL_SECONDS = 600</code> 表示缓存 10 分钟。这样访客每次打开页面时,不会都去打 Umami 接口。</p>
<p>如果你只想统计某个建站时间之后的数据,可以额外设置:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;" tabindex="0" data-language="txt"><code><span class="line"><span>STATS_START_AT = 1735660800000</span></span></code></pre>
<p>这个值是毫秒时间戳。不设置的话默认从 <code>0</code> 开始,也就是统计全部数据。</p>
<h2 id="部署方式">部署方式</h2>
<p>如果只是单个 Worker,最简单的方法是在 Cloudflare 后台新建 Worker,然后把 JS 代码直接粘到在线编辑器里。</p>
<p>如果遇到这样的提示:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;" tabindex="0" data-language="txt"><code><span class="line"><span>此上传程序暂不支持需要构建过程的项目。至少找到了一个 JavaScript 文件。请改用 wrangler deploy 以获得完整功能支持。</span></span></code></pre>
<p>通常是因为你上传了整个项目目录。这个场景不需要上传整个博客项目,只需要把 Worker 的 JS 内容粘进去即可。</p>
<p>如果想用 Wrangler,也可以写一个配置文件:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;" tabindex="0" data-language="toml"><code><span class="line"><span style="color:#E1E4E8">name = </span><span style="color:#9ECBFF">"umami-site-stats"</span></span>
<span class="line"><span style="color:#E1E4E8">main = </span><span style="color:#9ECBFF">"umami-site-stats.js"</span></span>
<span class="line"><span style="color:#E1E4E8">compatibility_date = </span><span style="color:#9ECBFF">"2026-06-19"</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">[</span><span style="color:#B392F0">vars</span><span style="color:#E1E4E8">]</span></span>
<span class="line"><span style="color:#E1E4E8">UMAMI_BASE_URL = </span><span style="color:#9ECBFF">"https://umami.example.com"</span></span>
<span class="line"><span style="color:#E1E4E8">UMAMI_SHARE_ID = </span><span style="color:#9ECBFF">"Hqx6lIBhIHBR13RY"</span></span>
<span class="line"><span style="color:#E1E4E8">ALLOWED_ORIGIN = </span><span style="color:#9ECBFF">"https://blog.example.com"</span></span>
<span class="line"><span style="color:#E1E4E8">CACHE_TTL_SECONDS = </span><span style="color:#9ECBFF">"600"</span></span></code></pre>
<p>然后执行:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">wrangler</span><span style="color:#9ECBFF"> deploy</span></span></code></pre>
<h2 id="测试-worker">测试 Worker</h2>
<p>部署好之后,直接访问 Worker 地址,正常会看到:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;" tabindex="0" data-language="json"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF"> "visitors"</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF"> "pageviews"</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">2</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF"> "visits"</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF"> "updatedAt"</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">1781829905467</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>如果返回 502,可以先看 <code>message</code> 字段。常见原因有:</p>
<ul>
<li><code>UMAMI_SHARE_ID</code> 写错</li>
<li><code>UMAMI_BASE_URL</code> 写错</li>
<li>分享链接没有保存成功</li>
<li>Umami 的分享权限里没有勾选“概览”</li>
</ul>
<h2 id="接入网站信息卡">接入网站信息卡</h2>
<p>前端信息卡里,静态数据可以直接由博客生成:</p>
<ul>
<li>文章数目:读取文章列表长度</li>
<li>本站总字数:遍历文章内容统计</li>
<li>最后更新时间:取最新文章发布时间</li>
</ul>
<p>访客数和访问量则用 Worker 动态填充。页面里先渲染占位:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;" tabindex="0" data-language="html"><code><span class="line"><span style="color:#E1E4E8">&#x3C;</span><span style="color:#85E89D">dd</span><span style="color:#B392F0"> data-stat-key</span><span style="color:#E1E4E8">=</span><span style="color:#9ECBFF">"visitors"</span><span style="color:#E1E4E8">>--&#x3C;/</span><span style="color:#85E89D">dd</span><span style="color:#E1E4E8">></span></span>
<span class="line"><span style="color:#E1E4E8">&#x3C;</span><span style="color:#85E89D">dd</span><span style="color:#B392F0"> data-stat-key</span><span style="color:#E1E4E8">=</span><span style="color:#9ECBFF">"pageviews"</span><span style="color:#E1E4E8">>--&#x3C;/</span><span style="color:#85E89D">dd</span><span style="color:#E1E4E8">></span></span></code></pre>
<p>再用 JS 请求 Worker</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;" tabindex="0" data-language="js"><code><span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> endpoint</span><span style="color:#F97583"> =</span><span style="color:#9ECBFF"> 'https://your-worker.example.workers.dev'</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> formatter</span><span style="color:#F97583"> =</span><span style="color:#F97583"> new</span><span style="color:#E1E4E8"> Intl.</span><span style="color:#B392F0">NumberFormat</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'en-US'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">fetch</span><span style="color:#E1E4E8">(endpoint, { headers: { Accept: </span><span style="color:#9ECBFF">'application/json'</span><span style="color:#E1E4E8"> } })</span></span>
<span class="line"><span style="color:#E1E4E8"> .</span><span style="color:#B392F0">then</span><span style="color:#E1E4E8">((</span><span style="color:#FFAB70">response</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> response.</span><span style="color:#B392F0">json</span><span style="color:#E1E4E8">())</span></span>
<span class="line"><span style="color:#E1E4E8"> .</span><span style="color:#B392F0">then</span><span style="color:#E1E4E8">((</span><span style="color:#FFAB70">stats</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8"> document.</span><span style="color:#B392F0">querySelector</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'[data-stat-key="visitors"]'</span><span style="color:#E1E4E8">).textContent </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> formatter.</span><span style="color:#B392F0">format</span><span style="color:#E1E4E8">(stats.visitors);</span></span>
<span class="line"><span style="color:#E1E4E8"> document.</span><span style="color:#B392F0">querySelector</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'[data-stat-key="pageviews"]'</span><span style="color:#E1E4E8">).textContent </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> formatter.</span><span style="color:#B392F0">format</span><span style="color:#E1E4E8">(stats.pageviews);</span></span>
<span class="line"><span style="color:#E1E4E8"> });</span></span></code></pre>
<p>我这里还在浏览器端加了一层 <code>localStorage</code> 缓存,缓存 key 里带上 Worker 地址,防止以后更换接口后还读到旧数据:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;" tabindex="0" data-language="js"><code><span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> cacheKey</span><span style="color:#F97583"> =</span><span style="color:#9ECBFF"> `site-info-stats:${</span><span style="color:#E1E4E8">endpoint</span><span style="color:#9ECBFF">}`</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> cacheDuration</span><span style="color:#F97583"> =</span><span style="color:#79B8FF"> 10</span><span style="color:#F97583"> *</span><span style="color:#79B8FF"> 60</span><span style="color:#F97583"> *</span><span style="color:#79B8FF"> 1000</span><span style="color:#E1E4E8">;</span></span></code></pre>
<p>这样前端和 Worker 都有缓存,访问体验会稳一些。</p>
<h2 id="最后">最后</h2>
<p>这个方案的核心是:不要把 Umami 后台账号放到前端,也不需要专门创建只读用户。既然 Umami 分享页本身已经提供了公开数据访问方式,就让 Worker 代替前端去请求它,并顺手做缓存和字段整理。</p>
<p>最终网站信息卡只需要关心这几个字段:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;" tabindex="0" data-language="json"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF"> "visitors"</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF"> "pageviews"</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">2</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF"> "visits"</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF"> "updatedAt"</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">1781829905467</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>小功能,但很适合放在侧边栏。它不会喧宾夺主,却能让网站看起来更像一个正在被维护、正在呼吸的地方。</p> </div> <section class="post-copyright-card" aria-label="文章版权信息"> <div class="post-copyright-mark" aria-hidden="true"> <span>CC</span> </div> <div class="post-copyright-main"> <h2>用 Umami 分享链接给网站信息卡提供统计数据</h2> <a class="post-copyright-link" href="/posts/4c91a8f2/">/posts/4c91a8f2/</a> <dl class="post-copyright-meta"> <div> <dt>作者</dt> <dd>biss</dd> </div> <div> <dt>发布于</dt> <dd><time datetime="2026-06-19T08:58:00.000Z">2026/06/19</time></dd> </div> <div> <dt>许可协议</dt> <dd><a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" rel="noreferrer">CC BY-NC-SA 4.0</a></dd> </div> </dl> </div> <div class="post-copyright-footer"> <div class="post-copyright-tags" aria-label="文章标签"> <a href="/tags/%E7%BD%91%E7%AB%99/">网站</a><a href="/tags/Umami/">Umami</a><a href="/tags/Cloudflare%20Worker/">Cloudflare Worker</a> </div> <div class="post-copyright-actions" aria-label="文章操作"> <button class="post-copyright-action is-primary post-share-button" type="button" data-share-title="用 Umami 分享链接给网站信息卡提供统计数据" data-share-url="https://blog.biss.click/posts/4c91a8f2/" title="分享文章"> <svg aria-hidden="true" viewBox="0 0 24 24"> <path d="M4 12v7a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-7"></path> <path d="M12 16V4"></path> <path d="m7 9 5-5 5 5"></path> </svg> <span>分享</span> </button> <a class="post-copyright-action" href="/rss.xml" title="订阅 RSS"> <svg aria-hidden="true" viewBox="0 0 24 24"> <path d="M5 5a14 14 0 0 1 14 14"></path> <path d="M5 11a8 8 0 0 1 8 8"></path> <path d="M6 18h.01"></path> </svg> <span>订阅</span> </a> <a class="post-copyright-action" href="/about/" title="打赏作者"> <svg aria-hidden="true" viewBox="0 0 24 24"> <path d="M7 8h10"></path> <path d="M8 8V5h8v3"></path> <path d="M6 8h12l-1.5 11h-9L6 8Z"></path> <path d="M10 12h4"></path> </svg> <span>打赏</span> </a> <a class="post-copyright-icon" href="https://twitter.com/intent/tweet?url=https%3A%2F%2Fblog.biss.click%2Fposts%2F4c91a8f2%2F&amp;text=%E7%94%A8%20Umami%20%E5%88%86%E4%BA%AB%E9%93%BE%E6%8E%A5%E7%BB%99%E7%BD%91%E7%AB%99%E4%BF%A1%E6%81%AF%E5%8D%A1%E6%8F%90%E4%BE%9B%E7%BB%9F%E8%AE%A1%E6%95%B0%E6%8D%AE" target="_blank" rel="noreferrer" title="分享到 X"> <span aria-hidden="true">x</span> </a> <a class="post-copyright-icon" href="https://service.weibo.com/share/share.php?url=https%3A%2F%2Fblog.biss.click%2Fposts%2F4c91a8f2%2F&amp;title=%E7%94%A8%20Umami%20%E5%88%86%E4%BA%AB%E9%93%BE%E6%8E%A5%E7%BB%99%E7%BD%91%E7%AB%99%E4%BF%A1%E6%81%AF%E5%8D%A1%E6%8F%90%E4%BE%9B%E7%BB%9F%E8%AE%A1%E6%95%B0%E6%8D%AE" target="_blank" rel="noreferrer" title="分享到微博"> <span aria-hidden="true"></span> </a> </div> </div> </section> <script>
(() => {
document.querySelectorAll('.post-share-button').forEach((button) => {
button.addEventListener('click', async () => {
const title = button.dataset.shareTitle || document.title;
const url = button.dataset.shareUrl || window.location.href;
const label = button.querySelector('span');
const originalText = label?.textContent || '分享';
try {
if (navigator.share) {
await navigator.share({ title, url });
return;
}
await navigator.clipboard.writeText(url);
if (label) label.textContent = '已复制';
} catch {
if (label) label.textContent = '复制失败';
} finally {
window.setTimeout(() => {
if (label) label.textContent = originalText;
}, 1800);
}
});
});
})();
</script> <section id="post-comment" class="twikoo-comments" aria-label="Comments" data-env-id="https://comment.biss.click" data-region data-path="/posts/4c91a8f2/" data-script="https://cdn.jsdelivr.net/npm/twikoo@1.7.7/dist/twikoo.min.js"> <div class="twikoo-comments-head"> <span class="twikoo-comments-icon" aria-hidden="true"> <svg viewBox="0 0 24 24"> <path d="M21 12a8 8 0 0 1-8 8H7l-4 3v-5a8 8 0 1 1 18-6Z"></path> </svg> </span> <div> <h2>Comments</h2> <p>用 Umami 分享链接给网站信息卡提供统计数据</p> </div> </div> <div id="twikoo-wrap" class="twikoo-wrap"></div> </section> <script>
(() => {
const root = document.getElementById('post-comment');
const wrap = document.getElementById('twikoo-wrap');
if (!root || !wrap || root.dataset.twikooLoaded === 'true') return;
const envId = root.dataset.envId;
const path = root.dataset.path || window.location.pathname;
const region = root.dataset.region || undefined;
if (!envId) return;
root.dataset.twikooLoaded = 'true';
const initTwikoo = () => {
if (!window.twikoo || typeof window.twikoo.init !== 'function') return;
window.twikoo.init({
el: wrap,
envId,
region,
path,
});
};
if (window.twikoo) {
initTwikoo();
return;
}
const script = document.createElement('script');
script.src = root.dataset.script;
script.defer = true;
script.onload = initTwikoo;
script.onerror = () => {
root.dataset.twikooLoaded = 'false';
};
document.head.appendChild(script);
})();
</script> </article> <aside class="article-sidebar" aria-label="文章侧边栏"> <section class="sidebar-card article-author-card"> <img class="profile-avatar" src="https://free.picui.cn/free/2025/08/10/689845496a283.png" alt="biss" loading="lazy"> <div class="profile-name">biss</div> <div class="article-author-meta"> <span>2026/06/19</span> <a href="/categories/site-notes/">建站手札</a> </div> </section> <section class="sidebar-card article-tags-card"> <h2> <span aria-hidden="true">#</span>
文章标签
<em>3</em> </h2> <div class="article-tag-list" aria-label="文章标签"> <a href="/tags/%E7%BD%91%E7%AB%99/">#网站</a><a href="/tags/Umami/">#Umami</a><a href="/tags/Cloudflare%20Worker/">#Cloudflare Worker</a> </div> </section> <div class="article-sticky-sidebar"> <section class="sidebar-card toc-card"> <h2> <span aria-hidden="true"></span>
文章目录
<em>9</em> </h2> <nav class="toc-list toc-tree" aria-label="文章目录" data-toc-tree> <div class="toc-group is-open" data-toc-group data-heading-id="整体思路"> <a class="toc-link toc-link-main" href="#整体思路" data-toc-link data-toc-top> <span class="toc-marker" aria-hidden="true"></span> <span>整体思路</span> </a> </div><div class="toc-group" data-toc-group data-heading-id="创建-umami-分享链接"> <a class="toc-link toc-link-main" href="#创建-umami-分享链接" data-toc-link data-toc-top> <span class="toc-marker" aria-hidden="true"></span> <span>创建 Umami 分享链接</span> </a> </div><div class="toc-group" data-toc-group data-heading-id="分享链接背后的请求"> <a class="toc-link toc-link-main" href="#分享链接背后的请求" data-toc-link data-toc-top> <span class="toc-marker" aria-hidden="true"></span> <span>分享链接背后的请求</span> </a> </div><div class="toc-group" data-toc-group data-heading-id="编写-cloudflare-worker"> <a class="toc-link toc-link-main" href="#编写-cloudflare-worker" data-toc-link data-toc-top> <span class="toc-marker" aria-hidden="true"></span> <span>编写 Cloudflare Worker</span> </a> </div><div class="toc-group" data-toc-group data-heading-id="配置-worker-变量"> <a class="toc-link toc-link-main" href="#配置-worker-变量" data-toc-link data-toc-top> <span class="toc-marker" aria-hidden="true"></span> <span>配置 Worker 变量</span> </a> </div><div class="toc-group" data-toc-group data-heading-id="部署方式"> <a class="toc-link toc-link-main" href="#部署方式" data-toc-link data-toc-top> <span class="toc-marker" aria-hidden="true"></span> <span>部署方式</span> </a> </div><div class="toc-group" data-toc-group data-heading-id="测试-worker"> <a class="toc-link toc-link-main" href="#测试-worker" data-toc-link data-toc-top> <span class="toc-marker" aria-hidden="true"></span> <span>测试 Worker</span> </a> </div><div class="toc-group" data-toc-group data-heading-id="接入网站信息卡"> <a class="toc-link toc-link-main" href="#接入网站信息卡" data-toc-link data-toc-top> <span class="toc-marker" aria-hidden="true"></span> <span>接入网站信息卡</span> </a> </div><div class="toc-group" data-toc-group data-heading-id="最后"> <a class="toc-link toc-link-main" href="#最后" data-toc-link data-toc-top> <span class="toc-marker" aria-hidden="true"></span> <span>最后</span> </a> </div> </nav> </section> <section class="sidebar-card same-category-card"> <h2> <span aria-hidden="true"></span>
同类文章
<em>8</em> </h2> <div class="same-category-list"> <a href="/posts/8f3b9d21/"> <strong>网站迁移日志</strong> <time datetime="2026-06-19T16:45:00.000Z">2026/06/20</time> </a><a href="/posts/7e921903/"> <strong>添加网站左上角菜单</strong> <time datetime="2026-02-10T07:11:14.000Z">2026/02/10</time> </a><a href="/posts/f287c563/"> <strong>添加typesense搜索</strong> <time datetime="2026-02-05T13:14:16.000Z">2026/02/05</time> </a><a href="/posts/b5601a7e/"> <strong>自定义字体</strong> <time datetime="2026-01-16T07:47:15.000Z">2026/01/16</time> </a><a href="/posts/5ed2f1e6/"> <strong>在侧边栏添加日历和倒计时</strong> <time datetime="2026-01-16T07:47:07.000Z">2026/01/16</time> </a><a href="/posts/3a26a97a/"> <strong>为博客添加CMS系统</strong> <time datetime="2025-08-25T22:03:00.000Z">2025/08/26</time> </a><a href="/posts/ce1ec3fe/"> <strong>使用GitHub推送Hexo到服务器</strong> <time datetime="2025-08-18T17:10:18.000Z">2025/08/19</time> </a><a href="/posts/c6143ad3/"> <strong>自定义页脚(新)</strong> <time datetime="2025-08-14T17:05:27.000Z">2025/08/15</time> </a> </div> </section> <section class="sidebar-card site-info-card" data-site-info-card data-stats-endpoint="https://blogumami.biss.click"> <h2><i class="fa-solid fa-chart-line" aria-hidden="true"></i> 网站信息</h2> <dl class="site-info-list"> <div> <dt>文章数目</dt> <dd>44</dd> </div><div> <dt>本站总字数</dt> <dd>36,128</dd> </div><div> <dt>本站访客数</dt> <dd data-stat-key="visitors">--</dd> </div><div> <dt>本站访问量</dt> <dd data-stat-key="pageviews">--</dd> </div><div> <dt>最后更新时间</dt> <dd>2026/06/20</dd> </div> </dl> </section> <script>
(() => {
const cacheDuration = 10 * 60 * 1000;
const formatter = new Intl.NumberFormat('en-US');
const cards = [...document.querySelectorAll('[data-site-info-card]')];
const endpoint = cards.find((card) => card.dataset.statsEndpoint)?.dataset.statsEndpoint;
if (!endpoint) return;
const cacheKey = `site-info-stats:${endpoint}`;
const updateCards = (stats) => {
const visitors = Number(stats?.visitors);
const pageviews = Number(stats?.pageviews);
cards.forEach((card) => {
const visitorsEl = card.querySelector('[data-stat-key="visitors"]');
const pageviewsEl = card.querySelector('[data-stat-key="pageviews"]');
if (visitorsEl && Number.isFinite(visitors)) visitorsEl.textContent = formatter.format(visitors);
if (pageviewsEl && Number.isFinite(pageviews)) pageviewsEl.textContent = formatter.format(pageviews);
});
};
try {
const cached = JSON.parse(localStorage.getItem(cacheKey) || 'null');
if (cached?.time && Date.now() - cached.time < cacheDuration) {
updateCards(cached.data);
return;
}
} catch {
localStorage.removeItem(cacheKey);
}
fetch(endpoint, { headers: { Accept: 'application/json' } })
.then((response) => {
if (!response.ok) throw new Error(`Stats request failed: ${response.status}`);
return response.json();
})
.then((data) => {
localStorage.setItem(cacheKey, JSON.stringify({ time: Date.now(), data }));
updateCards(data);
})
.catch(() => {
});
})();
</script> </div> </aside> </section> </main> <footer class="site-footer" aria-label="站点页脚"> <div class="footer-inner"> <section class="footer-top" aria-label="站点信息"> <a class="footer-brand" href="/" aria-label="Bi's Blog 首页"> <img src="/images/Bi.ico" alt="" loading="lazy"> <span>Bi&#39;s Blog</span> </a> <p class="footer-slogan">好奇心转化为代码的空间。</p> </section> <section class="footer-contact" aria-label="联系方式"> <p>联系方式</p> <div class="footer-socials"> <a href="mailto:bishsh2006@gmail.com" aria-label="发送邮件" title="发送邮件"> <i class="fa-solid fa-envelope" aria-hidden="true"></i> </a><a href="/rss.xml" aria-label="订阅 RSS" title="订阅 RSS"> <i class="fa-solid fa-rss" aria-hidden="true"></i> </a><a href="https://github.com/bishshi" target="_blank" rel="noreferrer" aria-label="GitHub" title="GitHub"> <i class="fa-brands fa-github" aria-hidden="true"></i> </a> </div> <nav class="footer-contact-links" aria-label="页脚链接"> <a href="/archives/">发展历程</a> <span aria-hidden="true">|</span><a href="/privacy/">隐私政策</a> </nav> </section> <section class="footer-bottom" aria-label="版权信息"> <div> <p>© 2025 - 2026 Bi&#39;s Blog</p> <p>blog.biss.click | 内容采用 CC BY-NC-SA 4.0 协议</p> </div> <div class="footer-meta"> <p>本站已运行:<span id="site-runtime">计算中...</span></p> <p> <a href="https://fastly.com/" target="_blank" rel="noreferrer">Fastly</a> <span aria-hidden="true">|</span><a href="https://astro.build/" target="_blank" rel="noreferrer">ASTRO</a> </p> </div> </section> </div> </footer> <aside class="hitokoto-card is-collapsed" aria-label="一言" data-hitokoto-card data-astro-cid-4cgfuthx> <div class="hitokoto-card__head" data-astro-cid-4cgfuthx> <button class="hitokoto-card__toggle" type="button" aria-label="展开一言" aria-expanded="false" title="一言" data-hitokoto-toggle data-astro-cid-4cgfuthx> <i class="fa-solid fa-quote-left" aria-hidden="true" data-astro-cid-4cgfuthx></i> <span data-astro-cid-4cgfuthx>一言</span> </button> <div class="hitokoto-card__actions" data-astro-cid-4cgfuthx> <button class="hitokoto-card__refresh" type="button" aria-label="换一句" title="换一句" data-hitokoto-refresh data-astro-cid-4cgfuthx> <i class="fa-solid fa-rotate" aria-hidden="true" data-astro-cid-4cgfuthx></i> </button> <button class="hitokoto-card__collapse" type="button" aria-label="收起一言" title="收起" data-hitokoto-collapse data-astro-cid-4cgfuthx> <i class="fa-solid fa-chevron-down" aria-hidden="true" data-astro-cid-4cgfuthx></i> </button> </div> </div> <a class="hitokoto-card__text" href="https://hitokoto.cn/" target="_blank" rel="noreferrer" data-hitokoto-text data-astro-cid-4cgfuthx>
获取中...
</a> <p class="hitokoto-card__from" data-hitokoto-from data-astro-cid-4cgfuthx>来自 Hitokoto</p> </aside> <script type="module">(()=>{const o=document.querySelector("[data-hitokoto-card]"),c=o?.querySelector("[data-hitokoto-toggle]"),s=o?.querySelector("[data-hitokoto-text]"),l=o?.querySelector("[data-hitokoto-from]"),g=o?.querySelector("[data-hitokoto-refresh]"),k=o?.querySelector("[data-hitokoto-collapse]");if(!(o instanceof HTMLElement)||!(c instanceof HTMLButtonElement)||!(s instanceof HTMLAnchorElement)||!(l instanceof HTMLElement))return;const y="https://yiyan.biss.click/api?encode=json",u="hitokoto-card-cache",d="hitokoto-card-expanded",x=360*60*1e3,f={sentence:"生活明朗,万物可爱。",source:"本地缓存",url:"https://hitokoto.cn/"},S=["/","/index.html"].includes(window.location.pathname),w=window.matchMedia("(max-width: 720px)");function E(){try{const t=localStorage.getItem(d);if(t==="true")return!0;if(t==="false")return!1}catch{}return null}function L(t){try{localStorage.setItem(d,String(t))}catch{}}function r(t,{persist:e=!1}={}){o.classList.toggle("is-collapsed",!t),c.setAttribute("aria-expanded",String(t)),c.setAttribute("aria-label",t?"收起一言":"展开一言"),e&&L(t)}function h(){try{const t=localStorage.getItem(u);if(!t)return null;const e=JSON.parse(t);return!e||typeof e.expiresAt!="number"||e.expiresAt<=Date.now()?null:e.data}catch{return null}}function H(t){try{localStorage.setItem(u,JSON.stringify({data:t,expiresAt:Date.now()+x}))}catch{}}function i(t){s.textContent=t.sentence,s.href=t.url||"https://hitokoto.cn/",l.textContent=t.source?`出自 ${t.source}`:"来自 Hitokoto"}async function p({force:t=!1}={}){if(!t){const e=h();if(e){i(e);return}}o.classList.add("is-loading"),s.textContent="获取中...";try{const e=await fetch(y);if(!e.ok)throw new Error(`Hitokoto request failed: ${e.status}`);const n=await e.json(),a=n.hitokoto||n.content||n.sentence||n.text,q=typeof a=="string"&&a.trim()?a.trim():f.sentence,A=typeof n.from=="string"&&n.from.trim()?n.from.trim():"Hitokoto",M=n.uuid?`https://hitokoto.cn/?uuid=${encodeURIComponent(n.uuid)}`:"https://hitokoto.cn/",m={sentence:q,source:A,url:M};H(m),i(m)}catch(e){console.error(e),i(h()||f)}finally{o.classList.remove("is-loading")}}const b=E();r(b??(S&&!w.matches)),c.addEventListener("click",()=>{r(o.classList.contains("is-collapsed"),{persist:!0})}),g?.addEventListener("click",t=>{t.stopPropagation(),r(!0,{persist:!0}),p({force:!0})}),k?.addEventListener("click",t=>{t.stopPropagation(),r(!1,{persist:!0})}),o.addEventListener("click",t=>{o.classList.contains("is-collapsed")&&(t.target instanceof Element&&t.target.closest("a, button")||r(!0,{persist:!0}))}),p()})();</script> <div class="custom-context-menu" data-context-menu role="menu" aria-hidden="true"> <div class="context-menu-nav" aria-label="页面导航"> <button type="button" data-context-action="back" aria-label="后退上一页" title="后退上一页"> <i class="fa-solid fa-arrow-left" aria-hidden="true"></i> </button> <button type="button" data-context-action="forward" aria-label="前进下一页" title="前进下一页"> <i class="fa-solid fa-arrow-right" aria-hidden="true"></i> </button> <button type="button" data-context-action="reload" aria-label="刷新页面" title="刷新页面"> <i class="fa-solid fa-rotate-right" aria-hidden="true"></i> </button> <a href="/" aria-label="返回首页" title="返回首页"> <i class="fa-solid fa-house" aria-hidden="true"></i> </a> </div> <div class="context-menu-group" data-selection-group hidden> <button type="button" data-context-action="copy-selection" data-selection-item> <i class="fa-regular fa-copy" aria-hidden="true"></i> <span>复制选中文字</span> </button> <button type="button" data-context-action="search-selection" data-selection-item> <i class="fa-brands fa-microsoft" aria-hidden="true"></i> <span>用 Bing 搜索</span> </button> <button type="button" data-context-action="quote-selection" data-selection-item data-comments-item> <i class="fa-regular fa-comment-dots" aria-hidden="true"></i> <span>引用到评论区</span> </button> </div> <div class="context-menu-group" data-image-group hidden> <button type="button" data-context-action="copy-image"> <i class="fa-regular fa-image" aria-hidden="true"></i> <span>复制图片</span> </button> <button type="button" data-context-action="open-image"> <i class="fa-solid fa-up-right-from-square" aria-hidden="true"></i> <span>新页面打开图片</span> </button> <button type="button" data-context-action="copy-image-url"> <i class="fa-solid fa-link" aria-hidden="true"></i> <span>复制图片地址</span> </button> </div> <div class="context-menu-group"> <a href="/archives/"> <i class="fa-solid fa-box-archive" aria-hidden="true"></i> <span>时间线</span> </a> <a href="/categories/"> <i class="fa-solid fa-folder" aria-hidden="true"></i> <span>分类</span> </a> <a href="/tags/"> <i class="fa-solid fa-hashtag" aria-hidden="true"></i> <span>标签</span> </a> <button type="button" data-context-action="random-post"> <i class="fa-solid fa-shuffle" aria-hidden="true"></i> <span>&#x968F;&#x673A;&#x6587;&#x7AE0;</span> </button> <button type="button" data-context-action="comments" data-comments-item> <i class="fa-regular fa-comment-dots" aria-hidden="true"></i> <span>跳转评论区</span> </button> <button type="button" data-context-action="print"> <i class="fa-solid fa-print" aria-hidden="true"></i> <span>打印页面</span> </button> <a href="/cc/"> <i class="fa-solid fa-circle-info" aria-hidden="true"></i> <span>网站版权</span> </a> </div> </div> <div class="context-menu-native-hint" data-context-native-hint role="note" aria-label="Ctrl 右键提示"> <kbd>Ctrl</kbd> + &#x53F3;&#x952E;&#xFF1A;&#x6D4F;&#x89C8;&#x5668;&#x539F;&#x751F;&#x83DC;&#x5355;
</div> <script>
(() => {
const menu = document.querySelector('[data-context-menu]');
if (!menu) return;
const nativeHint = document.querySelector('[data-context-native-hint]');
const selectionItems = [...menu.querySelectorAll('[data-selection-item]')];
const selectionGroup = menu.querySelector('[data-selection-group]');
const commentsItems = [...menu.querySelectorAll('[data-comments-item]')];
const imageGroup = menu.querySelector('[data-image-group]');
const focusableSelector = 'a[href], button:not([disabled])';
let lastSelectionText = '';
let lastImage = null;
let nativeHintTimer = 0;
const isNativeContextTarget = (target) =>
target instanceof Element &&
Boolean(target.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]'));
const getCommentsTarget = () =>
document.querySelector('.twikoo-comments, #twikoo-wrap, #twikoo, .tk-comments-container');
const getSelectionText = () => String(window.getSelection?.() || '').trim();
const getImageTarget = (target) => {
if (!(target instanceof Element)) return null;
const image = target.closest('img');
return image instanceof HTMLImageElement ? image : null;
};
const getImageUrl = () => {
if (!lastImage) return '';
return lastImage.currentSrc || lastImage.src || lastImage.getAttribute('src') || '';
};
const copyText = async (text) => {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.inset = '0 auto auto -9999px';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
textarea.remove();
};
const setMenuState = () => {
const hasSelection = lastSelectionText.length > 0;
const hasComments = Boolean(getCommentsTarget());
const hasImage = Boolean(getImageUrl());
selectionItems.forEach((item) => {
item.hidden = !hasSelection;
});
if (selectionGroup) selectionGroup.hidden = !hasSelection;
commentsItems.forEach((item) => {
item.hidden = !hasComments || (item.hasAttribute('data-selection-item') && !hasSelection);
});
if (imageGroup) imageGroup.hidden = !hasImage;
};
const closeMenu = () => {
menu.classList.remove('is-open');
menu.setAttribute('aria-hidden', 'true');
};
const openMenu = (x, y) => {
setMenuState();
menu.classList.add('is-open');
menu.setAttribute('aria-hidden', 'false');
const rect = menu.getBoundingClientRect();
const gap = 10;
const left = Math.min(Math.max(gap, x), window.innerWidth - rect.width - gap);
const top = Math.min(Math.max(gap, y), window.innerHeight - rect.height - gap);
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
};
const showNativeHint = () => {
if (!nativeHint) return;
window.clearTimeout(nativeHintTimer);
nativeHint.classList.add('is-visible');
nativeHintTimer = window.setTimeout(() => {
nativeHint.classList.remove('is-visible');
}, 2600);
};
const jumpToComments = () => {
const target = getCommentsTarget();
target?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const quoteToComments = () => {
jumpToComments();
window.setTimeout(() => {
const textarea = document.querySelector('#twikoo textarea, .twikoo-comments textarea, .tk-input textarea');
if (!(textarea instanceof HTMLTextAreaElement) || !lastSelectionText) return;
const quote = lastSelectionText
.split(/\r?\n/)
.map((line) => `> ${line}`)
.join('\n');
const current = textarea.value.trim();
textarea.value = current ? `${current}\n\n${quote}\n` : `${quote}\n`;
textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.focus();
}, 500);
};
const searchSelection = () => {
if (!lastSelectionText) return;
const url = `https://www.bing.com/search?q=${encodeURIComponent(lastSelectionText)}`;
window.open(url, '_blank', 'noopener,noreferrer');
};
const copyImage = async () => {
const imageUrl = getImageUrl();
if (!imageUrl) return;
try {
if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined') {
throw new Error('Image clipboard is not available.');
}
const response = await fetch(imageUrl, { mode: 'cors' });
const blob = await response.blob();
await navigator.clipboard.write([
new ClipboardItem({ [blob.type || 'image/png']: blob }),
]);
} catch {
await copyText(new URL(imageUrl, window.location.href).toString());
}
};
const openImage = () => {
const imageUrl = getImageUrl();
if (!imageUrl) return;
window.open(new URL(imageUrl, window.location.href).toString(), '_blank', 'noopener,noreferrer');
};
document.addEventListener('contextmenu', (event) => {
if (event.ctrlKey) {
closeMenu();
nativeHint?.classList.remove('is-visible');
return;
}
if (isNativeContextTarget(event.target)) return;
lastSelectionText = getSelectionText();
lastImage = getImageTarget(event.target);
event.preventDefault();
openMenu(event.clientX, event.clientY);
showNativeHint();
});
document.addEventListener('click', (event) => {
const actionTarget = event.target instanceof Element ? event.target.closest('[data-context-action]') : null;
if (!actionTarget || !menu.contains(actionTarget)) {
closeMenu();
return;
}
const action = actionTarget.dataset.contextAction;
closeMenu();
if (action === 'back') history.back();
if (action === 'forward') history.forward();
if (action === 'reload') location.reload();
if (action === 'copy-selection' && lastSelectionText) copyText(lastSelectionText);
if (action === 'search-selection') searchSelection();
if (action === 'quote-selection') quoteToComments();
if (action === 'copy-image') copyImage();
if (action === 'open-image') openImage();
if (action === 'copy-image-url') copyText(new URL(getImageUrl(), window.location.href).toString());
if (action === 'random-post' && typeof window.randomPost === 'function') window.randomPost(actionTarget);
if (action === 'comments') jumpToComments();
if (action === 'print') window.print();
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeMenu();
if (event.key === 'Tab' && menu.classList.contains('is-open')) {
const items = [...menu.querySelectorAll(focusableSelector)].filter((item) => !item.closest('[hidden]'));
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
});
window.addEventListener('scroll', closeMenu, { passive: true });
window.addEventListener('resize', closeMenu);
})();
</script> <aside class="pwa-toast" data-pwa-toast hidden role="status" aria-live="polite"> <div class="pwa-toast-icon" aria-hidden="true"><i class="fa-solid fa-download"></i></div> <div class="pwa-toast-copy"> <strong data-pwa-title>PWA 提示</strong> <p data-pwa-message></p> </div> <div class="pwa-toast-actions"> <button class="pwa-toast-primary" type="button" data-pwa-primary>确定</button> <button class="pwa-toast-close" type="button" data-pwa-close aria-label="关闭提示">稍后</button> </div> </aside> <script src="https://cdn.jsdmirror.com/npm/instantsearch.js@4.56.0"></script> <script src="https://cdn.jsdmirror.com/npm/typesense-instantsearch-adapter@2.7.0/dist/typesense-instantsearch-adapter.min.js"></script> <script src="https://cdn.jsdmirror.com/npm/@fancyapps/ui@5.0/dist/fancybox/fancybox.umd.js"></script> <script src="/js/random.js"></script> <script src="/js/search/typesense-search.js"></script> <script src="/js/external-links.js"></script> <script src="/js/pwa.js"></script> <script>
(() => {
const nav = document.querySelector('.nav');
const toggle = document.querySelector('[data-nav-toggle]');
const menu = document.querySelector('[data-nav-menu]');
const backdrop = document.querySelector('[data-nav-backdrop]');
if (!nav || !toggle || !menu) return;
function setMenu(open) {
nav.classList.toggle('is-open', open);
toggle.setAttribute('aria-expanded', String(open));
toggle.setAttribute('aria-label', open ? '关闭菜单' : '打开菜单');
menu.setAttribute('aria-hidden', String(!open && window.innerWidth <= 720));
document.body.classList.toggle('has-nav-drawer', open);
}
toggle.addEventListener('click', () => {
setMenu(!nav.classList.contains('is-open'));
});
backdrop?.addEventListener('click', () => setMenu(false));
menu.addEventListener('click', (event) => {
if (event.target instanceof Element && event.target.closest('a')) setMenu(false);
});
document.addEventListener('click', (event) => {
if (!nav.classList.contains('is-open')) return;
if (event.target instanceof Node && nav.contains(event.target)) return;
setMenu(false);
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && nav.classList.contains('is-open')) setMenu(false);
});
window.addEventListener('resize', () => {
if (window.innerWidth > 720) setMenu(false);
else menu.setAttribute('aria-hidden', String(!nav.classList.contains('is-open')));
});
menu.setAttribute('aria-hidden', String(window.innerWidth <= 720));
})();
(() => {
const modal = document.querySelector('[data-console-modal]');
const panel = modal?.querySelector('.control-console-panel');
const openButtons = document.querySelectorAll('[data-console-open]');
const closeButtons = modal?.querySelectorAll('[data-console-close]') || [];
if (!modal || !panel || openButtons.length === 0) return;
function setConsole(open) {
modal.classList.toggle('is-open', open);
modal.setAttribute('aria-hidden', String(!open));
document.body.classList.toggle('has-control-console', open);
if (open) panel.focus();
}
openButtons.forEach((button) => {
button.addEventListener('click', () => setConsole(true));
});
closeButtons.forEach((button) => {
button.addEventListener('click', () => setConsole(false));
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && modal.classList.contains('is-open')) setConsole(false);
});
})();
(() => {
const storageKey = 'site-theme';
const root = document.documentElement;
const button = document.querySelector('[data-theme-toggle]');
const media = window.matchMedia('(prefers-color-scheme: dark)');
const getSystemTheme = () => (media.matches ? 'dark' : 'light');
const getSavedTheme = () => {
try {
const theme = localStorage.getItem(storageKey);
return theme === 'dark' || theme === 'light' ? theme : null;
} catch {
return null;
}
};
const setSavedTheme = (theme) => {
try {
if (theme === getSystemTheme()) localStorage.removeItem(storageKey);
else localStorage.setItem(storageKey, theme);
} catch {
// localStorage can be unavailable in strict privacy modes.
}
};
const getTheme = () => getSavedTheme() || getSystemTheme();
function applyTheme(theme) {
root.dataset.theme = theme;
root.dataset.themeMode = getSavedTheme() ? 'manual' : 'auto';
button?.setAttribute('aria-label', theme === 'dark' ? '切换到日间模式' : '切换到夜间模式');
button?.setAttribute(
'title',
getSavedTheme()
? (theme === 'dark' ? '切换到日间模式' : '切换到夜间模式')
: `跟随系统:${theme === 'dark' ? '夜间' : '日间'}模式`
);
}
applyTheme(getTheme());
button?.addEventListener('click', () => {
const nextTheme = root.dataset.theme === 'dark' ? 'light' : 'dark';
setSavedTheme(nextTheme);
applyTheme(nextTheme);
});
const handleSystemThemeChange = () => {
if (!getSavedTheme()) applyTheme(getSystemTheme());
};
if (typeof media.addEventListener === 'function') {
media.addEventListener('change', handleSystemThemeChange);
} else if (typeof media.addListener === 'function') {
media.addListener(handleSystemThemeChange);
}
})();
(() => {
const runtime = document.getElementById('site-runtime');
if (!runtime) return;
const start = new Date(window.SITE_CONFIG.site.startTime).getTime();
const pad = (value) => String(value).padStart(2, '0');
function updateRuntime() {
const diff = Math.max(0, Date.now() - start);
const days = Math.floor(diff / 86400000);
const hours = Math.floor((diff / 3600000) % 24);
const minutes = Math.floor((diff / 60000) % 60);
const seconds = Math.floor((diff / 1000) % 60);
runtime.textContent = `${days}${pad(hours)}${pad(minutes)}${pad(seconds)} 秒`;
}
updateRuntime();
window.setInterval(updateRuntime, 1000);
})();
</script> </body> </html> <script>(function(){const postSlug = "4c91a8f2";
(() => {
const article = document.querySelector('.post-article');
const progressBar = document.querySelector('[data-reading-progress]');
const resume = document.querySelector('[data-reading-resume]');
const resumePercent = document.querySelector('[data-reading-resume-percent]');
const continueButton = document.querySelector('[data-reading-continue]');
const restartButton = document.querySelector('[data-reading-restart]');
if (!article || !progressBar || !resume) return;
const storageKey = `reading-progress:${postSlug}`;
let saved = null;
let canPersist = true;
let frame = 0;
try {
saved = JSON.parse(localStorage.getItem(storageKey) || 'null');
} catch {
saved = null;
}
const articleMetrics = () => {
const top = article.getBoundingClientRect().top + window.scrollY;
const distance = Math.max(1, article.offsetHeight - window.innerHeight);
return { top, distance };
};
const getProgress = () => {
const { top, distance } = articleMetrics();
return Math.min(1, Math.max(0, (window.scrollY - top) / distance));
};
const persist = (progress) => {
if (!canPersist) return;
try {
if (progress >= 0.97) localStorage.removeItem(storageKey);
else if (progress >= 0.02) localStorage.setItem(storageKey, JSON.stringify({ progress, savedAt: Date.now() }));
} catch {
// Storage can be unavailable in strict privacy modes.
}
};
const update = () => {
frame = 0;
const progress = getProgress();
progressBar.style.transform = `scaleX(${progress})`;
persist(progress);
};
const requestUpdate = () => {
if (!frame) frame = window.requestAnimationFrame(update);
};
const closeResume = () => {
resume.hidden = true;
canPersist = true;
};
const savedIsFresh = saved && Date.now() - Number(saved.savedAt) < 90 * 86400000;
const savedProgress = Number(saved?.progress);
if (savedIsFresh && savedProgress >= 0.05 && savedProgress < 0.97) {
canPersist = false;
if (resumePercent) resumePercent.textContent = `${Math.round(savedProgress * 100)}%`;
window.setTimeout(() => { resume.hidden = false; }, 350);
}
continueButton?.addEventListener('click', () => {
closeResume();
const { top, distance } = articleMetrics();
window.scrollTo({ top: top + distance * savedProgress, behavior: 'smooth' });
});
restartButton?.addEventListener('click', () => {
try { localStorage.removeItem(storageKey); } catch {}
closeResume();
window.scrollTo({ top: Math.max(0, articleMetrics().top - 90), behavior: 'smooth' });
});
update();
window.addEventListener('scroll', requestUpdate, { passive: true });
window.addEventListener('resize', requestUpdate);
window.addEventListener('pagehide', () => persist(getProgress()));
})();
})();</script> <script>
(() => {
document.querySelectorAll('.article-outdated-note[data-freshness-at]').forEach((note) => {
const freshnessAt = new Date(note.dataset.freshnessAt || '');
const hasNewVersion = note.dataset.hasNewVersion === 'true';
if (Number.isNaN(freshnessAt.valueOf())) return;
const threshold = new Date();
threshold.setFullYear(threshold.getFullYear() - 1);
note.hidden = !hasNewVersion && freshnessAt >= threshold;
});
})();
</script> <script>
(() => {
const prose = document.querySelector('.prose[data-toc]');
if (!prose) return;
let toc = [];
try {
toc = JSON.parse(prose.dataset.toc || '[]');
} catch {
toc = [];
}
prose.querySelectorAll('h1, h2, h3').forEach((heading, index) => {
const item = toc[index];
if (item?.id && !heading.id) heading.id = item.id;
});
const tocTree = document.querySelector('[data-toc-tree]');
if (tocTree && toc.length > 0) {
const links = [...tocTree.querySelectorAll('[data-toc-link]')];
const groups = [...tocTree.querySelectorAll('[data-toc-group]')];
const headings = toc
.map((item) => document.getElementById(item.id))
.filter(Boolean);
const setActiveToc = (id) => {
links.forEach((link) => {
const isActive = link.getAttribute('href') === `#${id}`;
link.classList.toggle('is-active', isActive);
if (isActive) link.setAttribute('aria-current', 'location');
else link.removeAttribute('aria-current');
});
const activeLink = tocTree.querySelector(`[data-toc-link][href="#${CSS.escape(id)}"]`);
const activeGroup = activeLink?.closest('[data-toc-group]');
groups.forEach((group) => group.classList.toggle('is-open', group === activeGroup));
};
const updateToc = () => {
const anchorOffset = 132;
let activeId = headings[0]?.id;
headings.forEach((heading) => {
if (heading.getBoundingClientRect().top <= anchorOffset) activeId = heading.id;
});
if (activeId) setActiveToc(activeId);
};
tocTree.addEventListener('click', (event) => {
const link = event.target.closest?.('[data-toc-link]');
if (!link) return;
const group = link.closest('[data-toc-group]');
groups.forEach((item) => item.classList.toggle('is-open', item === group));
});
updateToc();
window.addEventListener('scroll', updateToc, { passive: true });
window.addEventListener('resize', updateToc);
}
})();
</script> <script>
(() => {
const prose = document.querySelector('.post-article .prose');
if (!prose) return;
const collapsedLineCount = 8;
const copyLabels = {
idle: '复制',
done: '已复制',
error: '复制失败',
};
const languageNames = {
bash: 'Bash',
c: 'C',
cpp: 'C++',
cs: 'C#',
css: 'CSS',
html: 'HTML',
js: 'JavaScript',
json: 'JSON',
jsx: 'JSX',
md: 'Markdown',
powershell: 'PowerShell',
py: 'Python',
shell: 'Shell',
sh: 'Shell',
ts: 'TypeScript',
tsx: 'TSX',
txt: 'Text',
yaml: 'YAML',
yml: 'YAML',
};
function getCodeLanguage(pre, code) {
const rawLanguage =
pre.dataset.language ||
code.dataset.language ||
[...pre.classList, ...code.classList]
.find((className) => className.startsWith('language-'))
?.replace(/^language-/, '') ||
'';
const normalized = rawLanguage.trim().toLowerCase();
return languageNames[normalized] || normalized.toUpperCase() || 'Code';
}
function getCodeText(code, lines) {
if (lines.length > 0) return lines.map((line) => line.textContent || '').join('\n');
return code.textContent || '';
}
prose.querySelectorAll('img').forEach((image) => {
if (image.closest('a, .article-header-cover')) return;
const src = image.currentSrc || image.getAttribute('src');
if (!src) return;
const link = document.createElement('a');
link.className = 'fancybox';
link.href = src;
link.dataset.fancybox = 'article-gallery';
link.dataset.caption = image.getAttribute('alt') || '';
image.parentNode?.insertBefore(link, image);
link.appendChild(image);
});
prose.querySelectorAll('table').forEach((table) => {
if (table.closest('.table-scroll')) return;
const wrapper = document.createElement('div');
wrapper.className = 'table-scroll';
table.parentNode?.insertBefore(wrapper, table);
wrapper.appendChild(table);
});
prose.querySelectorAll('.butterfly-hide-inline').forEach((item) => {
const button = item.querySelector('.butterfly-hide-toggle');
if (!button) return;
button.addEventListener('click', () => {
item.classList.toggle('is-open');
});
});
if (window.Fancybox) {
window.Fancybox.bind('[data-fancybox="article-gallery"]', {
compact: false,
dragToClose: true,
Images: {
zoom: true,
},
});
}
async function copyText(text) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.top = '-9999px';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
textarea.remove();
}
prose.querySelectorAll('pre').forEach((pre) => {
if (pre.closest('.code-block')) return;
const code = pre.querySelector('code');
if (!code) return;
const wrapper = document.createElement('div');
wrapper.className = 'code-block';
pre.parentNode?.insertBefore(wrapper, pre);
wrapper.appendChild(pre);
const header = document.createElement('div');
header.className = 'code-block-header';
const language = document.createElement('span');
language.className = 'code-block-language';
language.textContent = getCodeLanguage(pre, code);
const toolbar = document.createElement('div');
toolbar.className = 'code-block-toolbar';
const copyButton = document.createElement('button');
copyButton.className = 'code-block-copy';
copyButton.type = 'button';
copyButton.textContent = copyLabels.idle;
copyButton.setAttribute('aria-label', '复制代码');
const toggleButton = document.createElement('button');
toggleButton.className = 'code-block-toggle';
toggleButton.type = 'button';
toggleButton.textContent = '展开';
toggleButton.setAttribute('aria-expanded', 'false');
const expandCue = document.createElement('button');
expandCue.className = 'code-block-expand-cue';
expandCue.type = 'button';
expandCue.setAttribute('aria-label', 'Expand code block');
expandCue.setAttribute('aria-expanded', 'false');
expandCue.innerHTML = '<span aria-hidden="true"></span>';
toolbar.append(copyButton, toggleButton);
header.append(language, toolbar);
wrapper.appendChild(header);
wrapper.appendChild(expandCue);
const codeLines = [...code.querySelectorAll(':scope > .line')];
if (codeLines.length > 0) {
wrapper.classList.add('has-line-numbers');
wrapper.style.setProperty('--line-number-width', `${String(codeLines.length).length + 1}ch`);
codeLines.forEach((line, index) => {
line.dataset.line = String(index + 1);
});
}
const lineCount = codeLines.length || (code.textContent || '').split(/\r?\n/).length;
if (lineCount > collapsedLineCount) wrapper.classList.add('is-collapsible');
copyButton.addEventListener('click', async () => {
try {
await copyText(getCodeText(code, codeLines));
copyButton.textContent = copyLabels.done;
window.setTimeout(() => {
copyButton.textContent = copyLabels.idle;
}, 1400);
} catch {
copyButton.textContent = copyLabels.error;
window.setTimeout(() => {
copyButton.textContent = copyLabels.idle;
}, 1600);
}
});
function toggleExpanded() {
const isExpanded = wrapper.classList.toggle('is-expanded');
toggleButton.textContent = isExpanded ? '收起' : '展开';
toggleButton.setAttribute('aria-expanded', String(isExpanded));
expandCue.setAttribute('aria-expanded', String(isExpanded));
}
toggleButton.addEventListener('click', toggleExpanded);
expandCue.addEventListener('click', toggleExpanded);
});
})();
</script>