V2ex Random Floor

祝你好运

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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}`)
    }
  }

})();