欢迎光临散文网 会员登陆 & 注册

转载 感谢 盛百凡 写的动态评论区抽奖脚本工具的源代码

2022-09-12 12:34 作者:善良使人健康快乐有钱  | 我要投稿

/**

 * 抽奖号的日常2: 如何专业地评论区开奖

 *

 * @author [盛百凡]{@link https://space.bilibili.com/14064125}

 * @version 1.5.0

 * @see [Weighted random sampling with a reservoir]{@link https://doi.org/10.1016/j.ipl.2005.11.003}

 */

(async () => {

  'use strict';

  // 暂停

  const wait = async delay => new Promise(resolve => setTimeout(resolve, delay));


  // 清空控制台

  await wait(0);

  console.clear();


  // 用户配置

  const USER_CONFIG = {

    // 本地保存所有评论

    SAVE_COMMENTS: true,

    // 单用户累计评论数上限

    MAX_REPEAT: 5,

    // 用户等级权重

    LEVEL_WEIGHT: {Lv0: 0, Lv1: 0, Lv2: 0, Lv3: 0.5, Lv4: 1, Lv5: 1, Lv6: 1},

    // 勋章等级权重

    MEDAL_WEIGHT: {

      Lv_0: 1,

      Lv_1: 1, Lv_2: 1, Lv_3: 1, Lv_4: 1, Lv_5: 1, Lv_6: 1, Lv_7: 1, Lv_8: 1, Lv_9: 1, Lv10: 1,

      Lv11: 1, Lv12: 1, Lv13: 1, Lv14: 1, Lv15: 1, Lv16: 1, Lv17: 1, Lv18: 1, Lv19: 1, Lv20: 1,

      Lv21: 1, Lv22: 1, Lv23: 1, Lv24: 1, Lv25: 1, Lv26: 1, Lv27: 1, Lv28: 1, Lv29: 1, Lv30: 1,

      Lv31: 1, Lv32: 1, Lv33: 1, Lv34: 1, Lv35: 1, Lv36: 1, Lv37: 1, Lv38: 1, Lv39: 1, Lv40: 1

    },

    // 会员类型权重

    VIP_WEIGHT: {普通: 1, 月度: 1, 年度: 1}

  };


  // 系统配置

  const SYS_CONFIG = {

    // API请求间隔(毫秒)

    API_INTERVAL: 250

  };


  // 控制台颜色

  const COLOR = {

    RED: '#EE230D',

    PINK: '#FF8CC6',

    ORANGE: '#FF9201',

    GREEN: '#1DB100',

    BLUE: '#02A2FF',

    GRAY: '#D6D5D5'

  };


  // 格式百分比

  const stylePercent = (num, digits) => {

    if (num < 0 || num > 1) {

      throw `百分比[${num}]必须在[0, 1]之间`;

    }

    const maxLen = digits === 0 ? 3 : digits + 4;

    if (num === 0) {

      return {text: 'N/A'.padStart(maxLen, ' '), css: `color:${COLOR.GRAY}`};

    }

    return {text: (100 * num).toFixed(digits).padStart(maxLen, ' '), css: ''};

  };


  // 格式整数

  const styleInt = (num, maxLen, color) => ({text: num.toString().padStart(maxLen, ' '), css: color === undefined ? '' : `color:${color}`});


  // 格式A(用户等级 认证类型 会员类型)

  const styleA = (words, color, key) => ({key: key, text: words[0], css: `border-radius:3px;color:#FFFFFF;background:${color};padding:1px`});


  // 格式B(勋章等级)

  const styleB = (words, color, key) => {

    const style = {

      key: key,

      prefix: {text: words[0], css: `border-radius:3px 0 0 3px;color:#FFFFFF;background:${color};padding:1px 0 1px 1px`},

      main: {text: words[1], css: `border-radius:0 3px 3px 0;border-style:solid;border-width:1px;border-color:${color};color:${color}`}

    };

    if (words[1].length === 1) {

      style.main.css += ';padding:0 0.5ch';

    }

    return style;

  };


  // 格式捕捉器

  const styleHandler = (style, prefix) => ({

    get(target, prop, receiver) {

      const origin = Reflect.get(target, prop, receiver);

      if (typeof prop === 'string' && Reflect.has(target, prop) && !Number.isNaN(+prop)) {

        if (prefix === undefined) {

          return style([origin[0]], origin[1], origin[0]);

        }

        return style([prefix, prop], origin[1], origin[0]);

      }

      return origin;

    }

  });


  // 控制台格式化

  const consoleFormat = (styles, separator = '') => {

    // 转义

    const escape = msg => {

      if (msg === null || msg === undefined) {

        return `%c${msg}`;

      }

      return '%c' + msg.toString().replaceAll('%', '%%');

    };

    const text = [];

    const css = [];

    for (const s of styles) {

      if (s === null || s === undefined || s === '') {

        continue;

      }

      if (typeof s === 'string') {

        text.push(escape(s));

        css.push('');

      } else if (s.main === undefined) {

        text.push(escape(s.text));

        css.push(s.css);

      } else if (s.prefix !== undefined) {

        text.push(escape(s.prefix.text) + escape(s.main.text));

        css.push(s.prefix.css, s.main.css);

      } else {

        throw `格式错误 ${styles}`;

      }

      text.push(escape(separator));

      css.push('');

    }

    return {text: text.join(''), css: css};

  };


  // 图标格式

  const STYLE = {

    // 用户等级

    LEVEL: new Proxy([

      ['Lv0', '#BFBFBF'],

      ['Lv1', '#BFBFBF'],

      ['Lv2', '#95DDB2'],

      ['Lv3', '#92D1E5'],

      ['Lv4', '#FFB37C'],

      ['Lv5', '#FF6C00'],

      ['Lv6', '#FF0000']

    ], styleHandler(styleA)),

    // 勋章等级

    MEDAL: new Proxy([

      ['Lv_0', '#BFBFBF'],

      ['Lv_1', '#5C968E'], ['Lv_2', '#5C968E'], ['Lv_3', '#5C968E'], ['Lv_4', '#5C968E'],

      ['Lv_5', '#5D7B9E'], ['Lv_6', '#5D7B9E'], ['Lv_7', '#5D7B9E'], ['Lv_8', '#5D7B9E'],

      ['Lv_9', '#8D7CA6'], ['Lv10', '#8D7CA6'], ['Lv11', '#8D7CA6'], ['Lv12', '#8D7CA6'],

      ['Lv13', '#BE6686'], ['Lv14', '#BE6686'], ['Lv15', '#BE6686'], ['Lv16', '#BE6686'],

      ['Lv17', '#C79D24'], ['Lv18', '#C79D24'], ['Lv19', '#C79D24'], ['Lv20', '#C79D24'],

      ['Lv21', '#1A544B'], ['Lv22', '#1A544B'], ['Lv23', '#1A544B'], ['Lv24', '#1A544B'],

      ['Lv25', '#06154C'], ['Lv26', '#06154C'], ['Lv27', '#06154C'], ['Lv28', '#06154C'],

      ['Lv29', '#2D0855'], ['Lv30', '#2D0855'], ['Lv31', '#2D0855'], ['Lv32', '#2D0855'],

      ['Lv33', '#7A0423'], ['Lv34', '#7A0423'], ['Lv35', '#7A0423'], ['Lv36', '#7A0423'],

      ['Lv37', '#FF610B'], ['Lv38', '#FF610B'], ['Lv39', '#FF610B'], ['Lv40', '#FF610B']

    ], styleHandler(styleB, '粉丝')),

    // 认证类型

    OFFICIAL: new Proxy([

      ['普通', '#BFBFBF'],

      ['个人', '#F6C851'],

      ['企业', '#6FC4FA']

    ], styleHandler(styleA)),

    // 会员类型

    VIP: new Proxy([

      ['普通', '#BFBFBF'],

      ['月度', '#FDB8CC'],

      ['年度', '#FB7299']

    ], styleHandler(styleA))

  };


  // 日志

  const LOG = [];


  // 日志记录器

  const LOGGER = {

    level: 0,

    plain_(msg) {

      if (msg === null || msg === undefined) {

        return `${msg}`;

      }

      return msg.toString().replaceAll('%c', '').replaceAll('%%', '%');

    },

    log_(msg) {

      LOG.push(this.plain_(msg));

    },

    group_(msg) {

      this.level++;

      LOG.push(`${'#'.repeat(this.level)} ${this.plain_(msg)}`);

    },

    log(msg, ...params) {

      this.log_(msg);

      console.log(msg, ...params);

    },

    table(data, properties) {

      this.log_('(暂不支持表格)');

      console.table(data, properties);

    },

    group(msg, ...params) {

      this.group_(msg);

      console.group(msg, ...params);

    },

    groupCollapsed(msg, ...params) {

      this.group_(msg);

      console.groupCollapsed(msg, ...params);

    },

    groupEnd() {

      if (this.level > 0) {

        this.level--;

        if (this.level === 0) {

          LOG.push('');

        }

      }

      console.groupEnd();

    }

  };


  // 比较数字

  const compareInt = (v1, v2) => v1 < v2 ? -1 : (v1 === v2 ? 0 : 1);


  // 比较器

  const comparator = (...extracts) => (c1, c2) => {

    for (const ext of extracts) {

      const res = compareInt(ext(c1), ext(c2));

      if (res !== 0) {

        return res;

      }

    }

    return 0;

  };


  // 格式化时间

  const formatTime = unix => {

    const d = new Date(unix * 1000);

    const year = d.getFullYear().toString();

    const month = (d.getMonth() + 1).toString().padStart(2, '0');

    const date = d.getDate().toString().padStart(2, '0');

    const hours = d.getHours().toString().padStart(2, '0');

    const minutes = d.getMinutes().toString().padStart(2, '0');

    const seconds = d.getSeconds().toString().padStart(2, '0');

    return `${year}-${month}-${date} ${hours}:${minutes}:${seconds}`;

  };


  // 检查整数

  const ckInt = value => {

    if (!Number.isInteger(value)) {

      throw `[${value}]非整数`;

    }

    return value;

  };


  // 检查字符串

  const ckStr = value => {

    if (typeof value !== 'string') {

      throw `[${value}]非字符串`;

    }

    return value;

  };


  // 检查时间

  const ckUnix = unix => {

    ckInt(unix);

    if (unix.toString().length !== 10) {

      throw `时间[${unix}]必须为10位数`;

    }

    return unix;

  };


  // 检查用户等级

  const ckLevel = level => {

    ckInt(level);

    if (level < 0 || level > 6) {

      throw `用户等级[${level}]必须在[0, 6]之间`;

    }

    return level;

  };


  // 检查勋章等级

  const ckMedal = medal => {

    ckInt(medal);

    if (medal < 0 || medal > 40) {

      throw `勋章等级[${medal}]必须在[0, 40]之间`;

    }

    return medal;

  };


  // 检查认证

  const ckOfficial = official => {

    ckInt(official);

    if (official < -1 || official > 1) {

      throw `认证类型[${official}]必须在[-1, 1]之间`;

    }

    return official + 1;

  };


  // 检查会员

  const ckVip = vip => {

    ckInt(vip.vipType);

    if (vip.vipType < 0 || vip.vipType > 2) {

      throw `会员类型[${vip.vipType}]必须在[0, 2]之间`;

    }

    return vip.vipStatus === 0 ? 0 : vip.vipType;

  };


  // 加入全局配置

  const globalConfig = () => {

    // 关闭监控

    if (window['Sentry']) {

      window['Sentry'].getCurrentHub().getClient().getOptions().enabled = false;

      // 恢复控制台函数

      for (const name of ['debug', 'info', 'warn', 'error', 'log', 'assert']) {

        if (console[name].__sentry__) {

          console[name] = console[name].__sentry_original__;

        }

      }

      // 恢复全局函数

      for (const name of ['addEventListener', 'setTimeout']) {

        if (window[name].__sentry__) {

          window[name] = window[name].__sentry_original__;

        }

      }

    }

    // 提示页面关闭

    addEventListener('beforeunload', event => {

      event.preventDefault();

      return event.returnValue = false;

    });

  };


  // 加入全局函数

  const globalFunctions = (...functions) => {

    for (const func of functions) {

      if (window[func.name] !== undefined) {

        throw '重复运行开奖脚本,请刷新当前网页页面后再次尝试。';

      }

      Object.defineProperty(window, func.name, {value: func});

    }

  };


  // 计算页面评论区参数

  const computeCommentInfo = () => {

    const url = new URL(location);

    const host = url.hostname;

    const path = url.pathname;

    // 检查路径

    const ckPath = prefix => {

      return path.length > prefix.length && path.substring(0, prefix.length) === prefix;

    };

    // 提取ID

    const extId = prefix => {

      return ckInt(+path.substring(prefix.length));

    };

    // 检查全局变量

    const ckW = prop => {

      if (prop === undefined || prop === null) {

        throw '无法获取全局变量';

      }

      return prop;

    };

    let info;

    if (host === 't.bilibili.com';) {

      if (ckPath('/')) {

        const vue = document.querySelector('div.bili-dyn-item').__vue__.data.basic;

        info = {web: '动态', type: vue.comment_type, oid: vue.comment_id_str};

      }

    } else if (host === 'www.bilibili.com';) {

      if (ckPath('/video/')) {

        info = {web: '投稿视频', type: 1, oid: ckW(window.aid)};

      } else if (ckPath('/bangumi/play/')) {

        info = {web: '版权视频', type: 1, oid: ckW(window.aid)};

      } else if (ckPath('/blackboard/')) {

        info = {web: '活动', type: 4, oid: ckW(window.activityId)};

      } else if (ckPath('/blackroom/ban/')) {

        info = {web: '小黑屋', type: 6, oid: extId('/blackroom/ban/')};

      } else if (ckPath('/read/cv')) {

        info = {web: '专栏', type: 12, oid: extId('/read/cv')};

      } else if (ckPath('/audio/au')) {

        info = {web: '音频', type: 14, oid: extId('/audio/au')};

      } else if (ckPath('/audio/am')) {

        info = {web: '音频列表', type: 19, oid: extId('/audio/am')};

      } else if (ckPath('/cheese/play/ep')) {

        info = {web: '课程', type: 33, oid: extId('/cheese/play/ep')};

      }

    } else if (host === 'h.bilibili.com';) {

      if (ckPath('/')) {

        info = {web: '相簿', type: 11, oid: extId('/')};

      }

    } else if (host === 'manga.bilibili.com';) {

      if (ckPath('/detail/mc')) {

        info = {web: '漫画', type: 22, oid: extId('/detail/mc')};

      }

    }

    if (info === undefined) {

      throw '不支持当前页面';

    }

    url.hash = '';

    for (const [key, value] of new URL(url).searchParams) {

      if (key !== 'type') {

        url.searchParams.delete(key);

      }

    }

    info.url = url;

    LOGGER.log(`[类型] %c${info.web}`, `color:${COLOR.BLUE}`);

    LOGGER.log(`%c[url] ${info.url}`, `color:${COLOR.GRAY}`);

    LOGGER.log(`%c[type] ${info.type}`, `color:${COLOR.GRAY}`);

    LOGGER.log(`%c[oid] ${info.oid}`, `color:${COLOR.GRAY}`);

    return info;

  };


  // 获取当前登录用户

  const getUser = async () => {

    const userRes = await $.ajax('https://api.bilibili.com/x/web-interface/nav', {

      type: 'GET',

      xhrFields: {withCredentials: true}

    });

    if (userRes.code !== 0) {

      return undefined;

    }

    return userRes.data.uname;

  };


  // 获取用户与当前登录用户关系

  const getRelation = async uid => {

    await wait(SYS_CONFIG.API_INTERVAL);

    const relationRes = await $.ajax('https://api.bilibili.com/x/space/acc/relation', {

      type: 'GET',

      data: {mid: uid},

      xhrFields: {withCredentials: true}

    });

    if (relationRes.code !== 0) {

      throw '无法获取用户关系';

    }

    const relation = relationRes.data.be_relation;

    return {followed: relation.attribute !== undefined && relation.attribute !== 0 && relation.attribute !== 128, ts: relation.mtime};

  };


  // 计算单项权重

  const computeWeights = (title, styles, weight, column = 1) => {

    LOGGER.group(`${title}权重 (相对百分比)`);

    let max = 0;

    let weights = [];

    for (const tag of styles) {

      const w = weight[tag.key];

      if (typeof w !== 'number' || w < 0) {

        throw `${title}权重[${tag.key}]必须为非负数`;

      }

      weights.push(w);

      max = Math.max(max, w);

    }

    if (max === 0) {

      throw `${title}权重全为零`;

    }

    weights = weights.map(w => w / max);

    let text = [];

    let css = [];

    for (let i = 0; i < weights.length; i++) {

      const format = consoleFormat([styles[i], stylePercent(weights[i], 1)], ' ');

      text.push(format.text);

      css.push(...format.css);

      if (i % column === 0) {

        LOGGER.log(text.join('   '), ...css);

        text = [];

        css = [];

      }

    }

    LOGGER.groupEnd();

    return weights;

  };


  // 获取单页评论

  const getPageComments = async (type, oid, next) => {

    await wait(SYS_CONFIG.API_INTERVAL);

    G.next = next;

    console.log(`%c[${G.call++}] ${next}`, `color:${COLOR.GRAY}`);

    const pageRes = await $.ajax('https://api.bilibili.com/x/v2/reply/main', {

      type: 'GET',

      data: {type: type, oid: oid, next: next, mode: 2}

    });

    if (pageRes.code !== 0) {

      throw '无法获取评论';

    }

    return [pageRes.data.cursor.is_end, pageRes.data.cursor.prev, pageRes.data.cursor.next, pageRes.data.replies ?? []];

  };


  // 保存评论

  const saveCommentsToMap = comments => {

    for (const [i, c] of comments.entries()) {

      try {

        const ts = ckUnix(c.ctime);

        if (ts > DRAW_TIME) {

          continue;

        }

        const rpid = ckInt(c.rpid);

        const msg = ckStr(c.content.message);

        const like = ckInt(c.like);

        const reply = ckInt(c.count);

        const detail = {rpid, msg, like, reply, ts};

        const uid = ckInt(c.mid);

        const user = G.uMap.get(uid);

        if (user === undefined) {

          const name = ckStr(c.member.uname);

          const level = ckLevel(c.member.level_info.current_level);

          const medal = ckMedal(c.member.fans_detail?.level ?? 0);

          const official = ckOfficial(c.member.official_verify.type);

          const vip = ckVip(c.member.vip);

          const weight = LEVEL_WEIGHTS[level] * MEDAL_WEIGHTS[medal] * VIP_WEIGHTS[vip];

          const details = new Map([[rpid, detail]]);

          G.uMap.set(uid, {uid, name, level, medal, official, vip, weight, details});

        } else {

          user.details.set(rpid, detail);

        }

      } catch (e) {

        console.warn(`[${G.call}] ${i} ${e} 跳过`);

      }

    }

  };


  // 加载评论

  const loadCommentMap = async () => {

    let [isEnd, prev, next, cs] = await getPageComments(INFO.type, INFO.oid, G.next);

    let mid = 0;

    while (!isEnd && prev !== 0 && next !== 0 && cs.length !== 0) {

      saveCommentsToMap(cs);

      mid = next + ((prev - next) >> 1);

      [isEnd, prev, next, cs] = await getPageComments(INFO.type, INFO.oid, next);

    }

    // 再次获取最早评论 (B站BUG 最早评论有可能被遗漏)

    [isEnd, prev, next, cs] = await getPageComments(INFO.type, INFO.oid, mid);

    saveCommentsToMap(cs);

  };


  // 用户概况

  const showSummary = total => {

    const pass = G.uList.length;

    const fail = G.uMap.size - pass;

    if (pass === 0) {

      throw '不存在有抽奖资格用户';

    }

    LOGGER.log(`[总评论数] ${total}`);

    LOGGER.log(`[有抽奖资格用户总数] %c${pass}`, `color:${COLOR.BLUE}`);

    LOGGER.log(`%c[无抽奖资格用户总数] ${fail}`, `color:${COLOR.GRAY}`);

  };


  // 用户分布

  const showDistribution = (title, styles, summary, column = 1) => {

    LOGGER.group(`${title}分布 (有资格数, %c无资格数%c)`, `color:${COLOR.GRAY}`, '');

    const passLen = Math.max(...summary.passes).toString().length;

    const failLen = Math.max(...summary.fails).toString().length;

    let text = [];

    let css = [];

    for (let i = 0; i < summary.passes.length; i++) {

      const format = consoleFormat([styles[i], styleInt(summary.passes[i], passLen), styleInt(summary.fails[i], failLen, COLOR.GRAY)], ' ');

      text.push(format.text);

      css.push(...format.css);

      if (i % column === 0) {

        LOGGER.log(text.join('   '), ...css);

        text = [];

        css = [];

      }

    }

    LOGGER.groupEnd();

  };


  // 用户映射

  const mapToUser = user => ({

    UID: user.uid,

    用户名: user.name,

    用户等级: user.level,

    勋章等级: user.medal,

    认证类型: user.official,

    会员类型: user.vip,

    累计评论数: user.details.size

  });


  // 评论映射

  const mapToDetail = detail => ({

    评论ID: detail.rpid,

    评论时间: formatTime(detail.ts),

    评论内容: detail.msg,

    被点赞数: detail.like,

    被评论数: detail.reply

  });


  // 用户列表

  const showUsers = (title, list) => {

    LOGGER.groupCollapsed(`${title}用户(${list.length})`);

    if (list.length === 0) {

      LOGGER.log('%cN/A', `color:${COLOR.GRAY}`);

    } else {

      LOGGER.table(list.map(mapToUser));

    }

    LOGGER.groupEnd();

  };


  // 展示用户

  const displayUser = async user => {

    const details = Array.from(user.details.values()).sort(comparator(detail => detail.ts, detail => detail.rpid));

    const replyUrl = new URL(INFO.url);

    replyUrl.hash = `reply${details[0].rpid}`;


    const level = STYLE.LEVEL[user.level];

    const medal = user.medal === 0 ? undefined : STYLE.MEDAL[user.medal];

    const official = user.official === 0 ? undefined : STYLE.OFFICIAL[user.official];

    const vip = user.vip === 0 ? undefined : STYLE.VIP[user.vip];

    const uid = styleB(['UID', user.uid], '#8A8A8A');

    const name = {text: user.name, css: 'font-weight:bold'};

    const weight = styleB(['权重', (100 * user.weight).toFixed(1)], '#FEFEFE');

    const prob = styleB(['概率', (100 * user.weight / G.uWeight).toFixed(6)], '#FEFEFE');

    const format = consoleFormat([level, medal, official, vip, uid, name, weight, prob], ' ');

    LOGGER.log(format.text, ...format.css);


    if (USER_NAME !== undefined) {

      const relation = await getRelation(user.uid);

      if (relation.followed) {

        LOGGER.log(`[是否关注%c${USER_NAME}%c] %c已关注 %c${formatTime(relation.ts)}`, `color:${COLOR.PINK};font-weight:bold`, '', `color:${COLOR.GREEN}`, '');

      } else {

        LOGGER.log(`[是否关注%c${USER_NAME}%c] %c未关注`, `color:${COLOR.PINK};font-weight:bold`, '', `color:${COLOR.RED}`);

      }

    }

    LOGGER.log(`[首条评论位置链接] ${replyUrl}`);

    LOGGER.table(details.map(mapToDetail));

  };


  // 保存文件

  const save = (data, fileName, fileType) => {

    const link = document.createElement('a');

    link.download = fileName;

    const blob = new Blob([data], {type: fileType});

    link.href = URL.createObjectURL(blob);

    link.click();

    URL.revokeObjectURL(link.href);

  };


  // CSV映射

  const mapToCSV = prop => {

    switch (typeof prop) {

      case 'undefined':

        return '';

      case 'number':

        return `"${prop}"`;

      case 'string':

        return `"${prop.replaceAll('"', '""').replaceAll('\r', '\u23CE').replaceAll('\n', '\u23CE')}"`;

      default:

        throw `不支持格式[${typeof prop}]`;

    }

  };


  // 保存所有评论

  const saveComments = rows => {

    const data = rows.map(row => row.map(mapToCSV).join(',')).join('\r\n') + '\r\n';

    save(data, `评论_${INFO.type}_${INFO.oid}_${DRAW_TIME}.csv`, 'text/csv');

  };


  // 加权随机排序

  const weightedRandomShuffle = () => {

    const MAX = 0x4000;

    const ua = new Uint32Array(MAX);

    for (const [i, user] of G.uList.entries()) {

      const index = i % MAX;

      if (index === 0) {

        window.crypto.getRandomValues(ua);

      }

      user.key = -Math.log((ua[index] + 1) / 0x100000001) / user.weight;

    }

    G.uList.sort(comparator(user => user.key));

  };


  // 统计评论

  const countCommentMap = () => {

    // 创建统计数组

    const newSummary = length => ({passes: new Array(length).fill(0), fails: new Array(length).fill(0)});

    let total = 0;

    const level = newSummary(7);

    const medal = newSummary(41);

    const official = newSummary(3);

    const vip = newSummary(3);

    const overLimits = [];

    const zeroWeights = [];

    const fans = [];

    const companies = [];

    const rows = [];

    for (const [uid, user] of G.uMap) {

      if (user.medal > 0) {

        fans.push(user);

      }

      if (user.official === 2) {

        companies.push(user);

      }

      if (user.details.size > USER_CONFIG.MAX_REPEAT || user.weight === 0) {

        level.fails[user.level]++;

        medal.fails[user.medal]++;

        official.fails[user.official]++;

        vip.fails[user.vip]++;

        if (user.details.size > USER_CONFIG.MAX_REPEAT) {

          overLimits.push(user);

        }

        if (user.weight === 0) {

          zeroWeights.push(user);

        }

      } else {

        level.passes[user.level]++;

        medal.passes[user.medal]++;

        official.passes[user.official]++;

        vip.passes[user.vip]++;

        G.uWeight += user.weight;

        G.uList.push(user);

      }

      for (const [rpid, detail] of user.details) {

        total++;

        if (USER_CONFIG.SAVE_COMMENTS) {

          rows.push([detail.rpid, formatTime(detail.ts), detail.msg, detail.like, detail.reply, user.uid, user.name, user.level, user.medal, user.official, user.vip, user.details.size, user.weight]);

        }

      }

    }

    // 打乱用户顺序

    weightedRandomShuffle();

    // 参与人数

    showSummary(total);

    // 用户分布

    showDistribution('用户等级', STYLE.LEVEL, level);

    showDistribution('勋章等级', STYLE.MEDAL, medal, 4);

    showDistribution('认证类型', STYLE.OFFICIAL, official);

    showDistribution('会员类型', STYLE.VIP, vip);

    // 特殊列表

    overLimits.sort(comparator(user => -user.details.size, user => user.uid));

    showUsers('超出累计评论数上限', overLimits);

    zeroWeights.sort(comparator(user => user.uid));

    showUsers('无权重', zeroWeights);

    fans.sort(comparator(user => -user.medal, user => user.uid));

    showUsers('勋章', fans);

    companies.sort(comparator(user => user.uid));

    showUsers('企业认证', companies);

    // 详细评论

    if (USER_CONFIG.SAVE_COMMENTS) {

      rows.sort(comparator(row => row[1], row => row[0]));

      rows.unshift(['评论ID', '评论时间', '评论内容', '被点赞数', '被评论数', 'UID', '用户名', '用户等级', '勋章等级', '认证类型', '会员类型', '累计评论数', '相对权重']);

      saveComments(rows);

    }

  };


  // 评论加载完成提示

  const commentPrompt = () => {

    if (G.done) {

      console.warn('加载与统计评论已完成,控制台输入%c draw(n) %c(n为正整数) 并回车以随机抽取n位用户。', `color:${COLOR.ORANGE}`, '');

      console.warn('控制台输入%c shuffle() %c并回车以重新打乱用户顺序。', `color:${COLOR.ORANGE}`, '');

      console.warn('控制台输入%c saveLog() %c并回车以保存当前控制台所有显示内容。', `color:${COLOR.ORANGE}`, '');

    } else {

      console.warn('加载与统计评论未完成,请尝试控制台输入%c resume() %c并回车以继续加载评论。', `color:${COLOR.ORANGE}`, '');

    }

  };


  // [加载与统计评论]

  const resume = async () => {

    await wait(0);

    if (!G.done) {

      // ---- 加载评论 ----

      console.group('加载评论');

      try {

        await loadCommentMap();

      } catch (e) {

        if (e.status === 412) {

          console.error('触发B站安全风控策略,当前IP被暂时屏蔽。%c请勿关闭或刷新本页面%c,以防丢失加载进度。', `color:${COLOR.ORANGE}`, '');

          console.error('请更换IP或等待1小时后,控制台输入%c resume() %c并回车以继续加载评论。', `color:${COLOR.ORANGE}`, '');

        } else {

          console.error('发生未知错误。%c请勿关闭或刷新本页面%c,以防丢失加载进度。', `color:${COLOR.ORANGE}`, '');

          console.error('控制台输入%c resume() %c并回车以继续加载评论。', `color:${COLOR.ORANGE}`, '');

        }

        throw e;

      } finally {

        console.groupEnd();

      }

      // ---- 统计评论 ----

      LOGGER.group('统计评论');

      countCommentMap();

      LOGGER.groupEnd();

      G.done = true;

    }

    commentPrompt();

  };


  // [保存日志]

  const saveLog = () => {

    save(LOG.join('\r\n') + '\r\n', `日志_${INFO.type}_${INFO.oid}_${DRAW_TIME}.txt`, 'text/plain');

  };


  // [重新打乱用户顺序]

  const shuffle = () => {

    if (G.done) {

      G.dIndex = 0;

      weightedRandomShuffle();

      console.warn('成功重新打乱用户顺序,控制台输入%c draw(n) %c(n为正整数) 并回车以重新随机抽取n位用户。', `color:${COLOR.ORANGE}`, '');

    } else {

      commentPrompt();

    }

  };


  // [随机抽取用户]

  const draw = async (num = 1) => {

    await wait(0);

    if (G.done) {

      if (!Number.isInteger(num) || num <= 0) {

        throw `用户数量[${num}]必须为正整数`;

      }

      // ---- 随机抽取用户 ----

      LOGGER.group(`随机抽取用户(${num})`);

      let count = num;

      try {

        while (count > 0) {

          if (G.dIndex === G.uList.length) {

            throw '无剩余有资格用户';

          }

          LOGGER.group(`%c第${G.dIndex + 1}名`, `color:${COLOR.BLUE}`);

          const user = G.uList[G.dIndex];

          await displayUser(user);

          LOGGER.groupEnd();

          count--;

          G.dIndex++;

        }

      } finally {

        LOGGER.groupEnd();

      }

      console.warn(`成功随机抽取${num}位用户,控制台输入%c draw(n) %c(n为正整数) 并回车以继续随机抽取n位用户。`, `color:${COLOR.ORANGE}`, '');

    } else {

      commentPrompt();

    }

  };


  // [查找用户]

  const search = key => {

    if (G.done) {

      console.group(`查找用户[${key}]`);

      let res = G.uMap.get(key);

      if (res === undefined) {

        for (const [uid, user] of G.uMap) {

          if (user.name === key) {

            res = user;

            break;

          }

        }

      }

      if (res === undefined) {

        console.warn(`评论区不存在用户[${key}]`);

      } else {

        const details = Array.from(res.details.values()).sort(comparator(detail => detail.ts, detail => detail.rpid));

        console.table([res].map(mapToUser));

        console.table(details.map(mapToDetail));

      }

      console.groupEnd();

    } else {

      commentPrompt();

    }

  };


  // ---- 窗口管理 ----

  globalConfig();

  globalFunctions(resume, saveLog, shuffle, draw, search);

  const G = {call: 1, next: 0, done: false, uMap: new Map(), uWeight: 0, uList: [], dIndex: 0};


  // ---- 当前页面 ----

  LOGGER.group('当前页面');

  const INFO = computeCommentInfo();

  const USER_NAME = await getUser();

  LOGGER.groupEnd();


  // ---- 运行配置 ----

  LOGGER.group('运行配置');

  const DRAW_TIME = Math.trunc(Date.now() / 1000);

  LOGGER.log(`[开奖时间] %c${formatTime(DRAW_TIME)}`, `color:${COLOR.BLUE}`);

  if (USER_NAME === undefined) {

    LOGGER.log('[当前登录用户] %c未登录', `color:${COLOR.GRAY};font-weight:bold`);

    console.warn('登录后可自动验证中奖用户是否关注当前登录用户(需刷新页面并重新运行开奖脚本)。');

  } else {

    LOGGER.log(`[当前登录用户] %c${USER_NAME}`, `color:${COLOR.PINK};font-weight:bold`);

  }

  if (USER_CONFIG.SAVE_COMMENTS) {

    LOGGER.log('[本地保存所有评论] %c保存', `color:${COLOR.GREEN}`);

  } else {

    LOGGER.log('[本地保存所有评论] %c不保存', `color:${COLOR.RED}`);

  }

  LOGGER.log(`[单用户累计评论数上限] ${USER_CONFIG.MAX_REPEAT}`);

  LOGGER.log(`%c[API请求间隔] ${SYS_CONFIG.API_INTERVAL}毫秒`, `color:${COLOR.GRAY}`);

  const LEVEL_WEIGHTS = computeWeights('用户等级', STYLE.LEVEL, USER_CONFIG.LEVEL_WEIGHT);

  const MEDAL_WEIGHTS = computeWeights('勋章等级', STYLE.MEDAL, USER_CONFIG.MEDAL_WEIGHT, 4);

  const VIP_WEIGHTS = computeWeights('会员类型', STYLE.VIP, USER_CONFIG.VIP_WEIGHT);

  LOGGER.groupEnd();


  // ---- 运行确认 ----

  console.group('运行确认');

  if (!confirm(`确认在 本页面评论区 (按照控制台所示配置) 开奖?`)) {

    throw '已取消';

  }

  console.log('%c已确认', `color:${COLOR.GREEN}`);

  console.groupEnd();


  // ---- 加载与统计评论 ----

  await resume();

})();


转载 感谢 盛百凡 写的动态评论区抽奖脚本工具的源代码的评论 (共 条)

分享到微博请遵守国家法律