祝你好运
// ==UserScript==
// @name V2ex Random Floor
// @namespace http://tampermonkey.net/
// @version 2025-05-28
// @description 祝你好运
// @author You
// @match https://www.v2ex.com/t/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=v2ex.com
// @grant GM_registerMenuCommand
// @require https://cdnjs.cloudflare.com/ajax/libs/seedrandom/3.0.5/seedrandom.js
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const V2exMaxPageSize = 20
// ==UserScript菜单==
if (typeof GM_registerMenuCommand !== 'undefined') {
GM_registerMenuCommand('启动抽奖', async function() {
const urlTopicId = window.location.pathname.match(/\/t\/(\d+)/)[1];
const topicId = prompt('请输入主题ID:', urlTopicId || '');
if (!topicId) return alert('主题ID不能为空');
const defaultToken = window.localStorage.getItem('v2ex-random-floor-token') || '';
const userToken = prompt('请输入用户Token,在设置 - Tokens 中生成:', defaultToken);
if (!userToken) return alert('用户Token不能为空');
window.localStorage.setItem('v2ex-random-floor-token', userToken);
const luckyCount = parseInt(prompt('请输入抽奖人数:', '1'), 10) || 1;
if (isNaN(luckyCount) || luckyCount < 1) return alert('抽奖人数必须大于0');
const isUnique = confirm('是否用户去重? (确定=是, 取消=否)');
const today = new Date();
const endTime = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1, 0, 0, 0);
const deadline = prompt('请输入截止时间(可留空, 格式: yyyy-mm-dd HH:MM):', `${endTime.toISOString().slice(0, 10)} 24:00`);
if (new Date(deadline).getTime() < 0) return alert('截止时间格式不正确');
const maxUserCount = parseInt(prompt('请输入最大参与人次(可留空):', '0'), 10) || 0;
if (isNaN(maxUserCount) || maxUserCount < 0) return alert('最大参与人次必须大于等于0');
if (maxUserCount && maxUserCount < luckyCount) return alert('最大参与人次必须大于等于抽奖人数');
const randomSeed = prompt('请输入随机种子,如总楼层数,可引入外部随机变量保证公平性:', '');
const options = { topicId, luckyCount, isUnique, deadline, randomSeed, userToken, maxUserCount };
const rf = new RandomFloor(options);
try {
console.log(`开始抽奖: 主题ID=${rf.topicId}, 抽奖人数=${rf.luckyCount}, 用户去重=${rf.isUnique}, 截止时间=${rf.deadline}, 最大参与人次=${rf.maxUserCount}`);
await rf.run();
alert('抽奖执行完毕, 结果请查看控制台日志');
} catch (e) {
alert('抽奖出错: ' + e.message);
}
});
}
class RandomFloor {
constructor(options) {
// 主题 ID
this.topicId = options.topicId
// 随机种子
this.nextRandomSeed = new Math.seedrandom(options.randomSeed || Math.random())
// 抽奖人数
this.luckyCount = options.luckyCount || 1
// 是否用户去重
this.isUnique = options.isUnique || false
// 截止时间
this.deadline = options.deadline || 0
// 最多参与人次
this.maxUserCount = options.maxUserCount || 0
// 回帖列表
this.replies = []
// token
this.token = options.userToken || ''
}
async run() {
const replies = await this.getReplyList(this.topicId)
const validateList = this.filterReplies(replies)
this.addLog(`累计 ${replies.length} 条回帖,去重后 ${validateList.length} 条`)
if (validateList.length < this.luckyCount) {
this.addLog(`回帖数量不足,无法抽奖`)
return
}
this.addLog(`开始抽奖...`)
const nextId = this.nextRandomSeed
const luckyList = []
const luckySet = new Set()
while (luckyList.length < this.luckyCount) {
const index = Math.floor(nextId() * validateList.length)
const item = validateList[index]
if (this.isUnique && luckySet.has(item.userId)) {
continue
}
luckySet.add(item.userId)
luckyList.push(item)
}
this.addLog(`抽奖完成,中奖名单如下:`)
let messageText = `抽奖完成,中奖名单如下:\n`
luckyList.forEach(item => {
messageText += `第${item.index}楼 @${item.userName} (${item.userId})\n`
})
this.addLog(messageText)
this.addLog(`抽奖结束`)
}
get deadlineStamp() {
if (typeof this.deadline === 'number') {
return this.deadline
}
if (typeof this.deadline === 'string') {
return new Date(this.deadline).getTime()
}
if (this.deadline instanceof Date) {
return this.deadline.getTime()
}
return 0
}
filterReplies(replies) {
const { isUnique, deadlineStamp, maxUserCount } = this
const userIds = new Set()
const items = replies.filter(item => {
if (isUnique && userIds.has(item.userId)) {
return false
}
if (deadlineStamp && item.created > deadlineStamp) {
return false
}
userIds.add(item.userId)
return true
})
return maxUserCount > 0 ? items.slice(0, maxUserCount) : items
}
async getReplyList(topicId, page = 1) {
this.addLog(`获取楼层列表: 第${page}页 获取中...`)
const replies = await fetch(`/api/v2/topics/${topicId}/replies?p=${page}`, {
method: 'GET',
headers: { Authorization: 'Bearer ' + this.token }
}).then(res => {
if (res.status !== 200) {
throw new Error(`获取楼层列表失败: ${res.status} ${res.statusText}`)
}
return res.json()
})
if (!replies.success) {
throw new Error(`获取楼层列表失败: ${replies.message}`)
}
if (!replies.result.length) {
return this.replies
}
const dataList = replies.result.map((item, index) => {
return {
index: this.replies.length + index + 1,
userId: item.member.id,
userName: item.member.username,
created: item.created * 1000,
}
})
this.replies.push(...dataList)
this.addLog(`获取楼层列表: 第${page}页 获取到 ${dataList.length} 条`)
if (dataList.length < V2exMaxPageSize) {
this.addLog(`获取楼层列表: 第${page}页 获取完毕`)
return this.replies
}
return this.getReplyList(topicId, page + 1)
}
addLog(message) {
console.info(`[v2ex RandomFloor] ${message}`)
}
}
})();