Reddit Remove Infinite Scroll

Replace infinite scroll with Old Reddit style pagination (25 posts per page) on Reddit feeds.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Reddit Remove Infinite Scroll
// @namespace    https://github.com/cantunborn
// @version      1.16
// @description  Replace infinite scroll with Old Reddit style pagination (25 posts per page) on Reddit feeds.
// @author       cantunborn
// @license      MIT
// @homepageURL  https://github.com/cantunborn/userscripts
// @supportURL   https://github.com/cantunborn/userscripts/issues
// @icon         data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+CiAgPGNpcmNsZSBjeD0iMzIiIGN5PSIzMiIgcj0iMzAiIGZpbGw9IiMxYTFhMWIiLz4KICA8dGV4dCB4PSIzMiIgeT0iMzIiIGZvbnQtc2l6ZT0iNjAiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGRvbWluYW50LWJhc2VsaW5lPSJjZW50cmFsIiBmaWxsPSIjZmY0NTAwIiBmb250LWZhbWlseT0iQXJpYWwsIEhlbHZldGljYSwgc2Fucy1zZXJpZiIgZm9udC13ZWlnaHQ9ImJvbGQiPiYjODczNDs8L3RleHQ+CiAgPGxpbmUgeDE9IjEwIiB5MT0iNTQiIHgyPSI1NCIgeTI9IjEwIiBzdHJva2U9IiNmZjQ1MDAiIHN0cm9rZS13aWR0aD0iNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+Cjwvc3ZnPgo=
// @match        https://www.reddit.com/*
// @run-at       document-start
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  function log(...args) {
    console.log('[rp]', ...args);
  }

  function isBlockedSentinel(el) {
    return !!(el && el.tagName === 'FACEPLATE-PARTIAL' && el.getAttribute('slot') === 'load-after');
  }

  function fakeHiddenEntry(entry) {
    return {
      isIntersecting: false,
      intersectionRatio: 0,
      target: entry.target,
      time: entry.time,
      boundingClientRect: entry.boundingClientRect,
      intersectionRect: entry.intersectionRect,
      rootBounds: entry.rootBounds,
    };
  }

  const NativeIntersectionObserver = window.IntersectionObserver;
  if (NativeIntersectionObserver) {
    window.IntersectionObserver = function (callback, options) {
      function wrappedCallback(entries, observer) {
        const patched = entries.map((e) => {
          if (!isBlockedSentinel(e.target)) return e;
          return fakeHiddenEntry(e);
        });
        callback(patched, observer);
      }
      return new NativeIntersectionObserver(wrappedCallback, options);
    };
    window.IntersectionObserver.prototype = NativeIntersectionObserver.prototype;
  }

  const PAGE_SIZE = 25;
  const LOAD_TIMEOUT_MS = 8000;

  let state = null;
  let lastPath = null;

  const SVG_NS = 'http://www.w3.org/2000/svg';
  const CARET_LEFT_PATH = 'M6.3 10c0-.23.088-.46.264-.636l4.6-4.6a.9.9 0 111.273 1.272L8.474 10l3.963 3.964a.9.9 0 01-1.273 1.272l-4.6-4.6A.897.897 0 016.3 10Z';
  const CARET_RIGHT_PATH = 'M13.7 10c0 .23-.088.46-.264.636l-4.6 4.6a.9.9 0 11-1.273-1.272L11.526 10 7.563 6.036a.9.9 0 011.273-1.272l4.6 4.6A.897.897 0 0113.7 10Z';

  function makeCaretIcon(path) {
    const svg = document.createElementNS(SVG_NS, 'svg');
    svg.setAttribute('fill', 'currentColor');
    svg.setAttribute('height', '16');
    svg.setAttribute('width', '16');
    svg.setAttribute('viewBox', '0 0 20 20');
    svg.style.flexShrink = '0';
    const pathEl = document.createElementNS(SVG_NS, 'path');
    pathEl.setAttribute('d', path);
    svg.appendChild(pathEl);
    return svg;
  }

  function isListingPath(pathname) {
    if (/\/comments\//.test(pathname)) return false;
    if (/^\/(message|settings|submit|premium|chat)/.test(pathname)) return false;
    return true;
  }

  const MAX_SNAPSHOT_BYTES = 3 * 1024 * 1024;

  function navigationType() {
    try {
      const entries = performance.getEntriesByType('navigation');
      if (entries.length) return entries[0].type;
    } catch (err) {
      log('navigationType: check failed', err);
    }
    return 'navigate';
  }

  function storageKeySuffix() {
    return location.pathname + location.search;
  }

  function pageStorageKey() {
    return 'reddit-paginate:page:' + storageKeySuffix();
  }

  function snapshotStorageKey() {
    return 'reddit-paginate:snapshot:' + storageKeySuffix();
  }

  function saveCurrentPage() {
    try {
      sessionStorage.setItem(pageStorageKey(), String(state.currentPage));
    } catch (err) {
      log('saveCurrentPage: failed', err);
    }
  }

  function readSavedPage() {
    try {
      const raw = sessionStorage.getItem(pageStorageKey());
      const n = parseInt(raw, 10);
      return Number.isFinite(n) && n > 1 ? n : null;
    } catch (err) {
      log('readSavedPage: failed', err);
      return null;
    }
  }

  function loadSnapshot() {
    try {
      const raw = sessionStorage.getItem(snapshotStorageKey());
      if (!raw) return [];
      const parsed = JSON.parse(raw);
      return Array.isArray(parsed) ? parsed : [];
    } catch (err) {
      log('loadSnapshot: failed', err);
      return [];
    }
  }

  function appendToSnapshot(post) {
    const key = snapshotStorageKey();
    const entries = loadSnapshot();
    entries.push({ id: post.id, html: wrapperOf(post).outerHTML });
    let serialized = JSON.stringify(entries);
    while (serialized.length > MAX_SNAPSHOT_BYTES && entries.length > 1) {
      entries.shift();
      serialized = JSON.stringify(entries);
    }
    try {
      sessionStorage.setItem(key, serialized);
    } catch (err) {
      log('appendToSnapshot: storage quota exceeded, leaving earlier snapshot in place', err);
    }
  }

  function clearSnapshot() {
    try {
      sessionStorage.removeItem(snapshotStorageKey());
    } catch (err) {
      log('clearSnapshot: failed', err);
    }
  }

  function restoreFromSnapshot(feed) {
    const entries = loadSnapshot();
    if (!entries.length) return null;

    const template = document.createElement('template');
    const restoredWrappers = [];
    const restoredPosts = [];
    for (const entry of entries) {
      template.innerHTML = entry.html;
      const wrapper = template.content.firstElementChild;
      if (!wrapper) continue;
      const post = wrapper.matches('shreddit-post') ? wrapper : wrapper.querySelector('shreddit-post');
      if (!post) continue;
      restoredWrappers.push(wrapper);
      restoredPosts.push(post);
    }
    if (!restoredPosts.length) return null;

    feed.querySelectorAll('shreddit-post').forEach((p) => wrapperOf(p).remove());

    const sentinel = feed.querySelector('faceplate-partial[slot="load-after"]');
    restoredWrappers.forEach((wrapper) => {
      if (sentinel) sentinel.insertAdjacentElement('beforebegin', wrapper);
      else feed.appendChild(wrapper);
    });
    return restoredPosts;
  }

  function wrapperOf(post) {
    return post.closest('article') || post;
  }

  function setVisible(post, visible) {
    const wrapper = wrapperOf(post);
    wrapper.style.display = visible ? '' : 'none';
    const sib = wrapper.nextElementSibling;
    if (sib && sib.tagName === 'HR') sib.style.display = visible ? '' : 'none';
  }

  function teardown() {
    if (state) {
      if (state.observer) state.observer.disconnect();
      if (state.pendingTimer) clearTimeout(state.pendingTimer);
      if (state.bar) state.bar.remove();
      const sentinel = findSentinel();
      if (sentinel) sentinel.style.removeProperty('display');
      state.posts.forEach((p) => {
        p.style.removeProperty('display');
        const sib = wrapperOf(p).nextElementSibling;
        if (sib && sib.tagName === 'HR') sib.style.removeProperty('display');
      });
    }
    state = null;
  }

  function scheduleInit() {
    const prevFeed = state ? state.feed : null;
    const prevFirstPostId = state && state.posts[0] ? state.posts[0].id : null;
    teardown();
    if (!isListingPath(location.pathname)) return;

    let tries = 0;
    const tryInit = () => {
      const feed = document.querySelector('shreddit-feed');
      const posts = feed ? Array.from(feed.querySelectorAll('shreddit-post')) : [];
      const stale = feed && feed === prevFeed && posts[0] && posts[0].id === prevFirstPostId;
      if (feed && posts.length > 0 && !stale) {
        init(feed, posts);
        return;
      }
      tries++;
      if (tries < 30) {
        setTimeout(tryInit, 300);
      } else {
        log('scheduleInit: gave up after', tries, 'tries, feed:', !!feed, 'posts:', posts.length);
      }
    };
    tryInit();
  }

  async function init(feed, posts) {
    log('init: starting with', posts.length, 'posts');
    const navType = navigationType();

    state = {
      feed,
      posts: [],
      currentPage: 1,
      hasMore: true,
      loading: false,
      pendingTimer: null,
      pendingResolve: null,
      bar: null,
      observer: null,
    };

    log('init: navType is', navType, 'live posts seen so far', posts.length);
    const restoredPosts = navType === 'back_forward' ? restoreFromSnapshot(feed) : null;
    log('init: restoreFromSnapshot returned', restoredPosts ? restoredPosts.length + ' posts' : 'null');
    if (!restoredPosts) {
      clearSnapshot();
      posts.forEach((p) => registerPost(p));
    } else {
      state.posts = restoredPosts;
    }

    state.observer = new MutationObserver((mutations) => {
      let added = false;
      for (const m of mutations) {
        for (const node of m.addedNodes) {
          if (node.nodeType !== 1) continue;
          if (node.tagName === 'SHREDDIT-POST') {
            if (registerPost(node)) added = true;
          } else if (node.querySelectorAll) {
            node.querySelectorAll('shreddit-post').forEach((p) => {
              if (registerPost(p)) added = true;
            });
          }
        }
      }
      if (added) {
        applyVisibility();
        if (state.pendingResolve) {
          clearTimeout(state.pendingTimer);
          const resolve = state.pendingResolve;
          state.pendingResolve = null;
          resolve();
        }
      }
    });
    state.observer.observe(feed, { childList: true, subtree: true });

    const savedPage = navType === 'back_forward' ? readSavedPage() : null;
    const target = savedPage || 1;
    log('init: savedPage is', savedPage, 'target is', target);
    await ensureLoadedThrough(target);
    if (state.posts.length > (target - 1) * PAGE_SIZE) {
      state.currentPage = target;
    }
    log('init: done, currentPage is', state.currentPage, 'total posts', state.posts.length);
    reflow();
  }

  function registerPost(node) {
    if (!state || state.posts.some((p) => p.id === node.id)) return false;
    state.posts.push(node);
    appendToSnapshot(node);
    return true;
  }

  function totalKnownPages() {
    return Math.max(1, Math.ceil(state.posts.length / PAGE_SIZE));
  }

  function applyVisibility() {
    const start = (state.currentPage - 1) * PAGE_SIZE;
    const end = start + PAGE_SIZE;
    state.posts.forEach((p, i) => setVisible(p, i >= start && i < end));
  }

  function reflow() {
    applyVisibility();
    renderBars();
  }

  function findSentinel() {
    const matches = state.feed.querySelectorAll('faceplate-partial[slot="load-after"]');
    if (matches.length > 1) {
      log('findSentinel: found', matches.length, 'sentinels, using last:', matches[matches.length - 1].id);
    }
    return matches.length ? matches[matches.length - 1] : null;
  }

  function getDividerBox() {
    const hr = state.feed.querySelector('article + hr') || state.feed.querySelector('hr');
    if (!hr) return null;
    const cs = getComputedStyle(hr);
    return { width: cs.width, marginLeft: cs.marginLeft, marginRight: cs.marginRight };
  }

  function applyDividerBox(el, box) {
    if (!box) return;
    el.style.width = box.width;
    el.style.marginLeft = box.marginLeft;
    el.style.marginRight = box.marginRight;
    el.style.maxWidth = 'none';
  }

  function positionBars() {
    const box = getDividerBox();

    const lastPost = state.posts[state.posts.length - 1];
    const bottomWrapper = lastPost ? wrapperOf(lastPost) : null;
    if (bottomWrapper) {
      bottomWrapper.insertAdjacentElement('afterend', state.bar);
      applyDividerBox(state.bar, box);
    } else {
      state.feed.appendChild(state.bar);
    }
    hideTrailingElements();
  }

  function hideTrailingElements() {
    if (!state.bar || !state.bar.isConnected) return;
    let sib = state.bar.nextElementSibling;
    while (sib) {
      sib.style.display = 'none';
      sib = sib.nextElementSibling;
    }
  }

  function waitForMorePosts(timeoutMs) {
    return new Promise((resolve) => {
      state.pendingResolve = resolve;
      state.pendingTimer = setTimeout(() => {
        state.pendingResolve = null;
        resolve();
      }, timeoutMs);
    });
  }

  async function requestNextBatch() {
    const sentinel = findSentinel();
    if (!sentinel) {
      log('requestNextBatch: no sentinel found, stopping');
      state.hasMore = false;
      return;
    }
    const before = state.posts.length;
    log('requestNextBatch: calling loadContent on', sentinel.id, 'before count', before);
    const waitPromise = waitForMorePosts(LOAD_TIMEOUT_MS);
    try {
      sentinel.loadContent();
    } catch (err) {
      log('requestNextBatch: loadContent threw', err);
    }
    await waitPromise;
    log('requestNextBatch: after count', state.posts.length);
    if (state.posts.length === before) {
      log('requestNextBatch: count unchanged, marking hasMore false');
      state.hasMore = false;
    }
  }

  async function ensureLoadedThrough(page) {
    const needed = page * PAGE_SIZE;
    while (state.posts.length < needed && state.hasMore) {
      state.loading = true;
      renderBars();
      await requestNextBatch();
    }
    state.loading = false;
    log('ensureLoadedThrough: done, have', state.posts.length, 'hasMore', state.hasMore);
  }

  function makeButton(label, enabled, onClick) {
    const el = document.createElement('button');
    el.type = 'button';
    const labelSpan = document.createElement('span');
    labelSpan.className = 'flex items-center gap-xs';
    labelSpan.textContent = label;
    el.appendChild(labelSpan);
    el.className = 'button button-small leading-none inline-flex items-center px-sm py-xs button-secondary';
    el.style.fontSize = 'var(--font-label-2-size, 12px)';
    if (!enabled) {
      el.disabled = true;
    } else {
      el.addEventListener('click', onClick);
    }
    return el;
  }

  function wrapIcon(svg, leading) {
    const wrap = document.createElement('span');
    wrap.className = leading ? 'flex me-2xs ms-[-2px]' : 'flex ms-2xs me-[-2px]';
    wrap.appendChild(svg);
    return wrap;
  }

  function buildPageLabel() {
    const el = document.createElement('span');
    el.className = 'whitespace-nowrap text-neutral-content-weak flex items-center';
    el.textContent = 'page ' + state.currentPage;
    return el;
  }

  function buildBarContents(barEl) {
    barEl.innerHTML = '';

    const prevBtn = makeButton('prev', state.currentPage > 1, goPrev);
    prevBtn.insertBefore(wrapIcon(makeCaretIcon(CARET_LEFT_PATH), true), prevBtn.firstChild);
    barEl.appendChild(prevBtn);

    barEl.appendChild(buildPageLabel());

    const canAdvance = state.hasMore || state.posts.length > state.currentPage * PAGE_SIZE;
    const nextBtn = makeButton(
      state.loading ? 'loading…' : 'next',
      canAdvance && !state.loading,
      goNext
    );
    if (!state.loading) {
      nextBtn.appendChild(wrapIcon(makeCaretIcon(CARET_RIGHT_PATH), false));
    }
    barEl.appendChild(nextBtn);

    if (!canAdvance) {
      const end = document.createElement('span');
      end.textContent = 'end of feed';
      applyEndLabelStyles(end);
      barEl.appendChild(end);
    }
  }

  function renderBars() {
    if (!state.bar) {
      state.bar = document.createElement('div');
      state.bar.setAttribute('data-reddit-paginate', 'bar');
      state.bar.className = 'gap-xs';
      applyBarStyles(state.bar);
    }
    positionBars();
    buildBarContents(state.bar);
  }

  function scrollToTopOfFeed() {
    const top = state.feed.getBoundingClientRect().top + window.scrollY - 8;
    window.scrollTo({ top, behavior: 'instant' });
  }

  function goPrev() {
    if (state.currentPage <= 1) return;
    state.currentPage--;
    saveCurrentPage();
    reflow();
    scrollToTopOfFeed();
  }

  async function goNext() {
    if (state.loading) return;
    const target = state.currentPage + 1;
    await ensureLoadedThrough(target);

    if (state.posts.length > (target - 1) * PAGE_SIZE) {
      state.currentPage = target;
      saveCurrentPage();
    }
    reflow();
    scrollToTopOfFeed();
  }

  function applyBarStyles(el) {
    el.style.display = 'flex';
    el.style.flexWrap = 'nowrap';
    el.style.alignItems = 'center';
    el.style.justifyContent = 'center';
    el.style.width = '100%';
    el.style.maxWidth = '100%';
    el.style.boxSizing = 'border-box';
    el.style.padding = '16px 8px';
    el.style.fontFamily = 'inherit';
    el.style.fontSize = 'var(--font-label-2-size, 14px)';
  }

  function applyEndLabelStyles(el) {
    el.style.marginLeft = '8px';
    el.style.color = 'var(--rp-fg-disabled)';
    el.style.fontSize = 'var(--font-label-2-size, 13px)';
  }

  function injectThemeVars() {
    if (document.getElementById('reddit-paginate-vars')) return;
    const style = document.createElement('style');
    style.id = 'reddit-paginate-vars';
    style.textContent = `
      :root {
        --rp-fg-disabled: var(--color-neutral-content-weak, #9b9ba1);
      }
      faceplate-partial[slot="load-after"] {
        display: none !important;
      }
    `;
    document.head.appendChild(style);
  }

  function watchNavigation() {
    lastPath = location.pathname + location.search;
    setInterval(() => {
      const currentPath = location.pathname + location.search;
      const pathChanged = currentPath !== lastPath;
      const feedGone = state && !state.feed.isConnected;
      if (pathChanged || feedGone) {
        lastPath = currentPath;
        scheduleInit();
      }
    }, 500);
  }

  function start() {
    injectThemeVars();
    scheduleInit();
    watchNavigation();
  }

  if (typeof module !== 'undefined' && module.exports) {
    // Test-only export. A userscript manager never defines `module`, so this
    // branch never runs in the browser, and `start()` never auto-runs under test.
    module.exports = {
      PAGE_SIZE,
      MAX_SNAPSHOT_BYTES,
      navigationType,
      pageStorageKey,
      snapshotStorageKey,
      saveCurrentPage,
      readSavedPage,
      loadSnapshot,
      appendToSnapshot,
      clearSnapshot,
      restoreFromSnapshot,
      registerPost,
      scheduleInit,
      watchNavigation,
      totalKnownPages,
      applyVisibility,
      wrapperOf,
      __setState: (s) => { state = s; },
      __getState: () => state,
    };
  } else if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', start);
  } else {
    start();
  }
})();