博客更新提醒系统升级:RSS 全文指纹检测 + 正文修改局部高亮 + 缓存漏洞修复

摘要

作者重构了博客更新提醒功能,彻底抛弃旧版时间判断方案,改用全文内容指纹比对。新系统不仅能精准识别新增及正文修改,还修复了缓存漏洞。视觉上仅对修改内容进行局部绿色高亮,且更新弹窗增加了滚动限制以防止页面卡顿。整体方案轻量、安全,大幅提升了阅读体验。

上一篇文章中,我为博客部署了一套新文章提醒功能,依靠 RSS 实现新文章推送弹窗,让访客可以快速知晓博客最新动态。

经过一段时间实际使用,旧版本存在不少硬性缺陷:依赖时间判断、时区不统一、只能识别新发文章、无法检测正文修改、清空提醒存在严重缓存漏洞、大面积下划线影响阅读、长时间未访问容易造成页面卡顿。

因此,我对整套更新提醒代码进行完整重构,彻底抛弃老旧的时间判断方案,改用全文内容指纹比对,搭配句子级差异识别,实现正文修改精准检测、仅改动内容下划线高亮、安全无漏洞、多更新自动滚动防炸屏,整体体验大幅提升。

旧版本存在的明显不足

  1. 依靠时间判断,时区错乱

    旧逻辑以文章发布时间为判定标准,不同地区、不同设备时区不同,容易出现误提醒、漏提醒,全球访客体验不一致。

  2. 只读取摘要,无法识别正文修改

    旧方案仅读取 RSS 摘要内容,只要文章不重新发布,哪怕大量修改、补充内容,网站也完全不会提示更新。

  3. 清空提醒存在高危 Bug

    访客点击清空按钮时,会直接删除本地全部文章比对缓存。

    一旦缓存被清空,下次打开网站,所有历史旧文章都会被误判为全新文章,大量弹窗刷屏。

  4. 更新标记范围过大

    检测到更新后,会给整篇文章添加下划线,整片内容密密麻麻,严重干扰正常阅读。

  5. 无高度限制,大量更新容易卡顿

    如果读者很久没有访问,累积几十条更新,旧弹窗没有滚动限制,会无限拉长页面、渲染大量节点,导致网页卡顿甚至卡死。

本次新版升级核心亮点

1. 彻底放弃时间机制,纯内容比对

新版不再依赖任何时间、时区、地区参数。

无论访客在国内还是海外,判断标准完全统一,从根源杜绝时间错乱带来的误判问题。

2. 读取完整正文,改一个字都能识别

不再读取简短摘要,专门读取 RSS 内 content:encoded 完整正文区块,完美适配 CDATA 纯文本格式。

将全文经过文本清洗、编码加密生成唯一内容指纹,只要文章内容发生任意修改,指纹即刻变化,精准判定为内容更新。

3. 修复缓存漏洞,区分「提醒」与「基准数据」

新版严格区分两种本地数据:

  • 文章内容指纹基准:长期保留,作为内容对比依据,永远不会被清空

  • 弹窗提醒、文字高亮:属于临时视觉提示,可手动一键清除

点击垃圾桶清空,只会关闭弹窗、移除页面高亮样式,不会触碰核心缓存,彻底解决旧版全量误标问题。

4. 局部句子高亮,只给修改内容添加下划线

这是本次最实用的体验升级。

通过前后文本差异对比,自动拆分段落与语句,精准筛选出新增、修改的内容

不会整篇划线,仅对改动文字增加:绿色下划线 + 淡绿色背景。

访客再次阅读旧文时,可以一眼看到作者后期补充、修改的段落,不用通篇翻阅回忆内容变化。

5. 弹窗滚动限制,再多更新也不会炸屏

更新弹窗增加固定最大高度与纵向滚动条。

即便间隔很久未访问、累积大量更新列表,也会局部滚动展示,不会无限撑开页面、不会造成 DOM 冗余渲染,兼顾美观与浏览性能。

6. 轻量纯前端实现,搜索引擎完全友好

整套功能全部为前端本地逻辑,不依赖后端、不会额外请求、无服务器压力。

更新弹窗、文字高亮、本地存储交互,仅对真人访客生效

搜索引擎爬虫不会执行前端交互脚本,只会抓取纯净静态文章内容,完全不影响网站收录与 SEO 表现。

新旧版本简单对比

  • 判断方式:旧版「时间」→ 新版「全文内容指纹」

  • 识别范围:旧版「仅新文章」→ 新版「新增文章 + 正文修改」

  • 地区适配:旧版「时区错乱」→ 新版「完全脱离时间,全局统一」

  • 清空逻辑:旧版「删缓存、漏洞严重」→ 新版「只清视觉,安全稳定」

  • 标记样式:旧版「整篇下划线」→ 新版「仅修改内容局部标注」

  • 长周期访问:旧版「无限列表易卡顿」→ 新版「滚动容器,性能稳定」

纯净开源代码(无隐私、无统计、无多余配置)

下面为精简后的纯净开源版本,移除了所有站点私有配置、统计代码、第三方脚本,只保留核心更新提醒能力,任意博客均可直接部署使用。

TSX
import { useEffect, useState, useMemo } from "react";

// 文章更新条目类型
interface ArticleChange {
  title: string;
  link: string;
  isNew: boolean;
  isModified: boolean;
  newText: string;
}

// 本地缓存指纹类型
interface ArticleCacheItem {
  link: string;
  title: string;
  fingerprint: string;
}

// 纯文本清洗
const cleanText = (text: string) => {
  return text.replace(/\s+/g, " ").trim();
};

// 生成内容指纹
const getFingerprint = (text: string) => {
  return btoa(encodeURIComponent(cleanText(text)));
};

// 解码指纹,还原旧版正文
const decodeFingerprint = (fp: string): string => {
  try {
    return decodeURIComponent(atob(fp));
  } catch {
    return "";
  }
};

// 句子级差异比对
const getDiffSentences = (oldText: string, newText: string): string[] => {
  const split = (s: string) =>
    s.split(/[。!?;]/).map(t => t.trim()).filter(Boolean);
  const oldArr = split(oldText);
  const newArr = split(newText);
  return newArr.filter(t => !oldArr.includes(t));
};

export default function RootNoticeLayout({ children }: { children: React.ReactNode }) {
  const [changes, setChanges] = useState<ArticleChange[]>([]);
  const [showPopup, setShowPopup] = useState(true);
  const [firstVisit, setFirstVisit] = useState(true);

  const CACHE_KEY = "blog_articles_v5";

  // 读取本地文章指纹缓存
  const localCache = useMemo<ArticleCacheItem[]>(() => {
    try {
      return JSON.parse(localStorage.getItem(CACHE_KEY) || "[]");
    } catch {
      return [];
    }
  }, []);

  // 安全清空:仅关闭提醒+移除高亮,不删除核心缓存
  const clearAllUpdates = () => {
    setChanges([]);
    setShowPopup(false);

    document.querySelectorAll(".diff-marked").forEach(el => {
      el.style.background = "";
      el.style.textDecoration = "";
      el.style.textDecorationColor = "";
      el.style.textDecorationThickness = "";
      el.style.textUnderlineOffset = "";
    });

    document.querySelectorAll(".article-update-tip").forEach(el => el.remove());
  };

  // 请求 RSS 检测文章更新
  const fetchAndDetectChanges = async () => {
    try {
      const res = await fetch("/rss.xml", { signal: AbortSignal.timeout(10000) });
      if (!res.ok) return;
      const text = await res.text();
      const xml = new DOMParser().parseFromString(text, "application/xml");
      const items = xml.querySelectorAll("item");

      const newCache: ArticleCacheItem[] = [];
      const newChanges: ArticleChange[] = [];

      items.forEach(item => {
        const title = item.querySelector("title")?.textContent || "无标题";
        const link = item.querySelector("link")?.textContent || "#";
        // 读取 content:encoded 完整正文,适配 CDATA
        const content = item.querySelector("*|encoded")?.textContent || "";
        const fp = getFingerprint(content);

        newCache.push({ link, title, fingerprint: fp });

        const found = localCache.find(x => x.link === link);
        if (!found) {
          newChanges.push({ title, link, isNew: true, isModified: false, newText: content });
        } else if (found.fingerprint !== fp) {
          newChanges.push({ title, link, isNew: false, isModified: true, newText: content });
        }
      });

      localStorage.setItem(CACHE_KEY, JSON.stringify(newCache));
      setChanges(newChanges);
    } catch (err) {}
  };

  useEffect(() => {
    // 首次访问直接初始化缓存
    if (localStorage.getItem(CACHE_KEY)) {
      setFirstVisit(false);
    }
    fetchAndDetectChanges();
  }, []);

  // 文章页:局部修改内容下划线高亮
  useEffect(() => {
    const path = window.location.pathname;
    const curr = changes.find(x => x.link === path);
    if (!curr || curr.isNew) return;

    const article = document.querySelector(".prose") || document.querySelector(".article-content");
    if (!article || article.querySelector(".diff-marked")) return;

    const cache = localCache.find(x => x.link === path);
    if (!cache) return;

    const oldText = decodeFingerprint(cache.fingerprint);
    const newText = cleanText(curr.newText);
    const diffs = getDiffSentences(oldText, newText);
    if (diffs.length === 0) return;

    // 遍历文本节点,标记改动内容
    const walk = (el: Element) => {
      el.childNodes.forEach(node => {
        if (node.nodeType === 3) {
          let txt = node.textContent || "";
          let changed = false;
          diffs.forEach(seg => {
            if (txt.includes(seg)) {
              changed = true;
              txt = txt.replaceAll(seg,
                `<span class="diff-marked" style="background:rgba(34,197,94,0.12);text-decoration:underline;text-decoration-color:#22c55e;text-underline-offset:2px;">${seg}</span>`
              );
            }
          });
          if (changed && node.parentNode) {
            const span = document.createElement("span");
            span.innerHTML = txt;
            node.parentNode.replaceChild(span, node);
          }
        } else if (node.nodeType === 1) {
          walk(node as Element);
        }
      });
    };

    walk(article);

    // 顶部更新提示条
    const tip = document.createElement("div");
    tip.style.cssText = "margin:0 0 16px;padding:8px 14px;border-left:3px solid #22c55e;background:rgba(34,197,94,0.08);border-radius:6px;font-size:14px;";
    tip.textContent = "✏️ 本文有内容更新,修改段落已标注";
    article.parentNode?.insertBefore(tip, article);
  }, [changes, localCache]);

  return (
    <>
      {children}

      {/* 更新弹窗 + 滚动限制,防止大量更新撑爆屏幕 */}
      {!firstVisit && showPopup && changes.length > 0 && (
        <div style={{
          position: "fixed",
          bottom: "80px",
          right: "20px",
          width: "320px",
          background: "var(--background)",
          border: "1px solid var(--border)",
          borderRadius: "14px",
          padding: "14px 16px",
          boxShadow: "0 10px 30px -8px rgba(0,0,0,0.08)",
          zIndex: 999
        }}>
          <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "10px" }}>
            <div style={{ fontWeight: 600 }}>最新更新</div>
            <div style={{ display: "flex", gap: "8px" }}>
              <button onClick={clearAllUpdates} style={{ background: "none", border: "none", cursor: "pointer" }}>🗑️</button>
              <button onClick={() => setShowPopup(false)} style={{ background: "none", border: "none", cursor: "pointer" }}>×</button>
            </div>
          </div>

          <div style={{ maxHeight: "220px", overflowY: "auto", display: "flex", flexDirection: "column", gap: "8px" }}>
            {changes.map((item, i) => (
              <a
                key={i}
                href={item.link}
                style={{
                  display: "flex",
                  alignItems: "center",
                  gap: "8px",
                  color: "var(--foreground)",
                  textDecoration: "none",
                  whiteSpace: "nowrap",
                  overflow: "hidden",
                  textOverflow: "ellipsis"
                }}>
                <span style={{
                  fontSize: "11px",
                  padding: "2px 6px",
                  border: "1px solid #f97316",
                  color: "#f97316",
                  borderRadius: "4px"
                }}>
                  {item.isNew ? "新增" : "更新"}
                </span>
                {item.title}
              </a>
            ))}
          </div>
        </div>
      )}
    </>
  );
}

总结

本次更新完全重构了博客更新提醒逻辑,

从「依赖时间」升级为「内容指纹比对」,

解决了时区、缓存漏洞、正文修改无法检测、阅读体验差、页面卡顿等全部问题。

既能让读者清晰看到文章后期修改与补充的内容,

又能保证长期访问的稳定性与页面性能,

整套方案轻量、安全、干净,适合长期使用。

正文结束
更新Cookie偏好设置