写了一个油猴脚本解决L站新标签打开帖子导致浏览话题数不涨(更新支持IDC Flare,多种方式打开新标签页)

起因 天塌了,我一直是使用右键打开帖子,难怪话题浏览数酷酷往下掉。 - 搞七捻三 - 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站了:rofl:

更新了一下脚本,也同时支持隔壁IDC Flare站了。

6.10更新
补发话题浏览数打开方式支持:
1.普通左键点击帖子。
2.Ctrl + 左键。
3.Cmd + 左键。
4.Shift + 左键。
5.鼠标中键点击。
6.右键菜单 → 在新标签页打开。
edge浏览器,win11环境进行了简单测试,目前都会增加话题浏览数,用不同设备的佬友请自行测试。

6.15 终于重回3级了。

真的吗 试试看 才知道现在不算了 右键新窗口真的好用啊 选一堆出来打开出来 然后慢慢看 :joy:

之前在tg的channel看帖子,发现也是不会加浏览话题的,有了这个脚本,在channel点击话题默认浏览器打开,也是一个解决方案 :saluting_face:

佬可以解释一下脚本逻辑吗?不知道是否违规,怕怕

原理就是左键点击帖子会在新标签页打开页面,和你用鼠标中键或者Ctrl加左键点击帖子的效果一样,只不过补发一次Discourse浏览追踪用于记录话题浏览数。

看看这位老友写的,似乎不需要新标签也打开了

挺好的,只不过我还是习惯水平的标签页

虽然安装了 佬的脚本 但是 还是忍不住去按鼠标中键打开…

也可以的,脚本主要是解决话题浏览数不生效的问题,不然要掉3级了

虽然已经飞升了,此乃造福还没飞升佬友,善举,善举!:saluting_face:

经常刷帖的时候,看到好几个想看的,点一个看完再返回翻不到了,或者要翻好久,这真是好东西,太需要了,感谢佬 :clap:

太感谢了,我就是掉了一直上不去,今天才知道原因

我一度怀疑是我账号出了问题,现在已经掉到100多了 :joy:
感谢佬友分享~

好用,感谢佬 :saluting_face:
一直奇怪怎么话题数涨不了,就差这个了
准备飞升 :nerd_face:

不知是哪里没配置对,加载脚本后,command + 鼠标左键打开跳去新标签页,实际并没有统计增加

多刷几个帖子看看,浏览话题数不是实时更新的。

还是不行,macos + chrome (149.0.7827.103),直接点击帖子,去查看马上能看到数字增加,command + 左键打开新标签页,就一直没变化,脚本应该是有加载的

mac设备我没有,你可以把脚本给gpt,让它帮你改一个适配command+左键打开标签页的功能。

非常好用,我的话题浏览数终于有救了,谢谢佬

救星啊,马上加入试用,体验看疗效