1

Inspired by the Fastest Gun question on the central Meta, this script implements answer sorting based on a modified Wilson score. Installing the script adds a ‘Best’ button on question pages that attempts to sort the highest-quality answers first.

A number of weighing tweaks have been applied to take various mechanics of Stack Exchange sites into account:

  • Since downvotes are rarer, require a higher reputation threshold and also cost reputation to cast, they are assumed a better indicator of answer quality than upvotes. As such, they are weighed five times as much as upvotes.
  • Bounties are counted with upvotes; to avoid high bounties biasing the scores too much, bounty awards are scaled and subjected to ‘gamma correction’. A bounty of 25 is worth 1 extra upvote; a bounty of 500 is worth 10 upvotes.
  • Answers with more votes are judged more harshly: that is, the more votes an answer has relative to other answers, the closer the final score is to the lower end of the Wilson confidence interval. Answers with few votes have scores closer to the middle of the confidence interval.
  • Answer acceptance is not taken into account. In my experience, it tends to be a poor proxy for quality relative to other factors. Since askers usually upvote simultaneously with accepting the question anyway, I just did not bother accounting for acceptance at all.

If the question has answers that weren’t displayed on the current page, the script will generate stubs linking to the full answers. It may happen that those answers score higher than those visible on the current page and as such, the stubs may appear between full answers; this is intended and not a bug. Deleted answers, when visible, are always sorted last.

The weighing factors were not determined by any principle other than ‘they felt right’, so beware. Statistical analyses of vote distribution might allow us to come up with better weighing factors.

Test case: under this question, the ‘Votes’ order currently places this answer (+27 / −3) first. The ‘Best’ order by this script places this one (+17 / −0) first; I agree that it’s a much better answer. Since I would probably not have noticed the latter at all otherwise, I think this counts as a success.

Script code

// ==UserScript==
// @name     StackExchange: Sort Best First
// @version  1
// @grant    none
// @match    https://*.stackexchange.com/*
// @match    https://*.superuser.com/*
// @match    https://*.stackoverflow.com/*
// @match    https://*.mathoverflow.net/*
// @match    https://*.serverfault.com/*
// @match    https://*.askubuntu.com/*
// @match    https://stackapps.com/*
// @exclude  https://chat.stackexchange.com/*
// @exclude  https://api.stackexchange.com/*
// @exclude  https://data.stackexchange.com/*
// @exclude  https://openid.stackexchange.com/*
// @exclude  https://stackexchange.com/*
// @exclude  https://contests.stackoverflow.com/*
// @exclude  /^https?:\/\/winterbash\d{4,}\.stackexchange\.com\//
// ==/UserScript==

const sleep = amount => {
  return new Promise((accept, reject) => setTimeout(accept, amount));
};

const getAnswerScores = async qid => {
  const usp = new URLSearchParams;
  usp.set('site', location.hostname);
  usp.set('filter', '!2oRRX0IVTN-3*1DAJ2N2SJiLCR.2VfC)CoC(Q7r-p.');

  const fullResult = [];

  for (let page = 1;; page++) {
    usp.set('page', page);
    const response = await fetch(`https://api.stackexchange.com/2.2/questions/${qid}/answers?${usp}`);
    const result = await response.json();

    if (result.error_id)
      throw new Error(`${result.error_message} [${result.error_name}, ${result.error_id}]`);

    if (fullResult.length === result.total)
      break;

    fullResult.push(...result.items);

    if (result.backoff)
      await sleep(result.backoff * 1000);
  }

  return fullResult;
}

const scoreWilson = (answer) => {
  const SCALE = Math.log(10) / Math.log(20);
  const up = answer.up_vote_count + (answer.awarded_bounty_amount ?
    Math.pow(answer.awarded_bounty_amount / 25, SCALE) : 0);
  const dn = 5 * answer.down_vote_count;
  const total = up + dn;
  const z = 1.96;

  let upper = 1, lower = 0;

  if (total !== 0) {
    const upRatio = up / total;
    const B = upRatio + z * z / (2 * total);
    const C = Math.sqrt((upRatio * (1 - upRatio) + z * z / (4 * total)) / total);
    const D = (1 + z * z / total);

    lower = (B - z * C) / D;
    upper = (B + z * C) / D;
  }

  return { lower, upper, total };
};

const qid = document.getElementById('question').dataset.questionid;
const answersList = document.getElementById('answers');
const sortSwitch = document.querySelector('.js-filter-btn');

const keyFunc = key => (apple, orange) => {
  const appleKey = key(apple);
  const orangeKey = key(orange);
  return (appleKey > orangeKey) - (appleKey < orangeKey);
};

const raw = s => {
  const result = new String(s);
  result.raw = true;
  return result;
};

const html = (lits, ...values) => {
  const ESCAPE = { '<': '&lt;', '&': '&amp;', '"': '&quot;', "'": '&#39;', };
  let result = lits[0];
  for (let i = 0; i < values.length; ) {
    const value = values[i++];
    if (value instanceof Node)
      result += (new XMLSerializer).serializeToString(value);
    else if (value instanceof String && value.raw)
      result += value;
    else
      result += String(value).replace(/[&<"']/g, s => ESCAPE[s]);
    result += lits[i];
  }
  return result;
};

const createLink = () => {
  const link = document.createElement('a');
  link.className = 'grid--cell s-btn s-btn__muted s-btn__outlined';
  link.textContent = 'Best';
  link.addEventListener('click', ev => {
    link.style.cursor = 'wait';
    (async () => {
      const answerNodes = answersList.querySelectorAll('.answer');
      const answerNodeMap = new Map({ [Symbol.iterator]: function *() {
        for (const node of answerNodes) {
          yield [+node.dataset.answerid, node];
        }
      } });
      const unscoredAnswers = new Set(answerNodeMap.keys());
      const afterLastAnswer = answerNodes[answerNodes.length - 1].nextSibling;
      const answers = await getAnswerScores(qid);

      const scoredAnswers = answers.map(answer => {
        unscoredAnswers.delete(answer.answer_id);

        const { upper, lower, total } = scoreWilson(answer);
        let node = answerNodeMap.get(answer.answer_id);
        let anchorNode;

        if (node)
          anchorNode = node.previousElementSibling;
        else {
          anchorNode = document.createElement('a');
          anchorNode.name = answer.answer_id;

          node = document.createElement('div');
          node.dataset.answerid = answer.answer_id;
          node.dataset.ownerid = answer.owner.user_id;
          node.className = 'answer';
          node.innerHTML = html`
            <div class="post-layout">
              <div class="votecell">
                <div class="grid fd-column ai-center">
                  +${answer.up_vote_count}
                  <hr class="vote-count-separator">
                  −${answer.down_vote_count}
                </div>
              </div>
              <div class="answercell">
                <div>
                  <a href="/a/${answer.answer_id}">
                    answer
                  </a>
                  by
                  <a href="/u/${answer.owner.user_id}">
                    <img src="${answer.owner.profile_image}" width="32" height="32" alt="" class="va-middle">
                    ${answer.owner.display_name}
                  </a>
                </div>
              </div>
            </div>
          `;
        }

        return {
          data: answer,
          node,
          anchorNode,
          wilsonInterval: [lower, upper],
          wilsonTotal: total,
        };
      })

      const maxWilsonTotal = Math.max(...scoredAnswers
        .map(answer =>answer.wilsonTotal)
        .filter(total => total === total));

      scoredAnswers.forEach(answer => {
        const { wilsonTotal, wilsonInterval: [lower, upper], data: { creation_date } } = answer;

        const votesWeight = (wilsonTotal / maxWilsonTotal) * 0.5 + 0.5;
        const weight = votesWeight;

        answer.wilsonWeight = weight;
        answer.wilsonScore = (lower * weight + upper * (1 - weight));
      });

      scoredAnswers.sort(keyFunc(answer => -answer.wilsonScore));

      for (const id of unscoredAnswers)
        scoredAnswers.push({
          node: answerNodeMap.get(id),
          anchorNode: answerNodeMap.get(id).previousElementSibling,
        });

      for (const answer of scoredAnswers) {
        const { node, anchorNode } = answer;

        afterLastAnswer.parentNode.insertBefore(node, afterLastAnswer);
        afterLastAnswer.parentNode.insertBefore(anchorNode, node);
      }

      const current = sortSwitch.querySelector('.is-selected');
      current.classList.remove('is-selected');
      current.classList.remove('youarehere');
      link.classList.add('is-selected');
      link.classList.add('youarehere');
    })().catch(e => {
      alert(e);
    }).finally(() => {
      link.style.cursor = '';
    });
  }, false);
  return link;
};

sortSwitch.appendChild(createLink());
1
  • Thank you for contributing to Stack Apps; I can't help but thinking about one of my own scripts which adds the Wilson confidence rating to posts.
    – Glorfindel
    Apr 11 at 18:10
0

Three more test cases:

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .