Skip to content
2 changes: 2 additions & 0 deletions src/components/layout/SideBar.astro
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Advertisement from "@/components/widget/Advertisement.astro";
import Announcement from "@/components/widget/Announcement.astro";
import Calendar from "@/components/widget/Calendar.astro";
import Categories from "@/components/widget/Categories.astro";
import LaStats from "@/components/widget/LaStats.astro";
import Music from "@/components/widget/Music.astro";
import Profile from "@/components/widget/Profile.astro";
import SidebarTOC from "@/components/widget/SidebarTOC.astro";
Expand Down Expand Up @@ -47,6 +48,7 @@ const componentMap = {
sidebarToc: SidebarTOC,
advertisement: Advertisement,
stats: SiteStats,
lastats: LaStats,
calendar: Calendar,
music: Music,
siteInfo: SiteInfo,
Expand Down
133 changes: 133 additions & 0 deletions src/components/widget/LaStats.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
---
import { Icon } from "astro-icon/components";
import WidgetLayout from "@/components/common/WidgetLayout.astro";
import { analyticsConfig } from "@/config";
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
import type { WidgetComponentConfig } from "@/types/config";

interface Props {
class?: string;
style?: string;
widgetConfig?: WidgetComponentConfig;
}

const { class: className, style, widgetConfig } = Astro.props;

const showTitle = widgetConfig?.showTitle !== false;

const la51Id = analyticsConfig?.la51Analytics?.Id || "";

// 51LA 统计指标定义
const stats = [
{
icon: "material-symbols:person-outline",
label: i18n(I18nKey.laStatsOnline),
id: "la-online",
},
{
icon: "material-symbols:calendar-today-outline",
label: i18n(I18nKey.laStatsTodayUV),
id: "la-today-uv",
},
{
icon: "material-symbols:visibility-outline",
label: i18n(I18nKey.laStatsTodayPV),
id: "la-today-pv",
},
{
icon: "material-symbols:calendar-month-outline",
label: i18n(I18nKey.laStatsYesterdayUV),
id: "la-yesterday-uv",
},
{
icon: "material-symbols:visibility-outline",
label: i18n(I18nKey.laStatsYesterdayPV),
id: "la-yesterday-pv",
},
{
icon: "material-symbols:calendar-clock-outline",
label: i18n(I18nKey.laStatsMonthPV),
id: "la-month-pv",
},
{
icon: "mingcute:chart-line-line",
label: i18n(I18nKey.laStatsTotalPV),
id: "la-total-pv",
},
];
---

{la51Id && (
<WidgetLayout name={i18n(I18nKey.laStats)} showTitle={showTitle} id="la-stats" class={className} style={style}>
<div class="flex flex-col gap-2">
{stats.map((stat) => (
<div class="flex items-center justify-between px-3 py-1.5">
<div class="flex items-center gap-2.5">
<div class="text-(--primary) text-xl">
<Icon is:inline name={stat.icon} />
</div>
<span class="text-neutral-700 dark:text-neutral-300 font-medium text-sm">
{stat.label}
</span>
</div>
<span
class="text-base font-bold text-neutral-900 dark:text-neutral-100"
data-stat-id={stat.id}>
-
</span>
</div>
))}
</div>
</WidgetLayout>
)}

<script is:inline define:vars={{ la51Id }}>
(function() {
// 验证 ID 格式:仅允许字母数字字符,防止 URL 注入
if (!la51Id || !/^[A-Za-z0-9]+$/.test(la51Id)) return;

// 索引到键名的映射,与 51LA quote.js 接口返回的 <p><span>索引</span><span>值</span></p> 格式对应
var INDEX_MAP = ["la-online", "la-today-uv", "la-today-pv", "la-yesterday-uv", "la-yesterday-pv", "la-month-pv", "la-total-pv"];

function updateLaStats() {
var url = "https://v6-widget.51.la/v6/" + la51Id + "/quote.js";

fetch(url)
.then(function(res) {
if (!res.ok) throw new Error("HTTP " + res.status);
return res.text();
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): 目前的 stats 定义和 INDEX_MAP 需要手动保持同步,随着时间推移很容易被改坏。

由于映射关系一部分在 stats 中,一部分在 INDEX_MAP 中,任何重排或新增项都可能在没有明显迹象的情况下导致标签与数值错位。更安全的做法是从单一信息源派生出 INDEX_MAP(例如在每个 stats 条目上存 51LA 的索引,然后据此生成映射)。

建议实现:

    // 通过 stats 配置派生出“51LA 索引 → statId”的映射,避免手动维护两份列表
    // 期望在上方有形如:
    // const stats = [
    //   { id: "la-online", /* ... */, laIndex: 0 },
    //   { id: "la-today-uv", /* ... */, laIndex: 1 },
    //   ...
    // ];
    var INDEX_MAP = (function() {
      var map = [];
      if (Array.isArray(stats)) {
        stats.forEach(function(stat) {
          if (
            stat &&
            typeof stat.laIndex === "number" &&
            stat.laIndex >= 0
          ) {
            map[stat.laIndex] = stat.id;
          }
        });
      }
      return map;
    })();

            var idx = parseInt(m[1], 10);
            var val = parseInt(m[2], 10);
            var statId = INDEX_MAP[idx];
            if (typeof statId === "string") {
              var elements = document.querySelectorAll('[data-stat-id="' + statId + '"]');

要完整落地这个“单一信息源”的建议,还需要:

  1. 确保在 LaStats.astro 文件中更前面(或从外部导入)定义了一个 stats 数组,用于渲染统计信息的 UI;
  2. 在每个与 51LA 对应的 stats 条目上增加一个数值型字段 laIndex,与 51LA 的索引一一对应:
    • 示例:
      • la-onlinelaIndex: 0
      • la-today-uvlaIndex: 1
      • la-today-pvlaIndex: 2
      • la-yesterday-uvlaIndex: 3
      • la-yesterday-pvlaIndex: 4
      • la-month-pvlaIndex: 5
      • la-total-pvlaIndex: 6
  3. 如果某些 stats 项并非由 51LA 提供数据,则不要为其设置 laIndex;派生出的 INDEX_MAP 只会包含设了 laIndex 的条目。
  4. 删除其他地方任何硬编码的数组或重复的索引映射信息,确保 stats 是唯一的“单一信息源”。
Original comment in English

suggestion (bug_risk): The stats definition and INDEX_MAP need to stay manually in sync, which is easy to break over time.

Because the mapping lives partly in stats and partly in INDEX_MAP, any reordering or additions can silently desync labels and values. It would be safer to derive INDEX_MAP from a single source of truth (for example, store the 51LA index on each stats entry and generate the map from that).

Suggested implementation:

    // 通过 stats 配置派生出“51LA 索引 → statId”的映射,避免手动维护两份列表
    // 期望在上方有形如:
    // const stats = [
    //   { id: "la-online", /* ... */, laIndex: 0 },
    //   { id: "la-today-uv", /* ... */, laIndex: 1 },
    //   ...
    // ];
    var INDEX_MAP = (function() {
      var map = [];
      if (Array.isArray(stats)) {
        stats.forEach(function(stat) {
          if (
            stat &&
            typeof stat.laIndex === "number" &&
            stat.laIndex >= 0
          ) {
            map[stat.laIndex] = stat.id;
          }
        });
      }
      return map;
    })();

            var idx = parseInt(m[1], 10);
            var val = parseInt(m[2], 10);
            var statId = INDEX_MAP[idx];
            if (typeof statId === "string") {
              var elements = document.querySelectorAll('[data-stat-id="' + statId + '"]');

To fully implement the “single source of truth” suggestion, you should also:

  1. Ensure there is a stats array defined earlier in LaStats.astro (or imported into it) that is used to render the stats UI.
  2. Add a laIndex numeric field to each relevant stats entry corresponding to the 51LA index:
    • Example:
      • la-onlinelaIndex: 0
      • la-today-uvlaIndex: 1
      • la-today-pvlaIndex: 2
      • la-yesterday-uvlaIndex: 3
      • la-yesterday-pvlaIndex: 4
      • la-month-pvlaIndex: 5
      • la-total-pvlaIndex: 6
  3. If some stats entries are not backed by 51LA, omit laIndex on those; the derived INDEX_MAP will only include entries where laIndex is set.
  4. Remove any old hard-coded arrays or mappings that duplicate this index information elsewhere to keep stats as the single source of truth.

.then(function(text) {
var match = text.match(/r\.innerHTML\s*=\s*"([^"]+)"/);
if (!match) return;
Comment on lines +111 to +112

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): 通过对 r.innerHTML 使用过于具体的正则来解析 quote.js,对上游实现变更非常脆弱。

当前实现依赖 51LA 的脚本使用形如 r.innerHTML="..." 的写法,并且假定变量名、双引号以及赋值结构都完全一致。任何变化(例如改用单引号、更换变量名,或只是空格格式的调整)都会导致提取失败,并在无提示的情况下停止更新统计数据。请让正则对这些变体更具容错能力,并且在 match 为 falsy 时输出日志,方便发现失败。

建议实现:

        .then(function(text) {
          // Match any innerHTML assignment, allowing for different variable names,
          // single or double quotes, and flexible whitespace/newlines.
          var match = text.match(/innerHTML\s*=\s*(["'])([\s\S]*?)\1/);
          if (!match) {
            console.warn("51.LA stats: failed to extract innerHTML from quote.js", { url: url, textSample: text.slice(0, 200) });
            return;
          }

          var html = match[2];

更新后的正则 /innerHTML\s*=\s*(["'])([\s\S]*?)\1/

  • 不再依赖特定变量名(如 r),而是聚焦任意 innerHTML 赋值语句;
  • 同时支持单引号和双引号;
  • 能处理 = 与被赋值字符串之间任意空白和换行。

新增的 console.warn 能确保在提取失败时,控制台会有可见日志,并附带 URL 和响应体前 200 个字符,方便调试。如果这里只在该处解析 quote.js,则无需做其他修改。

Original comment in English

suggestion (bug_risk): Parsing quote.js via a very specific regex on r.innerHTML is brittle to upstream implementation changes.

It currently relies on 51LA’s script using r.innerHTML="..." with that exact variable name, double quotes, and assignment shape. Any variation (e.g. single quotes, different variable, or spacing change) will break extraction and silently stop updating stats. Please make the regex more tolerant to these variations, and/or log when match is falsy so failures are visible.

Suggested implementation:

        .then(function(text) {
          // Match any innerHTML assignment, allowing for different variable names,
          // single or double quotes, and flexible whitespace/newlines.
          var match = text.match(/innerHTML\s*=\s*(["'])([\s\S]*?)\1/);
          if (!match) {
            console.warn("51.LA stats: failed to extract innerHTML from quote.js", { url: url, textSample: text.slice(0, 200) });
            return;
          }

          var html = match[2];

The updated regex /innerHTML\s*=\s*(["'])([\s\S]*?)\1/ now:

  • Ignores the specific variable name (r) and focuses on any innerHTML assignment.
  • Supports both single and double quotes.
  • Handles arbitrary spacing and newlines between = and the assigned string.

The new console.warn ensures that if extraction fails, there is a visible log with the URL and a small sample of the response body to aid debugging. No other changes are required if this is the only place that parses quote.js.


var html = match[1];
var pRegex = /<p><span>(\d+)<\/span><span>(\d+)<\/span><\/p>/g;
var m;

while ((m = pRegex.exec(html)) !== null) {
var idx = parseInt(m[1], 10);
var val = parseInt(m[2], 10);
if (idx >= 0 && idx < INDEX_MAP.length) {
var elements = document.querySelectorAll('[data-stat-id="' + INDEX_MAP[idx] + '"]');
elements.forEach(function(el) {
el.textContent = val.toLocaleString();
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated
});
}
}
})
.catch(function(err) {
console.error("LA Stats load error:", err);
});
}

// 页面加载时更新
updateLaStats();

// 页面切换时重新更新
document.addEventListener("swup:contentReplaced", function() {
setTimeout(updateLaStats, 100);
});
})();
</script>
18 changes: 18 additions & 0 deletions src/config/sidebarConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,16 @@ export const sidebarLayoutConfig: SidebarLayoutConfig = {
// 是否在文章详情页显示
showOnPostPage: true,
},
{
// 组件类型:51LA访问统计组件
type: "lastats",
// 是否启用该组件
enable: true,
// 组件位置
position: "top",
// 是否在文章详情页显示
showOnPostPage: true,
},
{
// 组件类型:站点信息组件
type: "siteInfo",
Expand Down Expand Up @@ -275,6 +285,14 @@ export const sidebarLayoutConfig: SidebarLayoutConfig = {
// 是否在文章详情页显示
showOnPostPage: true,
},
{
// 组件类型:51LA访问统计组件
type: "lastats",
// 是否启用该组件
enable: true,
// 是否在文章详情页显示
showOnPostPage: true,
},
{
// 组件类型:站点信息组件
type: "siteInfo",
Expand Down
10 changes: 10 additions & 0 deletions src/i18n/i18nKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,16 @@ enum I18nKey {
siteStatsDays = "siteStatsDays",
today = "today",

// 51LA 访问统计
laStats = "laStats",
laStatsOnline = "laStatsOnline",
laStatsTodayUV = "laStatsTodayUV",
laStatsTodayPV = "laStatsTodayPV",
laStatsYesterdayUV = "laStatsYesterdayUV",
laStatsYesterdayPV = "laStatsYesterdayPV",
laStatsMonthPV = "laStatsMonthPV",
laStatsTotalPV = "laStatsTotalPV",

// 站点信息
siteInfo = "siteInfo",
siteInfoBuildTime = "siteInfoBuildTime",
Expand Down
10 changes: 10 additions & 0 deletions src/i18n/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,16 @@ export const en: Translation = {
[Key.siteStatsDays]: "{days} days",
[Key.today]: "Today",

// 51LA Visitor Statistics
[Key.laStats]: "Visitor Statistics",
[Key.laStatsOnline]: "Online Now",
[Key.laStatsTodayUV]: "Today Visitors",
[Key.laStatsTodayPV]: "Today Views",
[Key.laStatsYesterdayUV]: "Yesterday Visitors",
[Key.laStatsYesterdayPV]: "Yesterday Views",
[Key.laStatsMonthPV]: "This Month Views",
[Key.laStatsTotalPV]: "Total Views",

// Site Info
[Key.siteInfo]: "Site Info",
[Key.siteInfoBuildTime]: "Build Time",
Expand Down
10 changes: 10 additions & 0 deletions src/i18n/languages/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,16 @@ export const ja: Translation = {
[Key.siteStatsDays]: "{days} 日",
[Key.today]: "今日",

// 51LA アクセス統計
[Key.laStats]: "アクセス統計",
[Key.laStatsOnline]: "現在オンライン",
[Key.laStatsTodayUV]: "本日の訪問者",
[Key.laStatsTodayPV]: "本日の閲覧数",
[Key.laStatsYesterdayUV]: "昨日の訪問者",
[Key.laStatsYesterdayPV]: "昨日の閲覧数",
[Key.laStatsMonthPV]: "今月の閲覧数",
[Key.laStatsTotalPV]: "累計閲覧数",

// サイト情報
[Key.siteInfo]: "サイト情報",
[Key.siteInfoBuildTime]: "ビルド日時",
Expand Down
10 changes: 10 additions & 0 deletions src/i18n/languages/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,16 @@ export const ru: Translation = {
[Key.siteStatsDays]: "{days} дней",
[Key.today]: "Сегодня",

// 51LA Статистика посещений
[Key.laStats]: "Статистика посещений",
[Key.laStatsOnline]: "Сейчас онлайн",
[Key.laStatsTodayUV]: "Посетители сегодня",
[Key.laStatsTodayPV]: "Просмотры сегодня",
[Key.laStatsYesterdayUV]: "Посетители вчера",
[Key.laStatsYesterdayPV]: "Просмотры вчера",
[Key.laStatsMonthPV]: "Просмотры за месяц",
[Key.laStatsTotalPV]: "Всего просмотров",

// Информация о сайте
[Key.siteInfo]: "Информация о сайте",
[Key.siteInfoBuildTime]: "Время сборки",
Expand Down
10 changes: 10 additions & 0 deletions src/i18n/languages/zh_CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,16 @@ export const zh_CN: Translation = {
[Key.siteStatsDays]: "{days} 天",
[Key.today]: "今天",

// 51LA 访问统计
[Key.laStats]: "访问统计",
[Key.laStatsOnline]: "当前在线",
[Key.laStatsTodayUV]: "今日访客",
[Key.laStatsTodayPV]: "今日浏览",
[Key.laStatsYesterdayUV]: "昨日访客",
[Key.laStatsYesterdayPV]: "昨日浏览",
[Key.laStatsMonthPV]: "本月浏览",
[Key.laStatsTotalPV]: "累计浏览",

// 站点信息
[Key.siteInfo]: "站点信息",
[Key.siteInfoBuildTime]: "构建时间",
Expand Down
10 changes: 10 additions & 0 deletions src/i18n/languages/zh_TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,16 @@ export const zh_TW: Translation = {
[Key.siteStatsDays]: "{days} 天",
[Key.today]: "今天",

// 51LA 訪問統計
[Key.laStats]: "訪問統計",
[Key.laStatsOnline]: "當前在線",
[Key.laStatsTodayUV]: "今日訪客",
[Key.laStatsTodayPV]: "今日瀏覽",
[Key.laStatsYesterdayUV]: "昨日訪客",
[Key.laStatsYesterdayPV]: "昨日瀏覽",
[Key.laStatsMonthPV]: "本月瀏覽",
[Key.laStatsTotalPV]: "累計瀏覽",

// 站點資訊
[Key.siteInfo]: "站點資訊",
[Key.siteInfoBuildTime]: "構建時間",
Expand Down
1 change: 1 addition & 0 deletions src/types/sidebarConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type WidgetComponentType =
| "sidebarToc"
| "advertisement"
| "stats"
| "lastats"
| "calendar"
| "music"
| "siteInfo";
Expand Down
Loading