V2ex Random Floor

祝你好运

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

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

})();