起因 天塌了,我一直是使用右键打开帖子,难怪话题浏览数酷酷往下掉。 - 搞七捻三 - LINUX DO
这应该算discourse的bug,因为习惯使用新标签打开每一个L站的帖子浏览,最近突然发现自己的浏览话题数居然不达标了,但是我基本都是高强度猛猛刷L站的,这下可难受了,3级要不保了。于是使用AI把自己的油猴脚本更新了一下,目前可以正常增加话题浏览数了。
// ==UserScript==
// @name Linux.do & IDCFlare 新标签打开话题并记录浏览量
// @namespace local-linuxdo
// @version 3.2.0
// @description 强制话题新标签打开,并在后台补发 Discourse 浏览追踪请求,支持 linux.do 和 idcflare.com
// @author sansan048
// @match https://linux.do/*
// @match https://idcflare.com/*
// @run-at document-start
// @grant none
// ==/UserScript==
(() => {
"use strict";
const CONFIG = {
SUPPORTED_HOSTS: ["linux.do", "idcflare.com"],
PENDING_TTL_MS: 30 * 1000,
DONE_TTL_MS: 8 * 60 * 60 * 1000,
TOPIC_PAGE_FALLBACK_DELAYS_MS: [2500, 10000],
FETCH_TIMEOUT_MS: 8000,
ENABLE_TOPIC_JSON_FALLBACK: true,
DEBUG_KEY: "discourse-track-view-patch-debug",
PREFIX: "discourse-track-view-v4.3.2:",
OLD_PREFIXES: [
"discourse-track-view-success:",
"discourse-track-view-attempt:",
"discourse-track-view-v4.2:",
"discourse-track-view-v4.3:"
],
/**
* 普通左键时,只有这些区域的话题链接才强制新标签打开。
* 这样可以避免误伤话题页标题、编辑按钮、页面内部按钮。
*/
FORCE_NEW_TAB_SELECTOR: [
".topic-list a.title",
".topic-list a.raw-topic-link",
".search-results a.topic-title",
".search-results .topic-title a",
".user-menu .notification a",
".user-menu .quick-access-panel li a",
// 帖子正文里的链接,包括引用里的话题链接
".topic-body .cooked a",
".topic-body blockquote a",
".cooked a"
].join(", "),
/**
* 这些区域绝不拦截。
* 主要用于保护话题标题、编辑标题按钮、帖子操作按钮、管理按钮等。
*/
NEVER_INTERCEPT_SELECTOR: [
"#topic-title",
".title-wrapper",
".topic-title-wrapper",
".fancy-title",
".edit-topic",
".edit-topic-title",
".topic-admin-menu-button",
".post-controls",
".actions",
".topic-map",
".timeline-container",
".topic-navigation",
".topic-footer-main-buttons",
".btn",
".d-button",
".widget-button",
"button",
"[role='button']",
".no-track-view-patch"
].join(", ")
};
const memoryStore = new Map();
function debugEnabled() {
return safeGetItem(CONFIG.DEBUG_KEY) === "1";
}
function log(...args) {
if (debugEnabled()) {
console.debug("[discourse-track-view-patch]", ...args);
}
}
function warn(...args) {
if (debugEnabled()) {
console.warn("[discourse-track-view-patch]", ...args);
}
}
function safeGetItem(key) {
try {
const value = localStorage.getItem(key);
return value === null ? memoryStore.get(key) || null : value;
} catch {
return memoryStore.get(key) || null;
}
}
function safeSetItem(key, value) {
try {
localStorage.setItem(key, value);
} catch {
memoryStore.set(key, value);
}
}
function safeRemoveItem(key) {
try {
localStorage.removeItem(key);
} catch {
// ignore
}
memoryStore.delete(key);
}
function safeSessionGetItem(key) {
try {
return sessionStorage.getItem(key);
} catch {
return memoryStore.get(`session:${key}`) || null;
}
}
function safeSessionSetItem(key, value) {
try {
sessionStorage.setItem(key, value);
} catch {
memoryStore.set(`session:${key}`, value);
}
}
function cleanupOldKeys() {
try {
for (const key of Object.keys(localStorage)) {
if (CONFIG.OLD_PREFIXES.some((prefix) => key.startsWith(prefix))) {
localStorage.removeItem(key);
}
}
} catch {
// ignore
}
}
function randomId() {
if (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function") {
return globalThis.crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function isSupportedHost(hostname) {
return CONFIG.SUPPORTED_HOSTS.some((host) => {
return hostname === host || hostname.endsWith(`.${host}`);
});
}
function getTopicInfo(rawUrl = location.href) {
let url;
try {
url = new URL(rawUrl, location.href);
} catch {
return null;
}
if (!isSupportedHost(url.hostname)) return null;
const parts = url.pathname.split("/").filter(Boolean);
const markerIndex = parts.findIndex((part) => part === "t" || part === "n");
if (markerIndex === -1) return null;
const topicId = parts
.slice(markerIndex + 1)
.find((part) => /^\d+$/.test(part));
if (!topicId) return null;
return {
url,
topicId: String(topicId)
};
}
function getCurrentTopicInfo() {
return getTopicInfo(location.href);
}
function isSameTopicAsCurrentPage(info) {
const current = getCurrentTopicInfo();
return Boolean(
current &&
info &&
current.url.origin === info.url.origin &&
current.topicId === info.topicId
);
}
function stateKey(info) {
return `${CONFIG.PREFIX}${info.url.hostname}:${info.topicId}`;
}
function readState(info) {
try {
return JSON.parse(safeGetItem(stateKey(info)) || "null");
} catch {
return null;
}
}
function writeState(info, state) {
safeSetItem(stateKey(info), JSON.stringify(state));
}
function clearStateIfTokenMatches(info, token) {
const state = readState(info);
if (state?.token === token) {
safeRemoveItem(stateKey(info));
}
}
function hasFreshState(info) {
const state = readState(info);
return Boolean(state?.expiresAt && state.expiresAt > Date.now());
}
function claim(info, source, force = false) {
if (!force && hasFreshState(info)) {
log("skip fresh state", {
source,
topicId: info.topicId,
state: readState(info)
});
return null;
}
const token = randomId();
writeState(info, {
status: "pending",
token,
source,
createdAt: Date.now(),
expiresAt: Date.now() + CONFIG.PENDING_TTL_MS
});
return token;
}
function markDone(info, token, result) {
writeState(info, {
status: result.confirmed ? "confirmed" : "accepted",
token,
result,
createdAt: Date.now(),
expiresAt: Date.now() + CONFIG.DONE_TTL_MS
});
}
function getMetaContent(name) {
return document.querySelector(`meta[name="${name}"]`)?.content || "";
}
function getBasePath() {
return (getMetaContent("discourse-base-uri") || "").replace(/\/$/, "");
}
function getCsrfToken() {
return getMetaContent("csrf-token");
}
function getTrackingSessionId() {
const metaValue = getMetaContent("discourse-track-view-session-id");
if (metaValue) return metaValue;
const key = `${CONFIG.PREFIX}session-id`;
let value = safeSessionGetItem(key);
if (!value) {
value = randomId();
safeSessionSetItem(key, value);
}
return value;
}
function commonHeaders() {
const headers = {
Accept: "application/json, text/javascript, */*; q=0.01",
"X-Requested-With": "XMLHttpRequest"
};
const csrfToken = getCsrfToken();
if (csrfToken) {
headers["X-CSRF-Token"] = csrfToken;
}
return headers;
}
function buildPageviewHeaders(info, referrerUrl) {
return {
...commonHeaders(),
"Discourse-Present": "true",
"Discourse-Track-View-Deferred": "true",
"Discourse-Track-View-Topic-Id": String(info.topicId),
"Discourse-Track-View-Url": info.url.href,
"Discourse-Track-View-Referrer": referrerUrl || document.referrer || "",
"Discourse-Track-View-Session-Id": getTrackingSessionId()
};
}
function buildTopicJsonHeaders(info) {
return {
...commonHeaders(),
"Discourse-Present": "true",
"Discourse-Track-View": "true",
"Discourse-Track-View-Topic-Id": String(info.topicId)
};
}
async function fetchWithTimeout(url, options, timeoutMs) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, {
...options,
signal: controller.signal
});
} finally {
clearTimeout(timer);
}
}
function readResponseResult(endpoint, response) {
return {
endpoint,
status: response.status,
ok: response.ok,
trackView: response.headers.get("x-discourse-trackview"),
browserPageView: response.headers.get("x-discourse-browserpageview"),
url: response.url
};
}
function serializeError(error) {
return {
name: error?.name || "Error",
message: error?.message || String(error)
};
}
async function sendPageview(info, referrerUrl) {
const url = `${info.url.origin}${getBasePath()}/pageview`;
const response = await fetchWithTimeout(
url,
{
method: "POST",
credentials: "same-origin",
cache: "no-store",
keepalive: true,
headers: buildPageviewHeaders(info, referrerUrl)
},
CONFIG.FETCH_TIMEOUT_MS
);
return readResponseResult("pageview", response);
}
async function sendTopicJsonTrack(info) {
const url = `${info.url.origin}${getBasePath()}/t/${info.topicId}.json?track_visit=true&forceLoad=true`;
const response = await fetchWithTimeout(
url,
{
method: "GET",
credentials: "same-origin",
cache: "no-store",
headers: buildTopicJsonHeaders(info)
},
CONFIG.FETCH_TIMEOUT_MS
);
return readResponseResult("topic-json-fallback", response);
}
function isConfirmedAttempt(result) {
if (!result) return false;
if (result.browserPageView === "1") return true;
if (result.trackView === "1") return true;
if (result.browserPageView === "0") return false;
if (result.trackView === "0") return false;
return false;
}
function isAcceptedAttempt(result) {
return Boolean(result?.ok);
}
async function sendTrackRequest(info, referrerUrl) {
const attempts = [];
try {
const pageviewResult = await sendPageview(info, referrerUrl);
attempts.push(pageviewResult);
if (isConfirmedAttempt(pageviewResult)) {
return {
confirmed: true,
accepted: true,
confirmedBy: "pageview-header",
attempts
};
}
} catch (error) {
attempts.push({
endpoint: "pageview",
ok: false,
error: serializeError(error)
});
}
if (CONFIG.ENABLE_TOPIC_JSON_FALLBACK) {
try {
const jsonResult = await sendTopicJsonTrack(info);
attempts.push(jsonResult);
if (isConfirmedAttempt(jsonResult)) {
return {
confirmed: true,
accepted: true,
confirmedBy: "topic-json-header",
attempts
};
}
} catch (error) {
attempts.push({
endpoint: "topic-json-fallback",
ok: false,
error: serializeError(error)
});
}
}
const accepted = attempts.some(isAcceptedAttempt);
return {
confirmed: false,
accepted,
confirmedBy: accepted ? "http-ok-without-track-header" : null,
attempts
};
}
async function trackTopicView(info, source, referrerUrl, force = false) {
if (!info) return;
if (info.url.origin !== location.origin) return;
const token = claim(info, source, force);
if (!token) return;
try {
log("track start", {
source,
topicId: info.topicId,
url: info.url.href
});
const result = await sendTrackRequest(info, referrerUrl);
log("track result", {
source,
topicId: info.topicId,
result
});
if (result.confirmed || result.accepted) {
markDone(info, token, result);
log("track done", {
topicId: info.topicId,
status: result.confirmed ? "confirmed" : "accepted",
confirmedBy: result.confirmedBy
});
} else {
clearStateIfTokenMatches(info, token);
warn("track not accepted", {
source,
topicId: info.topicId,
result
});
}
} catch (error) {
clearStateIfTokenMatches(info, token);
warn("track fatal error", {
source,
topicId: info.topicId,
error: serializeError(error)
});
}
}
function shouldNeverIntercept(link) {
if (!link) return true;
if (!link.href) return true;
/**
* 关键修复:
* 在话题页内,标题、编辑按钮、hash 链接等经常会解析成当前话题 URL。
* 如果目标就是当前话题,不拦截,让 Discourse 自己处理。
*/
const info = getTopicInfo(link.href);
if (info && info.url.origin === location.origin && isSameTopicAsCurrentPage(info)) {
return true;
}
if (link.closest(CONFIG.NEVER_INTERCEPT_SELECTOR)) return true;
return false;
}
function handleClick(event) {
const link = event.target?.closest?.("a[href]");
if (shouldNeverIntercept(link)) return;
const info = getTopicInfo(link.href);
if (!info || info.url.origin !== location.origin) return;
const isPlainLeftClick =
event.type === "click" &&
event.button === 0 &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
!event.altKey;
const isModifiedLeftClick =
event.type === "click" &&
event.button === 0 &&
(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey);
const isMiddleClick =
event.type === "auxclick" &&
event.button === 1;
if (isPlainLeftClick) {
/**
* 普通左键只对指定区域强制新标签。
* 这样不会误伤标题栏、编辑按钮、页面 UI 链接。
*/
if (!link.matches(CONFIG.FORCE_NEW_TAB_SELECTOR)) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
window.open(info.url.href, "_blank", "noopener");
trackTopicView(info, "opener-plain-left-click", location.href);
return;
}
if (isModifiedLeftClick || isMiddleClick) {
/**
* Ctrl/Cmd + 左键、中键:
* 不阻止默认行为,只补发浏览。
*/
trackTopicView(info, "modified-or-middle-click", location.href);
}
}
function trackCurrentTopic(source, force = false) {
const info = getTopicInfo(location.href);
if (!info) return;
trackTopicView(info, source, document.referrer || "", force);
}
function scheduleTopicPageFallback() {
const schedule = () => {
for (const delay of CONFIG.TOPIC_PAGE_FALLBACK_DELAYS_MS) {
setTimeout(() => {
trackCurrentTopic(`topic-page-fallback-${delay}ms`);
}, delay);
}
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", schedule, { once: true });
} else {
schedule();
}
}
cleanupOldKeys();
document.addEventListener("click", handleClick, true);
document.addEventListener("auxclick", handleClick, true);
scheduleTopicPageFallback();
window.__discourseTrackViewPatchRetry = () => {
trackCurrentTopic("manual-retry", true);
};
window.__discourseTrackViewPatchStatus = () => {
const info = getTopicInfo(location.href);
const state = info ? readState(info) : null;
console.log({
info,
state,
debug: debugEnabled(),
enableDebug: `localStorage.setItem("${CONFIG.DEBUG_KEY}", "1"); location.reload();`,
disableDebug: `localStorage.removeItem("${CONFIG.DEBUG_KEY}"); location.reload();`,
manualRetry: "__discourseTrackViewPatchRetry()"
});
};
})();
可以继续愉快的猛猛刷L站了![]()
更新了一下脚本,也同时支持隔壁IDC Flare站了。
6.10更新
补发话题浏览数打开方式支持:
1.普通左键点击帖子。
2.Ctrl + 左键。
3.Cmd + 左键。
4.Shift + 左键。
5.鼠标中键点击。
6.右键菜单 → 在新标签页打开。
edge浏览器,win11环境进行了简单测试,目前都会增加话题浏览数,用不同设备的佬友请自行测试。
6.15 终于重回3级了。


