Last active
July 14, 2026 15:00
-
-
Save suchasplus/19c7bdd0cc3325d8f7b223bf0f5c937e to your computer and use it in GitHub Desktop.
discourse 刷阅读量 from opus4.6
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| (async () => { | |
| // ============ CONFIG ============ | |
| const FLUSH_INTERVAL = 30000; // 30s flush 一次,远低于频控线 | |
| const SCROLL_STEP = 300; | |
| const SCROLL_INTERVAL = 1200; | |
| const IDLE_WAIT = 4000; | |
| const MAX_IDLE_RETRIES = 5; | |
| const sleep = ms => new Promise(r => setTimeout(r, ms)); | |
| // ============ 共享缓冲区 ============ | |
| const buffer = {}; // { topicId: { timings: { postNum: ms }, topicTime: number } } | |
| const stats = { jquery: 0, xhr: 0, fetch: 0 }; | |
| function accumulate(bodyData, source) { | |
| try { | |
| let params; | |
| if (typeof bodyData === 'string') { | |
| params = new URLSearchParams(bodyData); | |
| } else if (bodyData instanceof URLSearchParams) { | |
| params = bodyData; | |
| } else if (typeof bodyData === 'object' && bodyData !== null) { | |
| // jQuery 可能传 plain object | |
| params = new URLSearchParams(); | |
| const flat = (obj, prefix) => { | |
| for (const [k, v] of Object.entries(obj)) { | |
| const key = prefix ? `${prefix}[${k}]` : k; | |
| if (typeof v === 'object' && v !== null) flat(v, key); | |
| else params.append(key, v); | |
| } | |
| }; | |
| flat(bodyData, ''); | |
| } else { | |
| return false; | |
| } | |
| const topicId = params.get('topic_id'); | |
| if (!topicId) return false; | |
| if (!buffer[topicId]) buffer[topicId] = { timings: {}, topicTime: 0 }; | |
| buffer[topicId].topicTime += parseInt(params.get('topic_time') || '0', 10); | |
| for (const [key, val] of params.entries()) { | |
| const m = key.match(/^timings\[(\d+)\]$/); | |
| if (m) { | |
| const pn = m[1], ms = parseInt(val, 10); | |
| buffer[topicId].timings[pn] = Math.max(buffer[topicId].timings[pn] || 0, ms); | |
| } | |
| } | |
| stats[source]++; | |
| const count = Object.keys(buffer[topicId].timings).length; | |
| console.log(`🔄 [${source}] 拦截 topic ${topicId}, 积压 ${count} 条`); | |
| return true; | |
| } catch (e) { | |
| console.warn('accumulate 解析失败:', e); | |
| return false; | |
| } | |
| } | |
| // ============ LAYER 1: jQuery.ajaxTransport ============ | |
| // Discourse 的 ajax() 最终调 jQuery.ajax(),这是最靠谱的拦截点 | |
| if (window.jQuery) { | |
| jQuery.ajaxTransport('+*', function(options) { | |
| if (options.url && options.url.includes('/topics/timings')) { | |
| return { | |
| send: function(_headers, completeCallback) { | |
| accumulate(options.data, 'jquery'); | |
| // 返回 200,jQuery 会正确 resolve deferred | |
| setTimeout(() => completeCallback(200, 'OK', { text: '{}' }, ''), 5); | |
| }, | |
| abort: function() {} | |
| }; | |
| } | |
| // 其他 URL 返回 undefined → 走默认 transport | |
| }); | |
| console.log('✅ Layer 1: jQuery.ajaxTransport 已注册'); | |
| } else { | |
| console.warn('⚠️ Layer 1: jQuery 不存在,跳过'); | |
| } | |
| // ============ LAYER 2: XMLHttpRequest 原型 ============ | |
| const _xhrOpen = XMLHttpRequest.prototype.open; | |
| const _xhrSend = XMLHttpRequest.prototype.send; | |
| XMLHttpRequest.prototype.open = function(method, url, ...rest) { | |
| this.__isTimings = ( | |
| method.toUpperCase() === 'POST' && | |
| typeof url === 'string' && | |
| url.includes('/topics/timings') | |
| ); | |
| return _xhrOpen.call(this, method, url, ...rest); | |
| }; | |
| XMLHttpRequest.prototype.send = function(body) { | |
| if (this.__isTimings && accumulate(body, 'xhr')) { | |
| const xhr = this; | |
| setTimeout(() => { | |
| try { | |
| Object.defineProperty(xhr, 'readyState', { value: 4, configurable: true }); | |
| Object.defineProperty(xhr, 'status', { value: 200, configurable: true }); | |
| Object.defineProperty(xhr, 'statusText', { value: 'OK', configurable: true }); | |
| Object.defineProperty(xhr, 'responseText', { value: '{}', configurable: true }); | |
| Object.defineProperty(xhr, 'response', { value: '{}', configurable: true }); | |
| } catch (e) { /* 部分浏览器可能抛异常,后续事件仍可触发 */ } | |
| const rscEvt = new Event('readystatechange'); | |
| xhr.dispatchEvent(rscEvt); | |
| if (xhr.onreadystatechange) xhr.onreadystatechange(rscEvt); | |
| const loadEvt = new ProgressEvent('load', { loaded: 2, total: 2 }); | |
| xhr.dispatchEvent(loadEvt); | |
| if (xhr.onload) xhr.onload(loadEvt); | |
| const endEvt = new ProgressEvent('loadend', { loaded: 2, total: 2 }); | |
| xhr.dispatchEvent(endEvt); | |
| if (xhr.onloadend) xhr.onloadend(endEvt); | |
| }, 5); | |
| return; | |
| } | |
| return _xhrSend.call(this, body); | |
| }; | |
| console.log('✅ Layer 2: XHR prototype 已 patch'); | |
| // ============ LAYER 3: window.fetch ============ | |
| const _fetch = window.fetch; | |
| window.fetch = function(input, init) { | |
| const url = typeof input === 'string' ? input : input?.url; | |
| if (url?.includes('/topics/timings') && init?.method?.toUpperCase() === 'POST') { | |
| if (accumulate(init.body, 'fetch')) { | |
| return Promise.resolve(new Response('{}', { status: 200 })); | |
| } | |
| } | |
| return _fetch.apply(this, arguments); | |
| }; | |
| console.log('✅ Layer 3: window.fetch 已 patch'); | |
| // ============ FLUSH 定时器 ============ | |
| const csrfMeta = document.querySelector('meta[name="csrf-token"]'); | |
| const csrf = csrfMeta ? csrfMeta.content : ''; | |
| async function flush() { | |
| for (const topicId of Object.keys(buffer)) { | |
| const { timings, topicTime } = buffer[topicId]; | |
| const keys = Object.keys(timings); | |
| if (keys.length === 0) continue; | |
| const body = new URLSearchParams(); | |
| body.append('topic_id', topicId); | |
| body.append('topic_time', topicTime); | |
| for (const [pn, ms] of Object.entries(timings)) { | |
| body.append(`timings[${pn}]`, ms); | |
| } | |
| try { | |
| // 用原始 fetch 发送(绕过我们自己的 patch) | |
| const resp = await _fetch('/topics/timings', { | |
| method: 'POST', | |
| headers: { | |
| 'X-CSRF-Token': csrf, | |
| 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', | |
| 'X-Requested-With': 'XMLHttpRequest', | |
| 'Discourse-Present': 'true', | |
| }, | |
| body: body.toString(), | |
| credentials: 'same-origin', | |
| }); | |
| if (resp.status === 429) { | |
| console.warn(`⚠️ flush 仍 429, 60s 后重试`); | |
| await sleep(60000); | |
| return; // 不清空,下次重试 | |
| } | |
| console.log(`✅ flush topic ${topicId}: ${keys.length} posts, status ${resp.status}`); | |
| delete buffer[topicId]; | |
| } catch (e) { | |
| console.error('flush 失败:', e); | |
| } | |
| } | |
| } | |
| const flushTimer = setInterval(flush, FLUSH_INTERVAL); | |
| // ============ 诊断输出 ============ | |
| const diagTimer = setInterval(() => { | |
| console.log(`📊 拦截统计 → jQuery: ${stats.jquery} | XHR: ${stats.xhr} | fetch: ${stats.fetch}`); | |
| const pending = Object.entries(buffer).map(([tid, b]) => | |
| `topic ${tid}: ${Object.keys(b.timings).length} posts` | |
| ).join(', '); | |
| if (pending) console.log(`📦 缓冲区: ${pending}`); | |
| }, 10000); | |
| // ============ 滚动逻辑 ============ | |
| let idleCount = 0, lastHeight = 0; | |
| console.log('🐙 开始滚动...'); | |
| while (true) { | |
| window.scrollBy({ top: SCROLL_STEP, behavior: 'smooth' }); | |
| await sleep(SCROLL_INTERVAL); | |
| const currentHeight = document.documentElement.scrollHeight; | |
| const scrollPos = window.scrollY + window.innerHeight; | |
| if (scrollPos >= currentHeight - 5) { | |
| if (currentHeight === lastHeight) { | |
| idleCount++; | |
| if (idleCount >= MAX_IDLE_RETRIES) { | |
| await flush(); | |
| clearInterval(flushTimer); | |
| clearInterval(diagTimer); | |
| // 恢复原始方法 | |
| XMLHttpRequest.prototype.open = _xhrOpen; | |
| XMLHttpRequest.prototype.send = _xhrSend; | |
| window.fetch = _fetch; | |
| console.log('📊 最终统计 → jQuery: ' + stats.jquery + ' | XHR: ' + stats.xhr + ' | fetch: ' + stats.fetch); | |
| console.log('✅ 滚动结束,所有 patch 已卸载'); | |
| break; | |
| } | |
| await sleep(IDLE_WAIT); | |
| } else { | |
| idleCount = 0; | |
| } | |
| lastHeight = currentHeight; | |
| } else { | |
| idleCount = 0; | |
| lastHeight = currentHeight; | |
| } | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment