一、起因
刷烧饼论坛(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 页之多,老用户了。
四、第二版:按真实结构重写
思路理顺后就简单了:
脚本从页面顶栏自动认出当前登录账号的 UID(不写死,换账号也能用);
去我的回复页一页页拉记录,只认带
reply_id的链接(那才是真回复条目),把话题号收集成一个集合;回到话题列表,逐条比对标题链接里的帖子号,命中就在标题后插一个「已回复」徽章;
集合存浏览器本地缓存,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 · 一个技术小白的折腾记录)
评论