Greasy Fork is available in English.

长毛象抽奖脚本

点击“开始抽奖”后,随机抽出五名中奖候选者。

// ==UserScript==
// @name        长毛象抽奖脚本
// @namespace   https://blog.bgme.me
// @match       https://bgme.me/*
// @match       https://bgme.bid/*
// @match       https://c.bgme.bid/*
// @grant       none
// @run-at      document-end
// @version     1.0.0
// @author      bgme
// @description 点击“开始抽奖”后,随机抽出五名中奖候选者。
// @supportURL  https://github.com/yingziwu/Greasemonkey/issues
// @license     AGPL-3.0-or-later
// ==/UserScript==

window.addEventListener('load', function () {
  activateMastodonLottery();
}, false)

function chromeClickChecker(event) {
  return (
    event.target.tagName.toLowerCase() === 'i' &&
    event.target.classList.contains('fa-ellipsis-h') &&
    document.querySelector('div.dropdown-menu') === null
  );
}

function firefoxClickChecker(event) {
  return (
    event.target.tagName.toLowerCase() === 'button' &&
    event.target.classList.contains('icon-button') &&
    document.querySelector('div.dropdown-menu') === null
  );
}

function activateMastodonLottery() {
  document.querySelector('body').addEventListener('click', function (event) {
    if (chromeClickChecker(event) || firefoxClickChecker(event)) {
      // Get the status for this event
      let status = event.target.parentNode.parentNode.parentNode.parentNode.parentNode;
      if (status.className.match('detailed-status__wrapper')) {
        addLotteryLink(status);
      }
    };
  }, false);
}

function addLotteryLink(status) {
  setTimeout(function () {
    const lotteryStatusUrl = status.querySelector('.detailed-status__datetime').getAttribute('href');
    const dropdown = document.querySelector('div.dropdown-menu ul');
    const separator = dropdown.querySelector('li.dropdown-menu__separator');

    const listItem = document.createElement('li');
    listItem.classList.add('dropdown-menu__item');
    listItem.classList.add('mastodon__lottery');

    const link = document.createElement('a');
    link.setAttribute('href', '#');
    link.setAttribute('target', '_blank');
    link.textContent = '开始抽奖';

    link.addEventListener('click', function (e) {
      e.preventDefault();
      if (!window.lotteryRunning) {
        window.lotteryRunning = true;
        link.textContent = '抽奖中,请等待……';
        run(lotteryStatusUrl).then(() => { window.lotteryRunning = false }).catch(() => { window.lotteryRunning = false });
      }
    }, false);

    listItem.appendChild(link);
    dropdown.insertBefore(listItem, separator);
  }, 100);
}

async function run(lotteryStatusUrl, lotteryType = 'reblog', candidateNumber = 5) {
  // lotteryStatusUrl 抽奖嘟文URL
  // lotteryType 抽奖类型:转发(reblog),收藏(favourite)
  // candidateNumber 候选中奖者人数

  const domain = document.location.hostname;
  const token = JSON.parse(document.querySelector('#initial-state').text).meta.access_token;
  const API = {
    'verify': `https://${domain}/api/v1/accounts/verify_credentials`,
    'notifications': `https://${domain}/api/v1/notifications`,
    'status': `https://${domain}/api/v1/statuses/`,
  };
  const searchParamMap = new Map([
    ['reblog', 'exclude_types[]=follow&exclude_types[]=follow_request&exclude_types[]=favourite&exclude_types[]=mention&exclude_types[]=poll'],
    ['favourite', 'exclude_types[]=follow&exclude_types[]=follow_request&exclude_types[]=reblog&exclude_types[]=mention&exclude_types[]=poll'],
  ]);
  const searchParam = new URLSearchParams(searchParamMap.get(lotteryType));

  const statusID = lotteryStatusUrl.match(/(\d+)$/)[0];
  let statusTNumber;
  let lotterLog;


  logout(`开始抽奖……\n当前浏览器:${navigator.userAgent}\n开始时间:${(new Date()).toISOString()}`);
  logout(`抽奖嘟文:${lotteryStatusUrl},抽奖类型:${lotteryType},候选中奖者人数:${candidateNumber}\n\n`);
  let verify;
  [verify, statusTNumber] = await doVerify(API, lotteryStatusUrl, statusID, lotteryType, statusTNumber);
  if (!verify) {
    throw Error('抽奖嘟文非本人发送');
  }
  const matchAccouts = await getmatchAccouts(API, statusID, statusTNumber, searchParam);
  randomTest(matchAccouts);
  const luckGuys = getLuckGuy(matchAccouts);
  const cadidatesText = getCandidate(luckGuys, candidateNumber);
  const notificationText = `嘿!感谢各位参与本次小抽奖活动。\n${cadidatesText}\n\n希望这条艾特您的信息没有造成骚扰,如您对奖品感兴趣请和我私信联系吧?`;
  await postStatus(notificationText, statusID, 'public');
  logout(`抽奖结束!\n结束时间:${(new Date()).toISOString()}`);
  saveFile(lotterLog, `lotterLog-${Date.now()}.log`, 'text/plain; charset=utf-8');


  async function doVerify(API, lotteryStatusUrl, statusID, lotteryType, statusTNumber) {
    const v = await request(API.verify);
    const s = await request(`${API.status}${statusID}`);
    logout(`抽奖嘟文URL:${lotteryStatusUrl}\n回复数:${s.replies_count},转发数:${s.reblogs_count},收藏数:${s.favourites_count}`);

    const numbers = new Map([['reblog', s.reblogs_count], ['favourite', s.favourites_count]]);
    if (numbers.has(lotteryType)) {
      statusTNumber = numbers.get(lotteryType);
    } else {
      throw Error('抽奖类型设置不正确');
    }

    if (v.acct === s.account.acct && (new URL(s.account.url)).hostname === (new URL(lotteryStatusUrl)).hostname) {
      return [true, statusTNumber];
    } else {
      return [false, statusTNumber];
    }
  }

  async function getmatchAccouts(API, statusID, statusTNumber, searchParam) {
    const matchAccouts = [];

    while (matchAccouts.length !== statusTNumber) {
      const nlist = await request(`${API.notifications}?${searchParam.toString()}`);
      searchParam.set('max_id', nlist.slice(-1)[0].id);

      nlist.forEach((obj) => {
        if (obj.status.id === statusID) {
          matchAccouts.push(obj.account.acct);
        }
      });
    }

    matchAccouts.sort();
    logout(`共有${matchAccouts.length}名符合条件的抽奖参与者\n她们是:`);
    matchAccouts.forEach(logout);

    return matchAccouts;
  }

  function randomTest(matchAccouts) {
    logout('随机函数测试:');
    const testResults = [];
    const n = 20;
    for (let i = 0; i < (n * 20); i++) {
      testResults.push(getRandomIndex(matchAccouts));
    }
    for (let i = 0; i < n; i++) {
      logout(testResults.slice((i * 20), ((i + 1) * 20)).join(', '));
    }
  }

  function getLuckGuy(matchAccouts) {
    const luckGuys = [];
    const n = matchAccouts.length;
    const luckGuysMap = new Map();
    for (let i = 0; i < (n * 100); i++) {
      const luckGuy = matchAccouts[getRandomIndex(matchAccouts)];
      if (luckGuysMap.get(luckGuy)) {
        luckGuysMap.set(luckGuy, luckGuysMap.get(luckGuy) + 1);
      } else {
        luckGuysMap.set(luckGuy, 1);
      }
    }

    luckGuysMap.forEach((v, k, map) => {
      luckGuys.push([k, v]);
    });
    luckGuys.sort((a, b) => (b[1] - a[1]));
    return luckGuys;
  }

  function getCandidate(luckGuys, candidateNumber) {
    if (candidateNumber > luckGuys.length) {
      throw Error('抽奖参与者太少!')
    }

    let output = '本次抽奖备选中奖者:';
    for (let i = 0; i < candidateNumber; i++) {
      output = `${output}\nNo.${i + 1}:@${luckGuys[i][0]}  (幸运指数:${luckGuys[i][1]})`;
    }
    logout(output);
    return output;
  }

  function getRandomIndex(arr) {
    return Math.floor(arr.length * Math.random());
  }

  async function request(url) {
    logout(`正在请求:${url}`);
    const resp = await fetch(url, {
      headers: {
        Authorization: `Bearer ${token}`,
      },
      method: 'GET',
    });
    const date = new Date(resp.headers.get('date'));
    const request_id = resp.headers.get('x-request-id');
    const runtime = resp.headers.get('x-runtime');
    const ratelimit_remaining = resp.headers.get('x-ratelimit-remaining');
    logout(`请求 ${url} 完成\n请求时间:${date.toISOString()},API剩余限额:${ratelimit_remaining},x-runtime:${runtime},x-request-id:${request_id}`);
    return await resp.json();
  }

  function logout(text) {
    console.log(text);
    if (lotterLog) {
      lotterLog = lotterLog + '\n' + text;
    } else {
      lotterLog = text;
    }
  }

  function saveFile(data, filename, type) {
    const file = new Blob([data], { type: type });
    const a = document.createElement('a');
    const url = URL.createObjectURL(file);
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    setTimeout(function () {
      document.body.removeChild(a);
      window.URL.revokeObjectURL(url);
    }, 0);
  }

  async function postStatus(text, in_reply_to_id, visibility = 'public') {
    const postDate = {
      'in_reply_to_id': in_reply_to_id,
      'media_ids': [],
      'poll': null,
      'sensitive': false,
      'spoiler_text': '',
      'status': text,
      'visibility': visibility,
    };

    logout(`发送嘟文中……\n嘟文内容:\n${text}\n回复嘟文ID:${in_reply_to_id}\n可见范围:${visibility}`);
    const resp = await fetch(API.status, {
      'headers': {
        'Content-Type': 'application/json;charset=utf-8',
        'Authorization': `Bearer ${token}`,
      },
      'body': JSON.stringify(postDate),
      'method': 'POST',
      'mode': 'cors',
    });
    const date = new Date(resp.headers.get('date'));
    const request_id = resp.headers.get('x-request-id');
    const runtime = resp.headers.get('x-runtime');
    const ratelimit_remaining = resp.headers.get('x-ratelimit-remaining');
    logout(`嘟文发送完成,完成请求时间:${date.toISOString()},API剩余限额:${ratelimit_remaining},x-runtime:${runtime},x-request-id:${request_id}`);
    return await resp.json();
  }
}