一、起因

刷烧饼论坛(sb.sb)的时候有个烦恼:列表里几十个帖子,哪个我回过、哪个没回过,全靠脑子记。经常兴冲冲点进去,拉到底才发现——这帖我昨天就回过了。

就想给列表加个小功能:我回复过的话题,标题后面自动挂一个「已回复」标记,一眼跳过,不浪费点进去的那几秒。

论坛本身没这功能,但浏览器插件能办。最后走的是油猴(Tampermonkey)这条路:它能往网页里塞一段自己写的小脚本,改网页的显示。脚本请我的 AI 助手微光(Muse)来写,我负责出主意、验收和……当侦察兵。过程比想象中曲折,记录一下。

二、第一版:上来就翻车

我把需求一说,助手很快交了一版脚本:它默认这个论坛是 Discourse(国外很流行的一套论坛程序)搭的,接口、页面结构全按 Discourse 的规矩写的。

装上刷新——毫无反应。打开浏览器控制台一看,一条红色报错:它去请求的 /session/current.json 返回 404,不存在。

结论当场明确:sb.sb 根本不是 Discourse,是站长自研的一套系统。 第一版从根上就是猜的,猜错了,整版作废。

这一跤摔得挺值:AI 写代码不是许愿,它不知道现场长什么样的时候,就会拿「最常见的样子」来猜。猜的东西,必须先验。

三、侦查:拿控制台当放大镜

不知道结构,就查。我在论坛页面按 F12 打开控制台(Console),按助手给的探针命令一条条跑,把这个自研论坛的底摸清了三件事。

第一件:话题列表的条目长什么样。 把主区域的 HTML 打出来一段:

console.log(document.querySelector('main').innerHTML.slice(0,2000))

再专门挑话题链接看它的条目结构:

var as=[...document.querySelectorAll('a[href^="/t/"]')].filter(a=>/\/t\/\d+/.test(a.getAttribute('href'))); console.log('话题数', as.length); if(as[0]){var li=as[0].closest('li')||as[0].parentElement.parentElement; console.log(li.outerHTML.slice(0,1600));}

看清了:每条话题是一个 li.post-item,标题是 a.post-title,链接形如 /t/话题号/——帖子号就明摆在链接里,标记插在标题后面即可。

第二件:我的回复记录在哪儿。 个人主页里有「回复」页,地址是 /u/我的UID/replies。在那页跑一条:

console.log(location.href, [...document.querySelectorAll('a[href^="/t/"]')].map(x=>x.getAttribute('href')).filter(h=>/\/t\/\d+/.test(h)).slice(0,10).join(' '))

打出来的链接形如 /t/15075/?reply_id=250685——每条回复都直达对应话题,话题号照样能抠出来。数据源有了。

第三件:翻页格式。 回复页的分页是 /u/我的UID/replies/page/2、/page/3……顺带发现我的回复有 105 页之多,老用户了。

四、第二版:按真实结构重写

思路理顺后就简单了:

  1. 脚本从页面顶栏自动认出当前登录账号的 UID(不写死,换账号也能用);

  2. 去我的回复页一页页拉记录,只认带 reply_id 的链接(那才是真回复条目),把话题号收集成一个集合;

  3. 回到话题列表,逐条比对标题链接里的帖子号,命中就在标题后插一个「已回复」徽章;

  4. 集合存浏览器本地缓存,12 小时才全量刷新一次;所有比对都在本地进行,不往外发任何数据。

两个取舍说明一下:

  • 只拉最近 25 页,不拉全部 105 页。 一口气对论坛连发上百个请求,动静太大,也容易被限流。最近 500 多条回复覆盖日常刷帖绰绰有余,代价只是很久以前回过的老帖可能不亮标。

  • 徽章只管「回复过」,不管「发过」。 我自己发的主帖不算回复,不标——要的就是这个区分。

五、治「慢」:两个毛病,两味药

第二版能用,但慢,两个慢法:

毛病一:打开列表半天不亮标。 原因是 25 页排队一页页拉、全拉完才开始标。改法:5 页并行拉,拉到一批就先标一批,第一批零点几秒就到,体感基本是打开就有。

毛病二:刚回复完的帖子不亮标。 原因在缓存:刚写下的回复还没进缓存名单。改法:脚本盯着页面切换——从话题页一回到列表,就立刻补拉最新一页回复记录合并进来;切回浏览器窗口时也顺手补一次。新回复从此秒亮。

六、定稿

最后是审美环节:第一版徽章是浅绿描边,太温柔,扫列表时容易漏看。换成 橙红实心底 + 白色粗字——白底列表上最跳,又和论坛自带的「置顶」浅色徽章不混。一眼扫过去,回过的帖子全挂着红牌子,清清爽爽。

完整代码如下,装油猴(Tampermonkey)后新建脚本、整段粘贴保存即可,只对 sb.sb 生效:

// ==UserScript==
// @name         sb.sb 已回复标记
// @namespace    sb.sb.tools
// @version      2.1.1
// @description  在 sb.sb 话题列表中,为自己已经回复过的话题加上「已回复」标签(v2.1:刚回复立即显示、首屏加速)
// @author       Fraxinus & 微光
// @match        https://sb.sb/*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  var CACHE_KEY = 'sb_replied_topics_v2';
  var CACHE_TTL = 12 * 60 * 60 * 1000; // 全量记录缓存 12 小时
  var MAX_PAGES = 25;                  // 全量最多拉取 25 页回复记录
  var CONCURRENCY = 5;                 // 并行拉取的页数
  var REFRESH_GAP = 8 * 1000;          // 最新页补拉的最小间隔

  var myUid = null;
  var repliedIds = new Set(); // 回复过的话题 id(字符串)
  var lastRefresh = 0;
  var lastPath = location.pathname;

  // 从顶栏/菜单里找当前登录账号的个人主页链接,认出自己的 UID
  function findMyId() {
    var links = document.querySelectorAll(
      '.top a[href^="/u/"], header a[href^="/u/"], #mobile-menu a[href^="/u/"]'
    );
    for (var i = 0; i < links.length; i++) {
      var m = /^\/u\/(\d+)\/?($|[?#])/.exec(links[i].getAttribute('href') || '');
      if (m) return m[1];
    }
    return null;
  }

  function loadCache(uid) {
    try {
      var obj = JSON.parse(localStorage.getItem(CACHE_KEY) || 'null');
      if (!obj || obj.uid !== uid) return null;
      if (Date.now() - obj.ts > CACHE_TTL) return null;
      return obj.ids;
    } catch (e) {
      return null;
    }
  }

  function saveCache() {
    try {
      localStorage.setItem(CACHE_KEY, JSON.stringify({
        uid: myUid, ts: Date.now(), ids: Array.from(repliedIds)
      }));
    } catch (e) { /* 存不下就算了 */ }
  }

  // 拉一页回复记录,只认带 reply_id 的话题链接(那才是真回复条目)
  function fetchPageIds(url) {
    return fetch(url, { credentials: 'same-origin' })
      .then(function (r) {
        if (!r.ok) throw new Error('HTTP ' + r.status);
        return r.text();
      })
      .then(function (html) {
        var doc = new DOMParser().parseFromString(html, 'text/html');
        var ids = {};
        doc.querySelectorAll('a[href*="reply_id="]').forEach(function (a) {
          var m = /\/t\/(\d+)\//.exec(a.getAttribute('href') || '');
          if (m) ids[m[1]] = 1;
        });
        return Object.keys(ids);
      })
      .catch(function () { return []; });
  }

  function pageUrl(n) {
    return n === 1
      ? '/u/' + myUid + '/replies'
      : '/u/' + myUid + '/replies/page/' + n;
  }

  // 合并新 id:有新增就立刻补标并更新缓存
  function addIds(ids) {
    var grew = false;
    ids.forEach(function (id) {
      if (!repliedIds.has(id)) { repliedIds.add(id); grew = true; }
    });
    if (grew) { mark(); saveCache(); }
    return grew;
  }

  // 全量拉取:5 页一批并行,拉到一批标一批
  function fetchAllReplied() {
    var pages = [];
    for (var n = 1; n <= MAX_PAGES; n++) pages.push(n);
    var idx = 0;
    function worker() {
      if (idx >= pages.length) return Promise.resolve();
      var p = pages[idx++];
      return fetchPageIds(pageUrl(p)).then(function (ids) {
        addIds(ids);
        return worker();
      });
    }
    var workers = [];
    for (var i = 0; i < CONCURRENCY; i++) workers.push(worker());
    return Promise.all(workers).then(function () {
      console.info('[已回复标记] 全量拉取完成:已回复话题 ' + repliedIds.size + ' 个');
    });
  }

  // 只补拉最新一页(刚回复完回到列表时用)
  function refreshFirstPage(force) {
    var now = Date.now();
    if (!force && now - lastRefresh < REFRESH_GAP) return;
    lastRefresh = now;
    fetchPageIds(pageUrl(1)).then(addIds);
  }

  function addStyle() {
    if (document.getElementById('sb-replied-style')) return;
    var css = '.sb-replied-badge{display:inline-block;margin-left:8px;padding:0 7px;' +
      'font-size:11px;line-height:18px;font-weight:600;color:#fff;background:#ee4d2d;' +
      'border:1px solid #d63c1e;border-radius:9px;vertical-align:middle;white-space:nowrap}';
    var s = document.createElement('style');
    s.id = 'sb-replied-style';
    s.textContent = css;
    document.head.appendChild(s);
  }

  function mark() {
    if (!repliedIds.size) return;
    var titles = document.querySelectorAll('li.post-item a.post-title[href^="/t/"]');
    titles.forEach(function (a) {
      var m = /\/t\/(\d+)\//.exec(a.getAttribute('href') || '');
      if (!m || !repliedIds.has(m[1])) return;
      var row = a.closest('.post-title-row') || a.parentElement;
      if (!row || row.querySelector('.sb-replied-badge')) return;
      var b = document.createElement('span');
      b.className = 'sb-replied-badge';
      b.textContent = '已回复';
      a.after(b);
    });
  }

  var timer = null;
  function scheduleMark() {
    if (timer) return;
    timer = setTimeout(function () { timer = null; mark(); }, 200);
  }

  function observe() {
    if (!document.body) return;
    new MutationObserver(scheduleMark).observe(document.body, {
      childList: true, subtree: true
    });
    // 单页应用切页面时:补标;从话题页回到列表时,立刻补拉最新回复
    setInterval(function () {
      if (location.pathname === lastPath) return;
      var wasTopic = /^\/t\/\d+/.test(lastPath);
      lastPath = location.pathname;
      mark();
      refreshFirstPage(wasTopic);
    }, 800);
    document.addEventListener('visibilitychange', function () {
      if (!document.hidden) refreshFirstPage(false);
    });
    window.addEventListener('focus', function () { refreshFirstPage(false); });
  }

  function start(uid) {
    myUid = uid;
    addStyle();
    var cached = loadCache(uid);
    if (cached) {
      cached.forEach(function (id) { repliedIds.add(id); });
      console.info('[已回复标记] 用缓存:已回复话题 ' + repliedIds.size + ' 个');
      mark();
      observe();
      refreshFirstPage(true); // 缓存之外先补一页最新,防止刚回复的漏掉
      return;
    }
    console.info('[已回复标记] 当前账号 UID=' + uid + ',开始拉取回复记录…');
    observe();
    fetchAllReplied();
  }

  // 顶栏可能稍晚才渲染,最多等 10 秒找自己的 UID
  var tries = 0;
  (function waitForId() {
    var uid = findMyId();
    if (uid) { start(uid); return; }
    if (++tries > 20) return; // 没登录或找不到,就静默不工作
    setTimeout(waitForId, 500);
  })();
})();

七、小结

回头看,这个小脚本真正费劲的不是写代码,是搞清现场:论坛是什么系统、列表是什么结构、数据藏在哪一页。前两版之间的差距,全在侦查那三条控制台命令上。

给同样想折腾的朋友一句大实话:让 AI 写东西之前,先把真实情况喂给它;它交的东西,先验再用。这套配合下来,技术小白也能给自己常逛的网站添个顺手的小功能。

*(TECH NEWBIE · 一个技术小白的折腾记录)