Skip to content

Instantly share code, notes, and snippets.

@suchasplus
Last active July 14, 2026 15:00
Show Gist options
  • Select an option

  • Save suchasplus/19c7bdd0cc3325d8f7b223bf0f5c937e to your computer and use it in GitHub Desktop.

Select an option

Save suchasplus/19c7bdd0cc3325d8f7b223bf0f5c937e to your computer and use it in GitHub Desktop.
discourse 刷阅读量 from opus4.6
(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