GTA World Forums – Like All (Default Reaction) + Post Counter

One-click “Like All” (uses current/default reaction only) for the current GTA World forum topic page + live post counter.

当前为 2025-08-11 提交的版本,查看 最新版本

// ==UserScript==
// @name         GTA World Forums – Like All (Default Reaction) + Post Counter
// @version      1.1
// @description  One-click “Like All” (uses current/default reaction only) for the current GTA World forum topic page + live post counter.
// @author       blanco
// @license      All Rights Reserved
// @namespace    https://gf.qytechs.cn/users/1496525
// @match        https://forum.gta.world/*topic/*
// @run-at       document-idle
// @noframes
// @grant        none
// @icon         data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M2 21h4V9H2v12zM22 10a2 2 0 0 0-2-2h-5.31l.95-4.57.03-.32a1 1 0 0 0-.29-.7L14 2 7.59 8.41A2 2 0 0 0 7 9.83V19a2 2 0 0 0 2 2h8a2 2 0 0 0 1.85-1.23l3-7a2 2 0 0 0 .15-.77v-2z'/></svg>
// ==/UserScript==

(() => {
  'use strict';

  const CONFIG = {
    likeDelay: 500,
    likeDelayJitterPct: 0.4,
    badgeBottom: 150,
    counterDebounce: 250,
    maxRetries: 3,
    rateLimitPause: 30000
  };

  const state = {
    running: false,
    paused: false,
    aborted: false
  };

  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  const jitter = (ms, pct=CONFIG.likeDelayJitterPct) => {
    const d = ms * pct;
    return Math.max(0, Math.round(ms + (Math.random() * 2 - 1) * d));
  };
  const whenVisible = async () => {
    while (document.visibilityState === 'hidden') {
      await sleep(500);
    }
  };
  const parseRetryAfter = (v) => {
    if (!v) return null;
    const n = Number(v);
    if (!Number.isNaN(n)) return n * 1000;
    const t = Date.parse(v);
    const diff = t - Date.now();
    return Number.isFinite(diff) && diff > 0 ? diff : null;
  };

  const notifEl = (() => {
    const el = document.createElement('div');
    el.id = 'forum-like-notification';
    el.setAttribute('role', 'status');
    el.setAttribute('aria-live', 'polite');
    Object.assign(el.style, {
      position: 'fixed', top: '20px', right: '20px',
      padding: '12px 24px', borderRadius: '6px', fontSize: '16px', zIndex: 10001,
      boxShadow: '0 2px 8px rgba(0,0,0,.18)', opacity: '.98', pointerEvents: 'none',
      transition: 'opacity .2s', maxWidth: '60ch'
    });
    document.body.appendChild(el);
    return el;
  })();
  let notifTimer = null;
  function showNotification(msg, dur = 3000) {
    const dark = matchMedia('(prefers-color-scheme: dark)').matches;
    notifEl.style.background = dark ? '#222' : '#f5f5f5';
    notifEl.style.color = dark ? '#fff' : '#222';
    notifEl.textContent = msg;
    notifEl.style.display = 'block';
    if (notifTimer) clearTimeout(notifTimer);
    notifTimer = setTimeout(() => { notifEl.style.display = 'none'; }, dur);
  }

  const badgeEl = (() => {
    const el = document.createElement('div');
    el.id = 'forum-post-count';
    el.setAttribute('aria-live', 'polite');
    Object.assign(el.style, {
      position: 'fixed', right: '20px', bottom: `${CONFIG.badgeBottom}px`,
      padding: '8px 16px', borderRadius: '8px', fontSize: '15px', zIndex: 10001,
      boxShadow: '0 2px 8px rgba(0,0,0,.18)', pointerEvents: 'none', userSelect: 'none'
    });
    document.body.appendChild(el);
    return el;
  })();

  function updateBadge() {
    const cnt = document.querySelectorAll('article[id^="elComment_"]').length;
    const dark = matchMedia('(prefers-color-scheme: dark)').matches;
    badgeEl.style.background = dark ? '#222' : '#f5f5f5';
    badgeEl.style.color = dark ? '#fff' : '#222';
    badgeEl.textContent = `Posts: ${cnt}`;
  }

  const debounce = (fn, wait) => {
    let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn.apply(null, args), wait); };
  };
  const debouncedUpdate = debounce(() => {
    requestAnimationFrame(updateBadge);
  }, CONFIG.counterDebounce);

  window.addEventListener('load', updateBadge);
  const ROOT = document.getElementById('elContent') || document.body;
  const mo = new MutationObserver(muts => {
    for (const m of muts) {
      if (m.addedNodes.length || m.removedNodes.length) { debouncedUpdate(); break; }
    }
  });
  mo.observe(ROOT, { childList: true, subtree: true });

  const controlsWrap = (() => {
    const wrap = document.createElement('div');
    Object.assign(wrap.style, {
      position: 'fixed', right: '20px', bottom: '80px', zIndex: 10001,
      display: 'flex', gap: '8px', alignItems: 'center'
    });
    document.body.appendChild(wrap);
    return wrap;
  })();

  function makeBtn(label, aria, onClick) {
    const b = document.createElement('button');
    b.type = 'button';
    b.textContent = label;
    b.setAttribute('aria-label', aria);
    Object.assign(b.style, {
      padding: '8px 10px', borderRadius: '10px', border: '1px solid rgba(0,0,0,.1)',
      boxShadow: '0 2px 8px rgba(0,0,0,.08)', cursor: 'pointer',
      background: matchMedia('(prefers-color-scheme: dark)').matches ? '#222' : '#fff',
      color: matchMedia('(prefers-color-scheme: dark)').matches ? '#fff' : '#222',
      fontSize: '13px'
    });
    b.addEventListener('click', onClick);
    b.addEventListener('keydown', e => {
      if (e.key === 'Enter' || e.key === ' ') onClick();
    });
    controlsWrap.appendChild(b);
    return b;
  }

  const startBtn = makeBtn('Like All', 'Like all posts on this page', () => toggleRun());
  const pauseBtn = makeBtn('Pause', 'Pause liking', () => togglePause());
  const stopBtn  = makeBtn('Stop', 'Stop liking',  () => stopRun());

  function reflectButtons() {
    pauseBtn.textContent = state.paused ? 'Resume' : 'Pause';
    startBtn.disabled = state.running && !state.paused;
    pauseBtn.disabled = !state.running;
    stopBtn.disabled  = !state.running;
  }

  function toggleBtnProgress(i, total) {
    const label = i == null ? 'Like All' : `Liking ${i} / ${total}`;
    startBtn.title = label;
    startBtn.setAttribute('aria-label', label);
  }

  function fetchDefaultReactionLinks() {
    const sel = 'span.ipsReact_button[data-action="reactLaunch"]:not(.ipsReact_button_selected):not(.ipsReact_button--selected) a.ipsReact_reaction[data-role="reaction"][data-defaultreaction]';
    const seen = new Set();
    const links = [];
    document.querySelectorAll(sel).forEach(a => {
      const href = a.getAttribute('href') || '';
      if (href.includes('do=reactComment') && /[?&]reaction=\d+/.test(href) && /[?&]csrfKey=/.test(href)) {
        if (!seen.has(href)) { seen.add(href); links.push(a); }
      }
    });
    return links;
  }

  const abortCtrl = new AbortController();
  window.addEventListener('beforeunload', () => abortCtrl.abort());

  async function likeViaAjax(link, attempt = 0) {
    try {
      const r = await fetch(link.href, {
        method: 'GET',
        credentials: 'include',
        signal: abortCtrl.signal,
        headers: { 'X-Requested-With': 'XMLHttpRequest', 'Referer': location.href }
      });
      if (r.status === 429) {
        const ra = parseRetryAfter(r.headers.get('Retry-After'));
        const pause = ra ?? CONFIG.rateLimitPause;
        showNotification(`Rate-limited — pausing ${Math.round(pause/1000)}s`);
        await sleep(pause);
        return likeViaAjax(link, attempt + 1);
      }
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      return true;
    } catch {
      if (attempt < CONFIG.maxRetries && !abortCtrl.signal.aborted) {
        const backoff = jitter(2 ** attempt * 1000, 0.25);
        await sleep(backoff);
        return likeViaAjax(link, attempt + 1);
      }
      return false;
    }
  }

  async function run() {
    state.running = true;
    state.paused = false;
    state.aborted = false;
    reflectButtons();

    const links = fetchDefaultReactionLinks();
    if (!links.length) {
      state.running = false;
      reflectButtons();
      showNotification('No un-reacted posts found on this page.');
      return;
    }

    let ok = 0, fail = 0;
    toggleBtnProgress(0, links.length);
    links.sort(() => Math.random() - 0.5);

    for (let i = 0; i < links.length; i++) {
      if (state.aborted || abortCtrl.signal.aborted) break;
      while (state.paused) await sleep(150);
      await whenVisible();
      toggleBtnProgress(i + 1, links.length);
      (await likeViaAjax(links[i])) ? ok++ : fail++;
      await sleep(jitter(CONFIG.likeDelay));
    }

    state.running = false;
    state.paused = false;
    reflectButtons();
    toggleBtnProgress(null, null);
    if (!state.aborted) showNotification(`Finished! ✔️ ${ok} ❌ ${fail}`);
  }

  function toggleRun() {
    if (!state.running) run();
  }
  function togglePause() {
    if (!state.running) return;
    state.paused = !state.paused;
    reflectButtons();
    showNotification(state.paused ? 'Paused.' : 'Resumed.');
  }
  function stopRun() {
    if (!state.running) return;
    state.aborted = true;
    state.paused = false;
    state.running = false;
    reflectButtons();
    toggleBtnProgress(null, null);
    showNotification('Stopped.');
  }

  window.addEventListener('beforeunload', () => {
    mo.disconnect();
    if (notifTimer) clearTimeout(notifTimer);
  });

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址